diff --git a/client/java-armeria-xds/src/test/java/com/linecorp/centraldogma/client/armeria/xds/AuthUpstreamTest.java b/client/java-armeria-xds/src/test/java/com/linecorp/centraldogma/client/armeria/xds/AuthUpstreamTest.java index de50be2d62..d71861d1c2 100644 --- a/client/java-armeria-xds/src/test/java/com/linecorp/centraldogma/client/armeria/xds/AuthUpstreamTest.java +++ b/client/java-armeria-xds/src/test/java/com/linecorp/centraldogma/client/armeria/xds/AuthUpstreamTest.java @@ -26,7 +26,6 @@ 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.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -71,14 +70,10 @@ protected void configure(CentralDogmaBuilder builder) { @Override protected void configureClient(ArmeriaCentralDogmaBuilder builder) { - try { - final String accessToken = getAccessToken( - WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), - TestAuthMessageUtil.USERNAME, TestAuthMessageUtil.PASSWORD); - builder.accessToken(accessToken); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + final String accessToken = getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + TestAuthMessageUtil.USERNAME, TestAuthMessageUtil.PASSWORD); + builder.accessToken(accessToken); } @Override diff --git a/common/src/main/java/com/linecorp/centraldogma/common/MirrorAccessException.java b/common/src/main/java/com/linecorp/centraldogma/common/MirrorAccessException.java new file mode 100644 index 0000000000..146b36b747 --- /dev/null +++ b/common/src/main/java/com/linecorp/centraldogma/common/MirrorAccessException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 LINE Corporation + * + * LINE 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.common; + +/** + * A {@link MirrorException} raised when failed to access to the remote repository for mirroring. + */ +public final class MirrorAccessException extends MirrorException { + private static final long serialVersionUID = 6673537965128335081L; + + /** + * Creates a new instance. + */ + public MirrorAccessException(String message) { + super(message); + } + + /** + * Creates a new instance. + */ + public MirrorAccessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorDto.java b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorDto.java index 11d22d7423..0169b9e1f3 100644 --- a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorDto.java +++ b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorDto.java @@ -17,9 +17,6 @@ package com.linecorp.centraldogma.internal.api.v1; -import static com.google.common.base.MoreObjects.firstNonNull; -import static java.util.Objects.requireNonNull; - import java.util.Objects; import javax.annotation.Nullable; @@ -28,28 +25,11 @@ 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; @JsonInclude(Include.NON_NULL) -public final class MirrorDto { +public final class MirrorDto extends MirrorRequest { - private final String id; - private final boolean enabled; - private final String projectName; - @Nullable - private final String schedule; - private final String direction; - private final String localRepo; - private final String localPath; - private final String remoteScheme; - private final String remoteUrl; - private final String remotePath; - private final String remoteBranch; - @Nullable - private final String gitignore; - private final String credentialId; - @Nullable - private final String zone; + private final boolean allow; @JsonCreator public MirrorDto(@JsonProperty("id") String id, @@ -65,94 +45,16 @@ public MirrorDto(@JsonProperty("id") String id, @JsonProperty("remoteBranch") String remoteBranch, @JsonProperty("gitignore") @Nullable String gitignore, @JsonProperty("credentialId") String credentialId, - @JsonProperty("zone") @Nullable String zone) { - this.id = requireNonNull(id, "id"); - this.enabled = firstNonNull(enabled, true); - this.projectName = requireNonNull(projectName, "projectName"); - this.schedule = schedule; - this.direction = requireNonNull(direction, "direction"); - this.localRepo = requireNonNull(localRepo, "localRepo"); - this.localPath = requireNonNull(localPath, "localPath"); - this.remoteScheme = requireNonNull(remoteScheme, "remoteScheme"); - this.remoteUrl = requireNonNull(remoteUrl, "remoteUrl"); - this.remotePath = requireNonNull(remotePath, "remotePath"); - this.remoteBranch = requireNonNull(remoteBranch, "remoteBranch"); - this.gitignore = gitignore; - this.credentialId = requireNonNull(credentialId, "credentialId"); - this.zone = zone; - } - - @JsonProperty("id") - public String id() { - return id; - } - - @JsonProperty("enabled") - public boolean enabled() { - return enabled; - } - - @JsonProperty("projectName") - public String projectName() { - return projectName; - } - - @Nullable - @JsonProperty("schedule") - public String schedule() { - return schedule; - } - - @JsonProperty("direction") - public String direction() { - return direction; - } - - @JsonProperty("localRepo") - public String localRepo() { - return localRepo; - } - - @JsonProperty("localPath") - public String localPath() { - return localPath; - } - - @JsonProperty("remoteScheme") - public String remoteScheme() { - return remoteScheme; - } - - @JsonProperty("remoteUrl") - public String remoteUrl() { - return remoteUrl; - } - - @JsonProperty("remotePath") - public String remotePath() { - return remotePath; - } - - @JsonProperty("remoteBranch") - public String remoteBranch() { - return remoteBranch; - } - - @Nullable - @JsonProperty("gitignore") - public String gitignore() { - return gitignore; - } - - @JsonProperty("credentialId") - public String credentialId() { - return credentialId; + @JsonProperty("zone") @Nullable String zone, + @JsonProperty("allow") boolean allow) { + super(id, enabled, projectName, schedule, direction, localRepo, localPath, remoteScheme, remoteUrl, + remotePath, remoteBranch, gitignore, credentialId, zone); + this.allow = allow; } - @Nullable - @JsonProperty("zone") - public String zone() { - return zone; + @JsonProperty("allow") + public boolean allow() { + return allow; } @Override @@ -164,45 +66,16 @@ public boolean equals(Object o) { return false; } final MirrorDto mirrorDto = (MirrorDto) o; - return id.equals(mirrorDto.id) && - enabled == mirrorDto.enabled && - projectName.equals(mirrorDto.projectName) && - Objects.equals(schedule, mirrorDto.schedule) && - direction.equals(mirrorDto.direction) && - localRepo.equals(mirrorDto.localRepo) && - localPath.equals(mirrorDto.localPath) && - remoteScheme.equals(mirrorDto.remoteScheme) && - remoteUrl.equals(mirrorDto.remoteUrl) && - remotePath.equals(mirrorDto.remotePath) && - remoteBranch.equals(mirrorDto.remoteBranch) && - Objects.equals(gitignore, mirrorDto.gitignore) && - credentialId.equals(mirrorDto.credentialId) && - Objects.equals(zone, mirrorDto.zone); + return super.equals(o) && allow == mirrorDto.allow; } @Override public int hashCode() { - return Objects.hash(id, projectName, schedule, direction, localRepo, localPath, remoteScheme, - remoteUrl, remotePath, remoteBranch, gitignore, credentialId, enabled, zone); + return super.hashCode() * 31 + Objects.hash(allow); } @Override public String toString() { - return MoreObjects.toStringHelper(this) - .omitNullValues() - .add("id", id) - .add("enabled", enabled) - .add("projectName", projectName) - .add("schedule", schedule) - .add("direction", direction) - .add("localRepo", localRepo) - .add("localPath", localPath) - .add("remoteScheme", remoteScheme) - .add("remoteUrl", remoteUrl) - .add("remotePath", remotePath) - .add("gitignore", gitignore) - .add("credentialId", credentialId) - .add("zone", zone) - .toString(); + return toStringHelper().add("allow", allow).toString(); } } diff --git a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorRequest.java b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorRequest.java new file mode 100644 index 0000000000..ea3ccc3952 --- /dev/null +++ b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/MirrorRequest.java @@ -0,0 +1,213 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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.internal.api.v1; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static java.util.Objects.requireNonNull; + +import java.util.Objects; + +import javax.annotation.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.google.common.base.MoreObjects.ToStringHelper; + +@JsonInclude(Include.NON_NULL) +public class MirrorRequest { + + private final String id; + private final boolean enabled; + private final String projectName; + @Nullable + private final String schedule; + private final String direction; + private final String localRepo; + private final String localPath; + private final String remoteScheme; + private final String remoteUrl; + private final String remotePath; + private final String remoteBranch; + @Nullable + private final String gitignore; + private final String credentialId; + @Nullable + private final String zone; + + @JsonCreator + public MirrorRequest(@JsonProperty("id") String id, + @JsonProperty("enabled") @Nullable Boolean enabled, + @JsonProperty("projectName") String projectName, + @JsonProperty("schedule") @Nullable String schedule, + @JsonProperty("direction") String direction, + @JsonProperty("localRepo") String localRepo, + @JsonProperty("localPath") String localPath, + @JsonProperty("remoteScheme") String remoteScheme, + @JsonProperty("remoteUrl") String remoteUrl, + @JsonProperty("remotePath") String remotePath, + @JsonProperty("remoteBranch") String remoteBranch, + @JsonProperty("gitignore") @Nullable String gitignore, + @JsonProperty("credentialId") String credentialId, + @JsonProperty("zone") @Nullable String zone) { + this.id = requireNonNull(id, "id"); + this.enabled = firstNonNull(enabled, true); + this.projectName = requireNonNull(projectName, "projectName"); + this.schedule = schedule; + this.direction = requireNonNull(direction, "direction"); + this.localRepo = requireNonNull(localRepo, "localRepo"); + this.localPath = requireNonNull(localPath, "localPath"); + this.remoteScheme = requireNonNull(remoteScheme, "remoteScheme"); + this.remoteUrl = requireNonNull(remoteUrl, "remoteUrl"); + this.remotePath = requireNonNull(remotePath, "remotePath"); + this.remoteBranch = requireNonNull(remoteBranch, "remoteBranch"); + this.gitignore = gitignore; + this.credentialId = requireNonNull(credentialId, "credentialId"); + this.zone = zone; + } + + @JsonProperty("id") + public String id() { + return id; + } + + @JsonProperty("enabled") + public boolean enabled() { + return enabled; + } + + @JsonProperty("projectName") + public String projectName() { + return projectName; + } + + @Nullable + @JsonProperty("schedule") + public String schedule() { + return schedule; + } + + @JsonProperty("direction") + public String direction() { + return direction; + } + + @JsonProperty("localRepo") + public String localRepo() { + return localRepo; + } + + @JsonProperty("localPath") + public String localPath() { + return localPath; + } + + @JsonProperty("remoteScheme") + public String remoteScheme() { + return remoteScheme; + } + + @JsonProperty("remoteUrl") + public String remoteUrl() { + return remoteUrl; + } + + @JsonProperty("remotePath") + public String remotePath() { + return remotePath; + } + + @JsonProperty("remoteBranch") + public String remoteBranch() { + return remoteBranch; + } + + @Nullable + @JsonProperty("gitignore") + public String gitignore() { + return gitignore; + } + + @JsonProperty("credentialId") + public String credentialId() { + return credentialId; + } + + @Nullable + @JsonProperty("zone") + public String zone() { + return zone; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MirrorRequest)) { + return false; + } + final MirrorRequest mirrorDto = (MirrorRequest) o; + return id.equals(mirrorDto.id) && + enabled == mirrorDto.enabled && + projectName.equals(mirrorDto.projectName) && + Objects.equals(schedule, mirrorDto.schedule) && + direction.equals(mirrorDto.direction) && + localRepo.equals(mirrorDto.localRepo) && + localPath.equals(mirrorDto.localPath) && + remoteScheme.equals(mirrorDto.remoteScheme) && + remoteUrl.equals(mirrorDto.remoteUrl) && + remotePath.equals(mirrorDto.remotePath) && + remoteBranch.equals(mirrorDto.remoteBranch) && + Objects.equals(gitignore, mirrorDto.gitignore) && + credentialId.equals(mirrorDto.credentialId) && + Objects.equals(zone, mirrorDto.zone); + } + + @Override + public int hashCode() { + return Objects.hash(id, projectName, schedule, direction, localRepo, localPath, remoteScheme, + remoteUrl, remotePath, remoteBranch, gitignore, credentialId, enabled, zone); + } + + protected ToStringHelper toStringHelper() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("id", id) + .add("enabled", enabled) + .add("projectName", projectName) + .add("schedule", schedule) + .add("direction", direction) + .add("localRepo", localRepo) + .add("localPath", localPath) + .add("remoteScheme", remoteScheme) + .add("remoteUrl", remoteUrl) + .add("remotePath", remotePath) + .add("remoteBranch", remoteBranch) + .add("gitignore", gitignore) + .add("credentialId", credentialId) + .add("zone", zone); + } + + @Override + public String toString() { + return toStringHelper().toString(); + } +} diff --git a/gradle.properties b/gradle.properties index 4e61a920d2..a46fed7ee7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=com.linecorp.centraldogma -version=0.73.1-SNAPSHOT +version=0.73.2-SNAPSHOT projectName=Central Dogma projectUrl=https://line.github.io/centraldogma/ projectDescription=Highly-available version-controlled service configuration repository based on Git, ZooKeeper and HTTP/2 diff --git a/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/CustomMirrorListenerTest.java b/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/CustomMirrorListenerTest.java index 50ffa72dd7..d5dd6cc8f3 100644 --- a/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/CustomMirrorListenerTest.java +++ b/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/CustomMirrorListenerTest.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; import com.cronutils.model.Cron; @@ -43,6 +44,8 @@ import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.credential.Credential; import com.linecorp.centraldogma.server.internal.mirror.AbstractMirror; +import com.linecorp.centraldogma.server.internal.mirror.DefaultMirrorAccessController; +import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; import com.linecorp.centraldogma.server.internal.mirror.MirrorSchedulingService; import com.linecorp.centraldogma.server.mirror.Mirror; import com.linecorp.centraldogma.server.mirror.MirrorDirection; @@ -52,6 +55,7 @@ import com.linecorp.centraldogma.server.storage.project.ProjectManager; import com.linecorp.centraldogma.server.storage.repository.MetaRepository; import com.linecorp.centraldogma.server.storage.repository.Repository; +import com.linecorp.centraldogma.testing.internal.CrudRepositoryExtension; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -63,6 +67,11 @@ class CustomMirrorListenerTest { @TempDir static File temporaryFolder; + @RegisterExtension + static CrudRepositoryExtension repositoryExtension = + new CrudRepositoryExtension<>(MirrorAccessControl.class, "dogma", "dogma", + "/mirror_access_control/"); + @BeforeEach void setUp() { TestMirrorListener.reset(); @@ -113,8 +122,10 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo when(mr.mirrors()).thenReturn(CompletableFuture.completedFuture(ImmutableList.of(mirror))); + final DefaultMirrorAccessController ac = new DefaultMirrorAccessController(); + ac.setRepository(repositoryExtension.crudRepository()); final MirrorSchedulingService service = new MirrorSchedulingService( - temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null, false); + temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null, false, ac); final CommandExecutor executor = mock(CommandExecutor.class); service.start(executor); diff --git a/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/TestMirrorListener.java b/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/TestMirrorListener.java index 931300c44d..18e670f059 100644 --- a/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/TestMirrorListener.java +++ b/it/mirror-listener/src/test/java/com/linecorp/centraldogma/it/mirror/listener/TestMirrorListener.java @@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap; import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; @@ -38,6 +39,15 @@ static void reset() { errors.clear(); } + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) {} + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) {} + + @Override + public void onDisallowed(Mirror mirror) {} + @Override public void onStart(MirrorTask mirror) { startCount.merge(mirror.mirror(), 1, Integer::sum); diff --git a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorAccessControlTest.java b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorAccessControlTest.java new file mode 100644 index 0000000000..cfdc6ce3dc --- /dev/null +++ b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorAccessControlTest.java @@ -0,0 +1,241 @@ +/* + * Copyright 2025 LINE Corporation + * + * LINE 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.it.mirror.git; + +import static com.linecorp.centraldogma.it.mirror.git.MirrorRunnerTest.PRIVATE_KEY_FILE; +import static com.linecorp.centraldogma.it.mirror.git.MirrorRunnerTest.TEST_MIRROR_ID; +import static com.linecorp.centraldogma.it.mirror.git.MirrorRunnerTest.getCredential; +import static com.linecorp.centraldogma.it.mirror.git.TestMirrorRunnerListener.creationCount; +import static com.linecorp.centraldogma.it.mirror.git.TestMirrorRunnerListener.startCount; +import static com.linecorp.centraldogma.it.mirror.git.TestMirrorRunnerListener.updateCount; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import javax.annotation.Nullable; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +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.client.armeria.ArmeriaCentralDogmaBuilder; +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; +import com.linecorp.centraldogma.internal.api.v1.PushResultDto; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlRequest; +import com.linecorp.centraldogma.server.internal.credential.PublicKeyCredential; +import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; +import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +class MirrorAccessControlTest { + + static final String TEST_PROJ = "test_mirror_access_control"; + static final String TEST_REPO = "bar"; + + @RegisterExtension + final CentralDogmaExtension dogma = new CentralDogmaExtension() { + @Nullable + private String accessToken; + + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.authProviderFactory(new TestAuthProviderFactory()); + builder.systemAdministrators(USERNAME); + builder.pluginConfigs(new MirroringServicePluginConfig(true)); + } + + @Override + protected void configureClient(ArmeriaCentralDogmaBuilder builder) { + builder.accessToken(getAccessToken0()); + } + + @Override + protected void configureHttpClient(WebClientBuilder builder) { + builder.auth(AuthToken.ofOAuth2(getAccessToken0())); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject(TEST_PROJ).join(); + client.createRepository(TEST_PROJ, TEST_REPO).join(); + } + + private String getAccessToken0() { + if (accessToken != null) { + return accessToken; + } + accessToken = getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD); + return accessToken; + } + + @Override + protected boolean runForEachTest() { + return true; + } + }; + + private BlockingWebClient client; + + @BeforeEach + void setUp() throws Exception { + client = dogma.blockingHttpClient(); + TestMirrorRunnerListener.reset(); + } + + @Test + void shouldControlMirroringWithAccessController() throws Exception { + ResponseEntity accessResponse = + client.prepare() + .post("/api/v1/mirror/access") + .contentJson(new MirrorAccessControlRequest( + "default", + ".*", + false, + "disallow by default", + Integer.MAX_VALUE)) + .asJson(MirrorAccessControl.class) + .execute(); + assertThat(accessResponse.status()).isEqualTo(HttpStatus.CREATED); + assertThat(accessResponse.content().id()).isEqualTo("default"); + + createMirror(); + final String listenerKey = TEST_PROJ + '/' + TEST_MIRROR_ID + '/' + Author.SYSTEM.email(); + await().untilAsserted(() -> { + assertThat(creationCount.get("git+ssh://github.com/line/centraldogma-authtest.git")) + .isOne(); + + assertThat(startCount.get(listenerKey)).isNull(); + }); + + accessResponse = client.prepare() + .post("/api/v1/mirror/access") + .contentJson(new MirrorAccessControlRequest( + "centraldogma-authtest", + ".*github.com/line/centraldogma-authtest.git$", + true, + "allow centraldogma-authtest", + 0)) + .asJson(MirrorAccessControl.class) + .execute(); + assertThat(accessResponse.status()).isEqualTo(HttpStatus.CREATED); + + await().untilAsserted(() -> { + assertThat(startCount).hasSizeGreaterThan(0); + final Integer numMirroring = startCount.get(listenerKey); + assertThat(numMirroring).isNotNull(); + assertThat(numMirroring).isGreaterThanOrEqualTo(1); + }); + } + + @Test + void testMirrorCreationEvent() throws Exception { + assertThat(creationCount.get("git+ssh://github.com/line/centraldogma-authtest.git")) + .isNull(); + assertThat(updateCount.get("git+ssh://github.com/line/centraldogma-authtest.git")) + .isNull(); + createMirror(); + await().untilAsserted(() -> { + assertThat(creationCount.get("git+ssh://github.com/line/centraldogma-authtest.git")) + .isOne(); + }); + + final MirrorRequest updating = new MirrorRequest(TEST_MIRROR_ID, + true, + TEST_PROJ, + "0/2 * * * * ?", + "REMOTE_TO_LOCAL", + TEST_REPO, + "/", + "git+ssh", + "github.com/line/centraldogma-authtest.git", + "/", + "main", + null, + PRIVATE_KEY_FILE, + null); + + final ResponseEntity response = + client.prepare() + .put("/api/v1/projects/{proj}/repos/{repo}/mirrors/{mirrorId}") + .pathParam("proj", TEST_PROJ) + .pathParam("repo", TEST_REPO) + .pathParam("mirrorId", TEST_MIRROR_ID) + .contentJson(updating) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + + await().untilAsserted(() -> { + assertThat(updateCount.get("git+ssh://github.com/line/centraldogma-authtest.git")) + .isOne(); + }); + } + + private void createMirror() throws Exception { + final PublicKeyCredential credential = getCredential(); + ResponseEntity response = + client.prepare() + .post("/api/v1/projects/{proj}/repos/{repo}/credentials") + .pathParam("proj", TEST_PROJ) + .pathParam("repo", TEST_REPO) + .contentJson(credential) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + + final MirrorRequest newMirror = newMirror(); + response = client.prepare() + .post("/api/v1/projects/{proj}/repos/{repo}/mirrors") + .pathParam("proj", TEST_PROJ) + .pathParam("repo", TEST_REPO) + .contentJson(newMirror) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + } + + private static MirrorRequest newMirror() { + return new MirrorRequest(TEST_MIRROR_ID, + true, + TEST_PROJ, + "0/1 * * * * ?", + "REMOTE_TO_LOCAL", + TEST_REPO, + "/", + "git+ssh", + "github.com/line/centraldogma-authtest.git", + "/", + "main", + null, + PRIVATE_KEY_FILE, + null); + } +} diff --git a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorRunnerTest.java b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorRunnerTest.java index 1de6d29f8d..49e4c4e5f7 100644 --- a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorRunnerTest.java +++ b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorRunnerTest.java @@ -28,20 +28,22 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.io.Resources; import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; 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.client.armeria.ArmeriaCentralDogmaBuilder; -import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.internal.api.v1.PushResultDto; import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlRequest; import com.linecorp.centraldogma.server.internal.credential.PublicKeyCredential; +import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorStatus; import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; @@ -55,7 +57,7 @@ class MirrorRunnerTest { static final String TEST_MIRROR_ID = "test-mirror"; @RegisterExtension - static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + CentralDogmaExtension dogma = new CentralDogmaExtension() { @Override protected void configure(CentralDogmaBuilder builder) { @@ -65,14 +67,10 @@ protected void configure(CentralDogmaBuilder builder) { @Override protected void configureClient(ArmeriaCentralDogmaBuilder builder) { - try { - final String accessToken = getAccessToken( - WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), - USERNAME, PASSWORD); - builder.accessToken(accessToken); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + final String accessToken = getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD); + builder.accessToken(accessToken); } @Override @@ -80,6 +78,11 @@ protected void scaffold(CentralDogma client) { client.createProject(FOO_PROJ).join(); client.createRepository(FOO_PROJ, BAR_REPO).join(); } + + @Override + protected boolean runForEachTest() { + return true; + } }; private BlockingWebClient systemAdminClient; @@ -106,7 +109,7 @@ void triggerMirroring() throws Exception { .execute(); assertThat(response.status()).isEqualTo(HttpStatus.CREATED); - final MirrorDto newMirror = newMirror(); + final MirrorRequest newMirror = newMirror(); response = systemAdminClient.prepare() .post("/api/v1/projects/{proj}/repos/{repo}/mirrors") .pathParam("proj", FOO_PROJ) @@ -148,21 +151,87 @@ void triggerMirroring() throws Exception { assertThat(results.get(2).mirrorStatus()).isEqualTo(MirrorStatus.UP_TO_DATE); } - private static MirrorDto newMirror() { - return new MirrorDto(TEST_MIRROR_ID, - true, - FOO_PROJ, - null, - "REMOTE_TO_LOCAL", - BAR_REPO, - "/", - "git+ssh", - "github.com/line/centraldogma-authtest.git", - "/", - "main", - null, - PRIVATE_KEY_FILE, - null); + @Test + void shouldControlGitMirrorAccess() throws Exception { + ResponseEntity accessResponse = + systemAdminClient.prepare() + .post("/api/v1/mirror/access") + .contentJson(new MirrorAccessControlRequest( + "default", + ".*", + false, + "disallow by default", + Integer.MAX_VALUE)) + .asJson(MirrorAccessControl.class) + .execute(); + assertThat(accessResponse.status()).isEqualTo(HttpStatus.CREATED); + assertThat(accessResponse.content().id()).isEqualTo("default"); + + final PublicKeyCredential credential = getCredential(); + ResponseEntity response = + systemAdminClient.prepare() + .post("/api/v1/projects/{proj}/credentials") + .pathParam("proj", FOO_PROJ) + .contentJson(credential) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + + final MirrorRequest newMirror = newMirror(); + response = systemAdminClient.prepare() + .post("/api/v1/projects/{proj}/repos/{repo}/mirrors") + .pathParam("proj", FOO_PROJ) + .pathParam("repo", BAR_REPO) + .contentJson(newMirror) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + + AggregatedHttpResponse mirrorResponse = + systemAdminClient.prepare() + .post("/api/v1/projects/{proj}/repos/{repo}/mirrors/{mirrorId}/run") + .pathParam("proj", FOO_PROJ) + .pathParam("repo", BAR_REPO) + .pathParam("mirrorId", TEST_MIRROR_ID) + .execute(); + // Mirror execution should be forbidden. + assertThat(mirrorResponse.status()).isEqualTo(HttpStatus.FORBIDDEN); + + accessResponse = systemAdminClient.prepare() + .post("/api/v1/mirror/access") + .contentJson(new MirrorAccessControlRequest( + "centraldogma-authtest", + newMirror.remoteScheme() + "://" + newMirror.remoteUrl(), + true, + "allow centraldogma-authtest", + 0)) + .asJson(MirrorAccessControl.class) + .execute(); + assertThat(accessResponse.status()).isEqualTo(HttpStatus.CREATED); + mirrorResponse = systemAdminClient.prepare() + .post("/api/v1/projects/{proj}/repos/{repo}/mirrors/{mirrorId}/run") + .pathParam("proj", FOO_PROJ) + .pathParam("repo", BAR_REPO) + .pathParam("mirrorId", TEST_MIRROR_ID) + .execute(); + assertThat(mirrorResponse.status()).isEqualTo(HttpStatus.OK); + } + + private static MirrorRequest newMirror() { + return new MirrorRequest(TEST_MIRROR_ID, + true, + FOO_PROJ, + null, + "REMOTE_TO_LOCAL", + BAR_REPO, + "/", + "git+ssh", + "github.com/line/centraldogma-authtest.git", + "/", + "main", + null, + PRIVATE_KEY_FILE, + null); } static PublicKeyCredential getCredential() throws Exception { diff --git a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestMirrorRunnerListener.java b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestMirrorRunnerListener.java index 3c601a0fe5..ed077a4d11 100644 --- a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestMirrorRunnerListener.java +++ b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestMirrorRunnerListener.java @@ -21,17 +21,23 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; public class TestMirrorRunnerListener implements MirrorListener { + static final Map creationCount = new ConcurrentHashMap<>(); + static final Map updateCount = new ConcurrentHashMap<>(); static final Map startCount = new ConcurrentHashMap<>(); static final Map> completions = new ConcurrentHashMap<>(); static final Map> errors = new ConcurrentHashMap<>(); static void reset() { + creationCount.clear(); + updateCount.clear(); startCount.clear(); completions.clear(); errors.clear(); @@ -41,6 +47,19 @@ private static String key(MirrorTask task) { return task.project().name() + '/' + task.mirror().id() + '/' + task.triggeredBy().login(); } + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) { + creationCount.merge(mirror.remoteRepoUri().toString(), 1, Integer::sum); + } + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) { + updateCount.merge(mirror.remoteRepoUri().toString(), 1, Integer::sum); + } + + @Override + public void onDisallowed(Mirror mirror) {} + @Override public void onStart(MirrorTask mirror) { startCount.merge(key(mirror), 1, Integer::sum); diff --git a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestZoneAwareMirrorListener.java b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestZoneAwareMirrorListener.java index caf9e3f937..a05d5f6105 100644 --- a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestZoneAwareMirrorListener.java +++ b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/TestZoneAwareMirrorListener.java @@ -26,6 +26,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; @@ -48,6 +50,15 @@ private static String key(MirrorTask task) { return firstNonNull(task.currentZone(), "default"); } + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) {} + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) {} + + @Override + public void onDisallowed(Mirror mirror) {} + @Override public void onStart(MirrorTask mirror) { logger.debug("onStart: {}", mirror); diff --git a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/ZoneAwareMirrorTest.java b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/ZoneAwareMirrorTest.java index 1b5253ab39..d97186d68d 100644 --- a/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/ZoneAwareMirrorTest.java +++ b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/ZoneAwareMirrorTest.java @@ -54,7 +54,7 @@ import com.linecorp.centraldogma.common.Change; import com.linecorp.centraldogma.common.MirrorException; import com.linecorp.centraldogma.internal.Jackson; -import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.internal.api.v1.PushResultDto; import com.linecorp.centraldogma.server.CentralDogmaBuilder; import com.linecorp.centraldogma.server.ZoneConfig; @@ -220,7 +220,7 @@ private static void createMirror(@Nullable String zone) throws Exception { .execute(); assertThat(response.status()).isEqualTo(HttpStatus.CREATED); - final MirrorDto newMirror = newMirror(zone); + final MirrorRequest newMirror = newMirror(zone); response = client.prepare() .post("/api/v1/projects/{proj}/repos/{repo}/mirrors") .pathParam("proj", FOO_PROJ) @@ -231,20 +231,20 @@ private static void createMirror(@Nullable String zone) throws Exception { assertThat(response.status()).isEqualTo(HttpStatus.CREATED); } - private static MirrorDto newMirror(@Nullable String zone) { - return new MirrorDto(TEST_MIRROR_ID + '-' + (zone == null ? "default" : zone), - true, - FOO_PROJ, - "0/1 * * * * ?", - "REMOTE_TO_LOCAL", - BAR_REPO + '-' + (zone == null ? "default" : zone), - "/", - "git+ssh", - "github.com/line/centraldogma-authtest.git", - "/", - "main", - null, - PRIVATE_KEY_FILE, - zone); + private static MirrorRequest newMirror(@Nullable String zone) { + return new MirrorRequest(TEST_MIRROR_ID + '-' + (zone == null ? "default" : zone), + true, + FOO_PROJ, + "0/1 * * * * ?", + "REMOTE_TO_LOCAL", + BAR_REPO + '-' + (zone == null ? "default" : zone), + "/", + "git+ssh", + "github.com/line/centraldogma-authtest.git", + "/", + "main", + null, + PRIVATE_KEY_FILE, + zone); } } diff --git a/it/server-healthy-plugin/build.gradle b/it/server-healthy-plugin/build.gradle new file mode 100644 index 0000000000..ca7dcaf9e7 --- /dev/null +++ b/it/server-healthy-plugin/build.gradle @@ -0,0 +1,5 @@ +// Need a dedicated module for testing the com.linecorp.centraldogma.it.ServerHealthyTest$HealthCheckingPlugin + +dependencies { + testImplementation project(':server') +} diff --git a/it/server-healthy-plugin/src/test/java/com/linecorp/centraldogma/it/ServerHealthyTest.java b/it/server-healthy-plugin/src/test/java/com/linecorp/centraldogma/it/ServerHealthyTest.java new file mode 100644 index 0000000000..c557926fe8 --- /dev/null +++ b/it/server-healthy-plugin/src/test/java/com/linecorp/centraldogma/it/ServerHealthyTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 LINE Corporation + * + * LINE 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.it; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CompletionStage; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.util.UnmodifiableFuture; +import com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants; +import com.linecorp.centraldogma.server.CentralDogma; +import com.linecorp.centraldogma.server.plugin.AllReplicasPlugin; +import com.linecorp.centraldogma.server.plugin.PluginContext; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +public final class ServerHealthyTest { + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension(); + + @Test + void serverIsUnhealthyWhenPluginStarts() { + assertThat(dogma.httpClient().get(HttpApiV1Constants.HEALTH_CHECK_PATH).aggregate().join().status()) + .isSameAs(HttpStatus.OK); + } + + public static final class HealthCheckingPlugin extends AllReplicasPlugin { + + @Override + public CompletionStage start(PluginContext context) { + final CentralDogma centralDogma = dogma.dogma(); + // Can't call dogma.httpClient() here because the CentralDogmaExtension is not yet initialized. + final WebClient webClient = + WebClient.of("http://127.0.0.1:" + centralDogma.activePort().localAddress().getPort()); + assertThat(webClient.get(HttpApiV1Constants.HEALTH_CHECK_PATH) + .aggregate().join().status()).isSameAs(HttpStatus.SERVICE_UNAVAILABLE); + return UnmodifiableFuture.completedFuture(null); + } + + @Override + public CompletionStage stop(PluginContext context) { + return UnmodifiableFuture.completedFuture(null); + } + + @Override + public Class configType() { + return getClass(); + } + } +} diff --git a/it/server-healthy-plugin/src/test/resources/META-INF/services/com.linecorp.centraldogma.server.plugin.Plugin b/it/server-healthy-plugin/src/test/resources/META-INF/services/com.linecorp.centraldogma.server.plugin.Plugin new file mode 100644 index 0000000000..c6b5951b0c --- /dev/null +++ b/it/server-healthy-plugin/src/test/resources/META-INF/services/com.linecorp.centraldogma.server.plugin.Plugin @@ -0,0 +1 @@ +com.linecorp.centraldogma.it.ServerHealthyTest$HealthCheckingPlugin diff --git a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMetaRepositoryWithMirrorTest.java b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMetaRepositoryWithMirrorTest.java index e071115e62..8e24c420a0 100644 --- a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMetaRepositoryWithMirrorTest.java +++ b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMetaRepositoryWithMirrorTest.java @@ -41,7 +41,7 @@ import com.linecorp.centraldogma.common.Author; import com.linecorp.centraldogma.common.Change; import com.linecorp.centraldogma.common.Revision; -import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.CommitResult; import com.linecorp.centraldogma.server.credential.Credential; @@ -168,18 +168,18 @@ void testMirror(boolean useRawApi) { metaRepo.commit(Revision.HEAD, 0L, Author.SYSTEM, "", mirrors).join(); metaRepo.commit(Revision.HEAD, 0L, Author.SYSTEM, "", UPSERT_RAW_CREDENTIALS).join(); } else { - final List mirrors = ImmutableList.of( - new MirrorDto("foo", true, project.name(), DEFAULT_SCHEDULE, "LOCAL_TO_REMOTE", "foo", + final List mirrors = ImmutableList.of( + new MirrorRequest("foo", true, project.name(), DEFAULT_SCHEDULE, "LOCAL_TO_REMOTE", "foo", "/mirrors/foo", "git+ssh", "foo.com/foo.git", "", "", null, "alice", null), - new MirrorDto("bar", true, project.name(), "0 */10 * * * ?", "REMOTE_TO_LOCAL", "bar", + new MirrorRequest("bar", true, project.name(), "0 */10 * * * ?", "REMOTE_TO_LOCAL", "bar", "", "git+ssh", "bar.com/bar.git", "/some-path", "develop", null, "bob", null)); for (Credential credential : CREDENTIALS) { final Command command = - metaRepo.createPushCommand(credential, Author.SYSTEM, false).join(); + metaRepo.createCredentialPushCommand(credential, Author.SYSTEM, false).join(); pmExtension.executor().execute(command).join(); } - for (MirrorDto mirror : mirrors) { + for (MirrorRequest mirror : mirrors) { final Command command = metaRepo.createMirrorPushCommand(mirror, Author.SYSTEM, null, false).join(); pmExtension.executor().execute(command).join(); diff --git a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java index 513efe210e..dffc12c83b 100644 --- a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java +++ b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java @@ -16,6 +16,7 @@ package com.linecorp.centraldogma.server.internal.mirror; +import static com.google.common.collect.ImmutableMap.toImmutableMap; import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -23,6 +24,7 @@ import java.io.File; import java.net.URI; import java.time.Instant; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; @@ -35,10 +37,13 @@ import com.cronutils.parser.CronParser; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Streams; +import com.linecorp.armeria.common.util.UnmodifiableFuture; import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.credential.Credential; import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorDirection; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorStatus; @@ -94,7 +99,8 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo when(mr.mirrors()).thenReturn(CompletableFuture.completedFuture(ImmutableList.of(mirror))); final MirrorSchedulingService service = new MirrorSchedulingService( - temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null, false); + temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null, false, + AlwaysAllowedMirrorAccessController.INSTANCE); final CommandExecutor executor = mock(CommandExecutor.class); service.start(executor); @@ -105,4 +111,38 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo service.stop(); } } + + private enum AlwaysAllowedMirrorAccessController implements MirrorAccessController { + + INSTANCE; + + @Override + public CompletableFuture allow(String targetPattern, String reason, + int order) { + return UnmodifiableFuture.completedFuture(true); + } + + @Override + public CompletableFuture disallow(String targetPattern, String reason, + int order) { + return UnmodifiableFuture.completedFuture(true); + } + + @Override + public CompletableFuture isAllowed(Mirror mirror) { + return UnmodifiableFuture.completedFuture(true); + } + + @Override + public CompletableFuture isAllowed(String repoUri) { + return UnmodifiableFuture.completedFuture(true); + } + + @Override + public CompletableFuture> isAllowed(Iterable repoUris) { + return UnmodifiableFuture.completedFuture( + Streams.stream(repoUris) + .collect(toImmutableMap(uri -> uri, uri -> true))); + } + } } diff --git a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirroringAndCredentialServiceV1Test.java b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirroringAndCredentialServiceV1Test.java index f98f5677e8..03b8f15f8b 100644 --- a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirroringAndCredentialServiceV1Test.java +++ b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirroringAndCredentialServiceV1Test.java @@ -44,6 +44,7 @@ import com.linecorp.centraldogma.client.armeria.ArmeriaCentralDogmaBuilder; import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.internal.api.v1.PushResultDto; import com.linecorp.centraldogma.server.CentralDogmaBuilder; import com.linecorp.centraldogma.server.credential.Credential; @@ -70,14 +71,10 @@ protected void configure(CentralDogmaBuilder builder) { @Override protected void configureClient(ArmeriaCentralDogmaBuilder builder) { - try { - final String accessToken = getAccessToken( - WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), - USERNAME, PASSWORD); - builder.accessToken(accessToken); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + final String accessToken = getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD); + builder.accessToken(accessToken); } @Override @@ -118,22 +115,22 @@ void cruTest() { } private void rejectInvalidRepositoryUri() { - final MirrorDto newMirror = - new MirrorDto("invalid-mirror", - true, - FOO_PROJ, - "5 * * * * ?", - "REMOTE_TO_LOCAL", - BAR_REPO, - "/local-path/1/", - "git+https", - // Expect github.com/line/centraldogma-authtest.git - "github.com:line/centraldogma-authtest.git", - "/remote-path/1", - "mirror-branch", - ".my-env0\n.my-env1", - "public-key-credential", - null); + final MirrorRequest newMirror = + new MirrorRequest("invalid-mirror", + true, + FOO_PROJ, + "5 * * * * ?", + "REMOTE_TO_LOCAL", + BAR_REPO, + "/local-path/1/", + "git+https", + // Expect github.com/line/centraldogma-authtest.git + "github.com:line/centraldogma-authtest.git", + "/remote-path/1", + "mirror-branch", + ".my-env0\n.my-env1", + "public-key-credential", + null); final AggregatedHttpResponse response = userClient.prepare() .post("/api/v1/projects/{proj}/repos/{repo}/mirrors") @@ -274,7 +271,7 @@ private void updateCredential() { private void createAndReadMirror() { for (int i = 0; i < 3; i++) { - final MirrorDto newMirror = newMirror("mirror-" + i); + final MirrorRequest newMirror = newMirror("mirror-" + i); final ResponseEntity response0 = userClient.prepare() .post("/api/v1/projects/{proj}/repos/{repo}/mirrors") @@ -293,24 +290,27 @@ private void createAndReadMirror() { .asJson(MirrorDto.class) .execute(); final MirrorDto savedMirror = response1.content(); - assertThat(savedMirror).isEqualTo(newMirror); + assertThat(savedMirror) + .usingRecursiveComparison() + .ignoringFields("allow") + .isEqualTo(newMirror); } // Make sure that the mirror with a port number in the remote URL can be created and read. - final MirrorDto mirrorWithPort = new MirrorDto("mirror-with-port-3", - true, - FOO_PROJ, - "5 * * * * ?", - "REMOTE_TO_LOCAL", - BAR_REPO, - "/updated/local-path/", - "git+https", - "git.com:922/line/centraldogma-test.git", - "/updated/remote-path/", - "updated-mirror-branch", - ".updated-env", - "public-key-credential", - null); + final MirrorRequest mirrorWithPort = new MirrorRequest("mirror-with-port-3", + true, + FOO_PROJ, + "5 * * * * ?", + "REMOTE_TO_LOCAL", + BAR_REPO, + "/updated/local-path/", + "git+https", + "git.com:922/line/centraldogma-test.git", + "/updated/remote-path/", + "updated-mirror-branch", + ".updated-env", + "public-key-credential", + null); final ResponseEntity response0 = userClient.prepare() @@ -330,24 +330,27 @@ private void createAndReadMirror() { .asJson(MirrorDto.class) .execute(); final MirrorDto savedMirror = response1.content(); - assertThat(savedMirror).isEqualTo(mirrorWithPort); + assertThat(savedMirror) + .usingRecursiveComparison() + .ignoringFields("allow") + .isEqualTo(mirrorWithPort); } private void updateMirror() { - final MirrorDto mirror = new MirrorDto("mirror-2", - true, - FOO_PROJ, - "5 * * * * ?", - "REMOTE_TO_LOCAL", - BAR_REPO, - "/updated/local-path/", - "git+https", - "github.com/line/centraldogma-updated.git", - "/updated/remote-path/", - "updated-mirror-branch", - ".updated-env", - "access-token-credential", - null); + final MirrorRequest mirror = new MirrorRequest("mirror-2", + true, + FOO_PROJ, + "5 * * * * ?", + "REMOTE_TO_LOCAL", + BAR_REPO, + "/updated/local-path/", + "git+https", + "github.com/line/centraldogma-updated.git", + "/updated/remote-path/", + "updated-mirror-branch", + ".updated-env", + "access-token-credential", + null); final ResponseEntity updateResponse = userClient.prepare() .put("/api/v1/projects/{proj}/repos/{repo}/mirrors/{id}") @@ -367,7 +370,10 @@ private void updateMirror() { .asJson(MirrorDto.class) .execute(); final MirrorDto savedMirror = fetchResponse.content(); - assertThat(savedMirror).isEqualTo(mirror); + assertThat(savedMirror) + .usingRecursiveComparison() + .ignoringFields("allow") + .isEqualTo(mirror); } private void deleteMirror() { @@ -408,20 +414,20 @@ private void deleteCredential() { .isEqualTo(HttpStatus.NOT_FOUND); } - private static MirrorDto newMirror(String id) { - return new MirrorDto(id, - true, - FOO_PROJ, - "5 * * * * ?", - "REMOTE_TO_LOCAL", - BAR_REPO, - "/local-path/" + id + '/', - "git+https", - "github.com/line/centraldogma-authtest.git", - "/remote-path/" + id + '/', - "mirror-branch", - ".my-env0\n.my-env1", - "public-key-credential", - null); + private static MirrorRequest newMirror(String id) { + return new MirrorRequest(id, + true, + FOO_PROJ, + "5 * * * * ?", + "REMOTE_TO_LOCAL", + BAR_REPO, + "/local-path/" + id + '/', + "git+https", + "github.com/line/centraldogma-authtest.git", + "/remote-path/" + id + '/', + "mirror-branch", + ".my-env0\n.my-env1", + "public-key-credential", + null); } } 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 8d0b05ad31..dd488f63c2 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -27,6 +27,8 @@ import static com.linecorp.centraldogma.server.auth.AuthProvider.LOGIN_PATH; import static com.linecorp.centraldogma.server.auth.AuthProvider.LOGOUT_API_ROUTES; import static com.linecorp.centraldogma.server.auth.AuthProvider.LOGOUT_PATH; +import static com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlService.MIRROR_ACCESS_CONTROL_PATH; +import static com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer.INTERNAL_PROJECT_DOGMA; import static java.util.Objects.requireNonNull; import java.io.File; @@ -140,19 +142,24 @@ import com.linecorp.centraldogma.server.internal.api.MirroringServiceV1; import com.linecorp.centraldogma.server.internal.api.ProjectServiceV1; import com.linecorp.centraldogma.server.internal.api.RepositoryServiceV1; -import com.linecorp.centraldogma.server.internal.api.SystemAdministrativeService; -import com.linecorp.centraldogma.server.internal.api.TokenService; import com.linecorp.centraldogma.server.internal.api.WatchService; import com.linecorp.centraldogma.server.internal.api.auth.ApplicationTokenAuthorizer; import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRoleDecorator.RequiresProjectRoleDecoratorFactory; import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRoleDecorator.RequiresRepositoryRoleDecoratorFactory; import com.linecorp.centraldogma.server.internal.api.converter.HttpApiRequestConverter; +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.sysadmin.TokenService; +import com.linecorp.centraldogma.server.internal.mirror.DefaultMirrorAccessController; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin; +import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; import com.linecorp.centraldogma.server.internal.mirror.MirrorRunner; import com.linecorp.centraldogma.server.internal.replication.ZooKeeperCommandExecutor; import com.linecorp.centraldogma.server.internal.storage.project.DefaultProjectManager; import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; +import com.linecorp.centraldogma.server.internal.storage.repository.CrudRepository; import com.linecorp.centraldogma.server.internal.storage.repository.MirrorConfig; +import com.linecorp.centraldogma.server.internal.storage.repository.git.GitCrudRepository; import com.linecorp.centraldogma.server.internal.thrift.CentralDogmaExceptionTranslator; import com.linecorp.centraldogma.server.internal.thrift.CentralDogmaServiceImpl; import com.linecorp.centraldogma.server.internal.thrift.CentralDogmaTimeoutScheduler; @@ -166,6 +173,7 @@ import com.linecorp.centraldogma.server.plugin.PluginInitContext; import com.linecorp.centraldogma.server.plugin.PluginTarget; import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer; +import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.project.ProjectManager; import io.micrometer.core.instrument.MeterRegistry; @@ -256,6 +264,8 @@ public static CentralDogma forConfig(File configFile) throws IOException { private InternalProjectInitializer projectInitializer; @Nullable private volatile MirrorRunner mirrorRunner; + @Nullable + private volatile DefaultMirrorAccessController mirrorAccessController; CentralDogma(CentralDogmaConfig cfg, MeterRegistry meterRegistry, List plugins) { this.cfg = requireNonNull(cfg, "cfg"); @@ -385,7 +395,7 @@ public void close() { startStop.close(); } - private void doStart() throws Exception { + private boolean doStart() throws Exception { boolean success = false; ExecutorService repositoryWorker = null; ScheduledExecutorService purgeWorker = null; @@ -436,7 +446,6 @@ private void doStart() throws Exception { success = true; } finally { if (success) { - serverHealth.setHealthy(true); this.repositoryWorker = repositoryWorker; this.purgeWorker = purgeWorker; this.pm = pm; @@ -447,6 +456,7 @@ private void doStart() throws Exception { doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager, mirrorRunner); } } + return success; } private CommandExecutor startCommandExecutor( @@ -458,7 +468,8 @@ private CommandExecutor startCommandExecutor( if (pluginsForLeaderOnly != null) { logger.info("Starting plugins on the leader replica .."); pluginsForLeaderOnly - .start(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer) + .start(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer, + mirrorAccessController) .handle((unused, cause) -> { if (cause == null) { logger.info("Started plugins on the leader replica."); @@ -473,7 +484,8 @@ private CommandExecutor startCommandExecutor( final Consumer onReleaseLeadership = exec -> { if (pluginsForLeaderOnly != null) { logger.info("Stopping plugins on the leader replica .."); - pluginsForLeaderOnly.stop(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer) + pluginsForLeaderOnly.stop(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer, + mirrorAccessController) .handle((unused, cause) -> { if (cause == null) { logger.info("Stopped plugins on the leader replica."); @@ -495,7 +507,8 @@ private CommandExecutor startCommandExecutor( onTakeZoneLeadership = exec -> { logger.info("Starting plugins on the {} zone leader replica ..", zone); pluginsForZoneLeaderOnly - .start(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer) + .start(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer, + mirrorAccessController) .handle((unused, cause) -> { if (cause == null) { logger.info("Started plugins on the {} zone leader replica.", zone); @@ -508,7 +521,8 @@ private CommandExecutor startCommandExecutor( }; onReleaseZoneLeadership = exec -> { logger.info("Stopping plugins on the {} zone leader replica ..", zone); - pluginsForZoneLeaderOnly.stop(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer) + pluginsForZoneLeaderOnly.stop(cfg, pm, exec, meterRegistry, purgeWorker, projectInitializer, + mirrorAccessController) .handle((unused, cause) -> { if (cause == null) { logger.info("Stopped plugins on the {} zone leader replica.", @@ -544,6 +558,7 @@ private CommandExecutor startCommandExecutor( throw new Error("unknown replication method: " + replicationMethod); } projectInitializer = new InternalProjectInitializer(executor, pm); + mirrorAccessController = new DefaultMirrorAccessController(); final ServerStatus initialServerStatus = statusManager.serverStatus(); executor.setWritable(initialServerStatus.writable()); @@ -570,6 +585,11 @@ private CommandExecutor startCommandExecutor( // Trigger the exception if any. startFuture.get(); projectInitializer.initialize(); + final CrudRepository accessControlRepository = + new GitCrudRepository<>(MirrorAccessControl.class, executor, pm, + INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, + MIRROR_ACCESS_CONTROL_PATH); + mirrorAccessController.setRepository(accessControlRepository); } catch (Exception e) { projectInitializer.whenInitialized().complete(null); logger.warn("Failed to start the command executor. Entering read-only.", e); @@ -693,7 +713,7 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, if (pluginsForAllReplicas != null) { final PluginInitContext pluginInitContext = new PluginInitContext(config(), pm, executor, meterRegistry, purgeWorker, sb, - authService, projectInitializer); + authService, projectInitializer, mirrorAccessController); pluginsForAllReplicas.plugins() .forEach(p -> { if (!(p instanceof AllReplicasPlugin)) { @@ -860,15 +880,19 @@ private void configureHttpApi(ServerBuilder sb, assert statusManager != null; final ContextPathServicesBuilder apiV1ServiceBuilder = sb.contextPath(API_V1_PATH_PREFIX); apiV1ServiceBuilder - .annotatedService(new SystemAdministrativeService(executor, statusManager)) + .annotatedService(new ServerStatusService(executor, statusManager)) .annotatedService(new ProjectServiceV1(projectApiManager, executor)) .annotatedService(new RepositoryServiceV1(executor, mds)) .annotatedService(new CredentialServiceV1(projectApiManager, executor)); if (GIT_MIRROR_ENABLED) { - mirrorRunner = new MirrorRunner(projectApiManager, executor, cfg, meterRegistry); - apiV1ServiceBuilder.annotatedService(new MirroringServiceV1(projectApiManager, executor, - mirrorRunner, cfg)); + mirrorRunner = new MirrorRunner(projectApiManager, executor, cfg, meterRegistry, + mirrorAccessController); + + apiV1ServiceBuilder + .annotatedService(new MirroringServiceV1(projectApiManager, executor, mirrorRunner, cfg, + mirrorAccessController)) + .annotatedService(new MirrorAccessControlService(executor, mirrorAccessController)); } apiV1ServiceBuilder.annotatedService() @@ -1206,15 +1230,18 @@ private final class CentralDogmaStartStop extends StartStopSupport doStart(@Nullable Void unused) throws Exception { return execute("startup", () -> { try { - CentralDogma.this.doStart(); - if (pluginsForAllReplicas != null) { - final ProjectManager pm = CentralDogma.this.pm; - final CommandExecutor executor = CentralDogma.this.executor; - final MeterRegistry meterRegistry = CentralDogma.this.meterRegistry; - if (pm != null && executor != null && meterRegistry != null) { - pluginsForAllReplicas.start(cfg, pm, executor, meterRegistry, purgeWorker, - projectInitializer).join(); + final boolean success = CentralDogma.this.doStart(); + if (success) { + if (pluginsForAllReplicas != null) { + final ProjectManager pm = CentralDogma.this.pm; + final CommandExecutor executor = CentralDogma.this.executor; + final MeterRegistry meterRegistry = CentralDogma.this.meterRegistry; + if (pm != null && executor != null && meterRegistry != null) { + pluginsForAllReplicas.start(cfg, pm, executor, meterRegistry, purgeWorker, + projectInitializer, mirrorAccessController).join(); + } } + serverHealth.setHealthy(true); } } catch (Exception e) { Exceptions.throwUnsafely(e); @@ -1231,7 +1258,7 @@ protected CompletionStage doStop(@Nullable Void unused) throws Exception { final MeterRegistry meterRegistry = CentralDogma.this.meterRegistry; if (pm != null && executor != null && meterRegistry != null) { pluginsForAllReplicas.stop(cfg, pm, executor, meterRegistry, purgeWorker, - projectInitializer).join(); + projectInitializer, mirrorAccessController).join(); } } CentralDogma.this.doStop(); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/PluginGroup.java b/server/src/main/java/com/linecorp/centraldogma/server/PluginGroup.java index a68c12d0d3..f7aa0d7aa7 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/PluginGroup.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/PluginGroup.java @@ -44,6 +44,7 @@ import com.linecorp.armeria.common.util.StartStopSupport; import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.plugin.Plugin; import com.linecorp.centraldogma.server.plugin.PluginContext; import com.linecorp.centraldogma.server.plugin.PluginTarget; @@ -153,9 +154,11 @@ T findFirstPlugin(Class clazz) { CompletableFuture start(CentralDogmaConfig config, ProjectManager projectManager, CommandExecutor commandExecutor, MeterRegistry meterRegistry, ScheduledExecutorService purgeWorker, - InternalProjectInitializer internalProjectInitializer) { + InternalProjectInitializer internalProjectInitializer, + MirrorAccessController mirrorAccessController) { final PluginContext context = new PluginContext(config, projectManager, commandExecutor, meterRegistry, - purgeWorker, internalProjectInitializer); + purgeWorker, internalProjectInitializer, + mirrorAccessController); return startStop.start(context, context, true); } @@ -165,10 +168,11 @@ CompletableFuture start(CentralDogmaConfig config, ProjectManager projectM CompletableFuture stop(CentralDogmaConfig config, ProjectManager projectManager, CommandExecutor commandExecutor, MeterRegistry meterRegistry, ScheduledExecutorService purgeWorker, - InternalProjectInitializer internalProjectInitializer) { + InternalProjectInitializer internalProjectInitializer, + MirrorAccessController mirrorAccessController) { return startStop.stop( new PluginContext(config, projectManager, commandExecutor, meterRegistry, purgeWorker, - internalProjectInitializer)); + internalProjectInitializer, mirrorAccessController)); } private class PluginGroupStartStop extends StartStopSupport { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1.java index 6c0080358a..3228d54970 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1.java @@ -46,6 +46,7 @@ import com.linecorp.centraldogma.server.metadata.User; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.repository.MetaRepository; +import com.linecorp.centraldogma.server.storage.repository.Repository; /** * Annotated service object for managing credential service. @@ -69,16 +70,21 @@ public CredentialServiceV1(ProjectApiManager projectApiManager, CommandExecutor @Get("/projects/{projectName}/credentials") public CompletableFuture> listCredentials(User loginUser, @Param String projectName) { - final CompletableFuture> future = metaRepo(projectName, loginUser).credentials(); + final CompletableFuture> future = + metaRepo(projectName, loginUser).projectCredentials(); + return maybeMaskSecret(loginUser, future); + } + + private static CompletableFuture> maybeMaskSecret( + User loginUser, + CompletableFuture> future) { if (loginUser.isSystemAdmin()) { return future; } - return future.thenApply(credentials -> { - return credentials - .stream() - .map(Credential::withoutSecret) - .collect(toImmutableList()); - }); + return future.thenApply(credentials -> credentials + .stream() + .map(Credential::withoutSecret) + .collect(toImmutableList())); } /** @@ -90,7 +96,7 @@ public CompletableFuture> listCredentials(User loginUser, @Get("/projects/{projectName}/credentials/{id}") public CompletableFuture getCredentialById(User loginUser, @Param String projectName, @Param String id) { - final CompletableFuture future = metaRepo(projectName, loginUser).credential(id); + final CompletableFuture future = metaRepo(projectName, loginUser).projectCredential(id); if (loginUser.isSystemAdmin()) { return future; } @@ -135,7 +141,7 @@ public CompletableFuture updateCredential(@Param String projectNa public CompletableFuture deleteCredential(@Param String projectName, @Param String id, Author author, User user) { final MetaRepository metaRepository = metaRepo(projectName, user); - return metaRepository.credential(id).thenCompose(credential -> { + return metaRepository.projectCredential(id).thenCompose(credential -> { // credential exists. final Command command = Command.push(author, projectName, metaRepository.name(), @@ -147,15 +153,117 @@ public CompletableFuture deleteCredential(@Param String projectName, private CompletableFuture createOrUpdate(String projectName, Credential credential, Author author, User user, boolean update) { - return metaRepo(projectName, user).createPushCommand(credential, author, update).thenCompose( - command -> { - return executor().execute(command).thenApply(result -> { - return new PushResultDto(result.revision(), command.timestamp()); - }); - }); + final CompletableFuture> future = + metaRepo(projectName, user).createCredentialPushCommand(credential, author, update); + return push(future); + } + + private CompletableFuture push( + CompletableFuture> future) { + return future.thenCompose( + command -> executor().execute(command).thenApply( + result -> new PushResultDto(result.revision(), command.timestamp()))); } private MetaRepository metaRepo(String projectName, User user) { return projectApiManager.getProject(projectName, user).metaRepo(); } + + // Repository level credential management APIs. + + /** + * GET /projects/{projectName}/repos/{repoName}/credentials + * + *

Returns the list of the credentials in the repository. + */ + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Get("/projects/{projectName}/repos/{repoName}/credentials") + public CompletableFuture> listRepoCredentials(User loginUser, + @Param String projectName, + Repository repository) { + final CompletableFuture> future = + metaRepo(projectName, loginUser).repoCredentials(repository.name()); + return maybeMaskSecret(loginUser, future); + } + + /** + * GET /projects/{projectName}/credentials/{id} + * + *

Returns the credential for the ID in the project. + */ + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Get("/projects/{projectName}/repos/{repoName}/credentials/{id}") + public CompletableFuture getRepoCredentialById(User loginUser, + @Param String projectName, + Repository repository, + @Param String id) { + final CompletableFuture future = + metaRepo(projectName, loginUser).repoCredential(repository.name(), id); + if (loginUser.isSystemAdmin()) { + return future; + } + return future.thenApply(Credential::withoutSecret); + } + + /** + * POST /projects/{projectName}/credentials + * + *

Creates a new credential. + */ + @ConsumesJson + @StatusCode(201) + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Post("/projects/{projectName}/repos/{repoName}/credentials") + public CompletableFuture createRepoCredential(@Param String projectName, + Repository repository, + Credential credential, Author author, + User user) { + return createOrUpdateRepo(projectName, repository.name(), credential, author, user, false); + } + + /** + * PUT /projects/{projectName}/credentials/{id} + * + *

Update the existing credential. + */ + @ConsumesJson + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Put("/projects/{projectName}/repos/{repoName}/credentials/{id}") + public CompletableFuture updateRepoCredential(@Param String projectName, + Repository repository, + @Param String id, + Credential credential, Author author, + User user) { + checkArgument(id.equals(credential.id()), "The credential ID (%s) can't be updated", id); + return createOrUpdateRepo(projectName, repository.name(), credential, author, user, true); + } + + private CompletableFuture createOrUpdateRepo( + String projectName, String repoName, Credential credential, + Author author, User user, boolean update) { + final CompletableFuture> future = + metaRepo(projectName, user).createCredentialPushCommand(repoName, credential, author, update); + return push(future); + } + + /** + * DELETE /projects/{projectName}/credentials/{id} + * + *

Delete the existing credential. + */ + @RequiresRepositoryRole(RepositoryRole.ADMIN) + @Delete("/projects/{projectName}/repos/{repoName}/credentials/{id}") + public CompletableFuture deleteRepoCredential(@Param String projectName, + Repository repository, + @Param String id, Author author, User user) { + final MetaRepository metaRepository = metaRepo(projectName, user); + return metaRepository.repoCredential(repository.name(), id).thenCompose(credential -> { + // credential exists. + final Command command = + Command.push(author, projectName, metaRepository.name(), + Revision.HEAD, "Delete credential: " + id, "", + Markup.PLAINTEXT, Change.ofRemoval(credentialFile(repository.name(), id))); + return executor().execute(command).thenApply(result -> null); + }); + } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/HttpApiExceptionHandler.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/HttpApiExceptionHandler.java index e0d12559bb..c388f2c034 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/HttpApiExceptionHandler.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/HttpApiExceptionHandler.java @@ -37,6 +37,7 @@ import com.linecorp.centraldogma.common.EntryNoContentException; import com.linecorp.centraldogma.common.EntryNotFoundException; import com.linecorp.centraldogma.common.InvalidPushException; +import com.linecorp.centraldogma.common.MirrorAccessException; import com.linecorp.centraldogma.common.MirrorException; import com.linecorp.centraldogma.common.PermissionException; import com.linecorp.centraldogma.common.ProjectExistsException; @@ -117,6 +118,8 @@ public final class HttpApiExceptionHandler implements ServerErrorHandler { (ctx, cause) -> newResponse(ctx, HttpStatus.SERVICE_UNAVAILABLE, cause)) .put(MirrorException.class, (ctx, cause) -> newResponse(ctx, HttpStatus.INTERNAL_SERVER_ERROR, cause)) + .put(MirrorAccessException.class, + (ctx, cause) -> newResponse(ctx, HttpStatus.FORBIDDEN, cause)) .put(AuthorizationException.class, (ctx, cause) -> newResponse(ctx, HttpStatus.UNAUTHORIZED, cause)) .put(PermissionException.class, diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java index 77bb2362fb..ef25090d66 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java @@ -29,7 +29,11 @@ import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.cronutils.model.Cron; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.linecorp.armeria.server.annotation.ConsumesJson; @@ -48,6 +52,7 @@ import com.linecorp.centraldogma.common.RepositoryRole; import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.internal.api.v1.PushResultDto; import com.linecorp.centraldogma.server.CentralDogmaConfig; import com.linecorp.centraldogma.server.ZoneConfig; @@ -57,9 +62,12 @@ import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRole; import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRole; import com.linecorp.centraldogma.server.internal.mirror.MirrorRunner; +import com.linecorp.centraldogma.server.internal.mirror.MirrorSchedulingService; import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; import com.linecorp.centraldogma.server.metadata.User; import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; +import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig; import com.linecorp.centraldogma.server.storage.project.Project; @@ -72,6 +80,8 @@ @ProducesJson public class MirroringServiceV1 extends AbstractService { + private static final Logger logger = LoggerFactory.getLogger(MirroringServiceV1.class); + // TODO(ikhoon): // - Write documentation for the REST API specification // - Add Java APIs to the CentralDogma client @@ -81,14 +91,17 @@ public class MirroringServiceV1 extends AbstractService { private final Map mirrorZoneConfig; @Nullable private final ZoneConfig zoneConfig; + private final MirrorAccessController accessController; public MirroringServiceV1(ProjectApiManager projectApiManager, CommandExecutor executor, - MirrorRunner mirrorRunner, CentralDogmaConfig config) { + MirrorRunner mirrorRunner, CentralDogmaConfig config, + MirrorAccessController accessController) { super(executor); this.projectApiManager = projectApiManager; this.mirrorRunner = mirrorRunner; zoneConfig = config.zone(); mirrorZoneConfig = mirrorZoneConfig(config); + this.accessController = accessController; } private static Map mirrorZoneConfig(CentralDogmaConfig config) { @@ -138,8 +151,10 @@ public CompletableFuture> listRepoMirrors(@Param String projectN public CompletableFuture getMirror(@Param String projectName, Repository repository, @Param String id) { - return metaRepo(projectName).mirror(repository.name(), id).thenApply(mirror -> { - return convertToMirrorDto(projectName, mirror); + return metaRepo(projectName).mirror(repository.name(), id).thenCompose(mirror -> { + return accessController.isAllowed(mirror.remoteRepoUri()).thenApply(allowed -> { + return convertToMirrorDto(projectName, mirror, allowed); + }); }); } @@ -154,7 +169,7 @@ public CompletableFuture getMirror(@Param String projectName, @RequiresRepositoryRole(RepositoryRole.ADMIN) public CompletableFuture createMirror(@Param String projectName, Repository ignored, - MirrorDto newMirror, + MirrorRequest newMirror, Author author) { return createOrUpdate(projectName, newMirror, author, false); } @@ -169,7 +184,7 @@ public CompletableFuture createMirror(@Param String projectName, @RequiresRepositoryRole(RepositoryRole.ADMIN) public CompletableFuture updateMirror(@Param String projectName, Repository ignored, - MirrorDto mirror, + MirrorRequest mirror, @Param String id, Author author) { checkArgument(id.equals(mirror.id()), "The mirror ID (%s) can't be updated", id); return createOrUpdate(projectName, mirror, author, true); @@ -197,14 +212,37 @@ public CompletableFuture deleteMirror(@Param String projectName, }); } - private CompletableFuture createOrUpdate(String projectName, MirrorDto newMirror, + private CompletableFuture createOrUpdate(String projectName, MirrorRequest newMirror, Author author, boolean update) { - return metaRepo(projectName) - .createMirrorPushCommand(newMirror, author, zoneConfig, update).thenCompose(command -> { - return executor().execute(command).thenApply(result -> { - return new PushResultDto(result.revision(), command.timestamp()); - }); - }); + final MetaRepository metaRepo = metaRepo(projectName); + return metaRepo.createMirrorPushCommand(newMirror, author, zoneConfig, update).thenCompose(command -> { + return executor().execute(command).thenApply(result -> { + metaRepo.mirror(newMirror.localRepo(), newMirror.id(), result.revision()) + .handle((mirror, cause) -> { + if (cause != null) { + // This should not happen in normal cases. + logger.warn("Failed to get the mirror: {}", newMirror.id(), cause); + return null; + } + return notifyMirrorEvent(mirror, update); + }); + return new PushResultDto(result.revision(), command.timestamp()); + }); + }); + } + + private Void notifyMirrorEvent(Mirror mirror, boolean update) { + try { + final MirrorListener listener = MirrorSchedulingService.mirrorListener(); + if (update) { + listener.onUpdate(mirror, accessController); + } else { + listener.onCreate(mirror, accessController); + } + } catch (Throwable ex) { + logger.warn("Failed to notify the mirror listener. (mirror: {})", mirror, ex); + } + return null; } /** @@ -234,14 +272,26 @@ public Map config() { return mirrorZoneConfig; } - private static CompletableFuture> convertToMirrorDtos( + private CompletableFuture> convertToMirrorDtos( String projectName, CompletableFuture> future) { - return future.thenApply(mirrors -> mirrors.stream() - .map(mirror -> convertToMirrorDto(projectName, mirror)) - .collect(toImmutableList())); + return future.thenCompose(mirrors -> { + final ImmutableList remoteUris = mirrors.stream().map( + mirror -> mirror.remoteRepoUri().toString()).collect( + toImmutableList()); + return accessController.isAllowed(remoteUris).thenApply(acl -> { + return mirrors.stream() + .map(mirror -> convertToMirrorDto(projectName, mirror, acl)) + .collect(toImmutableList()); + }); + }); + } + + private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror, Map acl) { + final boolean allowed = acl.get(mirror.remoteRepoUri().toString()); + return convertToMirrorDto(projectName, mirror, allowed); } - private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror) { + private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror, boolean allowed) { final URI remoteRepoUri = mirror.remoteRepoUri(); final Cron schedule = mirror.schedule(); final String scheduleStr = schedule != null ? schedule.asString() : null; @@ -256,7 +306,7 @@ private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror) { mirror.remotePath(), mirror.remoteBranch(), mirror.gitignore(), - mirror.credential().id(), mirror.zone()); + mirror.credential().id(), mirror.zone(), allowed); } private MetaRepository metaRepo(String projectName) { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlRequest.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlRequest.java new file mode 100644 index 0000000000..165cdd70ff --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlRequest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.sysadmin; + +import static com.linecorp.centraldogma.server.internal.storage.repository.git.GitCrudRepository.validateId; +import static java.util.Objects.requireNonNull; + +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +public final class MirrorAccessControlRequest { + private final String id; + private final String targetPattern; + private final boolean allow; + private final String description; + private final int order; + + @JsonCreator + public MirrorAccessControlRequest(@JsonProperty("id") String id, + @JsonProperty("targetPattern") String targetPattern, + @JsonProperty("allow") boolean allow, + @JsonProperty("description") String description, + @JsonProperty("order") int order) { + this.id = validateId(id); + // Validate the target pattern. + try { + Pattern.compile(targetPattern); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("invalid targetPattern: " + targetPattern, e); + } + this.targetPattern = requireNonNull(targetPattern, "targetPattern"); + this.allow = allow; + this.description = requireNonNull(description, "description"); + this.order = order; + } + + /** + * Returns the ID of the mirror access control. + */ + @JsonProperty + public String id() { + return id; + } + + /** + * Returns the target pattern of the mirror. + */ + @JsonProperty + public String targetPattern() { + return targetPattern; + } + + /** + * Returns whether the mirror ACL allows or denies the target pattern. + */ + @JsonProperty + public boolean allow() { + return allow; + } + + /** + * Returns the description of the mirror access control. + */ + @JsonProperty + public String description() { + return description; + } + + /** + * Returns the order of the mirror access control. + */ + @JsonProperty + public int order() { + return order; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MirrorAccessControlRequest)) { + return false; + } + final MirrorAccessControlRequest that = (MirrorAccessControlRequest) o; + return allow == that.allow && + order == that.order && + id.equals(that.id) && + targetPattern.equals(that.targetPattern) && + description.equals(that.description); + } + + @Override + public int hashCode() { + return Objects.hash(id, targetPattern, allow, description, order); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("targetPattern", targetPattern) + .add("allow", allow) + .add("description", description) + .add("order", order) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlService.java new file mode 100644 index 0000000000..5f1d0fd902 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlService.java @@ -0,0 +1,107 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.sysadmin; + +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +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.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.internal.api.AbstractService; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator; +import com.linecorp.centraldogma.server.internal.mirror.DefaultMirrorAccessController; +import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; + +/** + * A service which provides the API for managing the mirror access control. + */ +@ProducesJson +@ConsumesJson +@RequiresSystemAdministrator +public final class MirrorAccessControlService extends AbstractService { + + public static final String MIRROR_ACCESS_CONTROL_PATH = "/mirror-access-control/"; + + private final DefaultMirrorAccessController accessController; + + public MirrorAccessControlService(CommandExecutor executor, + DefaultMirrorAccessController accessController) { + super(executor); + this.accessController = requireNonNull(accessController, "accessController"); + } + + /** + * GET /mirror/access + * + *

Returns the list of mirror access control. + */ + @Get("/mirror/access") + public CompletableFuture> list() { + return accessController.list(); + } + + /** + * POST /mirror/access + * + *

Creates a new mirror access control. + */ + @StatusCode(201) + @Post("/mirror/access") + public CompletableFuture create(MirrorAccessControlRequest request, Author author) { + return accessController.add(request, author); + } + + /** + * PUT /mirror/access + * + *

Updates the mirror access control. + */ + @Put("/mirror/access") + public CompletableFuture update(MirrorAccessControlRequest request, Author author) { + return accessController.update(request, author); + } + + /** + * GET /mirror/access/{id} + * + *

Returns the mirror access control. + */ + @Get("/mirror/access/{id}") + public CompletableFuture get(@Param String id) { + return accessController.get(id); + } + + /** + * DELETE /mirror/access/{id} + * + *

Deletes the mirror access control. + */ + @Delete("/mirror/access/{id}") + public CompletableFuture delete(@Param String id, Author author) { + return accessController.delete(id, author); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/SystemAdministrativeService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/ServerStatusService.java similarity index 88% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/SystemAdministrativeService.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/ServerStatusService.java index 6a261b05e5..5a27643f37 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/SystemAdministrativeService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/ServerStatusService.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api; +package com.linecorp.centraldogma.server.internal.api.sysadmin; import java.util.concurrent.CompletableFuture; @@ -26,17 +26,18 @@ import com.linecorp.armeria.server.annotation.Put; import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.CommandExecutor; -import com.linecorp.centraldogma.server.internal.api.UpdateServerStatusRequest.Scope; +import com.linecorp.centraldogma.server.internal.api.AbstractService; import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator; +import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest.Scope; import com.linecorp.centraldogma.server.management.ServerStatus; import com.linecorp.centraldogma.server.management.ServerStatusManager; @ProducesJson -public final class SystemAdministrativeService extends AbstractService { +public final class ServerStatusService extends AbstractService { private final ServerStatusManager serverStatusManager; - public SystemAdministrativeService(CommandExecutor executor, ServerStatusManager serverStatusManager) { + public ServerStatusService(CommandExecutor executor, ServerStatusManager serverStatusManager) { super(executor); this.serverStatusManager = serverStatusManager; } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/TokenLevelRequest.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/TokenLevelRequest.java similarity index 94% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/TokenLevelRequest.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/TokenLevelRequest.java index cead450bd8..fd65975d7b 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/TokenLevelRequest.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/TokenLevelRequest.java @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api; +package com.linecorp.centraldogma.server.internal.api.sysadmin; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/TokenService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/TokenService.java similarity index 97% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/TokenService.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/TokenService.java index 6ee120298f..9287847224 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/TokenService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/TokenService.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api; +package com.linecorp.centraldogma.server.internal.api.sysadmin; import static com.google.common.base.Preconditions.checkArgument; import static java.util.Objects.requireNonNull; @@ -48,6 +48,8 @@ import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.internal.api.AbstractService; +import com.linecorp.centraldogma.server.internal.api.HttpApiUtil; import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator; import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter; import com.linecorp.centraldogma.server.metadata.MetadataService; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/UpdateServerStatusRequest.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/UpdateServerStatusRequest.java similarity index 97% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/api/UpdateServerStatusRequest.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/UpdateServerStatusRequest.java index 45c81fa0bb..336c1bc63d 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/UpdateServerStatusRequest.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/UpdateServerStatusRequest.java @@ -14,7 +14,7 @@ * under the License. */ -package com.linecorp.centraldogma.server.internal.api; +package com.linecorp.centraldogma.server.internal.api.sysadmin; import static com.google.common.base.MoreObjects.firstNonNull; import static java.util.Objects.requireNonNull; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/package-info.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/package-info.java new file mode 100644 index 0000000000..9c8c4a34b6 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2021 LINE Corporation + * + * LINE 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. + */ +/** + * System administrative API. + */ +@NonNullByDefault +package com.linecorp.centraldogma.server.internal.api.sysadmin; + +import com.linecorp.centraldogma.common.util.NonNullByDefault; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/CompositeMirrorListener.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/CompositeMirrorListener.java index 8403d38b77..6d53912a2a 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/CompositeMirrorListener.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/CompositeMirrorListener.java @@ -21,6 +21,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; @@ -35,6 +37,39 @@ final class CompositeMirrorListener implements MirrorListener { this.delegates = delegates; } + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) { + for (MirrorListener delegate : delegates) { + try { + delegate.onCreate(mirror, accessController); + } catch (Exception e) { + logger.warn("Failed to notify a listener of the mirror create event: {}", delegate, e); + } + } + } + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) { + for (MirrorListener delegate : delegates) { + try { + delegate.onUpdate(mirror, accessController); + } catch (Exception e) { + logger.warn("Failed to notify a listener of the mirror update event: {}", delegate, e); + } + } + } + + @Override + public void onDisallowed(Mirror mirror) { + for (MirrorListener delegate : delegates) { + try { + delegate.onDisallowed(mirror); + } catch (Exception e) { + logger.warn("Failed to notify a listener of the mirror disallowed event: {}", delegate, e); + } + } + } + @Override public void onStart(MirrorTask mirrorTask) { for (MirrorListener delegate : delegates) { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessController.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessController.java new file mode 100644 index 0000000000..6b22e2aa4a --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessController.java @@ -0,0 +1,177 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.mirror; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.Streams; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlRequest; +import com.linecorp.centraldogma.server.internal.storage.repository.CrudRepository; +import com.linecorp.centraldogma.server.internal.storage.repository.HasRevision; +import com.linecorp.centraldogma.server.metadata.UserAndTimestamp; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; + +public final class DefaultMirrorAccessController implements MirrorAccessController { + + private static final Logger logger = LoggerFactory.getLogger(DefaultMirrorAccessController.class); + + private static final UuidGenerator idGenerator = new UuidGenerator(); + + @Nullable + private CrudRepository repository; + + public void setRepository(CrudRepository repository) { + checkState(this.repository == null, "repository is already set."); + this.repository = repository; + } + + private CrudRepository repository() { + checkState(repository != null, "repository is not set."); + return repository; + } + + public CompletableFuture add(MirrorAccessControlRequest request, Author author) { + return repository().save(MirrorAccessControl.from(request, author), author) + .thenApply(HasRevision::object); + } + + public CompletableFuture update(MirrorAccessControlRequest request, Author author) { + return repository().update(MirrorAccessControl.from(request, author), author) + .thenApply(HasRevision::object); + } + + public CompletableFuture get(String id) { + return repository().find(id).thenApply(HasRevision::object); + } + + public CompletableFuture> list() { + return repository().findAll().thenApply(list -> list.stream() + .map(HasRevision::object) + .collect(toImmutableList())); + } + + @Override + public CompletableFuture allow(String targetPattern, String reason, int order) { + final Author author = Author.SYSTEM; + final MirrorAccessControl accessControl = + new MirrorAccessControl(idGenerator.generateId().toString(), targetPattern, true, + reason, order, UserAndTimestamp.of(author)); + logger.info("Allowing the target pattern: {}", accessControl); + // If there is a duplicate target pattern, the order will be considered first. + // If the order is the same, the latest one will be considered first. + return repository().save(accessControl, author).thenApply(unused -> true); + } + + @Override + public CompletableFuture disallow(String targetPattern, String reason, int order) { + final Author author = Author.SYSTEM; + final MirrorAccessControl accessControl = + new MirrorAccessControl(idGenerator.generateId().toString(), targetPattern, false, + reason, order, UserAndTimestamp.of(author)); + logger.info("Disallowing the target pattern: {}", accessControl); + return repository().save(accessControl, author).thenApply(unused -> true); + } + + @Override + public CompletableFuture isAllowed(String repoUri) { + return repository().findAll().thenApply(acl -> { + if (acl.isEmpty()) { + // If there is no access control, it is allowed by default. + return true; + } + + final List> sorted = + acl.stream() + .sorted(AccessControlComparator.INSTANCE) + .collect(toImmutableList()); + for (HasRevision entity : sorted) { + try { + if (repoUri.equals(entity.object().targetPattern()) || + repoUri.matches(entity.object().targetPattern())) { + return entity.object().allow(); + } + } catch (Exception e) { + logger.warn("Failed to match the target pattern: {}", entity.object().targetPattern(), e); + continue; + } + } + // If there is no matching pattern, it is allowed by default. + return true; + }); + } + + @Override + public CompletableFuture> isAllowed(Iterable repoUris) { + return repository().findAll().thenApply(acl -> { + if (acl.isEmpty()) { + // If there is no access control, it is allowed by default. + return Streams.stream(repoUris) + .distinct() + .collect(toImmutableMap(uri -> uri, uri -> true)); + } + + final List> sorted = + acl.stream() + .sorted(AccessControlComparator.INSTANCE) + .collect(toImmutableList()); + return Streams.stream(repoUris).collect(toImmutableMap(uri -> uri, uri -> { + for (HasRevision entity : sorted) { + if (uri.matches(entity.object().targetPattern())) { + return entity.object().allow(); + } + } + // If there is no matching pattern, it is allowed by default. + return true; + })); + }); + } + + public CompletableFuture delete(String id, Author author) { + return repository().delete(id, author, "Delete '" + id + '\'') + .thenAccept(unused -> { + }); + } + + private enum AccessControlComparator implements Comparator> { + INSTANCE; + + @Override + public int compare(HasRevision o1, HasRevision o2) { + // A lower order comes first. + final int result = Integer.compare(o1.object().order(), o2.object().order()); + if (result != 0) { + return result; + } + // A recent creation comes first. + return o2.object().creation().timestamp().compareTo(o1.object().creation().timestamp()); + } + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorListener.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorListener.java index aef0814a00..d318fb7740 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorListener.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorListener.java @@ -19,6 +19,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; @@ -29,6 +31,22 @@ enum DefaultMirrorListener implements MirrorListener { private static final Logger logger = LoggerFactory.getLogger(DefaultMirrorListener.class); + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) { + logger.debug("A new mirroring from {} is created. mirror: {}", mirror.remoteRepoUri(), mirror); + } + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) { + logger.debug("The mirroring ID {} is updated. mirror: {}", mirror.id(), mirror); + } + + @Override + public void onDisallowed(Mirror mirror) { + logger.debug("The mirroring from {} is not allowed. mirror: {}", + mirror.remoteRepoUri(), mirror); + } + @Override public void onStart(MirrorTask mirrorTask) { if (mirrorTask.scheduled()) { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java index e7e161ee57..e2a40699ee 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java @@ -98,7 +98,8 @@ public synchronized CompletionStage start(PluginContext context) { context.meterRegistry(), numThreads, maxNumFilesPerMirror, - maxNumBytesPerMirror, zoneConfig, runMigration); + maxNumBytesPerMirror, zoneConfig, runMigration, + context.mirrorAccessController()); this.mirroringService = mirroringService; } mirroringService.start(context.commandExecutor()); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorAccessControl.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorAccessControl.java new file mode 100644 index 0000000000..91d998ace4 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorAccessControl.java @@ -0,0 +1,140 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.mirror; + +import static java.util.Objects.requireNonNull; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlRequest; +import com.linecorp.centraldogma.server.internal.storage.repository.HasId; +import com.linecorp.centraldogma.server.metadata.UserAndTimestamp; + +public final class MirrorAccessControl implements HasId { + + static MirrorAccessControl from(MirrorAccessControlRequest request, Author author) { + return new MirrorAccessControl(request.id(), request.targetPattern(), request.allow(), + request.description(), request.order(), UserAndTimestamp.of(author)); + } + + private final String id; + private final String targetPattern; + private final boolean allow; + private final String description; + private final int order; + private final UserAndTimestamp creation; + + @JsonCreator + MirrorAccessControl(@JsonProperty("id") String id, + @JsonProperty("targetPattern") String targetPattern, + @JsonProperty("allow") boolean allow, + @JsonProperty("description") String description, + @JsonProperty("order") int order, + @JsonProperty("creation") UserAndTimestamp creation) { + this.id = requireNonNull(id, "id"); + this.targetPattern = requireNonNull(targetPattern, "targetPattern"); + this.allow = allow; + this.description = requireNonNull(description, "description"); + this.creation = requireNonNull(creation, "creation"); + this.order = order; + } + + /** + * Returns the ID of the mirror access control. + */ + @Override + @JsonProperty + public String id() { + return id; + } + + /** + * Returns the target pattern of the mirror. + */ + @JsonProperty + public String targetPattern() { + return targetPattern; + } + + /** + * Returns whether the mirror ACL allows or denies the target pattern. + */ + @JsonProperty + public boolean allow() { + return allow; + } + + /** + * Returns the description of the mirror access control. + */ + @JsonProperty + public String description() { + return description; + } + + /** + * Returns the order of the mirror access control. + */ + @JsonProperty + public int order() { + return order; + } + + /** + * Returns who creates the mirror ACL when. + */ + @JsonProperty + public UserAndTimestamp creation() { + return creation; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MirrorAccessControl)) { + return false; + } + final MirrorAccessControl that = (MirrorAccessControl) o; + return allow == that.allow && + order == that.order && + id.equals(that.id) && + targetPattern.equals(that.targetPattern) && + description.equals(that.description) && + creation.equals(that.creation); + } + + @Override + public int hashCode() { + return Objects.hash(id, order, targetPattern, allow, description, creation); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("targetPattern", targetPattern) + .add("allow", allow) + .add("description", description) + .add("order", order) + .add("creation", creation) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java index 411f6b96ab..a5781e00a7 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java @@ -34,11 +34,13 @@ import com.google.common.base.MoreObjects; import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.centraldogma.common.MirrorAccessException; import com.linecorp.centraldogma.common.MirrorException; import com.linecorp.centraldogma.server.CentralDogmaConfig; import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; import com.linecorp.centraldogma.server.metadata.User; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; @@ -60,9 +62,11 @@ public final class MirrorRunner implements SafeCloseable { private final Map> inflightRequests = new ConcurrentHashMap<>(); @Nullable private final String currentZone; + private final MirrorAccessController mirrorAccessController; public MirrorRunner(ProjectApiManager projectApiManager, CommandExecutor commandExecutor, - CentralDogmaConfig cfg, MeterRegistry meterRegistry) { + CentralDogmaConfig cfg, MeterRegistry meterRegistry, + MirrorAccessController mirrorAccessController) { this.projectApiManager = projectApiManager; this.commandExecutor = commandExecutor; // TODO(ikhoon): Periodically clean up stale repositories. @@ -79,6 +83,7 @@ public MirrorRunner(ProjectApiManager projectApiManager, CommandExecutor command } else { currentZone = null; } + this.mirrorAccessController = mirrorAccessController; final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( 0, mirrorConfig.numMirroringThreads(), @@ -99,33 +104,45 @@ public CompletableFuture run(String projectName, String repoName, private CompletableFuture run(MirrorKey mirrorKey, User user) { try { - final CompletableFuture future = metaRepo(mirrorKey.projectName).mirror( - mirrorKey.repoName, mirrorKey.mirrorId).thenApplyAsync(mirror -> { - if (!mirror.enabled()) { - throw new MirrorException("The mirror is disabled: " + mirrorKey.projectName + '/' + - mirrorKey.repoName + '/' + mirrorKey.mirrorId); - } - - final String zone = mirror.zone(); - if (zone != null && !zone.equals(currentZone)) { - throw new MirrorException("The mirror is not in the current zone: " + currentZone); - } - final MirrorTask mirrorTask = new MirrorTask(mirror, user, Instant.now(), - currentZone, false); - final MirrorListener listener = MirrorSchedulingService.mirrorListener; - listener.onStart(mirrorTask); - try { - final MirrorResult mirrorResult = mirror.mirror(workDir, commandExecutor, - mirrorConfig.maxNumFilesPerMirror(), - mirrorConfig.maxNumBytesPerMirror(), - mirrorTask.triggeredTime()); - listener.onComplete(mirrorTask, mirrorResult); - return mirrorResult; - } catch (Exception e) { - listener.onError(mirrorTask, e); - throw e; - } - }, worker); + final CompletableFuture future = + metaRepo(mirrorKey.projectName).mirror(mirrorKey.repoName, mirrorKey.mirrorId).thenCompose( + mirror -> { + if (!mirror.enabled()) { + throw new MirrorException("The mirror is disabled: " + + mirrorKey.repoName + '/' + mirrorKey.mirrorId); + } + + return mirrorAccessController.isAllowed(mirror).thenApplyAsync(allowed -> { + if (!allowed) { + throw new MirrorAccessException( + "The mirroring from " + mirror.remoteRepoUri() + + " is not allowed: " + + mirrorKey.projectName + '/' + mirrorKey.mirrorId); + } + + final String zone = mirror.zone(); + if (zone != null && !zone.equals(currentZone)) { + throw new MirrorException( + "The mirror is not in the current zone: " + currentZone); + } + final MirrorTask mirrorTask = new MirrorTask(mirror, user, Instant.now(), + currentZone, false); + final MirrorListener listener = MirrorSchedulingService.mirrorListener(); + listener.onStart(mirrorTask); + try { + final MirrorResult mirrorResult = + mirror.mirror(workDir, commandExecutor, + mirrorConfig.maxNumFilesPerMirror(), + mirrorConfig.maxNumBytesPerMirror(), + mirrorTask.triggeredTime()); + listener.onComplete(mirrorTask, mirrorResult); + return mirrorResult; + } catch (Exception e) { + listener.onError(mirrorTask, e); + throw e; + } + }, worker); + }); // Remove the inflight request when the mirror task is done. future.handleAsync((unused0, unused1) -> inflightRequests.remove(mirrorKey), worker); return future; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java index 1052bc66d5..388161cb6e 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java @@ -56,6 +56,7 @@ import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.metadata.User; import com.linecorp.centraldogma.server.mirror.Mirror; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorListener; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorTask; @@ -69,7 +70,7 @@ public final class MirrorSchedulingService implements MirroringService { private static final Logger logger = LoggerFactory.getLogger(MirrorSchedulingService.class); - static final MirrorListener mirrorListener; + private static final MirrorListener mirrorListener; static { final List listeners = @@ -87,6 +88,10 @@ public final class MirrorSchedulingService implements MirroringService { */ private static final Duration TICK = Duration.ofSeconds(1); + public static MirrorListener mirrorListener() { + return mirrorListener; + } + private final File workDir; private final ProjectManager projectManager; private final int numThreads; @@ -96,6 +101,7 @@ public final class MirrorSchedulingService implements MirroringService { private final ZoneConfig zoneConfig; @Nullable private final String currentZone; + private final MirrorAccessController mirrorAccessController; private volatile CommandExecutor commandExecutor; private volatile ListeningScheduledExecutorService scheduler; @@ -109,7 +115,8 @@ public final class MirrorSchedulingService implements MirroringService { @VisibleForTesting public MirrorSchedulingService(File workDir, ProjectManager projectManager, MeterRegistry meterRegistry, int numThreads, int maxNumFilesPerMirror, long maxNumBytesPerMirror, - @Nullable ZoneConfig zoneConfig, boolean runMigration) { + @Nullable ZoneConfig zoneConfig, boolean runMigration, + MirrorAccessController mirrorAccessController) { this.workDir = requireNonNull(workDir, "workDir"); this.projectManager = requireNonNull(projectManager, "projectManager"); @@ -130,6 +137,7 @@ public MirrorSchedulingService(File workDir, ProjectManager projectManager, Mete } else { currentZone = null; } + this.mirrorAccessController = mirrorAccessController; } public boolean isStarted() { @@ -233,6 +241,20 @@ private void scheduleMirrors() { if (m.schedule() == null) { continue; } + + try { + final boolean allowed = mirrorAccessController.isAllowed(m) + .get(5, TimeUnit.SECONDS); + if (!allowed) { + mirrorListener.onDisallowed(m); + continue; + } + } catch (Exception e) { + logger.warn("Failed to check the access control. mirror: {}", + m, e); + continue; + } + if (zoneConfig != null) { String pinnedZone = m.zone(); if (pinnedZone == null) { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/UuidGenerator.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/UuidGenerator.java new file mode 100644 index 0000000000..7bf396be2f --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/UuidGenerator.java @@ -0,0 +1,73 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ +/* + * Copyright 2002-2015 the original author or authors. + * + * Licensed 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.mirror; + +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.Random; +import java.util.UUID; + +/** + * An {@link UuidGenerator} that uses {@link SecureRandom} for the initial seed and + * {@link Random} thereafter, instead of calling {@link UUID#randomUUID()} every + * time. This provides a better balance between securely random ids and performance. + */ +final class UuidGenerator { + + // Forked from https://github.com/spring-projects/spring-framework/blob/a2bc1ded73417332ac474f72f034f55f6f1e4ef6/spring-core/src/main/java/org/springframework/util/AlternativeJdkIdGenerator.java#L34 + + private final Random random; + + UuidGenerator() { + final SecureRandom secureRandom = new SecureRandom(); + final byte[] seed = new byte[8]; + secureRandom.nextBytes(seed); + random = new Random(new BigInteger(seed).longValue()); + } + + UUID generateId() { + final byte[] randomBytes = new byte[16]; + random.nextBytes(randomBytes); + + long mostSigBits = 0; + for (int i = 0; i < 8; i++) { + mostSigBits = (mostSigBits << 8) | (randomBytes[i] & 0xff); + } + + long leastSigBits = 0; + for (int i = 8; i < 16; i++) { + leastSigBits = (leastSigBits << 8) | (randomBytes[i] & 0xff); + } + + return new UUID(mostSigBits, leastSigBits); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/CrudRepository.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/CrudRepository.java new file mode 100644 index 0000000000..697843732f --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/CrudRepository.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.storage.repository; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.EntryNotFoundException; +import com.linecorp.centraldogma.common.Revision; +import com.linecorp.centraldogma.server.internal.admin.auth.AuthUtil; + +/** + * A repository that provides CRUD operations. + */ +public interface CrudRepository { + + CompletableFuture> save(String id, T entity, Author author, String description); + + default CompletableFuture> save(String id, T entity, Author author) { + return save(id, entity, author, "Create '" + id + '\''); + } + + default CompletableFuture> save(String id, T entity) { + return save(id, entity, AuthUtil.currentAuthor()); + } + + default CompletableFuture> save(HasId entity) { + return save(entity, AuthUtil.currentAuthor()); + } + + default CompletableFuture> save(HasId entity, Author author) { + return save(entity.id(), entity.object(), author); + } + + default CompletableFuture> update(String id, T entity, Author author, String description) { + return find(id).thenCompose(old -> { + if (old == null) { + throw new EntryNotFoundException("Cannot update a non-existent entity. (ID: " + id + ')'); + } + return save(id, entity, author, description); + }); + } + + default CompletableFuture> update(String id, T entity, Author author) { + return update(id, entity, author, "Update '" + id + '\''); + } + + default CompletableFuture> update(String id, T entity) { + return update(id, entity, AuthUtil.currentAuthor()); + } + + default CompletableFuture> update(HasId entity, Author author) { + return update(entity.id(), entity.object(), author); + } + + default CompletableFuture> update(HasId entity) { + return update(entity, AuthUtil.currentAuthor()); + } + + /** + * Retrieves the entity with the specified {@code id}. + * The returned {@link CompletableFuture} will be completed with {@code null} if there's no such entity. + */ + CompletableFuture> find(String id); + + CompletableFuture>> findAll(); + + CompletableFuture delete(String id, Author author, String description); + + default CompletableFuture delete(String id, Author author) { + return delete(id, author, "Delete '" + id + '\''); + } + + default CompletableFuture delete(String id) { + return delete(id, AuthUtil.currentAuthor()); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultHasRevision.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultHasRevision.java new file mode 100644 index 0000000000..b329336f77 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultHasRevision.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.storage.repository; + +import java.util.Objects; + +import com.google.common.base.MoreObjects; + +import com.linecorp.centraldogma.common.Revision; + +final class DefaultHasRevision implements HasRevision { + + private final T object; + private final Revision revision; + + DefaultHasRevision(T object, Revision revision) { + this.object = object; + this.revision = revision; + } + + @Override + public Revision revision() { + return revision; + } + + @Override + public T object() { + return object; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof DefaultHasRevision)) { + return false; + } + final DefaultHasRevision that = (DefaultHasRevision) o; + return object.equals(that.object) && revision.equals(that.revision); + } + + @Override + public int hashCode() { + return Objects.hash(object, revision); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("object", object) + .add("revision", revision) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultMetaRepository.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultMetaRepository.java index 0393621758..1b86099139 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultMetaRepository.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/DefaultMetaRepository.java @@ -24,7 +24,9 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; import java.util.regex.Pattern; +import java.util.stream.Stream; import javax.annotation.Nullable; @@ -46,7 +48,7 @@ import com.linecorp.centraldogma.common.Markup; import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.Jackson; -import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.server.ZoneConfig; import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.CommitResult; @@ -83,12 +85,16 @@ public static boolean isMirrorOrCredentialFile(String path) { REPO_CREDENTIAL_PATH_PATTERN.matcher(path).matches(); } + public static String mirrorFile(String repoName, String mirrorId) { + return "/repos/" + repoName + "/mirrors/" + mirrorId + ".json"; + } + public static String credentialFile(String credentialId) { return PATH_CREDENTIALS + credentialId + ".json"; } - public static String mirrorFile(String repoName, String mirrorId) { - return "/repos/" + repoName + "/mirrors/" + mirrorId + ".json"; + public static String credentialFile(String repoName, String credentialId) { + return "/repos/" + repoName + credentialFile(credentialId); } public DefaultMetaRepository(Repository repo) { @@ -121,14 +127,15 @@ private static CompletableFuture> maybeFilter(CompletableFuture mirror(String repoName, String id) { + public CompletableFuture mirror(String repoName, String id, Revision revision) { final String mirrorFile = mirrorFile(repoName, id); - return find(mirrorFile).thenCompose(entries -> { + return find(revision, mirrorFile).thenCompose(entries -> { @SuppressWarnings("unchecked") final Entry entry = (Entry) entries.get(mirrorFile); if (entry == null) { - throw new EntryNotFoundException("failed to find mirror '" + mirrorFile + "' in " + - parent().name() + '/' + name()); + throw new EntryNotFoundException( + "failed to find mirror '" + mirrorFile + "' in " + parent().name() + '/' + name() + + " (revision: " + revision + ')'); } final JsonNode mirrorJson = entry.content(); @@ -142,36 +149,40 @@ public CompletableFuture mirror(String repoName, String id) { throw new RepositoryMetadataException("failed to load the mirror configuration", e); } - final CompletableFuture> credentials; if (Strings.isNullOrEmpty(c.credentialId())) { - credentials = credentials(); - } else { - credentials = credential(c.credentialId()).thenApply(ImmutableList::of); + if (!parent().repos().exists(repoName)) { + throw mirrorNotFound(revision, mirrorFile); + } + return CompletableFuture.completedFuture(c.toMirror(parent(), Credential.FALLBACK)); } - return credentials.thenApply(credentials0 -> { - final Mirror mirror = c.toMirror(parent(), credentials0); + + return convert(ImmutableList.of(repoName), (repoCredentials, projectCredentials) -> { + final Mirror mirror = c.toMirror(parent(), repoCredentials, projectCredentials); if (mirror == null) { - throw new EntryNotFoundException("failed to find a mirror config for '" + mirrorFile + - "' in " + parent().name() + '/' + name()); + throw mirrorNotFound(revision, mirrorFile); } return mirror; }); }); } + private EntryNotFoundException mirrorNotFound(Revision revision, String mirrorFile) { + return new EntryNotFoundException( + "failed to find a mirror config for '" + mirrorFile + "' in " + + parent().name() + '/' + name() + " (revision: " + revision + ')'); + } + private CompletableFuture> allMirrors() { return find("/repos/*/mirrors/*.json").thenCompose(entries -> { if (entries.isEmpty()) { return UnmodifiableFuture.completedFuture(ImmutableList.of()); } - return credentials().thenApply(credentials -> { - try { - return parseMirrors(entries, credentials); - } catch (JsonProcessingException e) { - return Exceptions.throwUnsafely(e); - } - }); + final List repoNames = entries.keySet().stream() + .map(path -> path.substring(7, path.indexOf('/', 8))) + .distinct() + .collect(toImmutableList()); + return convert(entries, repoNames); }); } @@ -181,19 +192,51 @@ private CompletableFuture> allMirrors(String repoName) { return UnmodifiableFuture.completedFuture(ImmutableList.of()); } - return credentials().thenApply(credentials -> { - try { - return parseMirrors(entries, credentials); - } catch (JsonProcessingException e) { - return Exceptions.throwUnsafely(e); - } - }); + return convert(entries, ImmutableList.of(repoName)); }); } - private List parseMirrors(Map> entries, List credentials) - throws JsonProcessingException { + private CompletableFuture> convert( + Map> entries, List repoNames) { + return convert(repoNames, (repoCredentials, projectCredentials) -> { + try { + return parseMirrors(entries, repoCredentials, projectCredentials); + } catch (JsonProcessingException e) { + return Exceptions.throwUnsafely(e); + } + }); + } + private CompletableFuture convert( + List repoNames, + BiFunction>, List, T> convertFunction) { + final ImmutableList.Builder>> builder = ImmutableList.builder(); + + for (String repoName : repoNames) { + builder.add(repoCredentials(repoName)); + } + final ImmutableList>> futures = builder.build(); + final CompletableFuture> projectCredentialsFuture = projectCredentials(); + + final CompletableFuture allOfFuture = + CompletableFuture.allOf(Stream.concat(futures.stream(), + Stream.of(projectCredentialsFuture)) + .toArray(CompletableFuture[]::new)); + return allOfFuture.thenApply(unused -> { + final ImmutableMap.Builder> repoCredentialsBuilder = + ImmutableMap.builder(); + for (int i = 0; i < repoNames.size(); i++) { + repoCredentialsBuilder.put(repoNames.get(i), futures.get(i).join()); + } + final List projectCredentials = projectCredentialsFuture.join(); + return convertFunction.apply(repoCredentialsBuilder.build(), projectCredentials); + }); + } + + private List parseMirrors(Map> entries, + Map> repoCredentials, + List projectCredentials) + throws JsonProcessingException { return entries.entrySet().stream().map(entry -> { final JsonNode mirrorJson = (JsonNode) entry.getValue().content(); if (!mirrorJson.isObject()) { @@ -205,41 +248,64 @@ private List parseMirrors(Map> entries, List> credentials() { - return find(PATH_CREDENTIALS + "*.json").thenApply(entries -> { - if (entries.isEmpty()) { - return ImmutableList.of(); - } - try { - return parseCredentials(entries); - } catch (Exception e) { - throw new RepositoryMetadataException("failed to load the credential configuration", e); + public CompletableFuture> projectCredentials() { + return find(PATH_CREDENTIALS + "*.json").thenApply(entries -> credentials(entries, null)); + } + + @Override + public CompletableFuture> repoCredentials(String repoName) { + return find("/repos/" + repoName + PATH_CREDENTIALS + "*.json").thenApply( + entries -> credentials(entries, repoName)); + } + + private List credentials(Map> entries, @Nullable String repoName) { + if (entries.isEmpty()) { + return ImmutableList.of(); + } + try { + return parseCredentials(entries); + } catch (Exception e) { + String message = "failed to load the credential configuration"; + if (repoName != null) { + message += " for " + repoName; } - }); + throw new RepositoryMetadataException(message, e); + } } @Override - public CompletableFuture credential(String credentialId) { + public CompletableFuture projectCredential(String credentialId) { final String credentialFile = credentialFile(credentialId); + return credential0(credentialFile); + } + + @Override + public CompletableFuture repoCredential(String repoName, String id) { + final String credentialFile = credentialFile(repoName, id); + return credential0(credentialFile); + } + + private CompletableFuture credential0(String credentialFile) { return find(credentialFile).thenApply(entries -> { @SuppressWarnings("unchecked") final Entry entry = (Entry) entries.get(credentialFile); if (entry == null) { - throw new EntryNotFoundException("failed to find credential '" + credentialId + "' in " + + throw new EntryNotFoundException("failed to find credential file '" + credentialFile + "' in " + parent().name() + '/' + name()); } try { return parseCredential(credentialFile, entry); } catch (Exception e) { - throw new RepositoryMetadataException("failed to load the credential configuration", e); + throw new RepositoryMetadataException( + "failed to load the credential configuration. credential file: " + credentialFile, e); } }); } @@ -250,7 +316,8 @@ private List parseCredentials(Map> entries) .map(entry -> { try { //noinspection unchecked - return parseCredential(entry.getKey(), (Entry) entry.getValue()); + return parseCredential(entry.getKey(), + (Entry) entry.getValue()); } catch (JsonProcessingException e) { return Exceptions.throwUnsafely(e); } @@ -267,7 +334,8 @@ private Credential parseCredential(String credentialFile, Entry entry) return Jackson.treeToValue(credentialJson, Credential.class); } - private RepositoryMetadataException newInvalidJsonTypeException(String fileName, JsonNode credentialJson) { + private RepositoryMetadataException newInvalidJsonTypeException( + String fileName, JsonNode credentialJson) { return new RepositoryMetadataException(parent().name() + '/' + name() + fileName + " must be an object: " + credentialJson.getNodeType()); } @@ -277,64 +345,81 @@ private CompletableFuture>> find(String filePattern) { } @Override - public CompletableFuture> createMirrorPushCommand(MirrorDto mirrorDto, Author author, - @Nullable ZoneConfig zoneConfig, - boolean update) { - final String repoName = mirrorDto.localRepo(); - validateMirror(mirrorDto, zoneConfig); + public CompletableFuture> createMirrorPushCommand( + MirrorRequest mirrorRequest, Author author, @Nullable ZoneConfig zoneConfig, boolean update) { + final String repoName = mirrorRequest.localRepo(); + validateMirror(mirrorRequest, zoneConfig); if (update) { - final String summary = "Update the mirror '" + mirrorDto.id() + "' in " + repoName; - return mirror(repoName, mirrorDto.id()).thenApply(mirror -> { - // Perform the update operation only if the mirror exists. - return newCommand(mirrorDto, author, summary); + final String summary = "Update the mirror '" + mirrorRequest.id() + "' in " + repoName; + return mirror(repoName, mirrorRequest.id()).thenApply(mirror -> { + return newMirrorCommand(mirrorRequest, author, summary); }); } else { - String summary = "Create a new mirror from " + mirrorDto.remoteUrl() + - mirrorDto.remotePath() + '#' + mirrorDto.remoteBranch() + " into " + - repoName + mirrorDto.localPath(); - if (MirrorDirection.valueOf(mirrorDto.direction()) == MirrorDirection.REMOTE_TO_LOCAL) { + String summary = "Create a new mirror from " + mirrorRequest.remoteUrl() + + mirrorRequest.remotePath() + '#' + mirrorRequest.remoteBranch() + " into " + + repoName + mirrorRequest.localPath(); + if (MirrorDirection.valueOf(mirrorRequest.direction()) == MirrorDirection.REMOTE_TO_LOCAL) { summary = "[Remote-to-local] " + summary; } else { summary = "[Local-to-remote] " + summary; } - return UnmodifiableFuture.completedFuture(newCommand(mirrorDto, author, summary)); + return UnmodifiableFuture.completedFuture(newMirrorCommand(mirrorRequest, author, summary)); } } @Override - public CompletableFuture> createPushCommand(Credential credential, - Author author, boolean update) { + public CompletableFuture> createCredentialPushCommand(Credential credential, + Author author, boolean update) { checkArgument(!credential.id().isEmpty(), "Credential ID should not be empty"); if (update) { - return credential(credential.id()).thenApply(c -> { - assert c.id().equals(credential.id()); + return projectCredential(credential.id()).thenApply(c -> { final String summary = "Update the mirror credential '" + credential.id() + '\''; - return newCommand(credential, author, summary); + return newCredentialCommand(credentialFile(credential.id()), credential, author, summary); }); - } else { - final String summary = "Create a new mirror credential for " + credential.id(); - return UnmodifiableFuture.completedFuture(newCommand(credential, author, summary)); } + final String summary = "Create a new mirror credential for " + credential.id(); + return UnmodifiableFuture.completedFuture( + newCredentialCommand(credentialFile(credential.id()), credential, author, summary)); } - private Command newCommand(MirrorDto mirrorDto, Author author, String summary) { - final MirrorConfig mirrorConfig = converterToMirrorConfig(mirrorDto); - final JsonNode jsonNode = Jackson.valueToTree(mirrorConfig); - final Change change = - Change.ofJsonUpsert(mirrorFile(mirrorDto.localRepo(), mirrorConfig.id()), jsonNode); + @Override + public CompletableFuture> createCredentialPushCommand(String repoName, + Credential credential, + Author author, boolean update) { + checkArgument(!credential.id().isEmpty(), "Credential ID should not be empty"); + + if (update) { + return repoCredential(repoName, credential.id()).thenApply(c -> { + final String summary = + "Update the mirror credential '" + repoName + '/' + credential.id() + '\''; + return newCredentialCommand( + credentialFile(repoName, credential.id()), credential, author, summary); + }); + } + final String summary = "Create a new mirror credential for " + repoName + '/' + credential.id(); + return UnmodifiableFuture.completedFuture( + newCredentialCommand(credentialFile(repoName, credential.id()), credential, author, summary)); + } + + private Command newCredentialCommand(String credentialFile, Credential credential, + Author author, String summary) { + final JsonNode jsonNode = Jackson.valueToTree(credential); + final Change change = Change.ofJsonUpsert(credentialFile, jsonNode); return Command.push(author, parent().name(), name(), Revision.HEAD, summary, "", Markup.PLAINTEXT, change); } - private Command newCommand(Credential credential, Author author, String summary) { - final JsonNode jsonNode = Jackson.valueToTree(credential); - final Change change = Change.ofJsonUpsert(credentialFile(credential.id()), jsonNode); + private Command newMirrorCommand(MirrorRequest mirrorRequest, Author author, String summary) { + final MirrorConfig mirrorConfig = converterToMirrorConfig(mirrorRequest); + final JsonNode jsonNode = Jackson.valueToTree(mirrorConfig); + final Change change = + Change.ofJsonUpsert(mirrorFile(mirrorRequest.localRepo(), mirrorConfig.id()), jsonNode); return Command.push(author, parent().name(), name(), Revision.HEAD, summary, "", Markup.PLAINTEXT, change); } - private static void validateMirror(MirrorDto mirror, @Nullable ZoneConfig zoneConfig) { + private static void validateMirror(MirrorRequest mirror, @Nullable ZoneConfig zoneConfig) { checkArgument(!Strings.isNullOrEmpty(mirror.id()), "Mirror ID is empty"); final String scheduleString = mirror.schedule(); if (scheduleString != null) { @@ -352,7 +437,7 @@ private static void validateMirror(MirrorDto mirror, @Nullable ZoneConfig zoneCo } } - private static MirrorConfig converterToMirrorConfig(MirrorDto mirrorDto) { + private static MirrorConfig converterToMirrorConfig(MirrorRequest mirrorDto) { final String remoteUri = mirrorDto.remoteScheme() + "://" + mirrorDto.remoteUrl() + MirrorUtil.normalizePath(mirrorDto.remotePath()) + '#' + mirrorDto.remoteBranch(); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/HasId.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/HasId.java new file mode 100644 index 0000000000..89d8954dc3 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/HasId.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.storage.repository; + +@SuppressWarnings("InterfaceMayBeAnnotatedFunctional") +public interface HasId { + + /** + * Returns the {@link String}-formatted identifier. + */ + String id(); + + default T object() { + //noinspection unchecked + return (T) this; + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/HasRevision.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/HasRevision.java new file mode 100644 index 0000000000..f5ee612ce4 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/HasRevision.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.storage.repository; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.centraldogma.common.Revision; + +/** + * An interface that provides a {@link Revision} with an object. + */ +public interface HasRevision { + + /** + * Creates a new instance with the specified object and revision. + */ + static HasRevision of(T object, Revision revision) { + requireNonNull(object, "object"); + requireNonNull(revision, "revision"); + return new DefaultHasRevision<>(object, revision); + } + + /** + * Returns the {@link Revision}. + */ + Revision revision(); + + /** + * Returns the object. + */ + T object(); +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/MirrorConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/MirrorConfig.java index 14d01a3a08..45b52e15ba 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/MirrorConfig.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/MirrorConfig.java @@ -23,6 +23,7 @@ import java.net.URI; import java.util.List; +import java.util.Map; import java.util.ServiceLoader; import javax.annotation.Nullable; @@ -128,13 +129,21 @@ public MirrorConfig(@JsonProperty("id") String id, } @Nullable - Mirror toMirror(Project parent, Iterable credentials) { + Mirror toMirror(Project parent, Map> repoCredentials, + List projectCredentials) { if (!parent.repos().exists(localRepo)) { return null; } + final Credential credential = findCredential(repoCredentials, projectCredentials, localRepo, + credentialId); + return toMirror(parent, credential); + } + + Mirror toMirror(Project parent, Credential credential) { final MirrorContext mirrorContext = new MirrorContext( - id, enabled, schedule, direction, findCredential(credentials, credentialId), + id, enabled, schedule, direction, + credential, parent.repos().get(localRepo), localPath, remoteUri, gitignore, zone); for (MirrorProvider mirrorProvider : MIRROR_PROVIDERS) { final Mirror mirror = mirrorProvider.newMirror(mirrorContext); @@ -146,10 +155,22 @@ id, enabled, schedule, direction, findCredential(credentials, credentialId), throw new IllegalArgumentException("could not find a mirror provider for " + mirrorContext); } - public static Credential findCredential(Iterable credentials, - @Nullable String credentialId) { + public static Credential findCredential(Map> repoCredentials, + List projectCredentials, + String repoName, @Nullable String credentialId) { if (credentialId != null) { - for (Credential c : credentials) { + // Repository credentials take precedence over project credentials. + final List credentials = repoCredentials.get(repoName); + if (credentials != null) { + for (Credential c : credentials) { + final String id = c.id(); + if (credentialId.equals(id)) { + return c; + } + } + } + + for (Credential c : projectCredentials) { final String id = c.id(); if (credentialId.equals(id)) { return c; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepository.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepository.java new file mode 100644 index 0000000000..c851e31eec --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepository.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.storage.repository.git; + +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 com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.common.Entry; +import com.linecorp.centraldogma.common.Markup; +import com.linecorp.centraldogma.common.Revision; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.internal.Util; +import com.linecorp.centraldogma.server.command.Command; +import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.command.CommitResult; +import com.linecorp.centraldogma.server.internal.storage.repository.CrudRepository; +import com.linecorp.centraldogma.server.internal.storage.repository.HasRevision; +import com.linecorp.centraldogma.server.storage.project.ProjectManager; +import com.linecorp.centraldogma.server.storage.repository.Repository; + +/** + * A {@link CrudRepository} implementation which stores JSON objects in a Git repository. + */ +public final class GitCrudRepository implements CrudRepository { + + // For write operations + private final CommandExecutor executor; + // For read operations + private final Repository repository; + + private final Class entityType; + private final String projectName; + private final String repoName; + private final String targetPath; + + public GitCrudRepository(Class entityType, CommandExecutor executor, ProjectManager projectManager, + String projectName, String repoName, String targetPath) { + this.executor = executor; + repository = projectManager.get(projectName).repos().get(repoName); + this.entityType = entityType; + this.projectName = projectName; + this.repoName = repoName; + checkArgument(targetPath.startsWith("/"), "targetPath: %s (expected: starts with '/')", targetPath); + checkArgument(targetPath.endsWith("/"), "targetPath: %s (expected: ends with '/')", targetPath); + this.targetPath = targetPath; + } + + @Override + public CompletableFuture> save(String id, T entity, Author author, String description) { + final String path = getPath(id); + final Change change = Change.ofJsonUpsert(path, Jackson.valueToTree(entity)); + final Command command = + Command.push(author, projectName, repoName, Revision.HEAD, description, "", Markup.MARKDOWN, + change); + return executor.execute(command).thenCompose(result -> { + return repository.get(result.revision(), path).thenApply(this::entryToValue); + }); + } + + @Override + public CompletableFuture> find(String id) { + final String path = getPath(id); + return repository.getOrNull(Revision.HEAD, path).thenApply(this::entryToValue); + } + + @Override + public CompletableFuture>> findAll() { + return repository.find(Revision.HEAD, targetPath + "*.json") + .thenApply(entries -> { + return entries.values().stream() + .map(this::entryToValue) + .collect(toImmutableList()); + }); + } + + @Override + public CompletableFuture delete(String id, Author author, String description) { + final String path = getPath(id); + final Change change = Change.ofRemoval(path); + final Command command = + Command.push(Author.SYSTEM, projectName, repoName, Revision.HEAD, description, "", + Markup.MARKDOWN, change); + return executor.execute(command).thenApply(CommitResult::revision); + } + + private HasRevision entryToValue(@Nullable Entry entry) { + if (entry == null) { + return null; + } + try { + return HasRevision.of(Jackson.treeToValue(entry.contentAsJson(), entityType), entry.revision()); + } catch (JsonParseException | JsonMappingException e) { + throw new RuntimeException(e); + } + } + + private String getPath(String id) { + validateId(id); + return targetPath + id + ".json"; + } + + public static String validateId(String id) { + checkArgument(!id.isEmpty(), "id is empty."); + return Util.validateFileName(id, "id"); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("entityType", entityType) + .add("executor", executor) + .add("repository", repository) + .add("projectName", projectName) + .add("repoName", repoName) + .add("targetPath", targetPath) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorAccessController.java b/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorAccessController.java new file mode 100644 index 0000000000..651d4526da --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorAccessController.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.mirror; + +import java.net.URI; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * A mirror access controller that can allow or disallow access to the remote repositories for mirroring. + */ +public interface MirrorAccessController { + + /** + * Allow access to a Git repository URI that matches the specified pattern. + * + * @param targetPattern the pattern to match the Git repository URI + * @param reason the reason for allowing access + * @param order the order of the access control. The lower the order, the higher the priority. + */ + CompletableFuture allow(String targetPattern, String reason, int order); + + /** + * Disallow access to a Git repository URI that matches the specified pattern. + * + * @param targetPattern the pattern to match the Git repository URI + * @param reason the reason for disallowing access + * @param order the order of the access control. The lower the order, the higher the priority. + */ + CompletableFuture disallow(String targetPattern, String reason, int order); + + /** + * Check whether the specified Git repository URI is allowed to be mirrored. + */ + default CompletableFuture isAllowed(URI repoUri) { + return isAllowed(repoUri.toString()); + } + + /** + * Check whether the specified Git repository URI is allowed to be mirrored. + */ + CompletableFuture isAllowed(String repoUri); + + /** + * Check whether the specified {@link Mirror} is allowed to be mirrored. + */ + default CompletableFuture isAllowed(Mirror mirror) { + // XXX(ikhoon): Should we need to control access to the path or the branch of a mirror? + return isAllowed(mirror.remoteRepoUri().toString()); + } + + /** + * Check whether the specified Git repository URIs are allowed to be mirrored. + */ + CompletableFuture> isAllowed(Iterable repoUris); +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorListener.java b/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorListener.java index b6af565529..82bd67283b 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorListener.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/mirror/MirrorListener.java @@ -34,6 +34,21 @@ @Nullable public interface MirrorListener { + /** + * Invoked when a new {@link Mirror} is created. + */ + void onCreate(Mirror mirror, MirrorAccessController accessController); + + /** + * Invoked when the {@link Mirror} is updated. + */ + void onUpdate(Mirror mirror, MirrorAccessController accessController); + + /** + * Invoked when the {@link Mirror} operation is disallowed. + */ + void onDisallowed(Mirror mirror); + /** * Invoked when the {@link Mirror} operation is started. */ diff --git a/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginContext.java b/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginContext.java index 87eca18c40..9627060ec8 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginContext.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginContext.java @@ -22,6 +22,7 @@ import com.linecorp.centraldogma.server.CentralDogmaConfig; import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.project.ProjectManager; @@ -39,6 +40,7 @@ public class PluginContext { private final MeterRegistry meterRegistry; private final ScheduledExecutorService purgeWorker; private final InternalProjectInitializer internalProjectInitializer; + private final MirrorAccessController mirrorAccessController; /** * Creates a new instance. @@ -48,13 +50,16 @@ public class PluginContext { * @param commandExecutor the executor which executes the {@link Command}s * @param meterRegistry the {@link MeterRegistry} of the Central Dogma server * @param purgeWorker the {@link ScheduledExecutorService} for the purging service + * @param internalProjectInitializer the initializer for the internal projects + * @param mirrorAccessController the controller which controls the access to the remote repos of mirrors */ public PluginContext(CentralDogmaConfig config, ProjectManager projectManager, CommandExecutor commandExecutor, MeterRegistry meterRegistry, ScheduledExecutorService purgeWorker, - InternalProjectInitializer internalProjectInitializer) { + InternalProjectInitializer internalProjectInitializer, + MirrorAccessController mirrorAccessController) { this.config = requireNonNull(config, "config"); this.projectManager = requireNonNull(projectManager, "projectManager"); this.commandExecutor = requireNonNull(commandExecutor, "commandExecutor"); @@ -62,6 +67,7 @@ public PluginContext(CentralDogmaConfig config, this.purgeWorker = requireNonNull(purgeWorker, "purgeWorker"); this.internalProjectInitializer = requireNonNull(internalProjectInitializer, "internalProjectInitializer"); + this.mirrorAccessController = requireNonNull(mirrorAccessController, "mirrorAccessController"); } /** @@ -105,4 +111,11 @@ public ScheduledExecutorService purgeWorker() { public InternalProjectInitializer internalProjectInitializer() { return internalProjectInitializer; } + + /** + * Returns the {@link MirrorAccessController}. + */ + public MirrorAccessController mirrorAccessController() { + return mirrorAccessController; + } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java b/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java index ad8ea34bd9..1fa4b8e6cb 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java @@ -26,6 +26,7 @@ import com.linecorp.armeria.server.auth.AuthService; import com.linecorp.centraldogma.server.CentralDogmaConfig; import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.mirror.MirrorAccessController; import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer; import com.linecorp.centraldogma.server.storage.project.ProjectManager; @@ -48,8 +49,10 @@ public PluginInitContext(CentralDogmaConfig config, MeterRegistry meterRegistry, ScheduledExecutorService purgeWorker, ServerBuilder serverBuilder, Function authService, - InternalProjectInitializer projectInitializer) { - super(config, projectManager, commandExecutor, meterRegistry, purgeWorker, projectInitializer); + InternalProjectInitializer projectInitializer, + MirrorAccessController mirrorAccessController) { + super(config, projectManager, commandExecutor, meterRegistry, purgeWorker, projectInitializer, + mirrorAccessController); this.serverBuilder = requireNonNull(serverBuilder, "serverBuilder"); this.authService = requireNonNull(authService, "authService"); } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/storage/project/InternalProjectInitializer.java b/server/src/main/java/com/linecorp/centraldogma/server/storage/project/InternalProjectInitializer.java index a0fa0e7d17..84a9866efc 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/storage/project/InternalProjectInitializer.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/storage/project/InternalProjectInitializer.java @@ -90,7 +90,7 @@ public void initialize(String projectName) { /** * Creates an internal project and repositories such as a token storage. */ - public void initialize0(String projectName) { + private void initialize0(String projectName) { final long creationTimeMillis = System.currentTimeMillis(); if (!projectManager.exists(projectName)) { try { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/MetaRepository.java b/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/MetaRepository.java index 4081657a60..6c6c5a9120 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/MetaRepository.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/MetaRepository.java @@ -22,7 +22,9 @@ import javax.annotation.Nullable; import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.api.v1.MirrorDto; +import com.linecorp.centraldogma.internal.api.v1.MirrorRequest; import com.linecorp.centraldogma.server.ZoneConfig; import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.CommitResult; @@ -56,28 +58,51 @@ default CompletableFuture> mirrors() { /** * Returns a mirroring task of the specified {@code id}. */ - CompletableFuture mirror(String repoName, String id); + default CompletableFuture mirror(String repoName, String id) { + return mirror(repoName, id, Revision.HEAD); + } + + /** + * Returns a mirroring task of the specified {@code id} at the specified {@link Revision}. + */ + CompletableFuture mirror(String repoName, String id, Revision revision); + + /** + * Returns a list of project credentials. + */ + CompletableFuture> projectCredentials(); /** - * Returns a list of mirroring credentials. + * Returns a project credential of the specified {@code id}. */ - CompletableFuture> credentials(); + CompletableFuture projectCredential(String id); /** - * Returns a mirroring credential of the specified {@code id}. + * Returns a list of credentials of the specified repository. */ - CompletableFuture credential(String id); + CompletableFuture> repoCredentials(String repoName); + + /** + * Returns a credential of the specified {@code id} in the specified repository. + */ + CompletableFuture repoCredential(String repoName, String id); /** * Create a push {@link Command} for the {@link MirrorDto}. */ - CompletableFuture> createMirrorPushCommand(MirrorDto mirrorDto, Author author, + CompletableFuture> createMirrorPushCommand(MirrorRequest mirrorRequest, Author author, @Nullable ZoneConfig zoneConfig, boolean update); /** * Create a push {@link Command} for the {@link Credential}. */ - CompletableFuture> createPushCommand(Credential credential, Author author, - boolean update); + CompletableFuture> createCredentialPushCommand(Credential credential, Author author, + boolean update); + + /** + * Create a push {@link Command} for the {@link Credential}. + */ + CompletableFuture> createCredentialPushCommand(String repoName, Credential credential, + Author author, boolean update); } diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1Test.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1Test.java new file mode 100644 index 0000000000..e760e17534 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/CredentialServiceV1Test.java @@ -0,0 +1,150 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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; + +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +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.client.armeria.ArmeriaCentralDogmaBuilder; +import com.linecorp.centraldogma.internal.api.v1.PushResultDto; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.credential.Credential; +import com.linecorp.centraldogma.server.internal.credential.AccessTokenCredential; +import com.linecorp.centraldogma.server.internal.credential.NoneCredential; +import com.linecorp.centraldogma.server.internal.credential.PasswordCredential; +import com.linecorp.centraldogma.server.internal.credential.PublicKeyCredential; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +class CredentialServiceV1Test { + + private static final String FOO_PROJ = "foo-proj"; + private static final String BAR_REPO = "bar-repo"; + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.authProviderFactory(new TestAuthProviderFactory()); + builder.systemAdministrators(USERNAME); + } + + @Override + protected void configureHttpClient(WebClientBuilder builder) { + // TODO(minwoox): Override accessToken to provide token to both WebClient and CentralDogma client. + final String accessToken = getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD); + builder.auth(AuthToken.ofOAuth2(accessToken)); + } + + @Override + protected void configureClient(ArmeriaCentralDogmaBuilder builder) { + final String accessToken = getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD); + builder.accessToken(accessToken); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject(FOO_PROJ).join(); + client.createRepository(FOO_PROJ, BAR_REPO).join(); + } + }; + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void createAndReadCredential(boolean projectLevel) { + final List> credentials = ImmutableList.of( + ImmutableMap.of("type", "password", "id", "password-credential", + "username", "username-0", "password", "password-0"), + ImmutableMap.of("type", "access_token", "id", "access-token-credential", + "accessToken", "secret-token-abc-1"), + ImmutableMap.of("type", "public_key", "id", "public-key-credential", + "username", "username-2", + "publicKey", "public-key-2", "privateKey", "private-key-2", + "passphrase", "password-0"), + ImmutableMap.of("type", "none", "id", "non-credential")); + + final BlockingWebClient client = dogma.blockingHttpClient(); + for (int i = 0; i < credentials.size(); i++) { + final Map credential = credentials.get(i); + final String credentialId = (String) credential.get("id"); + final ResponseEntity creationResponse = + client.prepare() + .post(projectLevel ? "/api/v1/projects/{proj}/credentials" + : "/api/v1/projects/{proj}/repos/" + BAR_REPO + "/credentials") + .pathParam("proj", FOO_PROJ) + .contentJson(credential) + .responseTimeoutMillis(0) + .asJson(PushResultDto.class) + .execute(); + assertThat(creationResponse.status()).isEqualTo(HttpStatus.CREATED); + + final ResponseEntity fetchResponse = + client.prepare() + .get(projectLevel ? "/api/v1/projects/{proj}/credentials/{id}" + : "/api/v1/projects/{proj}/repos/" + BAR_REPO + "/credentials/{id}") + .pathParam("proj", FOO_PROJ) + .pathParam("id", credentialId) + .responseTimeoutMillis(0) + .asJson(Credential.class) + .execute(); + final Credential credentialDto = fetchResponse.content(); + assertThat(credentialDto.id()).isEqualTo(credentialId); + final String credentialType = (String) credential.get("type"); + if ("password".equals(credentialType)) { + final PasswordCredential actual = (PasswordCredential) credentialDto; + assertThat(actual.username()).isEqualTo(credential.get("username")); + assertThat(actual.password()).isEqualTo(credential.get("password")); + } else if ("access_token".equals(credentialType)) { + final AccessTokenCredential actual = (AccessTokenCredential) credentialDto; + assertThat(actual.accessToken()).isEqualTo(credential.get("accessToken")); + } else if ("public_key".equals(credentialType)) { + final PublicKeyCredential actual = (PublicKeyCredential) credentialDto; + assertThat(actual.username()).isEqualTo(credential.get("username")); + assertThat(actual.publicKey()).isEqualTo(credential.get("publicKey")); + assertThat(actual.rawPrivateKey()).isEqualTo(credential.get("privateKey")); + assertThat(actual.rawPassphrase()).isEqualTo(credential.get("passphrase")); + } else if ("none".equals(credentialType)) { + assertThat(credentialDto).isInstanceOf(NoneCredential.class); + } else { + throw new AssertionError("Unexpected credential type: " + credential.getClass().getName()); + } + } + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/SystemAdministrativeServiceTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/ServerStatusServiceTest.java similarity index 96% rename from server/src/test/java/com/linecorp/centraldogma/server/internal/api/SystemAdministrativeServiceTest.java rename to server/src/test/java/com/linecorp/centraldogma/server/internal/api/ServerStatusServiceTest.java index 5cdf0ef14c..0934def4ac 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/SystemAdministrativeServiceTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/ServerStatusServiceTest.java @@ -32,11 +32,12 @@ import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.centraldogma.server.internal.api.UpdateServerStatusRequest.Scope; +import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest; +import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest.Scope; import com.linecorp.centraldogma.server.management.ServerStatus; import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; -class SystemAdministrativeServiceTest { +class ServerStatusServiceTest { @RegisterExtension final CentralDogmaExtension dogma = new CentralDogmaExtension() { diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/TokenServiceTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/TokenServiceTest.java index 74d19095cc..12a7f0489b 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/TokenServiceTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/TokenServiceTest.java @@ -54,6 +54,8 @@ import com.linecorp.centraldogma.server.CentralDogmaBuilder; import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.StandaloneCommandExecutor; +import com.linecorp.centraldogma.server.internal.api.sysadmin.TokenLevelRequest; +import com.linecorp.centraldogma.server.internal.api.sysadmin.TokenService; import com.linecorp.centraldogma.server.metadata.MetadataService; import com.linecorp.centraldogma.server.metadata.Token; import com.linecorp.centraldogma.server.metadata.Tokens; diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessControllerTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessControllerTest.java new file mode 100644 index 0000000000..088c87074f --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessControllerTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.mirror; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlRequest; +import com.linecorp.centraldogma.testing.internal.CrudRepositoryExtension; + +class DefaultMirrorAccessControllerTest { + + @RegisterExtension + CrudRepositoryExtension repositoryExtension = + new CrudRepositoryExtension(MirrorAccessControl.class, "test_proj", + "test_repo", "/test_path/") { + @Override + protected boolean runForEachTest() { + return true; + } + }; + + private DefaultMirrorAccessController accessController; + + @BeforeEach + void setUp() { + accessController = new DefaultMirrorAccessController(); + accessController.setRepository(repositoryExtension.crudRepository()); + } + + @Test + void testAddAndUpdate() { + final MirrorAccessControlRequest req0 = new MirrorAccessControlRequest("foo_1", + "https://github.com/foo/*", + true, + "description", + 0); + final MirrorAccessControl stored0 = accessController.add(req0, Author.SYSTEM).join(); + assertThat(stored0) + .usingRecursiveComparison() + .ignoringFields("creation") + .isEqualTo(req0); + + final MirrorAccessControlRequest req1 = new MirrorAccessControlRequest("foo_1", + "https://github.com/bar/*", + true, + "description", + 0); + final MirrorAccessControl stored1 = accessController.update(req1, Author.SYSTEM).join(); + assertThat(stored1) + .usingRecursiveComparison() + .ignoringFields("creation") + .isEqualTo(req1); + } + + @Test + void shouldControlAccess() { + final MirrorAccessControlRequest req0 = new MirrorAccessControlRequest( + "id_0", "https://private.github.com/.*", false, "default", Integer.MAX_VALUE); + accessController.add(req0, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isFalse(); + assertThat(accessController.isAllowed("https://github.com/line/centraldogma").join()).isTrue(); + + final MirrorAccessControlRequest req1 = new MirrorAccessControlRequest( + "id_1", "https://private.github.com/line/.*", true, "allow line org", 0); + accessController.add(req1, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isTrue(); + + final MirrorAccessControlRequest req2 = new MirrorAccessControlRequest( + "id_2", "https://private.github.com/line/armeria", false, "disallow armeria", 0); + accessController.add(req2, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/armeria").join()).isFalse(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isTrue(); + assertThat(accessController.isAllowed("https://github.com/line/centraldogma").join()).isTrue(); + assertThat(accessController.isAllowed("https://private.github.com/dot/block").join()).isFalse(); + + accessController.allow("https://private.github.com/dot/block", "allow dot/block", 0).join(); + assertThat(accessController.isAllowed("https://private.github.com/dot/block").join()).isTrue(); + } + + @Test + void respectOrder() { + final MirrorAccessControlRequest req0 = new MirrorAccessControlRequest( + "id_0", "https://private.github.com/.*", false, "default", Integer.MAX_VALUE); + accessController.add(req0, Author.SYSTEM).join(); + + final MirrorAccessControlRequest req1 = new MirrorAccessControlRequest( + "id_1", "https://private.github.com/line/centraldogma", true, "allow centraldogma", 0); + final MirrorAccessControlRequest req2 = new MirrorAccessControlRequest( + "id_2", "https://private.github.com/line/centraldogma", false, "disallow centraldogma", 1); + accessController.add(req1, Author.SYSTEM).join(); + accessController.add(req2, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isTrue(); + + final MirrorAccessControlRequest req3 = new MirrorAccessControlRequest( + "id_3", "https://private.github.com/line/centraldogma", false, "disallow centraldogma", -1); + accessController.add(req3, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isFalse(); + } + + @Test + void testNewItemsHaveHigherPriority() { + final MirrorAccessControlRequest req0 = new MirrorAccessControlRequest( + "id_0", "https://private.github.com/.*", false, "default", Integer.MAX_VALUE); + accessController.add(req0, Author.SYSTEM).join(); + + final MirrorAccessControlRequest req1 = new MirrorAccessControlRequest( + "id_1", "https://private.github.com/line/centraldogma", true, "allow centraldogma", 0); + accessController.add(req1, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isTrue(); + final MirrorAccessControlRequest req2 = new MirrorAccessControlRequest( + "id_2", "https://private.github.com/line/centraldogma", false, "disallow centraldogma", 0); + accessController.add(req2, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isFalse(); + final MirrorAccessControlRequest req3 = new MirrorAccessControlRequest( + "id_3", "https://private.github.com/line/centraldogma", true, "allow centraldogma", 0); + accessController.add(req3, Author.SYSTEM).join(); + assertThat(accessController.isAllowed("https://private.github.com/line/centraldogma").join()).isTrue(); + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepositoryTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepositoryTest.java new file mode 100644 index 0000000000..1901e37689 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepositoryTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.storage.repository.git; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Objects; +import java.util.concurrent.CompletionException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.EntryNotFoundException; +import com.linecorp.centraldogma.server.internal.storage.repository.CrudRepository; +import com.linecorp.centraldogma.server.internal.storage.repository.HasId; +import com.linecorp.centraldogma.server.internal.storage.repository.HasRevision; +import com.linecorp.centraldogma.testing.internal.CrudRepositoryExtension; + +class GitCrudRepositoryTest { + + private static final String TEST_PROJ = "test-proj"; + private static final String TEST_REPO = "test-repo"; + + @RegisterExtension + static CrudRepositoryExtension repositoryExtension = + new CrudRepositoryExtension<>(Foo.class, TEST_PROJ, TEST_REPO, "/test-storage/"); + + @Test + void crudTest() { + final CrudRepository repository = repositoryExtension.crudRepository(); + // Create + final Foo data1 = new Foo("id_1", "test1", 100); + final Foo data1Saved = repository.save(data1, Author.DEFAULT).join().object(); + assertThat(data1Saved).isEqualTo(data1); + + // Read + final Foo data1Found = repository.find(data1.id()).join().object(); + assertThat(data1Found).isEqualTo(data1Saved); + assertThat(repository.find("id_unknown").join()).isNull(); + + // Update + final Foo data1Updated = new Foo("id_1", "test2", 100); + final Foo data1UpdatedFound = repository.update(data1Updated, Author.DEFAULT).join().object(); + assertThat(data1UpdatedFound).isEqualTo(data1Updated); + assertThatThrownBy(() -> { + repository.update(new Foo("id_2", "test2", 100), Author.DEFAULT).join(); + }).isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(EntryNotFoundException.class) + .hasMessageContaining("Cannot update a non-existent entity. (ID: id_2)"); + + // Create again + final Foo data3 = new Foo("id_3", "test3", 100); + repository.save(data3, Author.DEFAULT).join(); + final Foo data3Found = repository.find(data3.id()).join().object(); + assertThat(data3Found).isEqualTo(data3); + // Make sure the previous data is not affected. + assertThat(repository.find(data1.id()).join().object()).isEqualTo(data1Updated); + + // Read all + assertThat(repository.findAll().join().stream().map(HasRevision::object)) + .containsExactly(data1Updated, data3); + + // Delete + repository.delete(data1.id(), Author.DEFAULT).join(); + assertThat(repository.findAll().join().stream().map(HasRevision::object)) + .containsExactly(data3); + assertThat(repository.find(data1.id()).join()).isNull(); + + // Reuse the deleted ID + assertThat(repository.save(data1, Author.DEFAULT).join().object()).isEqualTo(data1); + assertThat(repository.find(data1.id()).join().object()).isEqualTo(data1); + assertThat(repository.findAll().join().stream().map(HasRevision::object)) + .containsExactly(data1, data3); + } + + private static class Foo implements HasId { + private final String id; + private final String bar; + private final int baz; + + @JsonCreator + Foo(@JsonProperty("id") String id, @JsonProperty("bar") String bar, @JsonProperty("baz") int baz) { + this.id = id; + this.bar = bar; + this.baz = baz; + } + + @JsonProperty + @Override + public String id() { + return id; + } + + @JsonProperty + public String bar() { + return bar; + } + + @JsonProperty + public int baz() { + return baz; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Foo)) { + return false; + } + final Foo foo = (Foo) o; + return baz == foo.baz && Objects.equals(id, foo.id) && Objects.equals(bar, foo.bar); + } + + @Override + public int hashCode() { + return Objects.hash(id, bar, baz); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("bar", bar) + .add("baz", baz) + .toString(); + } + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/management/ServerStatusManagerIntegrationTest.java b/server/src/test/java/com/linecorp/centraldogma/server/management/ServerStatusManagerIntegrationTest.java index 47c883fd54..7c58ec8d29 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/management/ServerStatusManagerIntegrationTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/management/ServerStatusManagerIntegrationTest.java @@ -27,8 +27,8 @@ import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.UnprocessedRequestException; import com.linecorp.centraldogma.common.ReadOnlyException; -import com.linecorp.centraldogma.server.internal.api.UpdateServerStatusRequest; -import com.linecorp.centraldogma.server.internal.api.UpdateServerStatusRequest.Scope; +import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest; +import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest.Scope; import com.linecorp.centraldogma.testing.internal.CentralDogmaReplicationExtension; import com.linecorp.centraldogma.testing.internal.CentralDogmaRuleDelegate; diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java index ce8bc67c6c..b4fed9864b 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java @@ -41,8 +41,8 @@ import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.internal.jsonpatch.AddOperation; import com.linecorp.centraldogma.internal.jsonpatch.TestAbsenceOperation; -import com.linecorp.centraldogma.server.internal.api.TokenLevelRequest; -import com.linecorp.centraldogma.server.internal.api.TokenService; +import com.linecorp.centraldogma.server.internal.api.sysadmin.TokenLevelRequest; +import com.linecorp.centraldogma.server.internal.api.sysadmin.TokenService; import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.repository.Repository; diff --git a/settings.gradle b/settings.gradle index 594bfbd019..e22d3cd9fe 100644 --- a/settings.gradle +++ b/settings.gradle @@ -28,17 +28,19 @@ includeWithFlags ':xds', 'java', 'publish', ' project(':testing:testing-common').projectDir = file('testing/common') // Unpublished Java projects -includeWithFlags ':benchmarks:jmh', 'java' -includeWithFlags ':it:it-mirror', 'java11', 'relocate' -// Set correct directory names +includeWithFlags ':benchmarks:jmh', 'java' + +includeWithFlags ':it:it-mirror', 'java11', 'relocate' project(':it:it-mirror').projectDir = file('it/mirror') -includeWithFlags ':it:it-server', 'java', 'relocate' -// Set correct directory names + +includeWithFlags ':it:it-server', 'java', 'relocate' project(':it:it-server').projectDir = file('it/server') -includeWithFlags ':it:mirror-listener', 'java', 'relocate' -includeWithFlags ':it:zone-leader-plugin', 'java', 'relocate' -includeWithFlags ':it:xds-member-permission', 'java', 'relocate' -includeWithFlags ':testing-internal', 'java', 'relocate' + +includeWithFlags ':it:mirror-listener', 'java', 'relocate' +includeWithFlags ':it:server-healthy-plugin', 'java', 'relocate' +includeWithFlags ':it:zone-leader-plugin', 'java', 'relocate' +includeWithFlags ':it:xds-member-permission', 'java', 'relocate' +includeWithFlags ':testing-internal', 'java', 'relocate' // Unpublished projects includeWithFlags ':webapp', 'java11' diff --git a/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/CrudRepositoryExtension.java b/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/CrudRepositoryExtension.java new file mode 100644 index 0000000000..f8d4fd5abf --- /dev/null +++ b/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/CrudRepositoryExtension.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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.testing.internal; + +import static com.google.common.base.Preconditions.checkState; + +import javax.annotation.Nullable; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.ProjectExistsException; +import com.linecorp.centraldogma.common.RepositoryExistsException; +import com.linecorp.centraldogma.server.internal.storage.repository.CrudRepository; +import com.linecorp.centraldogma.server.internal.storage.repository.git.GitCrudRepository; +import com.linecorp.centraldogma.server.storage.project.Project; +import com.linecorp.centraldogma.server.storage.project.ProjectManager; +import com.linecorp.centraldogma.testing.junit.AbstractAllOrEachExtension; + +/** + * An extension which provides a {@link CrudRepository} for testing. + */ +public class CrudRepositoryExtension extends AbstractAllOrEachExtension { + + private final ProjectManagerExtension projectManagerExtension = new ProjectManagerExtension(); + private final Class entityType; + private final String projectName; + private final String repoName; + private final String targetPath; + + @Nullable + private CrudRepository crudRepository; + + /** + * Creates a new instance. + */ + public CrudRepositoryExtension(Class entityType, String projectName, String repoName, + String targetPath) { + //noinspection unchecked + this.entityType = (Class) entityType; + this.projectName = projectName; + this.repoName = repoName; + this.targetPath = targetPath; + } + + @Override + protected final void before(ExtensionContext context) throws Exception { + projectManagerExtension.before(context); + + final ProjectManager projectManager = projectManagerExtension.projectManager(); + Project project; + try { + project = projectManager.create(projectName, Author.DEFAULT); + } catch (ProjectExistsException e) { + project = projectManager.get(projectName); + } + try { + project.repos().create(repoName, Author.DEFAULT); + } catch (RepositoryExistsException e) { + // Ignore. + } + crudRepository = new GitCrudRepository<>(entityType, projectManagerExtension.executor(), + projectManager, projectName, + repoName, targetPath); + } + + @Override + protected final void after(ExtensionContext context) throws Exception { + projectManagerExtension.after(context); + } + + /** + * Returns the {@link CrudRepository} which is created by this extension. + */ + public final CrudRepository crudRepository() { + checkState(crudRepository != null, "crudRepository not initialized yet."); + return crudRepository; + } +} diff --git a/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/auth/TestAuthMessageUtil.java b/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/auth/TestAuthMessageUtil.java index a2029f9f8e..4a01a153f1 100644 --- a/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/auth/TestAuthMessageUtil.java +++ b/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/auth/TestAuthMessageUtil.java @@ -77,12 +77,15 @@ public static AggregatedHttpResponse usersMe(WebClient client, String sessionId) HttpHeaderNames.AUTHORIZATION, "Bearer " + sessionId)).aggregate().join(); } - public static String getAccessToken(WebClient client, String username, String password) - throws JsonProcessingException { + public static String getAccessToken(WebClient client, String username, String password) { final AggregatedHttpResponse response = login(client, username, password); assertThat(response.status()).isEqualTo(HttpStatus.OK); - return Jackson.readValue(response.content().array(), AccessToken.class) - .accessToken(); + try { + return Jackson.readValue(response.content().array(), AccessToken.class) + .accessToken(); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } private TestAuthMessageUtil() {} diff --git a/webapp/src/dogma/common/UserAndTimestamp.ts b/webapp/src/dogma/common/UserAndTimestamp.ts new file mode 100644 index 0000000000..790a5ee5ee --- /dev/null +++ b/webapp/src/dogma/common/UserAndTimestamp.ts @@ -0,0 +1,4 @@ +export interface UserAndTimestamp { + user: string; + timestamp: string; +} diff --git a/webapp/src/dogma/features/project/settings/DeleteConfirmationModal.tsx b/webapp/src/dogma/common/components/DeleteConfirmationModal.tsx similarity index 56% rename from webapp/src/dogma/features/project/settings/DeleteConfirmationModal.tsx rename to webapp/src/dogma/common/components/DeleteConfirmationModal.tsx index 32e25605d0..575775bc05 100644 --- a/webapp/src/dogma/features/project/settings/DeleteConfirmationModal.tsx +++ b/webapp/src/dogma/common/components/DeleteConfirmationModal.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LINE Corporation + * + * LINE 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. + */ + import { Button, HStack, @@ -15,7 +31,8 @@ interface DeleteConfirmationModalProps { onClose: () => void; type: string; id: string; - projectName: string; + projectName?: string; + repoName?: string; handleDelete: () => void; isLoading: boolean; } @@ -26,9 +43,16 @@ export const DeleteConfirmationModal = ({ id, type, projectName, + repoName, handleDelete, isLoading, }: DeleteConfirmationModalProps): JSX.Element => { + let from; + if (repoName) { + from = ` from ${repoName}`; + } else if (projectName) { + from = ` from ${projectName}`; + } return ( @@ -36,7 +60,7 @@ export const DeleteConfirmationModal = ({ Are you sure? - Delete {type} '{id}' from {projectName}? + Delete {type} '{id}'{from}? diff --git a/webapp/src/dogma/common/components/Navbar.tsx b/webapp/src/dogma/common/components/Navbar.tsx index a69b66d3ba..b4feb388d9 100644 --- a/webapp/src/dogma/common/components/Navbar.tsx +++ b/webapp/src/dogma/common/components/Navbar.tsx @@ -76,10 +76,12 @@ export const Navbar = () => { const topMenus: TopMenu[] = [ { name: title, path: '/' }, { name: 'Projects', path: '/app/projects' }, + { name: 'Settings', path: '/app/settings' }, ]; return ( + {title} = { (arg: Arg): { unwrap: () => Promise }; @@ -98,7 +102,7 @@ export const apiSlice = createApi({ return headers; }, }), - tagTypes: ['Project', 'Metadata', 'Repo', 'File', 'Token'], + tagTypes: ['Project', 'Metadata', 'Repo', 'File', 'Token', 'Mirror'], endpoints: (builder) => ({ getProjects: builder.query({ async queryFn(arg, _queryApi, _extraOptions, fetchWithBQ) { @@ -186,7 +190,7 @@ export const apiSlice = createApi({ }), invalidatesTags: ['Metadata'], }), - addUserRepositoryRole: builder.mutation({ + addUserRepositoryRole: builder.mutation({ query: ({ projectName, repoName, data }) => ({ url: `/api/v1/metadata/${projectName}/repos/${repoName}/roles/users`, method: 'POST', @@ -194,14 +198,14 @@ export const apiSlice = createApi({ }), invalidatesTags: ['Metadata'], }), - deleteUserRepositoryRole: builder.mutation({ + deleteUserRepositoryRole: builder.mutation({ query: ({ projectName, repoName, id }) => ({ url: `/api/v1/metadata/${projectName}/repos/${repoName}/roles/users/${id}`, method: 'DELETE', }), invalidatesTags: ['Metadata'], }), - addTokenRepositoryRole: builder.mutation({ + addTokenRepositoryRole: builder.mutation({ query: ({ projectName, repoName, data }) => ({ url: `/api/v1/metadata/${projectName}/repos/${repoName}/roles/tokens`, method: 'POST', @@ -209,7 +213,7 @@ export const apiSlice = createApi({ }), invalidatesTags: ['Metadata'], }), - deleteTokenRepositoryRole: builder.mutation({ + deleteTokenRepositoryRole: builder.mutation({ query: ({ projectName, repoName, id }) => ({ url: `/api/v1/metadata/${projectName}/repos/${repoName}/roles/tokens/${id}`, method: 'DELETE', @@ -340,7 +344,7 @@ export const apiSlice = createApi({ providesTags: ['Metadata'], }), // eslint-disable-next-line @typescript-eslint/no-explicit-any - addNewMirror: builder.mutation({ + addNewMirror: builder.mutation({ query: (mirror) => ({ url: `/api/v1/projects/${mirror.projectName}/mirrors`, method: 'POST', @@ -349,7 +353,7 @@ export const apiSlice = createApi({ invalidatesTags: ['Metadata'], }), // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateMirror: builder.mutation({ + updateMirror: builder.mutation({ query: ({ projectName, id, mirror }) => ({ url: `/api/v1/projects/${projectName}/mirrors/${id}`, method: 'PUT', @@ -377,6 +381,39 @@ export const apiSlice = createApi({ method: 'GET', }), }), + getMirrorAccessControl: builder.query({ + query: ({ id }) => `/api/v1/mirror/access/${id}`, + providesTags: ['Mirror'], + }), + getMirrorAccessControls: builder.query({ + query: () => `/api/v1/mirror/access`, + providesTags: ['Mirror'], + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addNewMirrorAccessControl: builder.mutation({ + query: (data) => ({ + url: `/api/v1/mirror/access`, + method: 'POST', + body: data, + }), + invalidatesTags: ['Mirror'], + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateMirrorAccessControl: builder.mutation({ + query: (data) => ({ + url: `/api/v1/mirror/access`, + method: 'PUT', + body: data, + }), + invalidatesTags: ['Mirror'], + }), + deleteMirrorAccessControl: builder.mutation({ + query: (id) => ({ + url: `/api/v1/mirror/access/${id}`, + method: 'DELETE', + }), + invalidatesTags: ['Mirror'], + }), getCredentials: builder.query({ query: (projectName) => `/api/v1/projects/${projectName}/credentials`, providesTags: ['Metadata'], @@ -410,6 +447,46 @@ export const apiSlice = createApi({ }), invalidatesTags: ['Metadata'], }), + getRepoCredentials: builder.query({ + query: ({ projectName, repoName }) => `/api/v1/projects/${projectName}/repos/${repoName}/credentials`, + providesTags: ['Metadata'], + }), + getRepoCredential: builder.query({ + query: ({ projectName, id, repoName }) => + `/api/v1/projects/${projectName}/repos/${repoName}/credentials/${id}`, + providesTags: ['Metadata'], + }), + addNewRepoCredential: builder.mutation< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + { projectName: string; credential: CredentialDto; repoName: string } + >({ + query: ({ projectName, credential, repoName }) => ({ + url: `/api/v1/projects/${projectName}/repos/${repoName}/credentials`, + method: 'POST', + body: credential, + }), + invalidatesTags: ['Metadata'], + }), + updateRepoCredential: builder.mutation< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + { projectName: string; id: string; credential: CredentialDto; repoName: string } + >({ + query: ({ projectName, id, credential, repoName }) => ({ + url: `/api/v1/projects/${projectName}/repos/${repoName}/credentials/${id}`, + method: 'PUT', + body: credential, + }), + invalidatesTags: ['Metadata'], + }), + deleteRepoCredential: builder.mutation({ + query: ({ projectName, id, repoName }) => ({ + url: `/api/v1/projects/${projectName}/repos/${repoName}/credentials/${id}`, + method: 'DELETE', + }), + invalidatesTags: ['Metadata'], + }), getTitle: builder.query({ query: () => ({ url: `/title`, @@ -462,12 +539,22 @@ export const { useDeleteMirrorMutation, useRunMirrorMutation, useGetMirrorConfigQuery, + useGetMirrorAccessControlQuery, + useGetMirrorAccessControlsQuery, + useUpdateMirrorAccessControlMutation, + useAddNewMirrorAccessControlMutation, + useDeleteMirrorAccessControlMutation, // Credential useGetCredentialsQuery, useGetCredentialQuery, useAddNewCredentialMutation, useUpdateCredentialMutation, useDeleteCredentialMutation, + useGetRepoCredentialsQuery, + useGetRepoCredentialQuery, + useAddNewRepoCredentialMutation, + useUpdateRepoCredentialMutation, + useDeleteRepoCredentialMutation, // Title useGetTitleQuery, } = apiSlice; diff --git a/webapp/src/dogma/features/auth/ProjectRole.tsx b/webapp/src/dogma/features/auth/ProjectRole.tsx index 7ab919f4af..b9a8d94a79 100644 --- a/webapp/src/dogma/features/auth/ProjectRole.tsx +++ b/webapp/src/dogma/features/auth/ProjectRole.tsx @@ -4,7 +4,7 @@ import { ReactNode } from 'react'; import { UserDto } from './UserDto'; import { ProjectMetadataDto } from '../project/ProjectMetadataDto'; -type ProjectRole = 'OWNER' | 'MEMBER' | 'GUEST'; +export type ProjectRole = 'OWNER' | 'MEMBER' | 'GUEST'; type WithProjectRoleProps = { projectName: string; diff --git a/webapp/src/dogma/features/auth/RepositoryRole.tsx b/webapp/src/dogma/features/auth/RepositoryRole.tsx new file mode 100644 index 0000000000..2ee154d730 --- /dev/null +++ b/webapp/src/dogma/features/auth/RepositoryRole.tsx @@ -0,0 +1,32 @@ +import { UserDto } from './UserDto'; +import { ProjectMetadataDto } from '../project/ProjectMetadataDto'; +import { ProjectRole } from './ProjectRole'; + +export type RepositoryRole = 'READ' | 'WRITE' | 'ADMIN'; + +export function findUserRepositoryRole(repoName: string, user: UserDto, metadata: ProjectMetadataDto) { + if (user && user.systemAdmin) { + return 'ADMIN'; + } + const projectRole = metadata.members[user.email]?.role as ProjectRole; + if (projectRole === 'OWNER') { + return 'ADMIN'; + } + + const roles = metadata.repos[repoName]?.roles; + const memberOrGuestRole = projectRole === 'MEMBER' ? roles?.projects.member : roles?.projects.guest; + const userRepositoryRole = roles?.users[user.email]; + + if (userRepositoryRole === 'ADMIN' || memberOrGuestRole === 'ADMIN') { + return 'ADMIN'; + } + + if (userRepositoryRole === 'WRITE' || memberOrGuestRole === 'WRITE') { + return 'WRITE'; + } + + if (userRepositoryRole === 'READ' || memberOrGuestRole === 'READ') { + return 'READ'; + } + return null; +} diff --git a/webapp/src/dogma/features/mirror/RunMirrorButton.tsx b/webapp/src/dogma/features/mirror/RunMirrorButton.tsx index c35eaabe12..fd15fcfb2f 100644 --- a/webapp/src/dogma/features/mirror/RunMirrorButton.tsx +++ b/webapp/src/dogma/features/mirror/RunMirrorButton.tsx @@ -21,7 +21,7 @@ import { SerializedError } from '@reduxjs/toolkit'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; import { MirrorResult } from './MirrorResult'; -import { MirrorDto } from '../project/settings/mirrors/MirrorDto'; +import { MirrorRequest } from 'dogma/features/project/settings/mirrors/MirrorRequest'; import { Button, ButtonGroup, @@ -40,7 +40,7 @@ import { import { ReactNode } from 'react'; type RunMirrorProps = { - mirror: MirrorDto; + mirror: MirrorRequest; children: ({ isLoading }: { isLoading: boolean; onToggle: () => void }) => ReactNode; }; export const RunMirror = ({ mirror, children }: RunMirrorProps) => { diff --git a/webapp/src/dogma/features/project/ProjectDto.ts b/webapp/src/dogma/features/project/ProjectDto.ts index b272546bd6..62474580d6 100644 --- a/webapp/src/dogma/features/project/ProjectDto.ts +++ b/webapp/src/dogma/features/project/ProjectDto.ts @@ -14,14 +14,13 @@ * under the License. */ +import { ProjectRole } from 'dogma/features/auth/ProjectRole'; import { CreatorDto } from 'dogma/features/repo/RepoDto'; -export type ProjectUserRole = 'OWNER' | 'MEMBER' | 'GUEST'; - export interface ProjectDto { name: string; url?: string; creator?: CreatorDto; createdAt?: string; - userRole?: ProjectUserRole; + userRole?: ProjectRole; } diff --git a/webapp/src/dogma/features/project/settings/AppEntityList.tsx b/webapp/src/dogma/features/project/settings/AppEntityList.tsx new file mode 100644 index 0000000000..8555359a53 --- /dev/null +++ b/webapp/src/dogma/features/project/settings/AppEntityList.tsx @@ -0,0 +1,87 @@ +import { Text, VStack } from '@chakra-ui/react'; +import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip'; +import { UserRole } from 'dogma/common/components/UserRole'; +import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination'; +import { DeleteAppEntity } from 'dogma/features/project/settings/DeleteAppEntity'; + +export type AppEntityListProps = { + data: Data[]; + projectName: string; + entityType: 'member' | 'token'; + getId: (row: Data) => string; + getRole: (row: Data) => string; + getAddedBy: (row: Data) => string; + getTimestamp: (row: Data) => string; + showDeleteButton?: (row: Data) => boolean; + deleteMutation: (projectName: string, id: string) => Promise; + isLoading: boolean; +}; + +const AppEntityList = ({ + data, + projectName, + entityType, + getId, + getRole, + getAddedBy, + getTimestamp, + showDeleteButton = () => true, + deleteMutation, + isLoading, +}: AppEntityListProps): JSX.Element => { + const columnHelper = createColumnHelper(); + const columns = useMemo( + () => [ + columnHelper.accessor((row: Data) => getId(row), { + cell: (info) => ( + + {info.getValue()} + + + + + ), + header: entityType === 'member' ? 'Login ID' : 'App ID', + }), + columnHelper.accessor((row: Data) => getAddedBy(row), { + cell: (info) => info.getValue(), + header: 'Added By', + }), + columnHelper.accessor((row: Data) => getTimestamp(row), { + cell: (info) => , + header: 'Added At', + }), + columnHelper.accessor((row: Data) => getId(row), { + cell: (info) => + showDeleteButton(info.row.original) ? ( + + ) : null, + header: 'Actions', + enableSorting: false, + }), + ], + [ + columnHelper, + projectName, + entityType, + getId, + getRole, + getAddedBy, + getTimestamp, + showDeleteButton, + deleteMutation, + isLoading, + ], + ); + return []} data={data} />; +}; + +export default AppEntityList; diff --git a/webapp/src/dogma/features/project/settings/members/DeleteMember.tsx b/webapp/src/dogma/features/project/settings/DeleteAppEntity.tsx similarity index 67% rename from webapp/src/dogma/features/project/settings/members/DeleteMember.tsx rename to webapp/src/dogma/features/project/settings/DeleteAppEntity.tsx index 484350a47f..215ab79cf8 100644 --- a/webapp/src/dogma/features/project/settings/members/DeleteMember.tsx +++ b/webapp/src/dogma/features/project/settings/DeleteAppEntity.tsx @@ -3,27 +3,31 @@ import { newNotification } from 'dogma/features/notification/notificationSlice'; import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; import { useAppDispatch } from 'dogma/hooks'; import { MdDelete } from 'react-icons/md'; -import { DeleteConfirmationModal } from '../DeleteConfirmationModal'; +import { DeleteConfirmationModal } from 'dogma/common/components/DeleteConfirmationModal'; -export const DeleteMember = ({ - projectName, - repoName, - id, - deleteMember, - isLoading, -}: { +type DeleteEntityProps = { projectName: string; repoName?: string; id: string; - deleteMember: (projectName: string, id: string, repoName?: string) => Promise; + entityType: 'member' | 'token' | 'user'; + deleteEntity: (projectName: string, id: string, repoName?: string) => Promise; isLoading: boolean; -}): JSX.Element => { +}; + +export const DeleteAppEntity = ({ + projectName, + repoName, + id, + entityType, + deleteEntity, + isLoading, +}: DeleteEntityProps): JSX.Element => { const { isOpen, onToggle, onClose } = useDisclosure(); const dispatch = useAppDispatch(); const handleDelete = async () => { try { - await deleteMember(projectName, id, repoName); - dispatch(newNotification('Member deleted.', `Successfully deleted ${id}`, 'success')); + await deleteEntity(projectName, id, repoName); + dispatch(newNotification(`${entityType} deleted.`, `Successfully deleted ${id}`, 'success')); onClose(); } catch (error) { dispatch(newNotification(`Failed to delete ${id}`, ErrorMessageParser.parse(error), 'error')); @@ -38,8 +42,9 @@ export const DeleteMember = ({ isOpen={isOpen} onClose={onClose} id={id} - type={'member'} + type={entityType} projectName={projectName} + repoName={repoName} handleDelete={handleDelete} isLoading={isLoading} /> diff --git a/webapp/src/dogma/features/project/settings/ProjectSettingsView.tsx b/webapp/src/dogma/features/project/settings/ProjectSettingsView.tsx index 5fdf757fdb..55b6995098 100644 --- a/webapp/src/dogma/features/project/settings/ProjectSettingsView.tsx +++ b/webapp/src/dogma/features/project/settings/ProjectSettingsView.tsx @@ -26,7 +26,7 @@ import { useAppSelector } from 'dogma/hooks'; import { FiBox } from 'react-icons/fi'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { HttpStatusCode } from 'dogma/features/api/HttpStatusCode'; -import { findUserRole } from '../../auth/ProjectRole'; +import { findUserRole, ProjectRole } from 'dogma/features/auth/ProjectRole'; interface ProjectSettingsViewProps { projectName: string; @@ -35,12 +35,11 @@ interface ProjectSettingsViewProps { } type TabName = 'repositories' | 'roles' | 'members' | 'tokens' | 'mirrors' | 'credentials' | 'danger zone'; -type UserRole = 'OWNER' | 'MEMBER' | 'GUEST'; export interface TapInfo { name: TabName; path: string; - accessRole: UserRole; + accessRole: ProjectRole; allowAnonymous: boolean; } diff --git a/webapp/src/dogma/features/project/settings/credentials/CredentialForm.tsx b/webapp/src/dogma/features/project/settings/credentials/CredentialForm.tsx index 94598acf31..e7b12b39a6 100644 --- a/webapp/src/dogma/features/project/settings/credentials/CredentialForm.tsx +++ b/webapp/src/dogma/features/project/settings/credentials/CredentialForm.tsx @@ -42,9 +42,11 @@ import { LabelledIcon } from 'dogma/common/components/LabelledIcon'; import FieldErrorMessage from 'dogma/common/components/form/FieldErrorMessage'; import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; import { FiBox } from 'react-icons/fi'; +import { GoRepo } from 'react-icons/go'; interface CredentialFormProps { projectName: string; + repoName?: string; defaultValue: CredentialDto; onSubmit: (credential: CredentialDto, onSuccess: () => void) => Promise; isWaitingResponse: boolean; @@ -62,7 +64,13 @@ const CREDENTIAL_TYPES: CredentialType[] = [ { type: 'none', description: 'No authentication' }, ]; -const CredentialForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: CredentialFormProps) => { +const CredentialForm = ({ + projectName, + repoName, + defaultValue, + onSubmit, + isWaitingResponse, +}: CredentialFormProps) => { const [credentialType, setCredentialType] = useState(defaultValue.type); const isNew = defaultValue.id === ''; @@ -104,10 +112,17 @@ const CredentialForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse {isNew ? 'New Credential' : 'Edit Credential'} - - - {projectName} - + {repoName ? ( + + + {repoName} + + ) : ( + + + {projectName} + + )} diff --git a/webapp/src/dogma/features/project/settings/credentials/CredentialList.tsx b/webapp/src/dogma/features/project/settings/credentials/CredentialList.tsx index 5cc188a761..43f6185783 100644 --- a/webapp/src/dogma/features/project/settings/credentials/CredentialList.tsx +++ b/webapp/src/dogma/features/project/settings/credentials/CredentialList.tsx @@ -1,7 +1,6 @@ import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; import React, { useMemo } from 'react'; import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination'; -import { useGetCredentialsQuery, useDeleteCredentialMutation } from 'dogma/features/api/apiSlice'; import { Badge } from '@chakra-ui/react'; import { ChakraLink } from 'dogma/common/components/ChakraLink'; import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; @@ -10,22 +9,30 @@ import { DeleteCredential } from 'dogma/features/project/settings/credentials/De // eslint-disable-next-line @typescript-eslint/no-unused-vars export type CredentialListProps = { projectName: string; + repoName?: string; + credentials: CredentialDto[]; + deleteCredential: (projectName: string, id: string, repoName?: string) => Promise; + isLoading: boolean; }; -const CredentialList = ({ projectName }: CredentialListProps) => { - const { data } = useGetCredentialsQuery(projectName); - const [deleteCredential, { isLoading }] = useDeleteCredentialMutation(); +const CredentialList = ({ + projectName, + repoName, + credentials, + deleteCredential, + isLoading, +}: CredentialListProps) => { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ columnHelper.accessor((row: CredentialDto) => row.id, { cell: (info) => { const id = info.getValue() || 'undefined'; + const credentialLink = repoName + ? `/app/projects/${projectName}/repos/${repoName}/settings/credentials/${info.row.original.id}` + : `/app/projects/${projectName}/settings/credentials/${info.row.original.id}`; return ( - + {id} ); @@ -52,8 +59,9 @@ const CredentialList = ({ projectName }: CredentialListProp cell: (info) => ( deleteCredential({ projectName, id }).unwrap()} + deleteCredential={deleteCredential} isLoading={isLoading} /> ), @@ -61,9 +69,9 @@ const CredentialList = ({ projectName }: CredentialListProp enableSorting: false, }), ], - [columnHelper, deleteCredential, isLoading, projectName], + [columnHelper, deleteCredential, isLoading, projectName, repoName], ); - return []} data={data || []} />; + return []} data={credentials || []} />; }; export default CredentialList; diff --git a/webapp/src/dogma/features/project/settings/credentials/CredentialView.tsx b/webapp/src/dogma/features/project/settings/credentials/CredentialView.tsx index 0fa04825c0..abdf0fcc9e 100644 --- a/webapp/src/dogma/features/project/settings/credentials/CredentialView.tsx +++ b/webapp/src/dogma/features/project/settings/credentials/CredentialView.tsx @@ -47,6 +47,7 @@ import { RiGitRepositoryPrivateLine } from 'react-icons/ri'; import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; import { CiLock } from 'react-icons/ci'; import { FiBox } from 'react-icons/fi'; +import { GoRepo } from 'react-icons/go'; const HeadRow = ({ children }: { children: ReactNode }) => ( @@ -103,12 +104,13 @@ const SecretViewer = ({ dispatch, secretProvider }: SecretViewerProps) => { interface CredentialViewProps { projectName: string; + repoName?: string; credential: CredentialDto; } const AlignedIcon = ({ as }: { as: IconType }) => ; -const CredentialView = ({ projectName, credential }: CredentialViewProps) => { +const CredentialView = ({ projectName, repoName, credential }: CredentialViewProps) => { const dispatch = useAppDispatch(); return ( @@ -127,12 +129,21 @@ const CredentialView = ({ projectName, credential }: CredentialViewProps) => { - - - Project - - - + {repoName ? ( + + + Repository + + + + ) : ( + + + Project + + + + )} Credential ID @@ -219,7 +230,9 @@ const CredentialView = ({ projectName, credential }: CredentialViewProps) => {
- + - - - ); -}; diff --git a/webapp/src/dogma/features/repo/DeleteRepo.tsx b/webapp/src/dogma/features/repo/DeleteRepo.tsx index b65e061fae..406ba69628 100644 --- a/webapp/src/dogma/features/repo/DeleteRepo.tsx +++ b/webapp/src/dogma/features/repo/DeleteRepo.tsx @@ -20,10 +20,14 @@ export const DeleteRepo = ({ projectName, repoName, hidden, + buttonVariant, + buttonSize, }: { projectName: string; repoName: string; hidden: boolean; + buttonVariant: 'solid' | 'outline'; + buttonSize: 'sm' | 'lg'; }) => { const { isOpen, onToggle, onClose } = useDisclosure(); const dispatch = useAppDispatch(); @@ -42,8 +46,8 @@ export const DeleteRepo = ({ + + + ); +}; diff --git a/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControl.ts b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControl.ts new file mode 100644 index 0000000000..70c8a39b25 --- /dev/null +++ b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControl.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2025 LINE Corporation + * + * LINE 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. + */ + +import { UserAndTimestamp } from 'dogma/common/UserAndTimestamp'; + +export interface MirrorAccessControlRequest { + id: string; + targetPattern: string; + allow: boolean; + description: string; + order: number; +} + +export interface MirrorAccessControl extends MirrorAccessControlRequest { + creation: UserAndTimestamp; +} diff --git a/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlForm.tsx b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlForm.tsx new file mode 100644 index 0000000000..d2bbfb1db0 --- /dev/null +++ b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlForm.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import { + Button, + Center, + FormControl, + FormHelperText, + FormLabel, + Heading, + Input, + Radio, + RadioGroup, + Spacer, + Stack, + VStack, +} from '@chakra-ui/react'; +import { HiOutlineIdentification } from 'react-icons/hi'; +import { Controller, useForm } from 'react-hook-form'; +import React, { useMemo } from 'react'; +import { LabelledIcon } from 'dogma/common/components/LabelledIcon'; +import FieldErrorMessage from 'dogma/common/components/form/FieldErrorMessage'; +import { MirrorAccessControlRequest } from 'dogma/features/settings/mirror-access/MirrorAccessControl'; +import { LuRegex } from 'react-icons/lu'; +import { MdOutlineDescription, MdPolicy } from 'react-icons/md'; +import { RiSortNumberAsc } from 'react-icons/ri'; + +interface MirrorAccessControlFormProps { + defaultValue: MirrorAccessControlRequest; + onSubmit: (credential: MirrorAccessControlRequest, onSuccess: () => void) => Promise; + isWaitingResponse: boolean; +} + +const MirrorAccessControlForm = ({ + defaultValue, + onSubmit, + isWaitingResponse, +}: MirrorAccessControlFormProps) => { + const isNew = defaultValue.id === ''; + const { + register, + handleSubmit, + setValue, + control, + formState: { errors, isDirty }, + } = useForm({ + defaultValues: {}, + }); + + useMemo(() => { + if (!isNew) { + // @ts-expect-error 'allow' in a radio group is a string + setValue('allow', defaultValue.allow + ''); + } + }, [isNew, setValue, defaultValue.allow]); + + return ( +
onSubmit(data, () => {}))}> +
+ + + {isNew ? 'New Mirror Access Control' : 'Edit Mirror Access Control'} + + + + + + + {errors.id ? ( + + ) : ( + + The mirror access control ID must be unique and contain alphanumeric characters, dashes, + underscores, and periods only. + + )} + + + + + + + + + + The pattern of the mirror URI for access control. Regular expressions are supported. + + + + + + + + + + + ( + + + + Allow + + Disallow + + + )} + /> + + + + + + + + + + {errors.order ? ( + + ) : ( + + The order of the mirror access control. Lower numbers are evaluated first. + + )} + + + + + + + + + + + + + {isNew ? ( + + ) : ( + + )} + +
+ + ); +}; + +export default MirrorAccessControlForm; diff --git a/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlList.tsx b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlList.tsx new file mode 100644 index 0000000000..f786841c06 --- /dev/null +++ b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlList.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2025 LINE Corporation + * + * LINE 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. + */ + +import { + useDeleteMirrorAccessControlMutation, + useGetMirrorAccessControlsQuery, +} from 'dogma/features/api/apiSlice'; +import { createColumnHelper } from '@tanstack/react-table'; +import { MirrorAccessControl } from 'dogma/features/settings/mirror-access/MirrorAccessControl'; +import React, { useMemo } from 'react'; +import { Badge, Code, Text } from '@chakra-ui/react'; +import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination'; +import { ChakraLink } from 'dogma/common/components/ChakraLink'; +import { DeleteMirrorAccessControl } from 'dogma/features/settings/mirror-access/DeleteMirrorAccess'; + +const MirrorAccessControlList = () => { + const { data } = useGetMirrorAccessControlsQuery(); + const [deleteMirrorAccessControl, { isLoading }] = useDeleteMirrorAccessControlMutation(); + + const columnHelper = createColumnHelper(); + const columns = useMemo( + () => [ + columnHelper.accessor((row: MirrorAccessControl) => row.id, { + cell: (info) => { + return ( + {info.getValue()} + ); + }, + header: 'ID', + }), + columnHelper.accessor((row: MirrorAccessControl) => row.order, { + cell: (info) => {info.getValue()}, + header: 'Order', + }), + columnHelper.accessor((row: MirrorAccessControl) => row.targetPattern, { + cell: (info) => { + return {info.getValue()}; + }, + header: 'URI Pattern', + }), + columnHelper.accessor((row: MirrorAccessControl) => row.allow, { + cell: (info) => ( + + {info.getValue() ? 'Allowed' : 'Disallowed'} + + ), + header: 'Access', + }), + columnHelper.accessor((row: MirrorAccessControl) => row.creation.user, { + cell: (info) => {info.getValue()}, + header: 'Created By', + }), + columnHelper.accessor((row: MirrorAccessControl) => row.id, { + cell: (info) => ( + deleteMirrorAccessControl(id).unwrap()} + isLoading={isLoading} + /> + ), + header: 'Actions', + enableSorting: false, + }), + ], + [columnHelper, deleteMirrorAccessControl, isLoading], + ); + + return ; +}; + +export default MirrorAccessControlList; diff --git a/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlView.tsx b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlView.tsx new file mode 100644 index 0000000000..0658224ef7 --- /dev/null +++ b/webapp/src/dogma/features/settings/mirror-access/MirrorAccessControlView.tsx @@ -0,0 +1,147 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import { + Badge, + Box, + Button, + Center, + Code, + Heading, + HStack, + Icon, + Link, + Spacer, + Table, + TableContainer, + Tbody, + Td, + Text, + Tr, + VStack, +} from '@chakra-ui/react'; +import { EditIcon } from '@chakra-ui/icons'; +import React, { ReactNode } from 'react'; +import { IconType } from 'react-icons'; +import { HiOutlineIdentification } from 'react-icons/hi'; + +import { MdOutlineDescription, MdPolicy } from 'react-icons/md'; +import { RiSortNumberAsc } from 'react-icons/ri'; +import { MirrorAccessControl } from 'dogma/features/settings/mirror-access/MirrorAccessControl'; +import { LuRegex } from 'react-icons/lu'; +import { FaUser } from 'react-icons/fa'; +import { IoCalendarNumberOutline } from 'react-icons/io5'; +import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip'; +import { GiMirrorMirror } from 'react-icons/gi'; + +const HeadRow = ({ children }: { children: ReactNode }) => ( +
+); + +interface MirrorAccessControlViewProps { + mirrorAccessControl: MirrorAccessControl; +} + +const AlignedIcon = ({ as }: { as: IconType }) => ; + +const MirrorAccessControlView = ({ mirrorAccessControl }: MirrorAccessControlViewProps) => { + return ( +
+ + + + + + + Mirror access control + + + + +
{projectName}
{repoName}
{projectName}
+ {children} +
+ + + + ID + + + + + + Git URI Pattern + + + + + + Access + + + + + + Order + + + + + + Description + + + + + + Created By + + + + + + Created At + + + + +
{mirrorAccessControl.id}
+ {mirrorAccessControl.targetPattern} +
+ + {mirrorAccessControl.allow ? 'Allowed' : 'Disallowed'} + +
{mirrorAccessControl.order}
+ {mirrorAccessControl.description} +
{mirrorAccessControl.creation.user}
+ +
+
+ +

+ + + +
+ + + ); +}; + +export default MirrorAccessControlView; diff --git a/webapp/src/dogma/features/token/TokenDto.ts b/webapp/src/dogma/features/token/TokenDto.ts index 0f337e1d70..ea1c4239d4 100644 --- a/webapp/src/dogma/features/token/TokenDto.ts +++ b/webapp/src/dogma/features/token/TokenDto.ts @@ -1,9 +1,9 @@ -import { RepoCreatorDto } from 'dogma/features/repo/RepositoriesMetadataDto'; +import { UserAndTimestamp } from 'dogma/common/UserAndTimestamp'; export interface TokenDto { appId: string; secret?: string; systemAdmin: boolean; - creation: RepoCreatorDto; - deactivation?: RepoCreatorDto; + creation: UserAndTimestamp; + deactivation?: UserAndTimestamp; } diff --git a/webapp/src/pages/api/v1/projects/[projectName]/index.ts b/webapp/src/pages/api/v1/projects/[projectName]/index.ts index e13141f70f..58287125c7 100644 --- a/webapp/src/pages/api/v1/projects/[projectName]/index.ts +++ b/webapp/src/pages/api/v1/projects/[projectName]/index.ts @@ -1,5 +1,5 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { RepositoryRole } from '../../../../../dogma/features/repo/RepositoriesMetadataDto'; +import { RepositoryRole } from 'dogma/features/auth/RepositoryRole'; const projectMetadata = { name: 'abcd', diff --git a/webapp/src/pages/api/v1/projects/[projectName]/mirrors/[id]/index.ts b/webapp/src/pages/api/v1/projects/[projectName]/mirrors/[id]/index.ts index 6192be5b4d..428c6a881d 100644 --- a/webapp/src/pages/api/v1/projects/[projectName]/mirrors/[id]/index.ts +++ b/webapp/src/pages/api/v1/projects/[projectName]/mirrors/[id]/index.ts @@ -15,11 +15,11 @@ */ import { NextApiRequest, NextApiResponse } from 'next'; -import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; +import { MirrorRequest } from 'dogma/features/project/settings/mirrors/MirrorRequest'; -const mirrors: Map = new Map(); +const mirrors: Map = new Map(); -function newMirror(index: number, projectName: string): MirrorDto { +function newMirror(index: number, projectName: string): MirrorRequest { return { id: `mirror-${index}`, projectName: projectName, diff --git a/webapp/src/pages/api/v1/projects/[projectName]/mirrors/index.ts b/webapp/src/pages/api/v1/projects/[projectName]/mirrors/index.ts index 19e70d6c09..af5f976990 100644 --- a/webapp/src/pages/api/v1/projects/[projectName]/mirrors/index.ts +++ b/webapp/src/pages/api/v1/projects/[projectName]/mirrors/index.ts @@ -15,9 +15,9 @@ */ import { NextApiRequest, NextApiResponse } from 'next'; -import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; +import { MirrorRequest } from 'dogma/features/project/settings/mirrors/MirrorRequest'; -let mirrors: MirrorDto[] = []; +let mirrors: MirrorRequest[] = []; for (let i = 0; i < 10; i++) { mirrors.push({ id: `mirror-${i}`, diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/roles/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/roles/index.tsx deleted file mode 100644 index a35049bc68..0000000000 --- a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/roles/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Box, Flex, Heading, HStack, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react'; -import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; -import { - useAddTokenRepositoryRoleMutation, - useAddUserRepositoryRoleMutation, - useDeleteTokenRepositoryRoleMutation, - useDeleteUserRepositoryRoleMutation, - useGetMetadataByProjectNameQuery, -} from 'dogma/features/api/apiSlice'; -import { NewUserRepositoryRole } from 'dogma/features/repo/roles/NewUserRepositoryRole'; -import { ProjectRolesForm } from 'dogma/features/repo/roles/ProjectRolesForm'; -import { UserRepositoryRole } from 'dogma/features/repo/roles/UserRepositoryRole'; -import { useRouter } from 'next/router'; -import { UserOrTokenRepositoryRoleDto } from 'dogma/features/repo/RepositoriesMetadataDto'; -import { NewTokenRepositoryRole } from 'dogma/features/repo/roles/NewTokenRepositoryRole'; -import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import { Deferred } from 'dogma/common/components/Deferred'; -import { GoRepo } from 'react-icons/go'; -import { isInternalRepo } from 'dogma/util/repo-util'; - -let tabs = ['role', 'user', 'token']; - -const RepoRolePage = () => { - const router = useRouter(); - const projectName = router.query.projectName ? (router.query.projectName as string) : ''; - const repoName = router.query.repoName ? (router.query.repoName as string) : ''; - if (repoName === 'meta') { - tabs = ['user', 'token']; - } - const { - data: metadata, - isLoading, - error, - } = useGetMetadataByProjectNameQuery(projectName, { - refetchOnFocus: true, - skip: false, - }); - const [addUserRepositoryRole, { isLoading: isAddUserLoading }] = useAddUserRepositoryRoleMutation(); - const [deleteUserRepositoryRole, { isLoading: isDeleteUserLoading }] = useDeleteUserRepositoryRoleMutation(); - const [addTokenRepositoryRole, { isLoading: isAddTokenLoading }] = useAddTokenRepositoryRoleMutation(); - const [deleteTokenRepositoryRole, { isLoading: isDeleteTokenLoading }] = - useDeleteTokenRepositoryRoleMutation(); - const [tabIndex, setTabIndex] = useState(0); - const tab = router.query.tab ? (router.query.tab as string) : ''; - useEffect(() => { - const index = tabs.findIndex((tabName) => tabName === tab); - if (index !== -1 && index !== tabIndex) { - setTabIndex(index); - } - }, [tab, tabIndex]); - return ( - - {() => ( - - - - - - - - - {repoName} - roles - - - - - - {tabs.map((tabName) => ( - - - {tabName.charAt(0).toUpperCase()} - {tabName.slice(1)} - - - ))} - - - {!isInternalRepo(repoName) && ( - - {metadata?.repos[repoName]?.roles?.projects && ( - - )} - - )} - - - - - - - - - - - - - - - - - - )} - - ); -}; - -export default RepoRolePage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/[id]/edit/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/[id]/edit/index.tsx new file mode 100644 index 0000000000..66b01730ba --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/[id]/edit/index.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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. + */ + +import Router, { useRouter } from 'next/router'; +import { useGetRepoCredentialQuery, useUpdateRepoCredentialMutation } from 'dogma/features/api/apiSlice'; +import { useAppDispatch } from 'dogma/hooks'; +import { Deferred } from 'dogma/common/components/Deferred'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { SerializedError } from '@reduxjs/toolkit'; +import { newNotification } from 'dogma/features/notification/notificationSlice'; +import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; +import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; +import React from 'react'; +import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; +import CredentialForm from 'dogma/features/project/settings/credentials/CredentialForm'; + +const RepoCredentialEditPage = () => { + const router = useRouter(); + const projectName = router.query.projectName as string; + const repoName = router.query.repoName as string; + const id = router.query.id as string; + + const { + data, + isLoading: isCredentialLoading, + error, + } = useGetRepoCredentialQuery({ projectName, id, repoName }); + const [updateCredential, { isLoading: isWaitingMutationResponse }] = useUpdateRepoCredentialMutation(); + const dispatch = useAppDispatch(); + + const onSubmit = async (credential: CredentialDto, onSuccess: () => void) => { + try { + const response = await updateCredential({ projectName, id, credential, repoName }).unwrap(); + if ((response as { error: FetchBaseQueryError | SerializedError }).error) { + throw (response as { error: FetchBaseQueryError | SerializedError }).error; + } + dispatch(newNotification(`Credential '${credential.id}' is updated`, `Successfully updated`, 'success')); + onSuccess(); + Router.push(`/app/projects/${projectName}/repos/${repoName}/settings/credentials/${id}`); + } catch (error) { + dispatch(newNotification(`Failed to update the credential`, ErrorMessageParser.parse(error), 'error')); + } + }; + + return ( + + {() => { + return ( + <> + + + + ); + }} + + ); +}; + +export default RepoCredentialEditPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/[id]/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/[id]/index.tsx new file mode 100644 index 0000000000..199c6e5416 --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/[id]/index.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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. + */ + +import { useRouter } from 'next/router'; +import { useGetRepoCredentialQuery } from 'dogma/features/api/apiSlice'; +import { Deferred } from 'dogma/common/components/Deferred'; +import React from 'react'; +import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; +import { Flex, Spacer } from '@chakra-ui/react'; +import CredentialView from 'dogma/features/project/settings/credentials/CredentialView'; + +const RepoCredentialViewPage = () => { + const router = useRouter(); + const projectName = router.query.projectName as string; + const repoName = router.query.repoName as string; + const id = router.query.id as string; + const { data, isLoading, error } = useGetRepoCredentialQuery({ projectName, id, repoName }); + return ( + + {() => { + return ( + <> + + + + + + + ); + }} + + ); +}; + +export default RepoCredentialViewPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/index.tsx new file mode 100644 index 0000000000..3aabfead87 --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/index.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import { useRouter } from 'next/router'; +import { Button, Flex, Spacer } from '@chakra-ui/react'; +import Link from 'next/link'; +import { AiOutlinePlus } from 'react-icons/ai'; +import React from 'react'; +import { useGetRepoCredentialsQuery, useDeleteRepoCredentialMutation } from 'dogma/features/api/apiSlice'; +import RepositorySettingsView from 'dogma/features/repo/settings/RepositorySettingsView'; +import CredentialList from 'dogma/features/project/settings/credentials/CredentialList'; + +const RepositoryCredentialPage = () => { + const router = useRouter(); + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const repoName = router.query.repoName ? (router.query.repoName as string) : ''; + const { data: credentialsData } = useGetRepoCredentialsQuery({ + projectName: projectName as string, + repoName: repoName as string, + }); + const [deleteCredentialMutation, { isLoading }] = useDeleteRepoCredentialMutation(); + return ( + + {() => ( + <> + + + + + + deleteCredentialMutation({ projectName, id, repoName }).unwrap() + } + isLoading={isLoading} + /> + + )} + + ); +}; + +export default RepositoryCredentialPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/new/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/new/index.tsx new file mode 100644 index 0000000000..0563f0c26b --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/credentials/new/index.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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. + */ + +import Router, { useRouter } from 'next/router'; +import { useAppDispatch } from 'dogma/hooks'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { SerializedError } from '@reduxjs/toolkit'; +import { newNotification } from 'dogma/features/notification/notificationSlice'; +import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; +import { useAddNewRepoCredentialMutation } from 'dogma/features/api/apiSlice'; +import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; +import React from 'react'; +import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; +import CredentialForm from 'dogma/features/project/settings/credentials/CredentialForm'; + +const EMPTY_CREDENTIAL: CredentialDto = { + id: '', + type: 'public_key', + enabled: true, +}; +const NewRepoCredentialPage = () => { + const router = useRouter(); + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const repoName = router.query.repoName ? (router.query.repoName as string) : ''; + + const [addNewCredential, { isLoading }] = useAddNewRepoCredentialMutation(); + const dispatch = useAppDispatch(); + + const onSubmit = async (credential: CredentialDto, onSuccess: () => void) => { + try { + const response = await addNewCredential({ projectName, credential, repoName }).unwrap(); + if ((response as { error: FetchBaseQueryError | SerializedError }).error) { + throw (response as { error: FetchBaseQueryError | SerializedError }).error; + } + dispatch(newNotification('New credential is created', `Successfully created`, 'success')); + onSuccess(); + Router.push(`/app/projects/${projectName}/repos/${repoName}/settings/credentials`); + } catch (error) { + dispatch(newNotification(`Failed to create a new credential`, ErrorMessageParser.parse(error), 'error')); + } + }; + + return ( + <> + + + + ); +}; + +export default NewRepoCredentialPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/danger-zone/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/danger-zone/index.tsx new file mode 100644 index 0000000000..38faf87873 --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/danger-zone/index.tsx @@ -0,0 +1,27 @@ +import { useRouter } from 'next/router'; +import { Box } from '@chakra-ui/react'; +import RepositorySettingsView from 'dogma/features/repo/settings/RepositorySettingsView'; +import { DeleteRepo } from 'dogma/features/repo/DeleteRepo'; + +const DangerZonePage = () => { + const router = useRouter(); + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const repoName = router.query.repoName ? (router.query.repoName as string) : ''; + return ( + + {() => ( + + + )} + + ); +}; + +export default DangerZonePage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/index.tsx new file mode 100644 index 0000000000..c0d5990edf --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import { useRouter } from 'next/router'; +import RepositorySettingsView from 'dogma/features/repo/settings/RepositorySettingsView'; +import { ProjectRolesForm } from 'dogma/features/repo/roles/ProjectRolesForm'; + +const RepositorySettingsPage = () => { + const router = useRouter(); + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const repoName = router.query.repoName ? (router.query.repoName as string) : ''; + return ( + <> + + {(metadata) => ( + <> + + + )} + + + ); +}; + +export default RepositorySettingsPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/tokens/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/tokens/index.tsx new file mode 100644 index 0000000000..68e507c867 --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/tokens/index.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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. + */ + +import { useRouter } from 'next/router'; +import { Flex, Spacer } from '@chakra-ui/react'; +import RepositorySettingsView from 'dogma/features/repo/settings/RepositorySettingsView'; +import { NewTokenRepositoryRole } from 'dogma/features/repo/settings/tokens/NewTokenRepositoryRole'; +import { UserOrTokenRepositoryRoleDto } from 'dogma/features/repo/RepositoriesMetadataDto'; +import { UserOrTokenRepositoryRoleList } from 'dogma/features/repo/settings/UserOrTokenRepositoryRoleList'; +import { + useAddTokenRepositoryRoleMutation, + useDeleteTokenRepositoryRoleMutation, +} from 'dogma/features/api/apiSlice'; + +const ProjectTokenPage = () => { + const router = useRouter(); + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const repoName = router.query.repoName ? (router.query.repoName as string) : ''; + const [addTokenRepositoryRole, { isLoading: isAddTokenLoading }] = useAddTokenRepositoryRoleMutation(); + const [deleteTokenRepositoryRole, { isLoading: isDeleteTokenLoading }] = + useDeleteTokenRepositoryRoleMutation(); + return ( + + {(metadata) => ( + <> + + + + + + + )} + + ); +}; + +export default ProjectTokenPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/users/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/users/index.tsx new file mode 100644 index 0000000000..cf38f54486 --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/settings/users/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import { useRouter } from 'next/router'; +import { Flex, Spacer } from '@chakra-ui/react'; +import RepositorySettingsView from 'dogma/features/repo/settings/RepositorySettingsView'; +import { NewUserRepositoryRole } from 'dogma/features/repo/settings/users/NewUserRepositoryRole'; +import { UserOrTokenRepositoryRoleDto } from 'dogma/features/repo/RepositoriesMetadataDto'; +import { UserOrTokenRepositoryRoleList } from 'dogma/features/repo/settings/UserOrTokenRepositoryRoleList'; +import { + useAddUserRepositoryRoleMutation, + useDeleteUserRepositoryRoleMutation, +} from 'dogma/features/api/apiSlice'; + +const RepositoryUserPage = () => { + const router = useRouter(); + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const repoName = router.query.repoName ? (router.query.repoName as string) : ''; + const [addUserRepositoryRole, { isLoading: isAddUserLoading }] = useAddUserRepositoryRoleMutation(); + const [deleteUserRepositoryRole, { isLoading: isDeleteUserLoading }] = useDeleteUserRepositoryRoleMutation(); + return ( + + {(metadata) => ( + <> + + + + + + + )} + + ); +}; + +export default RepositoryUserPage; diff --git a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/tree/[revision]/[[...path]]/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/tree/[revision]/[[...path]]/index.tsx index db19560d99..9ce6255102 100644 --- a/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/tree/[revision]/[[...path]]/index.tsx +++ b/webapp/src/pages/app/projects/[projectName]/repos/[repoName]/tree/[revision]/[[...path]]/index.tsx @@ -142,13 +142,13 @@ cat ${project}/${repo}${path}`; > History - {projectName == 'dogma' ? null : ( + {projectName === 'dogma' ? null : ( {() => ( )} diff --git a/webapp/src/pages/app/projects/[projectName]/repos/index.tsx b/webapp/src/pages/app/projects/[projectName]/repos/index.tsx new file mode 100644 index 0000000000..fbb2c876be --- /dev/null +++ b/webapp/src/pages/app/projects/[projectName]/repos/index.tsx @@ -0,0 +1,12 @@ +import { useRouter } from 'next/router'; + +const ReposPage = () => { + const router = useRouter(); + + const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + router.replace(`/app/projects/${projectName}`); + + return <>; +}; + +export default ReposPage; diff --git a/webapp/src/pages/app/projects/[projectName]/settings/credentials/index.tsx b/webapp/src/pages/app/projects/[projectName]/settings/credentials/index.tsx index bf21441e81..f38db3e3c5 100644 --- a/webapp/src/pages/app/projects/[projectName]/settings/credentials/index.tsx +++ b/webapp/src/pages/app/projects/[projectName]/settings/credentials/index.tsx @@ -19,12 +19,16 @@ import { Button, Flex, Spacer } from '@chakra-ui/react'; import Link from 'next/link'; import { AiOutlinePlus } from 'react-icons/ai'; import React from 'react'; +import { useGetCredentialsQuery, useDeleteCredentialMutation } from 'dogma/features/api/apiSlice'; import ProjectSettingsView from 'dogma/features/project/settings/ProjectSettingsView'; import CredentialList from 'dogma/features/project/settings/credentials/CredentialList'; const ProjectCredentialPage = () => { const router = useRouter(); const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const { data: credentialsData } = useGetCredentialsQuery(projectName); + const [deleteCredentialMutation, { isLoading }] = useDeleteCredentialMutation(); + return ( {() => ( @@ -41,7 +45,12 @@ const ProjectCredentialPage = () => { New Credential - + deleteCredentialMutation({ projectName, id }).unwrap()} + isLoading={isLoading} + /> )} diff --git a/webapp/src/pages/app/projects/[projectName]/settings/members/index.tsx b/webapp/src/pages/app/projects/[projectName]/settings/members/index.tsx index fd882099f1..ef29cb2246 100644 --- a/webapp/src/pages/app/projects/[projectName]/settings/members/index.tsx +++ b/webapp/src/pages/app/projects/[projectName]/settings/members/index.tsx @@ -16,13 +16,18 @@ import { useRouter } from 'next/router'; import { Flex, Spacer } from '@chakra-ui/react'; +import { useAppSelector } from 'dogma/hooks'; +import { useDeleteMemberMutation } from 'dogma/features/api/apiSlice'; import ProjectSettingsView from 'dogma/features/project/settings/ProjectSettingsView'; import { AddMember } from 'dogma/features/project/settings/members/AddMember'; -import AppMemberList from 'dogma/features/project/settings/members/AppMemberList'; +import AppEntityList from 'dogma/features/project/settings/AppEntityList'; const ProjectMemberPage = () => { const router = useRouter(); const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const [deleteMember, { isLoading }] = useDeleteMemberMutation(); + const auth = useAppSelector((state) => state.auth); + return ( {(metadata) => ( @@ -31,7 +36,18 @@ const ProjectMemberPage = () => { - + row.login} + getRole={(row) => row.role} + getAddedBy={(row) => row.creation.user} + getTimestamp={(row) => row.creation.timestamp} + deleteMutation={(projectName, id) => deleteMember({ projectName, id }).unwrap()} + showDeleteButton={(row) => row.login !== auth.user?.email} + isLoading={isLoading} + /> )} diff --git a/webapp/src/pages/app/projects/[projectName]/settings/mirrors/[id]/edit/index.tsx b/webapp/src/pages/app/projects/[projectName]/settings/mirrors/[id]/edit/index.tsx index 1bd3717d7d..a53be883ab 100644 --- a/webapp/src/pages/app/projects/[projectName]/settings/mirrors/[id]/edit/index.tsx +++ b/webapp/src/pages/app/projects/[projectName]/settings/mirrors/[id]/edit/index.tsx @@ -24,7 +24,7 @@ import { newNotification } from 'dogma/features/notification/notificationSlice'; import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; import React from 'react'; -import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; +import { MirrorRequest } from 'dogma/features/project/settings/mirrors/MirrorRequest'; import MirrorForm from 'dogma/features/project/settings/mirrors/MirrorForm'; const MirrorEditPage = () => { @@ -36,7 +36,7 @@ const MirrorEditPage = () => { const [updateMirror, { isLoading: isWaitingMutationResponse }] = useUpdateMirrorMutation(); const dispatch = useAppDispatch(); - const onSubmit = async (mirror: MirrorDto, onSuccess: () => void) => { + const onSubmit = async (mirror: MirrorRequest, onSuccess: () => void) => { try { mirror.projectName = projectName; const response = await updateMirror({ projectName, id, mirror }).unwrap(); diff --git a/webapp/src/pages/app/projects/[projectName]/settings/mirrors/new/index.tsx b/webapp/src/pages/app/projects/[projectName]/settings/mirrors/new/index.tsx index 2df91544aa..f4acd7f1f6 100644 --- a/webapp/src/pages/app/projects/[projectName]/settings/mirrors/new/index.tsx +++ b/webapp/src/pages/app/projects/[projectName]/settings/mirrors/new/index.tsx @@ -24,14 +24,14 @@ import Router, { useRouter } from 'next/router'; import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; import React from 'react'; -import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; +import { MirrorRequest } from 'dogma/features/project/settings/mirrors/MirrorRequest'; import MirrorForm from 'dogma/features/project/settings/mirrors/MirrorForm'; const NewMirrorPage = () => { const router = useRouter(); const projectName = router.query.projectName ? (router.query.projectName as string) : ''; - const emptyMirror: MirrorDto = { + const emptyMirror: MirrorRequest = { id: '', direction: 'REMOTE_TO_LOCAL', schedule: '0 * * * * ?', @@ -50,7 +50,11 @@ const NewMirrorPage = () => { const [addNewMirror, { isLoading }] = useAddNewMirrorMutation(); const dispatch = useAppDispatch(); - const onSubmit = async (formData: MirrorDto, onSuccess: () => void, setError: UseFormSetError) => { + const onSubmit = async ( + formData: MirrorRequest, + onSuccess: () => void, + setError: UseFormSetError, + ) => { try { formData.projectName = projectName; if (formData.remoteScheme.startsWith('git') && !formData.remoteUrl.endsWith('.git')) { diff --git a/webapp/src/pages/app/projects/[projectName]/settings/tokens/index.tsx b/webapp/src/pages/app/projects/[projectName]/settings/tokens/index.tsx index c4688e8de6..cdf1af9e6d 100644 --- a/webapp/src/pages/app/projects/[projectName]/settings/tokens/index.tsx +++ b/webapp/src/pages/app/projects/[projectName]/settings/tokens/index.tsx @@ -16,13 +16,15 @@ import { useRouter } from 'next/router'; import { Flex, Spacer } from '@chakra-ui/react'; +import { useDeleteTokenMemberMutation } from 'dogma/features/api/apiSlice'; import ProjectSettingsView from 'dogma/features/project/settings/ProjectSettingsView'; import { AddAppToken } from 'dogma/features/project/settings/tokens/AddAppToken'; -import AppTokenList from 'dogma/features/project/settings/tokens/AppTokenList'; +import AppEntityList from 'dogma/features/project/settings/AppEntityList'; const ProjectTokenPage = () => { const router = useRouter(); const projectName = router.query.projectName ? (router.query.projectName as string) : ''; + const [deleteToken, { isLoading }] = useDeleteTokenMemberMutation(); return ( {(metadata) => ( @@ -31,9 +33,16 @@ const ProjectTokenPage = () => { - row.appId} + getRole={(row) => row.role} + getAddedBy={(row) => row.creation.user} + getTimestamp={(row) => row.creation.timestamp} + deleteMutation={(projectName, id) => deleteToken({ projectName, id }).unwrap()} + isLoading={isLoading} /> )} diff --git a/webapp/src/pages/app/settings/index.tsx b/webapp/src/pages/app/settings/index.tsx new file mode 100644 index 0000000000..fd0f97e79a --- /dev/null +++ b/webapp/src/pages/app/settings/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import SettingView from 'dogma/features/settings/SettingView'; +import Router from 'next/router'; + +const SystemSettingsPage = () => { + Router.push('/app/settings/tokens'); + return ( + <> + +
Redirecting...
+
+ + ); +}; + +export default SystemSettingsPage; diff --git a/webapp/src/pages/app/settings/mirror-access/[id]/edit/index.tsx b/webapp/src/pages/app/settings/mirror-access/[id]/edit/index.tsx new file mode 100644 index 0000000000..7a5afe681c --- /dev/null +++ b/webapp/src/pages/app/settings/mirror-access/[id]/edit/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE 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. + */ + +import Router, { useRouter } from 'next/router'; +import { + useGetMirrorAccessControlQuery, + useUpdateMirrorAccessControlMutation, +} from 'dogma/features/api/apiSlice'; +import { useAppDispatch } from 'dogma/hooks'; +import { Deferred } from 'dogma/common/components/Deferred'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { SerializedError } from '@reduxjs/toolkit'; +import { newNotification } from 'dogma/features/notification/notificationSlice'; +import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; +import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; +import React from 'react'; +import MirrorAccessControlForm from 'dogma/features/settings/mirror-access/MirrorAccessControlForm'; +import { MirrorAccessControlRequest } from 'dogma/features/settings/mirror-access/MirrorAccessControl'; + +const MirrorAccessControlEditPage = () => { + const router = useRouter(); + const id = router.query.id as string; + + const { data, isLoading: isDataLoading, error } = useGetMirrorAccessControlQuery({ id }); + const [updateMirrorAccessControl, { isLoading: isWaitingMutationResponse }] = + useUpdateMirrorAccessControlMutation(); + const dispatch = useAppDispatch(); + + const onSubmit = async (data: MirrorAccessControlRequest, onSuccess: () => void) => { + try { + const response = await updateMirrorAccessControl(data).unwrap(); + if ((response as { error: FetchBaseQueryError | SerializedError }).error) { + throw (response as { error: FetchBaseQueryError | SerializedError }).error; + } + dispatch( + newNotification(`Mirror access control '${data.id}' is updated`, `Successfully updated`, 'success'), + ); + onSuccess(); + Router.push(`/app/settings/mirror-access/${id}`); + } catch (error) { + dispatch( + newNotification(`Failed to update the mirror access control`, ErrorMessageParser.parse(error), 'error'), + ); + } + }; + + return ( + + {() => ( + <> + + + + )} + + ); +}; + +export default MirrorAccessControlEditPage; diff --git a/webapp/src/pages/app/settings/mirror-access/[id]/index.tsx b/webapp/src/pages/app/settings/mirror-access/[id]/index.tsx new file mode 100644 index 0000000000..ccb60b8aa6 --- /dev/null +++ b/webapp/src/pages/app/settings/mirror-access/[id]/index.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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. + */ + +import { useRouter } from 'next/router'; +import { useGetMirrorAccessControlQuery } from 'dogma/features/api/apiSlice'; +import { Deferred } from 'dogma/common/components/Deferred'; +import React from 'react'; +import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; +import { Flex, Spacer } from '@chakra-ui/react'; +import MirrorAccessControlView from 'dogma/features/settings/mirror-access/MirrorAccessControlView'; + +const MirrorAccessControlViewPage = () => { + const router = useRouter(); + const id = router.query.id as string; + const { data, isLoading, error } = useGetMirrorAccessControlQuery({ id }); + return ( + + {() => { + return ( + <> + + + + + + + ); + }} + + ); +}; + +export default MirrorAccessControlViewPage; diff --git a/webapp/src/pages/app/settings/mirror-access/index.tsx b/webapp/src/pages/app/settings/mirror-access/index.tsx new file mode 100644 index 0000000000..d18f73d7b7 --- /dev/null +++ b/webapp/src/pages/app/settings/mirror-access/index.tsx @@ -0,0 +1,29 @@ +import { Box, Button, Flex, Spacer } from '@chakra-ui/react'; +import SettingView from 'dogma/features/settings/SettingView'; +import MirrorAccessControlList from 'dogma/features/settings/mirror-access/MirrorAccessControlList'; +import { ChakraLink } from 'dogma/common/components/ChakraLink'; +import { FaPlus } from 'react-icons/fa6'; + +const MirrorAccessControlListPage = () => { + return ( + + + + + + + + + + ); +}; + +export default MirrorAccessControlListPage; diff --git a/webapp/src/pages/app/settings/mirror-access/new/index.tsx b/webapp/src/pages/app/settings/mirror-access/new/index.tsx new file mode 100644 index 0000000000..2ae330d7a7 --- /dev/null +++ b/webapp/src/pages/app/settings/mirror-access/new/index.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE 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. + */ + +import Router, { useRouter } from 'next/router'; +import { useAppDispatch } from 'dogma/hooks'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { SerializedError } from '@reduxjs/toolkit'; +import { newNotification } from 'dogma/features/notification/notificationSlice'; +import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; +import { useAddNewMirrorAccessControlMutation } from 'dogma/features/api/apiSlice'; +import { Breadcrumbs } from 'dogma/common/components/Breadcrumbs'; +import React from 'react'; +import MirrorAccessControlForm from 'dogma/features/settings/mirror-access/MirrorAccessControlForm'; +import { MirrorAccessControlRequest } from 'dogma/features/settings/mirror-access/MirrorAccessControl'; + +const EMPTY_MACR: MirrorAccessControlRequest = { + id: '', + targetPattern: '', + allow: null, + description: '', + order: 0, +}; +const NewMirrorAccessControlPage = () => { + const router = useRouter(); + + const [addNewMirrorAccessControl, { isLoading }] = useAddNewMirrorAccessControlMutation(); + const dispatch = useAppDispatch(); + + const onSubmit = async (data: MirrorAccessControlRequest, onSuccess: () => void) => { + try { + const response = await addNewMirrorAccessControl(data).unwrap(); + if ((response as { error: FetchBaseQueryError | SerializedError }).error) { + throw (response as { error: FetchBaseQueryError | SerializedError }).error; + } + dispatch(newNotification('New mirror access control is created', `Successfully created`, 'success')); + onSuccess(); + Router.push(`/app/settings/mirror-access`); + } catch (error) { + dispatch( + newNotification( + `Failed to create a new mirror access control`, + ErrorMessageParser.parse(error), + 'error', + ), + ); + } + }; + + return ( + <> + + + + ); +}; + +export default NewMirrorAccessControlPage; diff --git a/webapp/src/pages/app/settings/tokens.tsx b/webapp/src/pages/app/settings/tokens/index.tsx similarity index 84% rename from webapp/src/pages/app/settings/tokens.tsx rename to webapp/src/pages/app/settings/tokens/index.tsx index f0a92cd98c..7a05bb3f9b 100644 --- a/webapp/src/pages/app/settings/tokens.tsx +++ b/webapp/src/pages/app/settings/tokens/index.tsx @@ -1,4 +1,4 @@ -import { Badge, Box, Flex, Heading, Spacer, Text, Wrap } from '@chakra-ui/react'; +import { Badge, Box, Flex, Spacer, Text, Wrap } from '@chakra-ui/react'; import { createColumnHelper } from '@tanstack/react-table'; import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip'; import { NewToken } from 'dogma/features/token/NewToken'; @@ -12,6 +12,7 @@ import { DeactivateToken } from 'dogma/features/token/DeactivateToken'; import { ActivateToken } from 'dogma/features/token/ActivateToken'; import { DeleteToken } from 'dogma/features/token/DeleteToken'; import { Deferred } from 'dogma/common/components/Deferred'; +import SettingView from 'dogma/features/settings/SettingView'; const TokenPage = () => { const columnHelper = createColumnHelper(); @@ -62,20 +63,19 @@ const TokenPage = () => { ); const { data, error, isLoading } = useGetTokensQuery(); return ( - - {() => ( - - - Application Tokens - - - - - - - - )} - + + + {() => ( + + + + + + + + )} + + ); }; diff --git a/webapp/src/pages/index.tsx b/webapp/src/pages/index.tsx index 12df25f051..e2ac2707ed 100644 --- a/webapp/src/pages/index.tsx +++ b/webapp/src/pages/index.tsx @@ -20,6 +20,7 @@ import ProjectSearchBox from 'dogma/common/components/ProjectSearchBox'; const HomePage = () => { return (
+ Central Dogma Welcome to Central Dogma! diff --git a/webapp/src/test/java/com/linecorp/centraldogma/webapp/ShiroCentralDogmaTestServer.java b/webapp/src/test/java/com/linecorp/centraldogma/webapp/ShiroCentralDogmaTestServer.java index 557f953b19..d5b4c03167 100644 --- a/webapp/src/test/java/com/linecorp/centraldogma/webapp/ShiroCentralDogmaTestServer.java +++ b/webapp/src/test/java/com/linecorp/centraldogma/webapp/ShiroCentralDogmaTestServer.java @@ -32,9 +32,11 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableList; +import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.auth.AuthToken; import com.linecorp.centraldogma.client.armeria.ArmeriaCentralDogmaBuilder; import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.internal.api.v1.AccessToken; @@ -42,6 +44,7 @@ import com.linecorp.centraldogma.server.CentralDogmaBuilder; import com.linecorp.centraldogma.server.ZoneConfig; import com.linecorp.centraldogma.server.auth.shiro.ShiroAuthProviderFactory; +import com.linecorp.centraldogma.server.internal.credential.NoneCredential; import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig; final class ShiroCentralDogmaTestServer { @@ -76,12 +79,22 @@ public static void main(String[] args) throws IOException { } private static void scaffold() throws UnknownHostException, JsonProcessingException { + final String token = getSessionToken(); final com.linecorp.centraldogma.client.CentralDogma client = new ArmeriaCentralDogmaBuilder() .host("127.0.0.1", PORT) - .accessToken(getSessionToken()) + .accessToken(token) .build(); client.createProject("foo").join(); client.createRepository("foo", "bar").join(); + + final BlockingWebClient webClient = WebClient.builder("http://127.0.0.1:" + PORT) + .auth(AuthToken.ofOAuth2(token)) + .build() + .blocking(); + final AggregatedHttpResponse res = webClient.prepare() + .post("/api/v1/projects/foo/credentials") + .contentJson(new NoneCredential("none", true)) + .execute(); } private static String getSessionToken() throws JsonProcessingException { diff --git a/webapp/tests/dogma/feature/repo/RepoPermissionList.test.tsx b/webapp/tests/dogma/feature/repo/RepoPermissionList.test.tsx index 70a64b53e5..23595353f9 100644 --- a/webapp/tests/dogma/feature/repo/RepoPermissionList.test.tsx +++ b/webapp/tests/dogma/feature/repo/RepoPermissionList.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render } from '@testing-library/react'; +import { RepositoryRole } from 'dogma/features/auth/RepositoryRole'; import RepoRoleList, { RepoRoleListProps } from 'dogma/features/repo/RepoRoleList'; -import { RepositoryMetadataDto, RepositoryRole } from 'dogma/features/repo/RepositoriesMetadataDto'; +import { RepositoryMetadataDto } from 'dogma/features/repo/RepositoriesMetadataDto'; import '@testing-library/jest-dom'; describe('RepoRoleList', () => { @@ -90,7 +91,7 @@ describe('RepoRoleList', () => { const firstRepoName = 'meta'; expect(firstCell).toHaveAttribute( 'href', - `/app/projects/${expectedProps.projectName}/repos/${firstRepoName}/roles`, + `/app/projects/${expectedProps.projectName}/repos/${firstRepoName}/settings`, ); }); }); diff --git a/xds/src/main/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesService.java b/xds/src/main/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesService.java index 0067c56e21..57e1bd6606 100644 --- a/xds/src/main/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesService.java +++ b/xds/src/main/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesService.java @@ -225,7 +225,7 @@ private static CompletableFuture toConfig(Kubeconfig kubeconfig, MetaRep return CompletableFuture.completedFuture(configBuilder.build()); } - return metaRepository.credential(credentialId) + return metaRepository.projectCredential(credentialId) .thenApply(credential -> { if (!(credential instanceof AccessTokenCredential)) { throw new IllegalArgumentException( diff --git a/xds/src/test/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesServiceTest.java b/xds/src/test/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesServiceTest.java index 0a5ba26b09..bfeaf0f6dd 100644 --- a/xds/src/test/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesServiceTest.java +++ b/xds/src/test/java/com/linecorp/centraldogma/xds/k8s/v1/XdsKubernetesServiceTest.java @@ -184,7 +184,9 @@ void invalidProperty() throws IOException { assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); assertThatJson(response.contentUtf8()) .node("grpc-code").isEqualTo("INVALID_ARGUMENT") - .node("message").isEqualTo("failed to find credential 'invalid-credential-id' in @xds/meta"); + .node("message").isEqualTo( + "failed to find credential file " + + "'/credentials/invalid-credential-id.json' in @xds/meta"); } @Test