diff --git a/.gitignore b/.gitignore index 013b84705..761183c03 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ typings/ # Codex AGENTS.md + +# Claude +.claude/settings.local.json diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java index 73c3de9b7..15bb0becf 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -164,7 +164,8 @@ import com.linecorp.centraldogma.server.internal.api.sysadmin.KeyManagementService; import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlService; import com.linecorp.centraldogma.server.internal.api.sysadmin.ServerStatusService; -import com.linecorp.centraldogma.server.internal.api.variable.VariableServiceV1; +import com.linecorp.centraldogma.server.internal.api.template.SecretServiceV1; +import com.linecorp.centraldogma.server.internal.api.template.VariableServiceV1; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirrorAccessController; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin; import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; @@ -1008,7 +1009,8 @@ private void configureHttpApi(ServerBuilder sb, .annotatedService(new ProjectServiceV1(projectApiManager, executor)) .annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager)) .annotatedService(new CredentialServiceV1(projectApiManager, executor)) - .annotatedService(new VariableServiceV1(pm, executor)); + .annotatedService(new VariableServiceV1(pm, executor)) + .annotatedService(new SecretServiceV1(pm, executor)); if (LOGBACK_ENABLED) { apiV1ServiceBuilder.annotatedService(new LoggerService()); } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ContentServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ContentServiceV1.java index 2f592a0b6..9f397d7f2 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ContentServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ContentServiceV1.java @@ -84,7 +84,8 @@ import com.linecorp.centraldogma.server.internal.api.converter.TemplateParamsConverter; import com.linecorp.centraldogma.server.internal.api.converter.WatchRequestConverter; import com.linecorp.centraldogma.server.internal.api.converter.WatchRequestConverter.WatchRequest; -import com.linecorp.centraldogma.server.internal.api.variable.Templater; +import com.linecorp.centraldogma.server.internal.api.template.Templater; +import com.linecorp.centraldogma.server.metadata.User; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.project.ProjectManager; import com.linecorp.centraldogma.server.storage.repository.EntryTransformer; @@ -129,22 +130,23 @@ public ContentServiceV1(CommandExecutor executor, ProjectManager pm, WatchServic public CompletableFuture>> listFiles(ServiceRequestContext ctx, @Param String path, @Param @Default("-1") String revision, - Repository repository) { + Repository repository, User user) { final String normalizedPath = normalizePath(path); final Revision normalizedRev = repository.normalizeNow(new Revision(revision)); increaseCounterIfOldRevisionUsed(ctx, repository, normalizedRev); final CompletableFuture>> future = new CompletableFuture<>(); - findFiles(repository, normalizedPath, normalizedRev, false, false, TemplateParams.disabled(), future); + findFiles(repository, normalizedPath, normalizedRev, false, false, TemplateParams.disabled(), future, + user); return future; } private void findFiles(Repository repository, String pathPattern, Revision normalizedRev, boolean withContent, boolean viewRaw, TemplateParams templateParams, - CompletableFuture>> result) { + CompletableFuture>> result, User user) { final Map, ?> options = withContent ? FindOptions.FIND_ALL_WITH_CONTENT : FindOptions.FIND_ALL_WITHOUT_CONTENT; - final EntryTransformer transformer = newTemplater(repository, templateParams); + final EntryTransformer transformer = newTemplater(repository, templateParams, user); repository.find(normalizedRev, pathPattern, options, transformer).handle((entries, thrown) -> { if (thrown != null) { result.completeExceptionally(thrown); @@ -157,7 +159,7 @@ private void findFiles(Repository repository, String pathPattern, Revision norma if (isValidFilePath(pathPattern) && entries.size() == 1 && entries.values().iterator().next().type() == DIRECTORY) { findFiles(repository, pathPattern + "/*", normalizedRev, withContent, viewRaw, - templateParams, result); + templateParams, result, user); } else { result.complete(entries.values().stream() .map(entry -> newEntryDto(repository, normalizedRev, entry, withContent, @@ -258,7 +260,7 @@ public CompletableFuture>> preview( * jsonpath={jsonpath} * *

Returns the entry of files in the path. This is same with - * {@link #listFiles(ServiceRequestContext, String, String, Repository)} except that containing + * {@link #listFiles(ServiceRequestContext, String, String, Repository, User)} except that containing * the content of the files. * Note that if the {@link HttpHeaderNames#IF_NONE_MATCH} in which has a revision is sent with, * this will await for the time specified in {@link HttpHeaderNames#PREFER}. @@ -274,7 +276,7 @@ public CompletableFuture getFiles( @RequestConverter(TemplateParamsConverter.class) TemplateParams templateParams, Repository repository, @RequestConverter(WatchRequestConverter.class) @Nullable WatchRequest watchRequest, - @RequestConverter(QueryRequestConverter.class) @Nullable Query query) { + @RequestConverter(QueryRequestConverter.class) @Nullable Query query, User user) { increaseCounterIfOldRevisionUsed(ctx, repository, new Revision(revision)); final String normalizedPath = normalizePath(path); @@ -286,7 +288,7 @@ public CompletableFuture getFiles( final boolean errorOnEntryNotFound = watchRequest.notifyEntryNotFound(); if (query != null) { return watchFile(ctx, repository, lastKnownRevision, query, timeOutMillis, - errorOnEntryNotFound, viewRaw, templateParams); + errorOnEntryNotFound, viewRaw, templateParams, user); } return watchRepository(ctx, repository, lastKnownRevision, normalizedPath, @@ -296,24 +298,24 @@ public CompletableFuture getFiles( final Revision normalizedRev = repository.normalizeNow(new Revision(revision)); if (query != null) { // get a file - return repository.get(normalizedRev, query, newTemplater(repository, templateParams)) + return repository.get(normalizedRev, query, newTemplater(repository, templateParams, user)) .thenApply(result -> newEntryDto(repository, normalizedRev, result, true, viewRaw)); } // get files final CompletableFuture>> future = new CompletableFuture<>(); - findFiles(repository, normalizedPath, normalizedRev, true, viewRaw, templateParams, future); + findFiles(repository, normalizedPath, normalizedRev, true, viewRaw, templateParams, future, user); return future; } private CompletableFuture watchFile(ServiceRequestContext ctx, Repository repository, Revision lastKnownRevision, Query query, long timeOutMillis, boolean errorOnEntryNotFound, - boolean viewRaw, TemplateParams templateParams) { + boolean viewRaw, TemplateParams templateParams, User user) { final CompletableFuture> future = watchService.watchFile( repository, lastKnownRevision, query, timeOutMillis, errorOnEntryNotFound, templateParams, - newTempRev -> newTemplater(repository, templateParams.withTemplateRevision(newTempRev))); + newTempRev -> newTemplater(repository, templateParams.withTemplateRevision(newTempRev), user)); if (!future.isDone()) { ctx.log().whenComplete().thenRun(() -> future.cancel(false)); @@ -326,12 +328,14 @@ private CompletableFuture watchFile(ServiceRequestContext ctx, }).exceptionally(ContentServiceV1::handleWatchFailure); } - private EntryTransformer newTemplater(Repository repository, TemplateParams templateParams) { + private EntryTransformer newTemplater(Repository repository, TemplateParams templateParams, + User user) { if (!templateParams.renderTemplate()) { return EntryTransformer.identity(); } else { return entry -> templater.render(repository, entry, - templateParams.variableFile(), templateParams.templateRevision()); + templateParams.variableFile(), templateParams.templateRevision(), + user); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Secret.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Secret.java new file mode 100644 index 000000000..1e2584e4e --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Secret.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.api.template; + +import static java.util.Objects.requireNonNull; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.centraldogma.server.metadata.UserAndTimestamp; + +@JsonInclude(Include.NON_NULL) +public final class Secret { + + private final String id; + private final String value; + @Nullable + private final String description; + + @Nullable + private String name; + @Nullable + private UserAndTimestamp creation; + + public Secret(String id, String value, @Nullable String description) { + this.id = requireNonNull(id, "id"); + this.value = requireNonNull(value, "value"); + this.description = description; + } + + @JsonCreator + public Secret(@JsonProperty("id") String id, + @JsonProperty("name") @Nullable String name, + @JsonProperty("value") String value, + @JsonProperty("creation") @Nullable UserAndTimestamp creation, + @JsonProperty("description") @Nullable String description) { + this.id = requireNonNull(id, "id"); + this.name = name; + this.value = requireNonNull(value, "value"); + this.description = description; + this.creation = creation; + } + + @JsonProperty("id") + public String id() { + return id; + } + + @Nullable + @JsonProperty("name") + public String name() { + return name; + } + + void setName(String name) { + this.name = name; + } + + @JsonProperty("value") + public String value() { + return value; + } + + @Nullable + @JsonProperty("description") + public String description() { + return description; + } + + @Nullable + @JsonProperty("creation") + public UserAndTimestamp creation() { + return creation; + } + + void setCreation(UserAndTimestamp creation) { + this.creation = creation; + } + + public Secret withoutValue() { + return new Secret(id, name, "****", creation, description); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Secret)) { + return false; + } + + final Secret secret = (Secret) o; + return id.equals(secret.id) && + Objects.equals(name, secret.name) && + value.equals(secret.value) && + Objects.equals(description, secret.description) && + Objects.equals(creation, secret.creation); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, value, description, creation); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("id", id) + .add("name", name) + .add("value", "****") + .add("description", description) + .add("creation", creation) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/SecretServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/SecretServiceV1.java new file mode 100644 index 000000000..b9d1a0608 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/SecretServiceV1.java @@ -0,0 +1,305 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.api.template; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.jspecify.annotations.Nullable; + +import com.linecorp.armeria.server.annotation.ConsumesJson; +import com.linecorp.armeria.server.annotation.Delete; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.Param; +import com.linecorp.armeria.server.annotation.Post; +import com.linecorp.armeria.server.annotation.ProducesJson; +import com.linecorp.armeria.server.annotation.Put; +import com.linecorp.armeria.server.annotation.StatusCode; +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.EntryNotFoundException; +import com.linecorp.centraldogma.common.ProjectRole; +import com.linecorp.centraldogma.common.RepositoryRole; +import com.linecorp.centraldogma.common.Revision; +import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.internal.api.AbstractService; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRole; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRole; +import com.linecorp.centraldogma.server.internal.storage.repository.git.CrudContext; +import com.linecorp.centraldogma.server.internal.storage.repository.git.CrudOperation; +import com.linecorp.centraldogma.server.internal.storage.repository.git.DefaultCrudOperation; +import com.linecorp.centraldogma.server.metadata.User; +import com.linecorp.centraldogma.server.metadata.UserAndTimestamp; +import com.linecorp.centraldogma.server.storage.project.Project; +import com.linecorp.centraldogma.server.storage.project.ProjectManager; +import com.linecorp.centraldogma.server.storage.repository.HasRevision; + +/** + * A service that provides CRUD operations for secrets at both project and repository levels. + * + *

Secret values are masked by the API for security reasons: + *

    + *
  • List APIs always mask secret values as {@code "****"}.
  • + *
  • GET, create, and update APIs mask secret values for non-system-admin users.
  • + *
+ */ +@ProducesJson +public final class SecretServiceV1 extends AbstractService { + + private static final String SECRETS = "/secrets/"; + + private final CrudOperation repository; + + public SecretServiceV1(ProjectManager pm, CommandExecutor executor) { + super(executor); + repository = new DefaultCrudOperation<>(Secret.class, executor, pm); + } + + /** + * GET /projects/{projectName}/secrets + * + *

Returns the list of the secrets defined in the specified project. + * Secret values are always masked as {@code "****"}. + */ + @RequiresProjectRole(ProjectRole.MEMBER) + @Get("/projects/{projectName}/secrets") + public CompletableFuture> list(@Param String projectName) { + return repository.findAll(secretCrudContext(projectName)).thenApply(secrets -> { + return secrets.stream() + .map(HasRevision::object) + // List API should not return the value of the secrets for security reasons. The value + // can be retrieved via the GET API for a specific secret. + .map(Secret::withoutValue) + .collect(toImmutableList()); + }); + } + + /** + * GET /projects/{projectName}/secrets/{id} + * + *

Returns the secret for the ID in the project. + * The secret value is masked as {@code "****"} for non-system-admin users. + */ + @RequiresProjectRole(ProjectRole.MEMBER) + @Get("/projects/{projectName}/secrets/{id}") + public CompletableFuture getSecret(@Param String projectName, @Param String id, User user) { + return repository.find(secretCrudContext(projectName), id).thenApply(secret -> { + if (secret == null) { + throw new EntryNotFoundException( + "Secret not found: " + id + " (project: " + projectName + ')'); + } + return maybeMaskValue(secret.object(), user); + }); + } + + /** + * POST /projects/{projectName}/secrets + * + *

Creates a new secret. + * The secret value in the response is masked as {@code "****"} for non-system-admin users. + */ + @ConsumesJson + @StatusCode(201) + @Post("/projects/{projectName}/secrets") + @RequiresProjectRole(ProjectRole.OWNER) + public CompletableFuture> createSecret(@Param String projectName, + Secret newSecret, + Author author, User user) { + setNameAndCreation(newSecret, projectName, null, author); + return repository.save(secretCrudContext(projectName), newSecret.id(), newSecret, author) + .thenApply(secret -> maybeMaskValue(secret, user)); + } + + /** + * PUT /projects/{projectName}/secrets/{id} + * + *

Update the existing secret. + * The secret value in the response is masked as {@code "****"} for non-system-admin users. + */ + @ConsumesJson + @Put("/projects/{projectName}/secrets/{id}") + @RequiresProjectRole(ProjectRole.OWNER) + public CompletableFuture> updateSecret(@Param String projectName, @Param String id, + Secret secret, Author author, User user) { + checkArgument(id.equals(secret.id()), + "ID in the path must be the same as that in the secret object."); + setNameAndCreation(secret, projectName, null, author); + return repository.update(secretCrudContext(projectName), id, secret, author) + .thenApply(updated -> maybeMaskValue(updated, user)); + } + + /** + * DELETE /projects/{projectName}/secrets/{id} + * + *

Delete the existing secret. + */ + @Delete("/projects/{projectName}/secrets/{id}") + @RequiresProjectRole(ProjectRole.OWNER) + public CompletableFuture deleteSecret(@Param String projectName, + @Param String id, Author author) { + return repository.delete(secretCrudContext(projectName), id, author).thenAccept(unused -> {}); + } + + /** + * GET /projects/{projectName}/repos/{repoName}/secrets + * + *

Returns the list of the secrets in the repository. + * Secret values are always masked as {@code "****"}. + */ + @RequiresRepositoryRole(RepositoryRole.READ) + @Get("/projects/{projectName}/repos/{repoName}/secrets") + public CompletableFuture> listRepoSecrets(@Param String projectName, + @Param String repoName) { + return repository.findAll(secretCrudContext(projectName, repoName)).thenApply(secrets -> { + return secrets.stream() + .map(HasRevision::object) + // List API should not return the value of the secrets for security reasons. The value + // can be retrieved via the GET API for a specific secret. + .map(Secret::withoutValue) + .collect(toImmutableList()); + }); + } + + /** + * GET /projects/{projectName}/repos/{repoName}/secrets/{id} + * + *

Returns the secret for the ID in the repository. + * The secret value is masked as {@code "****"} for non-system-admin users. + */ + @RequiresRepositoryRole(RepositoryRole.READ) + @Get("/projects/{projectName}/repos/{repoName}/secrets/{id}") + public CompletableFuture getRepoSecret(@Param String projectName, + @Param String repoName, + @Param String id, + User user) { + return repository.find(secretCrudContext(projectName, repoName), id).thenApply(secret -> { + if (secret == null) { + throw new EntryNotFoundException( + "Secret not found: " + id + " (project: " + projectName + + ", repository: " + repoName + ')'); + } + return maybeMaskValue(secret.object(), user); + }); + } + + /** + * POST /projects/{projectName}/repos/{repoName}/secrets + * + *

Creates a new secret in the repository. + * The secret value in the response is masked as {@code "****"} for non-system-admin users. + */ + @ConsumesJson + @StatusCode(201) + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Post("/projects/{projectName}/repos/{repoName}/secrets") + public CompletableFuture> createRepoSecret( + @Param String projectName, @Param String repoName, + Secret newSecret, Author author, User user) { + setNameAndCreation(newSecret, projectName, repoName, author); + return repository.save(secretCrudContext(projectName, repoName), newSecret.id(), newSecret, author) + .thenApply(secret -> maybeMaskValue(secret, user)); + } + + /** + * PUT /projects/{projectName}/repos/{repoName}/secrets/{id} + * + *

Update the existing secret in the repository. + * The secret value in the response is masked as {@code "****"} for non-system-admin users. + */ + @ConsumesJson + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Put("/projects/{projectName}/repos/{repoName}/secrets/{id}") + public CompletableFuture> updateRepoSecret(@Param String projectName, + @Param String repoName, + @Param String id, + Secret secret, Author author, User user) { + checkArgument(id.equals(secret.id()), + "ID in the path must be the same as that in the secret object."); + setNameAndCreation(secret, projectName, repoName, author); + return repository.update(secretCrudContext(projectName, repoName), id, secret, author) + .thenApply(updated -> maybeMaskValue(updated, user)); + } + + /** + * DELETE /projects/{projectName}/repos/{repoName}/secrets/{id} + * + *

Delete the existing secret. + */ + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Delete("/projects/{projectName}/repos/{repoName}/secrets/{id}") + public CompletableFuture deleteRepoSecret(@Param String projectName, + @Param String repoName, + @Param String id, Author author) { + return repository.delete(secretCrudContext(projectName, repoName), id, author) + .thenAccept(unused -> {}); + } + + private static Secret setNameAndCreation(Secret secret, String projectName, @Nullable String repoName, + Author author) { + String name = "projects/" + projectName; + if (repoName != null) { + name += "/repos/" + repoName; + } + name += "/secrets/" + secret.id(); + secret.setName(name); + secret.setCreation(UserAndTimestamp.of(author)); + return secret; + } + + private static HasRevision maybeMaskValue(HasRevision secret, User user) { + if (user.isSystemAdmin()) { + return secret; + } else { + // Hide the value of the secret for non-admin users. + return HasRevision.of(secret.object().withoutValue(), secret.revision()); + } + } + + private static Secret maybeMaskValue(Secret secret, User user) { + if (user.isSystemAdmin()) { + return secret; + } else { + // Hide the value of the secret for non-admin users. + return secret.withoutValue(); + } + } + + static CrudContext secretCrudContext(String projectName) { + return secretCrudContext(projectName, null, Revision.HEAD); + } + + static CrudContext secretCrudContext(String projectName, Revision revision) { + return secretCrudContext(projectName, null, revision); + } + + private static CrudContext secretCrudContext(String projectName, @Nullable String repoName) { + return secretCrudContext(projectName, repoName, Revision.HEAD); + } + + static CrudContext secretCrudContext(String projectName, @Nullable String repoName, + Revision revision) { + final String targetPath; + if (repoName == null) { + targetPath = SECRETS; + } else { + targetPath = "/repos/" + repoName + SECRETS; + } + return new CrudContext(projectName, Project.REPO_DOGMA, targetPath, revision); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Templater.java similarity index 76% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Templater.java index 4ce12c7e5..fc89b66de 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Templater.java @@ -14,9 +14,10 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api.variable; +package com.linecorp.centraldogma.server.internal.api.template; -import static com.linecorp.centraldogma.server.internal.api.variable.VariableServiceV1.crudContext; +import static com.linecorp.centraldogma.server.internal.api.template.SecretServiceV1.secretCrudContext; +import static com.linecorp.centraldogma.server.internal.api.template.VariableServiceV1.variableCrudContext; import java.io.StringWriter; import java.time.Duration; @@ -49,6 +50,8 @@ import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.internal.storage.repository.git.CrudOperation; import com.linecorp.centraldogma.server.internal.storage.repository.git.DefaultCrudOperation; +import com.linecorp.centraldogma.server.metadata.User; +import com.linecorp.centraldogma.server.metadata.UserWithAppIdentity; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.project.ProjectManager; import com.linecorp.centraldogma.server.storage.repository.HasRevision; @@ -68,11 +71,13 @@ public final class Templater { private static final UnmodifiableFuture> EMPTY_MAP_FUTURE = UnmodifiableFuture.completedFuture(ImmutableMap.of()); - private final CrudOperation crudRepo; + private final CrudOperation variableCrudRepo; + private final CrudOperation secretCrudRepo; private final LoadingCache, Template> cache; public Templater(CommandExecutor executor, ProjectManager pm) { - crudRepo = new DefaultCrudOperation<>(Variable.class, executor, pm); + variableCrudRepo = new DefaultCrudOperation<>(Variable.class, executor, pm); + secretCrudRepo = new DefaultCrudOperation<>(Secret.class, executor, pm); final Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding("UTF-8"); cfg.setLogTemplateExceptions(false); @@ -101,7 +106,7 @@ public Templater(CommandExecutor executor, ProjectManager pm) { public CompletableFuture> render(Repository repo, Entry entry, @Nullable String variableFile, - @Nullable Revision templateRevision) { + @Nullable Revision templateRevision, User user) { if (!entry.hasContent()) { return UnmodifiableFuture.completedFuture(entry); } @@ -116,13 +121,23 @@ public CompletableFuture> render(Repository repo, Entry entry, final String projectName = project.name(); // TODO(ikhoon): Optimize by caching the rendering result for the same set of variables and template. - return mergeVariables(crudRepo.findAll(crudContext(projectName, normTemplateRevision)), - crudRepo.findAll(crudContext(projectName, repo.name(), normTemplateRevision)), - findRepoVariableFile(repo, entry), - findEntryPathVariableFile(repo, entry), - findClientVariableFile(repo, entry, variableFile)) - .thenApply(variables -> process(entry, variables, normTemplateRevision)) - .toCompletableFuture(); + final CompletionStage> variablesFuture = + mergeVariables(variableCrudRepo.findAll( + variableCrudContext(projectName, normTemplateRevision)), + variableCrudRepo.findAll( + variableCrudContext(projectName, repo.name(), normTemplateRevision)), + findRepoVariableFile(repo, entry), + findEntryPathVariableFile(repo, entry), + findClientVariableFile(repo, entry, variableFile)); + + final CompletionStage> secretsFuture = + mergeSecrets(secretCrudRepo.findAll(secretCrudContext(projectName, normTemplateRevision)), + secretCrudRepo.findAll( + secretCrudContext(projectName, repo.name(), normTemplateRevision)), + user); + return CompletableFutures.combine(variablesFuture, secretsFuture, (variables, secrets) -> { + return process(entry, variables, secrets, normTemplateRevision); + }).toCompletableFuture(); } private static CompletableFuture> findRepoVariableFile(Repository repo, @@ -204,11 +219,17 @@ private static Map parseVariableFile(Entry entry) { } } - private Entry process(Entry entry, Map variables, Revision templateRevision) { + private Entry process(Entry entry, Map variables, Map secrets, + Revision templateRevision) { final StringWriter out = new StringWriter(); final Template template = cache.get(entry); + final ImmutableMap dataModel = + ImmutableMap.builderWithExpectedSize(variables.size() + secrets.size()) + .putAll(variables) + .putAll(secrets) + .build(); try { - template.process(variables, out); + template.process(dataModel, out); //noinspection unchecked final Entry newEntry = (Entry) newEntryWithContent(entry, out.toString()); return newEntry.withTemplateRevision(templateRevision); @@ -268,13 +289,48 @@ private static CompletionStage> mergeVariables( final Map variables = builder.buildKeepingLast(); // Prefix variables map with "vars" key. // This allows using "vars.varName" in the template. - // TODO(ikhoon): Support secret variables that will be prefixed with "secrets" key. final Map vars = new HashMap<>(); vars.put("vars", variables); return vars; }); } + private static CompletionStage> mergeSecrets( + CompletableFuture>> projFuture, + CompletableFuture>> repoFuture, User user) { + return CompletableFutures.combine(projFuture, repoFuture, (projVars, repoVars) -> { + final ImmutableMap.Builder builder = + ImmutableMap.builderWithExpectedSize(projVars.size() + repoVars.size()); + + for (HasRevision it : projVars) { + final Secret secret = it.object(); + builder.put(secret.id(), maybeMaskSecret(secret, user)); + } + for (HasRevision it : repoVars) { + final Secret secret = it.object(); + builder.put(secret.id(), maybeMaskSecret(secret, user)); + } + + final Map secretsMap = builder.buildKeepingLast(); + // Prefix variables map with "secrets" key. + // This allows using "secrets.secretName" in the template. + final Map secrets = new HashMap<>(); + secrets.put("secrets", secretsMap); + return secrets; + }); + } + + private static String maybeMaskSecret(Secret secret, User user) { + if (user instanceof UserWithAppIdentity || user.isSystemAdmin()) { + // Render the secret value if the user is authenticated with an app identity or is a system admin. + return secret.value(); + } else { + // Mask the secret value if a user is accessing from web UI. + // The secret value will be shown as "****" in the template preview. + return "****"; + } + } + private static Object parseValue(Variable variable) { switch (variable.type()) { case JSON: diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Variable.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Variable.java similarity index 98% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Variable.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Variable.java index ffc2ddf95..802b1a793 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Variable.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/Variable.java @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api.variable; +package com.linecorp.centraldogma.server.internal.api.template; import static java.util.Objects.requireNonNull; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/VariableServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/VariableServiceV1.java similarity index 86% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/VariableServiceV1.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/VariableServiceV1.java index 320fa0361..fe4f26505 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/VariableServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/VariableServiceV1.java @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api.variable; +package com.linecorp.centraldogma.server.internal.api.template; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.ImmutableList.toImmutableList; @@ -72,7 +72,7 @@ public VariableServiceV1(ProjectManager pm, CommandExecutor executor) { @RequiresProjectRole(ProjectRole.MEMBER) @Get("/projects/{projectName}/variables") public CompletableFuture> list(@Param String projectName) { - return repository.findAll(crudContext(projectName)) + return repository.findAll(variableCrudContext(projectName)) .thenApply(variables -> variables.stream() .map(HasRevision::object) .collect(toImmutableList())); @@ -86,7 +86,7 @@ public CompletableFuture> list(@Param String projectName) { @RequiresProjectRole(ProjectRole.MEMBER) @Get("/projects/{projectName}/variables/{id}") public CompletableFuture getVariable(@Param String projectName, @Param String id) { - return repository.find(crudContext(projectName), id).thenApply(variable -> { + return repository.find(variableCrudContext(projectName), id).thenApply(variable -> { if (variable == null) { throw new EntryNotFoundException( "Variable not found: " + id + " (project: " + projectName + ')'); @@ -109,7 +109,7 @@ public CompletableFuture> createVariable(@Param String pro Author author) { validateVariable(newVariable); setNameAndCreation(newVariable, projectName, null, author); - return repository.save(crudContext(projectName), newVariable.id(), newVariable, author); + return repository.save(variableCrudContext(projectName), newVariable.id(), newVariable, author); } /** @@ -126,7 +126,7 @@ public CompletableFuture> updateVariable(@Param String pro "ID in the path must be the same as that in the variable object."); validateVariable(variable); setNameAndCreation(variable, projectName, null, author); - return repository.update(crudContext(projectName), id, variable, author); + return repository.update(variableCrudContext(projectName), id, variable, author); } /** @@ -138,7 +138,7 @@ public CompletableFuture> updateVariable(@Param String pro @RequiresProjectRole(ProjectRole.MEMBER) public CompletableFuture deleteVariable(@Param String projectName, @Param String id, Author author) { - return repository.delete(crudContext(projectName), id, author).thenAccept(unused -> {}); + return repository.delete(variableCrudContext(projectName), id, author).thenAccept(unused -> {}); } /** @@ -150,7 +150,7 @@ public CompletableFuture deleteVariable(@Param String projectName, @Get("/projects/{projectName}/repos/{repoName}/variables") public CompletableFuture> listRepoVariables(@Param String projectName, @Param String repoName) { - return repository.findAll(crudContext(projectName, repoName)) + return repository.findAll(variableCrudContext(projectName, repoName)) .thenApply(variables -> variables.stream() .map(HasRevision::object) .collect(toImmutableList())); @@ -166,7 +166,7 @@ public CompletableFuture> listRepoVariables(@Param String project public CompletableFuture getRepoVariable(@Param String projectName, @Param String repoName, @Param String id) { - return repository.find(crudContext(projectName, repoName), id).thenApply(variable -> { + return repository.find(variableCrudContext(projectName, repoName), id).thenApply(variable -> { if (variable == null) { throw new EntryNotFoundException( "Variable not found: " + id + " (project: " + projectName + @@ -192,7 +192,8 @@ public CompletableFuture> createRepoVariable( Author author) { validateVariable(newVariable); setNameAndCreation(newVariable, projectName, repoName, author); - return repository.save(crudContext(projectName, repoName), newVariable.id(), newVariable, author); + return repository.save(variableCrudContext(projectName, repoName), newVariable.id(), newVariable, + author); } /** @@ -211,7 +212,7 @@ public CompletableFuture> updateRepoVariable(@Param String "ID in the path must be the same as that in the variable object."); validateVariable(variable); setNameAndCreation(variable, projectName, repoName, author); - return repository.update(crudContext(projectName, repoName), id, variable, author); + return repository.update(variableCrudContext(projectName, repoName), id, variable, author); } /** @@ -224,7 +225,7 @@ public CompletableFuture> updateRepoVariable(@Param String public CompletableFuture deleteRepoVariable(@Param String projectName, @Param String repoName, @Param String id, Author author) { - return repository.delete(crudContext(projectName, repoName), id, author) + return repository.delete(variableCrudContext(projectName, repoName), id, author) .thenAccept(unused -> {}); } @@ -250,19 +251,20 @@ private static void validateVariable(Variable variable) { } } - public static CrudContext crudContext(String projectName) { - return crudContext(projectName, null, Revision.HEAD); + private static CrudContext variableCrudContext(String projectName) { + return variableCrudContext(projectName, null, Revision.HEAD); } - public static CrudContext crudContext(String projectName, Revision revision) { - return crudContext(projectName, null, revision); + static CrudContext variableCrudContext(String projectName, Revision revision) { + return variableCrudContext(projectName, null, revision); } - public static CrudContext crudContext(String projectName, @Nullable String repoName) { - return crudContext(projectName, repoName, Revision.HEAD); + private static CrudContext variableCrudContext(String projectName, @Nullable String repoName) { + return variableCrudContext(projectName, repoName, Revision.HEAD); } - public static CrudContext crudContext(String projectName, @Nullable String repoName, Revision revision) { + static CrudContext variableCrudContext(String projectName, @Nullable String repoName, + Revision revision) { final String targetPath; if (repoName == null) { targetPath = VARIABLES; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/VariableType.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/VariableType.java similarity index 91% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/VariableType.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/VariableType.java index 4fd368969..1aeed2187 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/VariableType.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/VariableType.java @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api.variable; +package com.linecorp.centraldogma.server.internal.api.template; public enum VariableType { STRING, diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/package-info.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/package-info.java similarity index 91% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/package-info.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/package-info.java index 99ce74ee0..e2cf6fbe3 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/package-info.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/template/package-info.java @@ -18,6 +18,6 @@ * An API for managing variables. */ @NullMarked -package com.linecorp.centraldogma.server.internal.api.variable; +package com.linecorp.centraldogma.server.internal.api.template; import org.jspecify.annotations.NullMarked; diff --git a/server/src/test/java/com/linecorp/centraldogma/server/SecretTemplateCrudTest.java b/server/src/test/java/com/linecorp/centraldogma/server/SecretTemplateCrudTest.java new file mode 100644 index 000000000..d532e1b6e --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/SecretTemplateCrudTest.java @@ -0,0 +1,444 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server; + +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD2; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME2; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getSessionCookie; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.login; +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.client.CentralDogmaRepository; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.common.Entry; +import com.linecorp.centraldogma.common.ProjectRole; +import com.linecorp.centraldogma.common.Query; +import com.linecorp.centraldogma.common.TemplateProcessingException; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.server.internal.admin.auth.SessionUtil; +import com.linecorp.centraldogma.server.internal.api.MetadataApiService.IdAndProjectRole; +import com.linecorp.centraldogma.server.internal.api.template.Secret; +import com.linecorp.centraldogma.server.internal.api.template.Variable; +import com.linecorp.centraldogma.server.internal.api.template.VariableType; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +class SecretTemplateCrudTest { + + private static final String TEST_PROJECT = "testProject"; + private static final String TEST_REPO_1 = "testRepo1"; + private static final String TEST_REPO_2 = "testRepo2"; + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.authProviderFactory(new TestAuthProviderFactory()); + builder.systemAdministrators(USERNAME); + } + + @Override + protected String accessToken() { + return getAccessToken(WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD, true); + } + }; + + private CentralDogmaRepository testRepo1; + private CentralDogmaRepository testRepo2; + private BlockingWebClient httpClient; + + @BeforeEach + void beforeEach() { + final CentralDogma client = dogma.client(); + try { + client.removeProject(TEST_PROJECT).join(); + client.purgeProject(TEST_PROJECT).join(); + } catch (Exception e) { + // Ignore + } + + client.createProject(TEST_PROJECT).join(); + testRepo1 = client.createRepository(TEST_PROJECT, TEST_REPO_1).join(); + testRepo2 = client.createRepository(TEST_PROJECT, TEST_REPO_2).join(); + httpClient = dogma.blockingHttpClient(); + + // Add non-admin user as a project member so they can access the repos + final String memberId = USERNAME2 + "@localhost.localdomain"; + final HttpRequest addMemberRequest = + HttpRequest.builder() + .post("/api/v1/metadata/" + TEST_PROJECT + "/members") + .contentJson(new IdAndProjectRole(memberId, ProjectRole.MEMBER)) + .build(); + assertThat(httpClient.execute(addMemberRequest).status()).isEqualTo(HttpStatus.OK); + } + + @Test + void renderTemplateWithProjectLevelSecret() { + createSecret(TEST_PROJECT, null, "apiKey", "my-api-key-123"); + + final String templateContent = "{ \"key\": \"${secrets.apiKey}\" }"; + final Change change = Change.ofJsonUpsert("/config.json", templateContent); + testRepo1.commit("Add config template", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofJson("/config.json")) + .renderTemplate(true) + .get() + .join(); + + assertThatJson(entry.content()).isEqualTo("{ \"key\": \"my-api-key-123\" }"); + } + + @Test + void renderTemplateWithRepoLevelSecret() { + createSecret(TEST_PROJECT, TEST_REPO_1, "dbPassword", "repo-db-pass"); + + final String templateContent = "{ \"password\": \"${secrets.dbPassword}\" }"; + final Change change = Change.ofJsonUpsert("/db.json", templateContent); + testRepo1.commit("Add db config template", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofJson("/db.json")) + .renderTemplate(true) + .get() + .join(); + + assertThatJson(entry.content()).isEqualTo("{ \"password\": \"repo-db-pass\" }"); + } + + @Test + void repoLevelSecretOverridesProjectLevel() { + createSecret(TEST_PROJECT, null, "token", "project-token"); + createSecret(TEST_PROJECT, TEST_REPO_1, "token", "repo-token"); + + final String templateContent = "{ \"token\": \"${secrets.token}\" }"; + final Change change = Change.ofJsonUpsert("/auth.json", templateContent); + testRepo1.commit("Add auth template", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofJson("/auth.json")) + .renderTemplate(true) + .get() + .join(); + + // Repo-level secret should override project-level + assertThatJson(entry.content()).isEqualTo("{ \"token\": \"repo-token\" }"); + } + + @Test + void repoLevelSecretIsScopedToRepo() { + createSecret(TEST_PROJECT, TEST_REPO_1, "repoSecret", "repo1-value"); + + final String templateContent = "{ \"secret\": \"${secrets.repoSecret}\" }"; + // Push the same template to both repos + final Change change1 = Change.ofJsonUpsert("/config.json", templateContent); + testRepo1.commit("Add template", change1).push().join(); + final Change change2 = Change.ofJsonUpsert("/config.json", templateContent); + testRepo2.commit("Add template", change2).push().join(); + + // testRepo1 should render successfully + final Entry entry1 = testRepo1.file(Query.ofJson("/config.json")) + .renderTemplate(true) + .get() + .join(); + assertThatJson(entry1.content()).isEqualTo("{ \"secret\": \"repo1-value\" }"); + + // testRepo2 should fail because the secret is not defined there + assertThatThrownBy(() -> testRepo2.file(Query.ofJson("/config.json")) + .renderTemplate(true) + .get() + .join()) + .hasCauseInstanceOf(TemplateProcessingException.class); + } + + @Test + void renderTemplateWithMultipleSecrets() { + createSecret(TEST_PROJECT, null, "host", "db.example.com"); + createSecret(TEST_PROJECT, null, "port", "5432"); + createSecret(TEST_PROJECT, TEST_REPO_1, "password", "s3cret"); + + final String templateContent = + "{ \"url\": \"jdbc:postgresql://${secrets.host}:${secrets.port}\", " + + "\"password\": \"${secrets.password}\" }"; + final Change change = Change.ofJsonUpsert("/db-config.json", templateContent); + testRepo1.commit("Add db config", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofJson("/db-config.json")) + .renderTemplate(true) + .get() + .join(); + + assertThatJson(entry.content()) + .node("url").isEqualTo("jdbc:postgresql://db.example.com:5432"); + assertThatJson(entry.content()) + .node("password").isEqualTo("s3cret"); + } + + @Test + void renderTemplateWithSecretsAndVariables() { + createSecret(TEST_PROJECT, null, "apiKey", "secret-key-456"); + // Create a variable via the variable API + createVariable(TEST_PROJECT, null, "env", "production"); + + final String templateContent = + "{ \"environment\": \"${vars.env}\", \"apiKey\": \"${secrets.apiKey}\" }"; + final Change change = Change.ofJsonUpsert("/app.json", templateContent); + testRepo1.commit("Add app config", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofJson("/app.json")) + .renderTemplate(true) + .get() + .join(); + + assertThatJson(entry.content()).node("environment").isEqualTo("production"); + assertThatJson(entry.content()).node("apiKey").isEqualTo("secret-key-456"); + } + + @Test + void renderTemplateWithSecretInTextFile() { + createSecret(TEST_PROJECT, null, "token", "bearer-abc-123"); + + final String templateContent = "Authorization: ${secrets.token}"; + final Change change = Change.ofTextUpsert("/auth.txt", templateContent); + testRepo1.commit("Add auth template", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofText("/auth.txt")) + .renderTemplate(true) + .get() + .join(); + + assertThat(entry.content()).isEqualTo("Authorization: bearer-abc-123\n"); + } + + @Test + void updateSecretAndRenderTemplate() { + createSecret(TEST_PROJECT, null, "password", "old-password"); + + final String templateContent = "{ \"password\": \"${secrets.password}\" }"; + final Change change = Change.ofJsonUpsert("/creds.json", templateContent); + testRepo1.commit("Add creds template", change).push().join(); + + Entry entry = testRepo1.file(Query.ofJson("/creds.json")) + .renderTemplate(true) + .get() + .join(); + assertThatJson(entry.content()).isEqualTo("{ \"password\": \"old-password\" }"); + + // Update the secret + updateSecret(TEST_PROJECT, null, "password", "new-password"); + + entry = testRepo1.file(Query.ofJson("/creds.json")) + .renderTemplate(true) + .get() + .join(); + assertThatJson(entry.content()).isEqualTo("{ \"password\": \"new-password\" }"); + } + + @Test + void deleteSecretAndRenderTemplateShouldFail() { + createSecret(TEST_PROJECT, null, "tempSecret", "temp-value"); + + final String templateContent = "Secret: ${secrets.tempSecret}"; + final Change change = Change.ofTextUpsert("/temp.txt", templateContent); + testRepo1.commit("Add temp template", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofText("/temp.txt")) + .renderTemplate(true) + .get() + .join(); + assertThat(entry.content()).isEqualTo("Secret: temp-value\n"); + + // Delete the secret + deleteSecret(TEST_PROJECT, null, "tempSecret"); + + // Rendering should now fail + assertThatThrownBy(() -> testRepo1.file(Query.ofText("/temp.txt")) + .renderTemplate(true) + .get() + .join()) + .hasCauseInstanceOf(TemplateProcessingException.class) + .hasMessageContaining("Failed to process the template for /temp.txt.") + .hasMessageContaining("The following has evaluated to null or missing:\n" + + "==> secrets.tempSecret"); + } + + @Test + void renderTemplateWithoutSecretsShouldWork() { + final String templateContent = "Static content without secrets"; + final Change change = Change.ofTextUpsert("/static.txt", templateContent); + testRepo1.commit("Add static file", change).push().join(); + + final Entry entry = testRepo1.file(Query.ofText("/static.txt")) + .renderTemplate(true) + .get() + .join(); + + assertThat(entry.content()).isEqualTo("Static content without secrets\n"); + } + + @Test + void renderTemplateWithUndefinedSecretShouldFail() { + final String templateContent = "Value: ${secrets.nonExistentSecret}"; + final Change change = Change.ofTextUpsert("/fail.txt", templateContent); + testRepo1.commit("Add failing template", change).push().join(); + + assertThatThrownBy(() -> testRepo1.file(Query.ofText("/fail.txt")) + .renderTemplate(true) + .get() + .join()) + .hasCauseInstanceOf(TemplateProcessingException.class); + } + + @Test + void secretsMaskedWhenAccessedViaSessionCookie() throws JsonProcessingException { + // Create secrets at project and repo level + createSecret(TEST_PROJECT, null, "projSecret", "project-secret-value"); + createSecret(TEST_PROJECT, TEST_REPO_1, "repoSecret", "repo-secret-value"); + + // Push a template that uses both secrets + final String templateContent = + "{ \"proj\": \"${secrets.projSecret}\", \"repo\": \"${secrets.repoSecret}\" }"; + final Change change = Change.ofJsonUpsert("/masked.json", templateContent); + testRepo1.commit("Add template with secrets", change).push().join(); + + // Verify token-based access can see real values + final Entry tokenEntry = testRepo1.file(Query.ofJson("/masked.json")) + .renderTemplate(true) + .get() + .join(); + assertThatJson(tokenEntry.content()).node("proj").isEqualTo("project-secret-value"); + assertThatJson(tokenEntry.content()).node("repo").isEqualTo("repo-secret-value"); + + // Create a session-based client (simulating web UI access) + final BlockingWebClient sessionClient = createSessionClient(); + + // Access the rendered template via session cookie + final String contentsPath = "/api/v1/projects/" + TEST_PROJECT + + "/repos/" + TEST_REPO_1 + + "/contents/masked.json?renderTemplate=true"; + final AggregatedHttpResponse sessionResponse = sessionClient.get(contentsPath); + assertThat(sessionResponse.status()).isEqualTo(HttpStatus.OK); + + // Secrets should be masked as "****" for session-based access + final JsonNode responseJson = Jackson.readTree(sessionResponse.contentUtf8()); + final String content = responseJson.get("content").toString(); + assertThatJson(content).node("proj").isEqualTo("****"); + assertThatJson(content).node("repo").isEqualTo("****"); + } + + /** + * Creates a session-based client for a non-admin user (USERNAME2), simulating web UI access. + * Unlike token-based access (UserWithAppIdentity), session-based users are plain User instances, + * so secrets should be masked in rendered templates. + */ + private static BlockingWebClient createSessionClient() throws JsonProcessingException { + final AggregatedHttpResponse loginResponse = login(dogma.httpClient(), USERNAME2, PASSWORD2); + assertThat(loginResponse.status()).isEqualTo(HttpStatus.OK); + final Cookie sessionCookie = getSessionCookie(loginResponse); + final String csrfToken = Jackson.readTree(loginResponse.contentUtf8()) + .get("csrf_token").asText(); + return WebClient.builder(dogma.httpClient().uri()) + .addHeader(SessionUtil.X_CSRF_TOKEN, csrfToken) + .addHeader(HttpHeaderNames.COOKIE, sessionCookie.toCookieHeader()) + .build() + .blocking(); + } + + private void createSecret(String projectName, @Nullable String repoName, + String id, String value) { + final Secret secret = new Secret(id, value, null); + final String path; + if (repoName == null) { + path = "/api/v1/projects/" + projectName + "/secrets"; + } else { + path = "/api/v1/projects/" + projectName + "/repos/" + repoName + "/secrets"; + } + + final AggregatedHttpResponse response = httpClient.prepare() + .post(path) + .contentJson(secret) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + } + + private void updateSecret(String projectName, @Nullable String repoName, + String id, String value) { + final Secret secret = new Secret(id, value, null); + final String path; + if (repoName == null) { + path = "/api/v1/projects/" + projectName + "/secrets/" + id; + } else { + path = "/api/v1/projects/" + projectName + "/repos/" + repoName + "/secrets/" + id; + } + + final AggregatedHttpResponse response = httpClient.prepare() + .put(path) + .contentJson(secret) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + } + + private void deleteSecret(String projectName, @Nullable String repoName, String id) { + final String path; + if (repoName == null) { + path = "/api/v1/projects/" + projectName + "/secrets/" + id; + } else { + path = "/api/v1/projects/" + projectName + "/repos/" + repoName + "/secrets/" + id; + } + + final AggregatedHttpResponse response = httpClient.prepare() + .delete(path) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.NO_CONTENT); + } + + private void createVariable(String projectName, @Nullable String repoName, + String id, String value) { + final Variable variable = new Variable(id, VariableType.STRING, value, null); + final String path; + if (repoName == null) { + path = "/api/v1/projects/" + projectName + "/variables"; + } else { + path = "/api/v1/projects/" + projectName + "/repos/" + repoName + "/variables"; + } + + final AggregatedHttpResponse response = httpClient.prepare() + .post(path) + .contentJson(variable) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/VariableTemplateCrudTest.java b/server/src/test/java/com/linecorp/centraldogma/server/VariableTemplateCrudTest.java index 0b98a4234..7f82a6c23 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/VariableTemplateCrudTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/VariableTemplateCrudTest.java @@ -50,8 +50,8 @@ import com.linecorp.centraldogma.common.Query; import com.linecorp.centraldogma.common.TemplateProcessingException; import com.linecorp.centraldogma.internal.Jackson; -import com.linecorp.centraldogma.server.internal.api.variable.Variable; -import com.linecorp.centraldogma.server.internal.api.variable.VariableType; +import com.linecorp.centraldogma.server.internal.api.template.Variable; +import com.linecorp.centraldogma.server.internal.api.template.VariableType; import com.linecorp.centraldogma.server.metadata.UserAndTimestamp; import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/template/SecretServiceV1Test.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/template/SecretServiceV1Test.java new file mode 100644 index 000000000..6f0f53573 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/template/SecretServiceV1Test.java @@ -0,0 +1,606 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.api.template; + +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD2; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME2; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.core.type.TypeReference; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.ResponseEntity; +import com.linecorp.armeria.common.auth.AuthToken; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.common.ProjectRole; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.internal.api.MetadataApiService.IdAndProjectRole; +import com.linecorp.centraldogma.server.storage.repository.HasRevision; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +class SecretServiceV1Test { + + private static final String FOO_PROJ = "foo-proj"; + private static final String BAR_REPO = "bar-repo"; + private static final String MEMBER_APP_ID = "memberAppId"; + private static final String OUTSIDER_APP_ID = "outsiderAppId"; + private static final TypeReference> secretTypeRef = new TypeReference<>() {}; + + // A client with project MEMBER role (not OWNER) + private static BlockingWebClient memberClient; + // A client with no project membership + private static BlockingWebClient outsiderClient; + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.authProviderFactory(new TestAuthProviderFactory()); + builder.systemAdministrators(USERNAME); + } + + @Override + protected String accessToken() { + return getAccessToken(WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD, true); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject(FOO_PROJ).join(); + client.createRepository(FOO_PROJ, BAR_REPO).join(); + } + }; + + @BeforeAll + static void setUp() { + final WebClient webClient = dogma.httpClient(); + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + + // Create a member token and add it as a project MEMBER + final String memberToken = getAccessToken(webClient, USERNAME2, PASSWORD2, + MEMBER_APP_ID, false); + memberClient = WebClient.builder(dogma.httpClient().uri()) + .auth(AuthToken.ofOAuth2(memberToken)) + .build() + .blocking(); + final HttpRequest addMemberRequest = + HttpRequest.builder() + .post("/api/v1/metadata/" + FOO_PROJ + "/appIdentities") + .contentJson(new IdAndProjectRole(MEMBER_APP_ID, ProjectRole.MEMBER)) + .build(); + assertThat(adminClient.execute(addMemberRequest).status()).isEqualTo(HttpStatus.OK); + + // Create an outsider token with no project membership + final String outsiderToken = getAccessToken(webClient, USERNAME2, PASSWORD2, + OUTSIDER_APP_ID, false); + outsiderClient = WebClient.builder(dogma.httpClient().uri()) + .auth(AuthToken.ofOAuth2(outsiderToken)) + .build() + .blocking(); + } + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void createAndReadSecret(boolean projectLevel) { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String basePath = apiPrefix(projectLevel); + final String namePrefix = namePrefix(projectLevel); + + // Create a secret + final Secret secret1 = new Secret("secret-1", "my-secret-value", "A test secret"); + final ResponseEntity> createResponse1 = client.prepare() + .post(basePath) + .contentJson(secret1) + .asJson(secretTypeRef) + .execute(); + assertThat(createResponse1.status()).isEqualTo(HttpStatus.CREATED); + assertThat(createResponse1.content().revision()).isNotNull(); + final Secret expected1 = new Secret("secret-1", namePrefix + "secret-1", + "my-secret-value", null, "A test secret"); + assertThat(createResponse1.content().object()) + .usingRecursiveComparison() + .ignoringFields("creation") + .isEqualTo(expected1); + + // Create another secret + final Secret secret2 = new Secret("secret-2", "another-secret", "Another test secret"); + final ResponseEntity> createResponse2 = + client.prepare() + .post(basePath) + .contentJson(secret2) + .asJson(secretTypeRef) + .execute(); + assertThat(createResponse2.status()).isEqualTo(HttpStatus.CREATED); + + // List secrets + final ResponseEntity> listResponse = + client.prepare() + .get(basePath) + .asJson(new TypeReference>() {}) + .execute(); + assertThat(listResponse.status()).isEqualTo(HttpStatus.OK); + assertThat(listResponse.content()) + .extracting(Secret::id) + .contains("secret-1", "secret-2"); + // List API always masks secret values + assertThat(listResponse.content()) + .allSatisfy(secret -> assertThat(secret.value()).isEqualTo("****")); + + // Get specific secret + final ResponseEntity getResponse = + client.prepare() + .get(basePath + "/secret-1") + .asJson(Secret.class) + .execute(); + assertThat(getResponse.status()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.content()) + .usingRecursiveComparison() + .ignoringFields("creation") + .isEqualTo(expected1); + } + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void updateSecret(boolean projectLevel) { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String basePath = apiPrefix(projectLevel); + final String namePrefix = namePrefix(projectLevel); + + // Create a secret + final Secret createSecret = new Secret("update-secret", "initial-value", "Secret to be updated"); + final ResponseEntity> createResponse = client.prepare() + .post(basePath) + .contentJson(createSecret) + .asJson(secretTypeRef) + .execute(); + assertThat(createResponse.status()).isEqualTo(HttpStatus.CREATED); + + // Update the secret + final Secret updateSecret = new Secret("update-secret", "updated-value", "Secret to be updated"); + final ResponseEntity> updateResponse = + client.prepare() + .put(basePath + "/update-secret") + .contentJson(updateSecret) + .asJson(secretTypeRef) + .execute(); + assertThat(updateResponse.status()).isEqualTo(HttpStatus.OK); + assertThat(updateResponse.content().object()) + .usingRecursiveComparison() + .ignoringFields("creation") + .isEqualTo(new Secret("update-secret", namePrefix + "update-secret", + "updated-value", null, "Secret to be updated")); + + // Verify the update + final ResponseEntity getResponse = client.prepare() + .get(basePath + "/update-secret") + .asJson(Secret.class) + .execute(); + assertThat(getResponse.status()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.content().value()).isEqualTo("updated-value"); + } + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void deleteSecret(boolean projectLevel) { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String basePath = apiPrefix(projectLevel); + + // Create a secret + final Secret createSecret = new Secret("delete-secret", "to-delete", "Secret to be deleted"); + final ResponseEntity> createResponse = client.prepare() + .post(basePath) + .contentJson(createSecret) + .asJson(secretTypeRef) + .execute(); + assertThat(createResponse.status()).isEqualTo(HttpStatus.CREATED); + + // Delete the secret + final AggregatedHttpResponse deleteResponse = client.prepare() + .delete(basePath + "/delete-secret") + .execute(); + assertThat(deleteResponse.status()).isEqualTo(HttpStatus.NO_CONTENT); + + // Verify it's deleted + final AggregatedHttpResponse getResponse = client.prepare() + .get(basePath + "/delete-secret") + .execute(); + assertThat(getResponse.status()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void getNonExistentSecret(boolean projectLevel) { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String basePath = apiPrefix(projectLevel); + + final AggregatedHttpResponse response = client.prepare() + .get(basePath + "/non-existent") + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void updateNonExistentSecret(boolean projectLevel) { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String basePath = apiPrefix(projectLevel); + + final Secret updateSecret = new Secret("non-existent", "value", "Non-existent secret"); + final AggregatedHttpResponse response = client.prepare() + .put(basePath + "/non-existent") + .contentJson(updateSecret) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void updateSecretWithMismatchedId(boolean projectLevel) { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String basePath = apiPrefix(projectLevel); + + // Create a secret + final Secret createSecret = new Secret("mismatch-secret", "value", + "Secret for ID mismatch test"); + client.prepare() + .post(basePath) + .contentJson(createSecret) + .asJson(secretTypeRef) + .execute(); + + // Try to update with mismatched ID + final Secret updateSecret = new Secret("different-id", "new-value", + "Secret for ID mismatch test"); + final AggregatedHttpResponse response = client.prepare() + .put(basePath + "/mismatch-secret") + .contentJson(updateSecret) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void listSecretsAreIsolatedBetweenProjectAndRepo() { + final BlockingWebClient client = dogma.blockingHttpClient(); + final String projectBasePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + final String repoBasePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + + // Create a project-level secret + final Secret projectSecret = new Secret("list-proj-only", "proj-value", "Project only secret"); + assertThat(client.prepare() + .post(projectBasePath) + .contentJson(projectSecret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Create a repo-level secret + final Secret repoSecret = new Secret("list-repo-only", "repo-value", "Repo only secret"); + assertThat(client.prepare() + .post(repoBasePath) + .contentJson(repoSecret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Project list should contain the project secret but not the repo secret + final ResponseEntity> projectList = + client.prepare() + .get(projectBasePath) + .asJson(new TypeReference>() {}) + .execute(); + assertThat(projectList.status()).isEqualTo(HttpStatus.OK); + assertThat(projectList.content()) + .extracting(Secret::id) + .contains("list-proj-only") + .doesNotContain("list-repo-only"); + + // Repo list should contain the repo secret but not the project secret + final ResponseEntity> repoList = + client.prepare() + .get(repoBasePath) + .asJson(new TypeReference>() {}) + .execute(); + assertThat(repoList.status()).isEqualTo(HttpStatus.OK); + assertThat(repoList.content()) + .extracting(Secret::id) + .contains("list-repo-only") + .doesNotContain("list-proj-only"); + + // Verify listed secrets have correct fields (value is masked for non-system-admin tokens) + final Secret listedProjectSecret = projectList.content().stream() + .filter(s -> "list-proj-only".equals(s.id())) + .findFirst().get(); + assertThat(listedProjectSecret.name()).isEqualTo("projects/" + FOO_PROJ + "/secrets/list-proj-only"); + assertThat(listedProjectSecret.description()).isEqualTo("Project only secret"); + assertThat(listedProjectSecret.creation()).isNotNull(); + + final Secret listedRepoSecret = repoList.content().stream() + .filter(s -> "list-repo-only".equals(s.id())) + .findFirst().get(); + assertThat(listedRepoSecret.name()).isEqualTo( + "projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets/list-repo-only"); + assertThat(listedRepoSecret.description()).isEqualTo("Repo only secret"); + assertThat(listedRepoSecret.creation()).isNotNull(); + } + + @Test + void secretValueMaskedForNonAdmin() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String projectBasePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + final String repoBasePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + + // Create project-level and repo-level secrets as admin + final Secret projectSecret = new Secret("masked-secret", "super-secret-value", + "A secret to test masking"); + assertThat(adminClient.prepare() + .post(projectBasePath) + .contentJson(projectSecret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + final Secret repoSecret = new Secret("masked-repo-secret", "repo-secret-value", + "A repo secret to test masking"); + assertThat(adminClient.prepare() + .post(repoBasePath) + .contentJson(repoSecret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Admin can see the actual values + final ResponseEntity adminGetProject = + adminClient.prepare() + .get(projectBasePath + "/masked-secret") + .asJson(Secret.class) + .execute(); + assertThat(adminGetProject.content().value()).isEqualTo("super-secret-value"); + + final ResponseEntity adminGetRepo = + adminClient.prepare() + .get(repoBasePath + "/masked-repo-secret") + .asJson(Secret.class) + .execute(); + assertThat(adminGetRepo.content().value()).isEqualTo("repo-secret-value"); + + // Non-admin sees masked values when getting individual secrets + final ResponseEntity nonAdminGetProject = + memberClient.prepare() + .get(projectBasePath + "/masked-secret") + .asJson(Secret.class) + .execute(); + assertThat(nonAdminGetProject.content().value()).isEqualTo("****"); + assertThat(nonAdminGetProject.content().id()).isEqualTo("masked-secret"); + + // Non-admin sees masked values when listing repo-level secrets + final ResponseEntity> nonAdminRepoList = + memberClient.prepare() + .get(repoBasePath) + .asJson(new TypeReference>() {}) + .execute(); + assertThat(nonAdminRepoList.content()) + .allSatisfy(secret -> assertThat(secret.value()).isEqualTo("****")); + } + + // --- Project-level permission tests --- + + @Test + void memberCanListAndGetProjectSecrets() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + + // Admin creates a secret + final Secret secret = new Secret("perm-proj-read", "secret-value", "Permission test"); + assertThat(adminClient.prepare() + .post(basePath) + .contentJson(secret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Member can list project secrets + assertThat(memberClient.get(basePath).status()).isEqualTo(HttpStatus.OK); + + // Member can get a specific project secret + assertThat(memberClient.get(basePath + "/perm-proj-read").status()).isEqualTo(HttpStatus.OK); + } + + @Test + void memberCannotCreateProjectSecret() { + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + final Secret secret = new Secret("perm-proj-create", "value", "Permission test"); + final AggregatedHttpResponse response = memberClient.prepare() + .post(basePath) + .contentJson(secret) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void memberCannotUpdateProjectSecret() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + + // Admin creates a secret + final Secret secret = new Secret("perm-proj-update", "value", "Permission test"); + assertThat(adminClient.prepare() + .post(basePath) + .contentJson(secret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Member cannot update it + final Secret updated = new Secret("perm-proj-update", "new-value", "Permission test"); + final AggregatedHttpResponse response = memberClient.prepare() + .put(basePath + "/perm-proj-update") + .contentJson(updated) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void memberCannotDeleteProjectSecret() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + + // Admin creates a secret + final Secret secret = new Secret("perm-proj-delete", "value", "Permission test"); + assertThat(adminClient.prepare() + .post(basePath) + .contentJson(secret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Member cannot delete it + final AggregatedHttpResponse response = memberClient.delete(basePath + "/perm-proj-delete"); + assertThat(response.status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + // --- Repo-level permission tests --- + + @Test + void memberCanListAndGetRepoSecrets() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + + // Admin creates a repo secret + final Secret secret = new Secret("perm-repo-read", "secret-value", "Permission test"); + assertThat(adminClient.prepare() + .post(basePath) + .contentJson(secret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Member (who has READ on repo by default) can list repo secrets + assertThat(memberClient.get(basePath).status()).isEqualTo(HttpStatus.OK); + + // Member can get a specific repo secret + assertThat(memberClient.get(basePath + "/perm-repo-read").status()).isEqualTo(HttpStatus.OK); + } + + @Test + void memberCannotCreateRepoSecret() { + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + final Secret secret = new Secret("perm-repo-create", "value", "Permission test"); + final AggregatedHttpResponse response = memberClient.prepare() + .post(basePath) + .contentJson(secret) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void memberCannotUpdateRepoSecret() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + + // Admin creates a repo secret + final Secret secret = new Secret("perm-repo-update", "value", "Permission test"); + assertThat(adminClient.prepare() + .post(basePath) + .contentJson(secret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Member cannot update it + final Secret updated = new Secret("perm-repo-update", "new-value", "Permission test"); + final AggregatedHttpResponse response = memberClient.prepare() + .put(basePath + "/perm-repo-update") + .contentJson(updated) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void memberCannotDeleteRepoSecret() { + final BlockingWebClient adminClient = dogma.blockingHttpClient(); + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + + // Admin creates a repo secret + final Secret secret = new Secret("perm-repo-delete", "value", "Permission test"); + assertThat(adminClient.prepare() + .post(basePath) + .contentJson(secret) + .asJson(secretTypeRef) + .execute().status()).isEqualTo(HttpStatus.CREATED); + + // Member cannot delete it + final AggregatedHttpResponse response = memberClient.delete(basePath + "/perm-repo-delete"); + assertThat(response.status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + // --- Outsider (no project membership) tests --- + + @Test + void outsiderCannotAccessProjectSecrets() { + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/secrets"; + + // Outsider cannot list project secrets + assertThat(outsiderClient.get(basePath).status()).isEqualTo(HttpStatus.FORBIDDEN); + + // Outsider cannot get a specific project secret + assertThat(outsiderClient.get(basePath + "/any-secret").status()).isEqualTo(HttpStatus.FORBIDDEN); + + // Outsider cannot create a project secret + final Secret secret = new Secret("outsider-secret", "value", "test"); + assertThat(outsiderClient.prepare() + .post(basePath) + .contentJson(secret) + .execute().status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void outsiderCannotAccessRepoSecrets() { + final String basePath = "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + + // Outsider cannot list repo secrets + assertThat(outsiderClient.get(basePath).status()).isEqualTo(HttpStatus.FORBIDDEN); + + // Outsider cannot get a specific repo secret + assertThat(outsiderClient.get(basePath + "/any-secret").status()).isEqualTo(HttpStatus.FORBIDDEN); + + // Outsider cannot create a repo secret + final Secret secret = new Secret("outsider-repo-secret", "value", "test"); + assertThat(outsiderClient.prepare() + .post(basePath) + .contentJson(secret) + .execute().status()).isEqualTo(HttpStatus.FORBIDDEN); + } + + private static String apiPrefix(boolean projectLevel) { + return projectLevel ? "/api/v1/projects/" + FOO_PROJ + "/secrets" + : "/api/v1/projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets"; + } + + private static String namePrefix(boolean projectLevel) { + return projectLevel ? "projects/" + FOO_PROJ + "/secrets/" + : "projects/" + FOO_PROJ + "/repos/" + BAR_REPO + "/secrets/"; + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/variable/VariableServiceV1Test.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/template/VariableServiceV1Test.java similarity index 99% rename from server/src/test/java/com/linecorp/centraldogma/server/internal/api/variable/VariableServiceV1Test.java rename to server/src/test/java/com/linecorp/centraldogma/server/internal/api/template/VariableServiceV1Test.java index 2051846f8..f4043912f 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/variable/VariableServiceV1Test.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/template/VariableServiceV1Test.java @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api.variable; +package com.linecorp.centraldogma.server.internal.api.template; import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME;