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..eb7cf8f783 --- /dev/null +++ b/common/src/main/java/com/linecorp/centraldogma/common/MirrorAccessException.java @@ -0,0 +1,31 @@ +/* + * 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); + } +} 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/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 bf992db847..3dbb248bc8 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,8 +44,11 @@ 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.MirrorAccessController; import com.linecorp.centraldogma.server.mirror.MirrorDirection; import com.linecorp.centraldogma.server.mirror.MirrorResult; import com.linecorp.centraldogma.server.mirror.MirrorStatus; @@ -52,6 +56,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 +68,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 +123,11 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo when(mr.mirrors()).thenReturn(CompletableFuture.completedFuture(ImmutableList.of(mirror))); + final MirrorAccessController ac = + new DefaultMirrorAccessController(repositoryExtension.crudRepository()); final MirrorSchedulingService service = new MirrorSchedulingService( - temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null); + temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null, + 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..394e69b240 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,12 @@ static void reset() { errors.clear(); } + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) {} + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) {} + @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..5b05d5899e --- /dev/null +++ b/it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/MirrorAccessControlTest.java @@ -0,0 +1,236 @@ +/* + * 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(); + Thread.sleep(10000); + assertThat(creationCount.get("git+ssh://github.com/line/centraldogma-authtest.git")) + .isOne(); + + final String listenerKey = TEST_PROJ + '/' + TEST_MIRROR_ID + '/' + Author.SYSTEM.email(); + 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}/mirrors/{mirrorId}") + .pathParam("proj", TEST_PROJ) + .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}/credentials") + .pathParam("proj", TEST_PROJ) + .contentJson(credential) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + + final MirrorRequest newMirror = newMirror(); + response = client.prepare() + .post("/api/v1/projects/{proj}/mirrors") + .pathParam("proj", TEST_PROJ) + .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 95e7bfe783..7318ee656d 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}/mirrors") .pathParam("proj", FOO_PROJ) @@ -146,21 +149,85 @@ 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}/mirrors") + .pathParam("proj", FOO_PROJ) + .contentJson(newMirror) + .asJson(PushResultDto.class) + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + + AggregatedHttpResponse mirrorResponse = + systemAdminClient.prepare() + .post("/api/v1/projects/{proj}/mirrors/{mirrorId}/run") + .pathParam("proj", FOO_PROJ) + .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}/mirrors/{mirrorId}/run") + .pathParam("proj", FOO_PROJ) + .pathParam("mirrorId", TEST_MIRROR_ID) + .execute(); + // Mirror execution should be forbidden. + 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..4d6f32e1ce 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,16 @@ 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 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..ceec305470 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,12 @@ 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 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 37c89b63d7..48f8893c3a 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(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}/mirrors") .pathParam("proj", FOO_PROJ) @@ -230,20 +230,20 @@ private static void createMirror(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", + 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); + "/", + "git+ssh", + "github.com/line/centraldogma-authtest.git", + "/", + "main", + null, + PRIVATE_KEY_FILE, + zone); } } 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 fa13c8bba6..86feb1f29d 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 @@ -42,7 +42,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; @@ -169,10 +169,10 @@ 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) { @@ -180,7 +180,7 @@ void testMirror(boolean useRawApi) { metaRepo.createPushCommand(credential, Author.SYSTEM, false).join(); pmExtension.executor().execute(command).join(); } - for (MirrorDto mirror : mirrors) { + for (MirrorRequest mirror : mirrors) { final Command command = metaRepo.createPushCommand(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 4cfd1327e9..134d7e7d5e 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; @@ -96,7 +101,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); + temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1, null, + AlwaysAllowedMirrorAccessController.INSTANCE); final CommandExecutor executor = mock(CommandExecutor.class); service.start(executor); @@ -107,4 +113,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 3ae649b6a1..f502e68a96 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}/mirrors") @@ -273,7 +270,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}/mirrors") @@ -290,24 +287,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() @@ -325,24 +325,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}/mirrors/{id}") @@ -360,7 +363,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() { @@ -399,20 +405,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..7350d43e1f 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"); @@ -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); @@ -671,7 +691,7 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, final Function authService = authService(mds, authProvider, sessionManager); configureHttpApi(sb, projectApiManager, executor, watchService, mds, authProvider, authService, - meterRegistry); + meterRegistry, pm); configureMetrics(sb, meterRegistry); // Add the CORS service as the last decorator(executed first) so that the CORS service is applied @@ -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)) { @@ -828,7 +848,7 @@ private void configureHttpApi(ServerBuilder sb, WatchService watchService, MetadataService mds, @Nullable AuthProvider authProvider, Function authService, - MeterRegistry meterRegistry) { + MeterRegistry meterRegistry, ProjectManager pm) { final DependencyInjector dependencyInjector = DependencyInjector.ofSingletons( // Use the default ObjectMapper without any configuration. // See JacksonRequestConverterFunctionTest @@ -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() @@ -1213,7 +1237,7 @@ protected CompletionStage doStart(@Nullable Void unused) throws Exception final MeterRegistry meterRegistry = CentralDogma.this.meterRegistry; if (pm != null && executor != null && meterRegistry != null) { pluginsForAllReplicas.start(cfg, pm, executor, meterRegistry, purgeWorker, - projectInitializer).join(); + projectInitializer, mirrorAccessController).join(); } } } catch (Exception e) { @@ -1231,7 +1255,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/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 e8029de3e2..77126d1d2b 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; @@ -47,6 +51,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; @@ -55,9 +60,12 @@ import com.linecorp.centraldogma.server.command.CommitResult; 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; @@ -69,6 +77,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 @@ -78,14 +88,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) { @@ -108,10 +121,14 @@ private static Map mirrorZoneConfig(CentralDogmaConfig config) { @RequiresRepositoryRole(value = RepositoryRole.READ, repository = Project.REPO_META) @Get("/projects/{projectName}/mirrors") public CompletableFuture> listMirrors(@Param String projectName) { - return metaRepo(projectName).mirrors(true).thenApply(mirrors -> { - return mirrors.stream() - .map(mirror -> convertToMirrorDto(projectName, mirror)) - .collect(toImmutableList()); + return metaRepo(projectName).mirrors(true).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()); + }); }); } @@ -123,8 +140,10 @@ public CompletableFuture> listMirrors(@Param String projectName) @RequiresRepositoryRole(value = RepositoryRole.READ, repository = Project.REPO_META) @Get("/projects/{projectName}/mirrors/{id}") public CompletableFuture getMirror(@Param String projectName, @Param String id) { - return metaRepo(projectName).mirror(id).thenApply(mirror -> { - return convertToMirrorDto(projectName, mirror); + return metaRepo(projectName).mirror(id).thenCompose(mirror -> { + return accessController.isAllowed(mirror.remoteRepoUri()).thenApply(allowed -> { + return convertToMirrorDto(projectName, mirror, allowed); + }); }); } @@ -137,7 +156,7 @@ public CompletableFuture getMirror(@Param String projectName, @Param @ConsumesJson @StatusCode(201) @RequiresRepositoryRole(value = RepositoryRole.WRITE, repository = Project.REPO_META) - public CompletableFuture createMirror(@Param String projectName, MirrorDto newMirror, + public CompletableFuture createMirror(@Param String projectName, MirrorRequest newMirror, Author author) { return createOrUpdate(projectName, newMirror, author, false); } @@ -150,7 +169,7 @@ public CompletableFuture createMirror(@Param String projectName, @ConsumesJson @Put("/projects/{projectName}/mirrors/{id}") @RequiresRepositoryRole(value = RepositoryRole.WRITE, repository = Project.REPO_META) - public CompletableFuture updateMirror(@Param String projectName, MirrorDto mirror, + public CompletableFuture updateMirror(@Param String projectName, 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); @@ -177,14 +196,38 @@ public CompletableFuture deleteMirror(@Param String projectName, } private CompletableFuture createOrUpdate(String projectName, - MirrorDto newMirror, + MirrorRequest newMirror, Author author, boolean update) { - return metaRepo(projectName) - .createPushCommand(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.createPushCommand(newMirror, author, zoneConfig, update).thenCompose(command -> { + return executor().execute(command).thenApply(result -> { + metaRepo.mirror(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) { + logger.debug("Notifying the mirror listener of the update event: {}", mirror); + listener.onUpdate(mirror, accessController); + } else { + logger.debug("Notifying the mirror listener of the create event: {}", mirror); + listener.onCreate(mirror, accessController); + } + } catch (Throwable ex) { + logger.warn("Failed to notify the mirror listener", ex); + } + return null; } /** @@ -212,7 +255,12 @@ public Map config() { return mirrorZoneConfig; } - private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror) { + 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, boolean allowed) { final URI remoteRepoUri = mirror.remoteRepoUri(); final Cron schedule = mirror.schedule(); final String scheduleStr = schedule != null ? schedule.asString() : null; @@ -227,7 +275,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..853bf249c6 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/MirrorAccessControlRequest.java @@ -0,0 +1,123 @@ +/* + * 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.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 = requireNonNull(id, "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..e53a2c3990 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,28 @@ 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 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..c8bf4c2b62 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessController.java @@ -0,0 +1,182 @@ +/* + * 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 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 DefaultMirrorAccessController(CrudRepository repository) { + this.repository = repository; + } + + public DefaultMirrorAccessController() {} + + 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, 0, 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, 0, 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) + .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..a3095e14a8 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,16 @@ enum DefaultMirrorListener implements MirrorListener { private static final Logger logger = LoggerFactory.getLogger(DefaultMirrorListener.class); + @Override + public void onCreate(Mirror mirror, MirrorAccessController accessController) { + // Do nothing + } + + @Override + public void onUpdate(Mirror mirror, MirrorAccessController accessController) { + // Do nothing + } + @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 b3fbbe7b64..13911697f8 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 @@ -95,7 +95,8 @@ public synchronized CompletionStage start(PluginContext context) { context.meterRegistry(), numThreads, maxNumFilesPerMirror, - maxNumBytesPerMirror, zoneConfig); + maxNumBytesPerMirror, zoneConfig, + 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 8e0079ceeb..59f1e32fd3 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(), @@ -98,32 +103,42 @@ public CompletableFuture run(String projectName, String mirrorId, private CompletableFuture run(MirrorKey mirrorKey, User user) { try { final CompletableFuture future = - metaRepo(mirrorKey.projectName).mirror(mirrorKey.mirrorId).thenApplyAsync(mirror -> { + metaRepo(mirrorKey.projectName).mirror(mirrorKey.mirrorId).thenCompose(mirror -> { if (!mirror.enabled()) { throw new MirrorException("The mirror is disabled: " + 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); + 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 4dc69f7432..5ec5cc507f 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; @@ -107,7 +113,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) { + @Nullable ZoneConfig zoneConfig, + MirrorAccessController mirrorAccessController) { this.workDir = requireNonNull(workDir, "workDir"); this.projectManager = requireNonNull(projectManager, "projectManager"); @@ -127,6 +134,7 @@ public MirrorSchedulingService(File workDir, ProjectManager projectManager, Mete } else { currentZone = null; } + this.mirrorAccessController = mirrorAccessController; } public boolean isStarted() { @@ -221,6 +229,21 @@ private void scheduleMirrors() { if (m.schedule() == null) { continue; } + + try { + final boolean allowed = mirrorAccessController.isAllowed(m) + .get(5, TimeUnit.SECONDS); + if (!allowed) { + logger.debug("The mirroring from {} is not allowed. mirror: {}", + m.remoteRepoUri(), 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 a1c838e2b5..ca157e0b62 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 @@ -45,7 +45,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; @@ -99,14 +99,15 @@ public CompletableFuture> mirrors(boolean includeDisabled) { } @Override - public CompletableFuture mirror(String id) { + public CompletableFuture mirror(String id, Revision revision) { final String mirrorFile = mirrorFile(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(); @@ -129,8 +130,9 @@ public CompletableFuture mirror(String id) { return credentials.thenApply(credentials0 -> { final Mirror mirror = c.toMirror(parent(), credentials0); if (mirror == null) { - throw new EntryNotFoundException("failed to find a mirror config for '" + mirrorFile + - "' in " + parent().name() + '/' + name()); + throw new EntryNotFoundException( + "failed to find a mirror config for '" + mirrorFile + "' in " + + parent().name() + '/' + name() + " (revision: " + revision + ')'); } return mirror; }); @@ -239,26 +241,26 @@ private CompletableFuture>> find(String filePattern) { } @Override - public CompletableFuture> createPushCommand(MirrorDto mirrorDto, Author author, - @Nullable ZoneConfig zoneConfig, - boolean update) { - validateMirror(mirrorDto, zoneConfig); + public CompletableFuture> createPushCommand( + MirrorRequest mirrorRequest, Author author, + @Nullable ZoneConfig zoneConfig, boolean update) { + validateMirror(mirrorRequest, zoneConfig); if (update) { - final String summary = "Update the mirror '" + mirrorDto.id() + '\''; - return mirror(mirrorDto.id()).thenApply(mirror -> { + final String summary = "Update the mirror '" + mirrorRequest.id() + '\''; + return mirror(mirrorRequest.id()).thenApply(mirror -> { // Perform the update operation only if the mirror exists. - return newCommand(mirrorDto, author, summary); + return newCommand(mirrorRequest, author, summary); }); } else { - String summary = "Create a new mirror from " + mirrorDto.remoteUrl() + - mirrorDto.remotePath() + '#' + mirrorDto.remoteBranch() + " into " + - mirrorDto.localRepo() + 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 " + + mirrorRequest.localRepo() + 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(newCommand(mirrorRequest, author, summary)); } } @@ -279,8 +281,8 @@ public CompletableFuture> createPushCommand(Credential cre } } - private Command newCommand(MirrorDto mirrorDto, Author author, String summary) { - final MirrorConfig mirrorConfig = converterToMirrorConfig(mirrorDto); + private Command newCommand(MirrorRequest mirrorRequest, Author author, String summary) { + final MirrorConfig mirrorConfig = converterToMirrorConfig(mirrorRequest); final JsonNode jsonNode = Jackson.valueToTree(mirrorConfig); final Change change = Change.ofJsonUpsert(mirrorFile(mirrorConfig.id()), jsonNode); return Command.push(author, parent().name(), name(), Revision.HEAD, summary, "", Markup.PLAINTEXT, @@ -294,7 +296,7 @@ private Command newCommand(Credential credential, Author author, S 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) { @@ -312,7 +314,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/git/GitCrudRepository.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitCrudRepository.java new file mode 100644 index 0000000000..ce73a98143 --- /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"; + } + + private static void validateId(String id) { + checkArgument(!id.isEmpty(), "id is empty."); + 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..7531a5571d 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,16 @@ @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 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 f82864a8d5..709e5cb99b 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; @@ -50,7 +52,14 @@ default CompletableFuture> mirrors() { /** * Returns a mirroring task of the specified {@code id}. */ - CompletableFuture mirror(String id); + default CompletableFuture mirror(String id) { + return mirror(id, Revision.HEAD); + } + + /** + * Returns a mirroring task of the specified {@code id} at the specified {@link Revision}. + */ + CompletableFuture mirror(String id, Revision revision); /** * Returns a list of mirroring credentials. @@ -65,7 +74,7 @@ default CompletableFuture> mirrors() { /** * Create a push {@link Command} for the {@link MirrorDto}. */ - CompletableFuture> createPushCommand(MirrorDto mirrorDto, Author author, + CompletableFuture> createPushCommand(MirrorRequest mirrorDto, Author author, @Nullable ZoneConfig zoneConfig, boolean update); 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..a09c0128b3 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirrorAccessControllerTest.java @@ -0,0 +1,137 @@ +/* + * 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(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/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..12f705d237 --- /dev/null +++ b/testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/CrudRepositoryExtension.java @@ -0,0 +1,82 @@ +/* + * 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.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(); + final Project project = projectManager.create(projectName, Author.DEFAULT); + project.repos().create(repoName, Author.DEFAULT); + 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 60% rename from webapp/src/dogma/features/project/settings/DeleteConfirmationModal.tsx rename to webapp/src/dogma/common/components/DeleteConfirmationModal.tsx index 32e25605d0..5edf7d0d44 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,7 @@ interface DeleteConfirmationModalProps { onClose: () => void; type: string; id: string; - projectName: string; + projectName?: string; handleDelete: () => void; isLoading: boolean; } @@ -36,7 +52,7 @@ export const DeleteConfirmationModal = ({ Are you sure? - Delete {type} '{id}' from {projectName}? + Delete {type} {`'${id}'`} {projectName ? `from ${projectName}` : ''}? 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) { @@ -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'], @@ -462,6 +499,11 @@ export const { useDeleteMirrorMutation, useRunMirrorMutation, useGetMirrorConfigQuery, + useGetMirrorAccessControlQuery, + useGetMirrorAccessControlsQuery, + useUpdateMirrorAccessControlMutation, + useAddNewMirrorAccessControlMutation, + useDeleteMirrorAccessControlMutation, // Credential useGetCredentialsQuery, useGetCredentialQuery, 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/settings/credentials/DeleteCredential.tsx b/webapp/src/dogma/features/project/settings/credentials/DeleteCredential.tsx index 79311dbded..f91c8c4667 100644 --- a/webapp/src/dogma/features/project/settings/credentials/DeleteCredential.tsx +++ b/webapp/src/dogma/features/project/settings/credentials/DeleteCredential.tsx @@ -3,7 +3,7 @@ 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 'dogma/features/project/settings/DeleteConfirmationModal'; +import { DeleteConfirmationModal } from 'dogma/common/components/DeleteConfirmationModal'; export const DeleteCredential = ({ projectName, diff --git a/webapp/src/dogma/features/project/settings/members/DeleteMember.tsx b/webapp/src/dogma/features/project/settings/members/DeleteMember.tsx index 484350a47f..022b4b0a9e 100644 --- a/webapp/src/dogma/features/project/settings/members/DeleteMember.tsx +++ b/webapp/src/dogma/features/project/settings/members/DeleteMember.tsx @@ -3,7 +3,7 @@ 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, diff --git a/webapp/src/dogma/features/project/settings/mirrors/DeleteMirror.tsx b/webapp/src/dogma/features/project/settings/mirrors/DeleteMirror.tsx index bc215d4170..7a68e34499 100644 --- a/webapp/src/dogma/features/project/settings/mirrors/DeleteMirror.tsx +++ b/webapp/src/dogma/features/project/settings/mirrors/DeleteMirror.tsx @@ -1,9 +1,9 @@ import { Button, useDisclosure } from '@chakra-ui/react'; -import { DeleteConfirmationModal } from 'dogma/features/project/settings/DeleteConfirmationModal'; 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 'dogma/common/components/DeleteConfirmationModal'; export const DeleteMirror = ({ projectName, diff --git a/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx b/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx index 30125e03d2..ba2353d440 100644 --- a/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx +++ b/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx @@ -48,7 +48,7 @@ import { useGetCredentialsQuery, useGetMirrorConfigQuery, useGetReposQuery } fro import React, { useMemo, useState } from 'react'; import FieldErrorMessage from 'dogma/common/components/form/FieldErrorMessage'; import { RepoDto } from 'dogma/features/repo/RepoDto'; -import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; +import { MirrorRequest } from 'dogma/features/project/settings/mirrors/MirrorRequest'; import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; import { FiBox } from 'react-icons/fi'; import cronstrue from 'cronstrue'; @@ -56,8 +56,12 @@ import { CiLocationOn } from 'react-icons/ci'; interface MirrorFormProps { projectName: string; - defaultValue: MirrorDto; - onSubmit: (mirror: MirrorDto, onSuccess: () => void, setError: UseFormSetError) => Promise; + defaultValue: MirrorRequest; + onSubmit: ( + mirror: MirrorRequest, + onSuccess: () => void, + setError: UseFormSetError, + ) => Promise; isWaitingResponse: boolean; } @@ -82,7 +86,7 @@ const MirrorForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: setValue, control, watch, - } = useForm({ + } = useForm({ defaultValues: defaultValue, }); @@ -132,6 +136,7 @@ const MirrorForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: defaultValue.remoteScheme, defaultValue.credentialId, defaultValue.direction, + defaultValue.zone, ]); const defaultRemoteScheme: OptionType = defaultValue.remoteScheme diff --git a/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx b/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx index cf5e3bd2e4..6bd1389b2c 100644 --- a/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx +++ b/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx @@ -1,11 +1,11 @@ import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; import React, { useMemo } from 'react'; import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination'; -import { useGetMirrorsQuery, useDeleteMirrorMutation } from 'dogma/features/api/apiSlice'; -import { Badge, Button, Code, HStack, Link, Wrap, WrapItem } from '@chakra-ui/react'; +import { useDeleteMirrorMutation, useGetMirrorsQuery } from 'dogma/features/api/apiSlice'; +import { Badge, Button, Code, HStack, Link, Tooltip, Wrap, WrapItem } from '@chakra-ui/react'; import { GoRepo } from 'react-icons/go'; import { LabelledIcon } from 'dogma/common/components/LabelledIcon'; -import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; +import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorRequest'; import { RunMirror } from '../../../mirror/RunMirrorButton'; import { FaPlay } from 'react-icons/fa'; import { DeleteMirror } from 'dogma/features/project/settings/mirrors/DeleteMirror'; @@ -73,6 +73,20 @@ const MirrorList = ({ projectName }: MirrorListProps) }, header: 'Status', }), + columnHelper.accessor((row: MirrorDto) => row.allow, { + cell: (info) => { + if (info.getValue()) { + return Allowed; + } else { + return ( + + Disallowed + + ); + } + }, + header: 'Access', + }), columnHelper.accessor((row: MirrorDto) => row.id, { cell: (info) => ( @@ -81,7 +95,7 @@ const MirrorList = ({ projectName }: MirrorListProps) {({ isLoading, onToggle }) => ( + + + ); +}; 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 }) => ( + + {children} + +); + +interface MirrorAccessControlViewProps { + mirrorAccessControl: MirrorAccessControl; +} + +const AlignedIcon = ({ as }: { as: IconType }) => ; + +const MirrorAccessControlView = ({ mirrorAccessControl }: MirrorAccessControlViewProps) => { + return ( +
+ + + + + + + Mirror access control + + + + + + + + + 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]/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]/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/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 {