diff --git a/it/server/build.gradle b/it/server/build.gradle index b0629bd24..f7ba0c31c 100644 --- a/it/server/build.gradle +++ b/it/server/build.gradle @@ -3,6 +3,8 @@ dependencies { testImplementation project(':server-auth:shiro') testImplementation libs.curator.test + + testImplementation libs.armeria.junit5 } // To use @SetEnvironmentVariable diff --git a/it/server/src/test/java/com/linecorp/centraldogma/it/CustomCertificateIdExtractorTest.java b/it/server/src/test/java/com/linecorp/centraldogma/it/CustomCertificateIdExtractorTest.java new file mode 100644 index 000000000..e135af725 --- /dev/null +++ b/it/server/src/test/java/com/linecorp/centraldogma/it/CustomCertificateIdExtractorTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.it; + +import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.API_V1_PATH_PREFIX; +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 org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ClientTlsConfig; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.SignedCertificateExtension; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.common.ProjectRole; +import com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.TlsConfig; +import com.linecorp.centraldogma.server.auth.MtlsConfig; +import com.linecorp.centraldogma.server.internal.api.MetadataApiService.IdAndProjectRole; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +final class CustomCertificateIdExtractorTest { + + private static final String CLIENT_CN = "my-client"; + // TestCertificateIdExtractor prepends "test-" to the CN. + private static final String CERT_ID = "test-" + CLIENT_CN; + + @Order(1) + @RegisterExtension + static final SelfSignedCertificateExtension serverCert = new SelfSignedCertificateExtension(); + + @Order(2) + @RegisterExtension + static final SelfSignedCertificateExtension ca = new SelfSignedCertificateExtension(); + + @Order(3) + @RegisterExtension + static final SignedCertificateExtension clientCert = + new SignedCertificateExtension(CLIENT_CN, ca); + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.authProviderFactory(new TestAuthProviderFactory()); + builder.port(0, SessionProtocol.HTTPS); + builder.tls( + new TlsConfig(serverCert.certificateFile(), serverCert.privateKeyFile(), null, null, null)); + builder.mtlsConfig( + new MtlsConfig(true, ImmutableList.of(ca.certificateFile()))); + builder.systemAdministrators(USERNAME); + } + + @Override + protected String accessToken() { + final WebClient client = WebClient.builder("https://127.0.0.1:" + dogma.serverAddress().getPort()) + .factory(ClientFactory.insecure()) + .build(); + return getAccessToken(client, USERNAME, PASSWORD, "testId", true, true, false); + } + + @Override + protected void configureHttpClient(WebClientBuilder builder) { + builder.factory(ClientFactory.insecure()); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject("foo").join(); + client.createRepository("foo", "bar").join(); + client.forRepo("foo", "bar").commit("Add a file", Change.ofTextUpsert("/a.txt", "hello")) + .push().join(); + } + }; + + @Test + void mtlsWithCustomExtractor() { + final TlsKeyPair tlsKeyPair = TlsKeyPair.of(clientCert.privateKey(), + clientCert.certificate()); + final ClientTlsConfig tlsConfig = + ClientTlsConfig.builder() + .tlsCustomizer(b -> b.trustManager(serverCert.certificate())) + .build(); + try (ClientFactory factory = ClientFactory.builder() + .tlsProvider(TlsProvider.of(tlsKeyPair), + tlsConfig) + .build()) { + final WebClientBuilder builder = + WebClient.builder("https://127.0.0.1:" + dogma.serverAddress().getPort()); + builder.factory(factory); + final WebClient mtlsClient = builder.build(); + final String contentPath = HttpApiV1Constants.PROJECTS_PREFIX + "/foo/repos/bar/contents/a.txt"; + + // Not authorized yet — no app identity registered. + AggregatedHttpResponse contentResponse = mtlsClient.get(contentPath).aggregate().join(); + assertThat(contentResponse.status()).isEqualTo(HttpStatus.UNAUTHORIZED); + + // Register an app identity using the certificate ID produced by TestCertificateIdExtractor + // which prepends "test-" to the CN. + final AggregatedHttpResponse response = + dogma.httpClient().post(API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "cert1", + "type", "CERTIFICATE", + "certificateId", CERT_ID, + "isSystemAdmin", false), + HttpData.empty()).aggregate().join(); + assertThat(response.status()).isEqualTo(HttpStatus.CREATED); + assertThat(response.contentUtf8()).contains("\"appId\":\"cert1\""); + + // Still forbidden — no project role granted yet. + contentResponse = mtlsClient.get(contentPath).aggregate().join(); + assertThat(contentResponse.status()).isEqualTo(HttpStatus.FORBIDDEN); + + // Grant the cert1 app identity access to the 'foo' project. + final HttpRequest request = HttpRequest.builder() + .post("/api/v1/metadata/foo/appIdentities") + .contentJson( + new IdAndProjectRole("cert1", ProjectRole.MEMBER)) + .build(); + assertThat(dogma.httpClient().execute(request).aggregate().join().status()).isSameAs(HttpStatus.OK); + + // Now the mTLS client can access the content. + contentResponse = mtlsClient.get(contentPath).aggregate().join(); + assertThat(contentResponse.status()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/it/server/src/test/java/com/linecorp/centraldogma/it/TestCertificateIdExtractor.java b/it/server/src/test/java/com/linecorp/centraldogma/it/TestCertificateIdExtractor.java new file mode 100644 index 000000000..8da43efc5 --- /dev/null +++ b/it/server/src/test/java/com/linecorp/centraldogma/it/TestCertificateIdExtractor.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.it; + +import java.security.cert.X509Certificate; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; + +import org.jspecify.annotations.Nullable; + +import com.linecorp.centraldogma.server.auth.ApplicationCertificateIdExtractor; + +/** + * A test {@link ApplicationCertificateIdExtractor} that extracts the CN from the certificate + * and prepends {@code "test-"} to verify that the SPI-loaded extractor is used. + */ +public final class TestCertificateIdExtractor implements ApplicationCertificateIdExtractor { + + @Nullable + @Override + public String extractCertificateId(X509Certificate certificate) { + try { + final LdapName ldapName = new LdapName(certificate.getSubjectX500Principal().getName()); + for (Rdn rdn : ldapName.getRdns()) { + if ("CN".equalsIgnoreCase(rdn.getType())) { + return "test-" + rdn.getValue().toString(); + } + } + } catch (InvalidNameException e) { + // ignore + } + return null; + } +} diff --git a/it/server/src/test/resources/META-INF/services/com.linecorp.centraldogma.server.auth.ApplicationCertificateIdExtractor b/it/server/src/test/resources/META-INF/services/com.linecorp.centraldogma.server.auth.ApplicationCertificateIdExtractor new file mode 100644 index 000000000..121a304ee --- /dev/null +++ b/it/server/src/test/resources/META-INF/services/com.linecorp.centraldogma.server.auth.ApplicationCertificateIdExtractor @@ -0,0 +1 @@ +com.linecorp.centraldogma.it.TestCertificateIdExtractor diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/ApplicationCertificateAuthorizer.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/ApplicationCertificateAuthorizer.java index 151fb0cc7..e91eed20f 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/ApplicationCertificateAuthorizer.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/ApplicationCertificateAuthorizer.java @@ -21,6 +21,8 @@ import java.security.cert.Certificate; import java.security.cert.X509Certificate; +import java.util.List; +import java.util.ServiceLoader; import java.util.concurrent.CompletionStage; import java.util.function.Function; @@ -31,6 +33,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.collect.ImmutableList; + import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.logging.RequestLogProperty; import com.linecorp.armeria.common.util.Exceptions; @@ -59,8 +63,22 @@ public final class ApplicationCertificateAuthorizer implements Authorizer CERTIFICATE_ID = AttributeKey.valueOf(ApplicationCertificateAuthorizer.class, "CERTIFICATE_ID"); - // TODO(minwoox): Make it configurable via SPI. - private static final ApplicationCertificateIdExtractor ID_EXTRACTOR = CommonNameExtractor.INSTANCE; + private static final ApplicationCertificateIdExtractor ID_EXTRACTOR; + + static { + final List extractors = ImmutableList.copyOf( + ServiceLoader.load(ApplicationCertificateIdExtractor.class, + ApplicationCertificateAuthorizer.class.getClassLoader())); + if (extractors.isEmpty()) { + ID_EXTRACTOR = CommonNameExtractor.INSTANCE; + } else if (extractors.size() == 1) { + ID_EXTRACTOR = extractors.get(0); + } else { + throw new IllegalStateException( + "Only one ApplicationCertificateIdExtractor implementation must be provided. " + + "found: " + extractors); + } + } private final Function certificateLookupFunc;