diff --git a/athenz/build.gradle b/athenz/build.gradle new file mode 100644 index 00000000000..3399d26a301 --- /dev/null +++ b/athenz/build.gradle @@ -0,0 +1,26 @@ +/* + * 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. + */ + +dependencies { + implementation project(":oauth2") + implementation libs.athenz.zts.client + implementation libs.athenz.zpe.client + implementation libs.caffeine + + testImplementation libs.athenz.zms.client + testImplementation libs.jwt + testImplementation libs.testcontainers.junit.jupiter +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/AccessTokenClient.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/AccessTokenClient.java new file mode 100644 index 00000000000..50f2eb94917 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/AccessTokenClient.java @@ -0,0 +1,113 @@ +/* + * 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.armeria.client.athenz; + +import static com.linecorp.armeria.client.athenz.RoleTokenClient.ROLE_JOINER; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.google.common.collect.ImmutableList; +import com.yahoo.athenz.auth.AuthorityConsts; + +import com.linecorp.armeria.client.auth.oauth2.AccessTokenRequest; +import com.linecorp.armeria.client.auth.oauth2.OAuth2AuthorizationGrant; +import com.linecorp.armeria.common.HttpHeadersBuilder; +import com.linecorp.armeria.common.QueryParamsBuilder; +import com.linecorp.armeria.common.athenz.AccessDeniedException; +import com.linecorp.armeria.common.auth.oauth2.ClientAuthentication; +import com.linecorp.armeria.common.auth.oauth2.GrantedOAuth2AccessToken; +import com.linecorp.armeria.common.util.Exceptions; + +final class AccessTokenClient implements TokenClient { + + private final AtomicBoolean tlsKeyPairUpdated = new AtomicBoolean(); + + private final long refreshBeforeMillis; + private final String domainName; + private final List roleNames; + private final OAuth2AuthorizationGrant authorizationGrant; + + AccessTokenClient(ZtsBaseClient ztsBaseClient, String domainName, List roleNames, + Duration refreshBefore) { + refreshBeforeMillis = refreshBefore.toMillis(); + this.domainName = domainName; + this.roleNames = roleNames; + + ztsBaseClient.addTlsKeyPairListener(tlsKeyPair -> tlsKeyPairUpdated.set(true)); + + // Scope syntax: + // - :domain + // - :role. + // https://github.com/AthenZ/athenz/blob/5e064414224eca025c7a4ae1df5b5eb381e71a16/clients/java/zts/src/main/java/com/yahoo/athenz/zts/ZTSClient.java#L1446 + final ImmutableList.Builder scopeBuilder = ImmutableList.builder(); + if (roleNames.isEmpty()) { + scopeBuilder.add(domainName + ":domain"); + } else { + for (String role : roleNames) { + scopeBuilder.add(domainName + AuthorityConsts.ROLE_SEP + role); + } + } + final AccessTokenRequest tokenRequest = AccessTokenRequest.ofClientCredentials( + NoopClientAuthentication.INSTANCE, scopeBuilder.build()); + authorizationGrant = OAuth2AuthorizationGrant.builder(ztsBaseClient.webClient(), + "/oauth2/token") + .accessTokenRequest(tokenRequest) + .refreshIf(this::shouldRefreshToken) + .build(); + } + + private boolean shouldRefreshToken(GrantedOAuth2AccessToken token) { + if (tlsKeyPairUpdated.compareAndSet(true, false)) { + // If the TLS key pair is updated, we need to refresh the token. + return true; + } + final Duration expiresIn = token.expiresIn(); + if (expiresIn == null) { + return false; + } + + return !token.isValid(Instant.now().plusMillis(refreshBeforeMillis)); + } + + @Override + public CompletableFuture getToken() { + return authorizationGrant.getAccessToken().handle((token, cause) -> { + if (cause != null) { + cause = Exceptions.peel(cause); + throw new AccessDeniedException("Failed to obtain an Athenz access token. (domain: " + + domainName + ", roles: " + ROLE_JOINER.join(roleNames) + ')', + cause); + } + return token.accessToken(); + }).toCompletableFuture(); + } + + private enum NoopClientAuthentication implements ClientAuthentication { + + INSTANCE; + + @Override + public void addAsHeaders(HttpHeadersBuilder headersBuilder) {} + + @Override + public void addAsBodyParams(QueryParamsBuilder formBuilder) {} + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/AthenzClient.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/AthenzClient.java new file mode 100644 index 00000000000..524b570d448 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/AthenzClient.java @@ -0,0 +1,178 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client.athenz; + +import static com.linecorp.armeria.internal.common.athenz.AthenzHeaderNames.YAHOO_ROLE_AUTH; +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.SimpleDecoratingHttpClient; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.RequestHeadersBuilder; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.common.util.Exceptions; + +/** + * An {@link HttpClient} that adds an Athenz token to the request headers. + * {@link TokenType#ACCESS_TOKEN} and {@link TokenType#ROLE_TOKEN} are supported. + * + *

The acquired token is cached and automatically refreshed before it expires based on the specified + * duration. If not specified, the default refresh duration is 10 minutes before the token expires. + * + *

Example: + *

{@code
+ * import com.linecorp.armeria.client.athenz.ZtsBaseClient;
+ * import com.linecorp.armeria.client.athenz.AthenzClient;
+ *
+ * ZtsBaseClient ztsBaseClient =
+ *   ZtsBaseClient
+ *     .builder("https://athenz.example.com:8443/zts/v1")
+ *     .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem")
+ *     .build();
+ *
+ * WebClient
+ *   .builder()
+ *   .decorator(AthenzClient.newDecorator(ztsBaseClient, "my-domain",
+ *                                        TokenType.ROLE_TOKEN)
+ *   ...
+ *   .build();
+ * }
+ */ +@UnstableApi +public final class AthenzClient extends SimpleDecoratingHttpClient { + + private static final Duration DEFAULT_REFRESH_BEFORE = Duration.ofMinutes(10); + + /** + * Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and + * adds it to the request headers. + * + * @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server + * @param domainName the Athenz domain name + * @param tokenType the type of Athenz token to obtain + */ + public static Function newDecorator(ZtsBaseClient ztsBaseClient, + String domainName, TokenType tokenType) { + return newDecorator(ztsBaseClient, domainName, ImmutableList.of(), tokenType); + } + + /** + * Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and + * role name, and adds it to the request headers. + * + * @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server + * @param domainName the Athenz domain name + * @param roleName the Athenz role name + * @param tokenType the type of Athenz token to obtain + */ + public static Function newDecorator(ZtsBaseClient ztsBaseClient, + String domainName, String roleName, + TokenType tokenType) { + return newDecorator(ztsBaseClient, domainName, ImmutableList.of(roleName), tokenType); + } + + /** + * Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and + * role names, and adds it to the request headers. + * + * @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server + * @param domainName the Athenz domain name + * @param roleNames the list of Athenz role names + * @param tokenType the type of Athenz token to obtain + */ + public static Function newDecorator(ZtsBaseClient ztsBaseClient, + String domainName, List roleNames, + TokenType tokenType) { + return newDecorator(ztsBaseClient, domainName, roleNames, tokenType, DEFAULT_REFRESH_BEFORE); + } + + /** + * Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and + * role names, and adds it to the request headers. + * + * @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server + * @param domainName the Athenz domain name + * @param roleNames the list of Athenz role names + * @param tokenType the type of Athenz token to obtain + * @param refreshBefore the duration before the token expires to refresh it + */ + public static Function newDecorator(ZtsBaseClient ztsBaseClient, + String domainName, List roleNames, + TokenType tokenType, Duration refreshBefore) { + requireNonNull(ztsBaseClient, "ztsBaseClient"); + requireNonNull(domainName, "domainName"); + requireNonNull(roleNames, "roleNames"); + final ImmutableList roleNames0 = ImmutableList.copyOf(roleNames); + requireNonNull(tokenType, "tokenType"); + requireNonNull(refreshBefore, "refreshBefore"); + return delegate -> new AthenzClient(delegate, ztsBaseClient, domainName, roleNames0, + tokenType, refreshBefore); + } + + private final TokenType tokenType; + private final TokenClient tokenClient; + + private AthenzClient(HttpClient delegate, ZtsBaseClient ztsBaseClient, String domainName, + List roleNames, TokenType tokenType, Duration refreshBefore) { + super(delegate); + this.tokenType = tokenType; + switch (tokenType) { + case ROLE_TOKEN: + tokenClient = new RoleTokenClient(ztsBaseClient, domainName, roleNames, refreshBefore); + break; + case ACCESS_TOKEN: + tokenClient = new AccessTokenClient(ztsBaseClient, domainName, roleNames, refreshBefore); + break; + default: + throw new Error("unknown auth type: " + tokenType); + } + } + + @Override + public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception { + final CompletableFuture future = tokenClient.getToken().thenApply(token -> { + final HttpRequest newReq = req.mapHeaders(headers -> { + final RequestHeadersBuilder builder = headers.toBuilder(); + if (tokenType == TokenType.ROLE_TOKEN) { + builder.set(YAHOO_ROLE_AUTH, token); + } else { + builder.set(HttpHeaderNames.AUTHORIZATION, "Bearer " + token); + } + return builder.build(); + }); + ctx.updateRequest(newReq); + try { + return unwrap().execute(ctx, newReq); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + }); + + return HttpResponse.of(future); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/RoleTokenClient.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/RoleTokenClient.java new file mode 100644 index 00000000000..0572b9a54d4 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/RoleTokenClient.java @@ -0,0 +1,110 @@ +/* + * 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.armeria.client.athenz; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.google.common.base.Joiner; +import com.yahoo.athenz.zts.RoleToken; + +import com.linecorp.armeria.client.InvalidHttpResponseException; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientRequestPreparation; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.athenz.AccessDeniedException; +import com.linecorp.armeria.common.util.AsyncLoader; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.common.util.UnmodifiableFuture; + +final class RoleTokenClient implements TokenClient { + + static final Joiner ROLE_JOINER = Joiner.on(","); + + private final WebClient webClient; + private final String domainName; + private final String roleNames; + private final long refreshBeforeSec; + private final AsyncLoader tokenLoader; + + RoleTokenClient(ZtsBaseClient ztsBaseClient, String domainName, List roleNames, + Duration refreshBefore) { + webClient = ztsBaseClient.webClient(); + this.domainName = domainName; + this.roleNames = ROLE_JOINER.join(roleNames); + refreshBeforeSec = refreshBefore.getSeconds(); + tokenLoader = AsyncLoader.builder(unused -> fetchRoleToken()) + .name("athenz-role-token/" + domainName + '/' + this.roleNames) + .exceptionHandler(this::errorHandler) + .refreshIf(token -> remainingTimeSec(token) < refreshBeforeSec) + .expireIf(token -> remainingTimeSec(token) == 0) + .build(); + } + + @Override + public CompletableFuture getToken() { + return tokenLoader.load().thenApply(RoleToken::getToken); + } + + private static long remainingTimeSec(RoleToken token) { + final long expiryTimeSec = token.getExpiryTime() - (System.currentTimeMillis() / 1000); + return Math.max(expiryTimeSec, 0); + } + + private CompletableFuture fetchRoleToken() { + final WebClientRequestPreparation preparation = + webClient.prepare() + .get("/domain/{domainName}/token") + .pathParam("domainName", domainName); + if (!roleNames.isEmpty()) { + preparation.queryParam("role", roleNames); + } + return preparation + .asJson(RoleToken.class) + .execute() + .handle((response, cause) -> { + if (cause != null) { + cause = Exceptions.peel(cause); + if (cause instanceof InvalidHttpResponseException) { + final InvalidHttpResponseException exception = (InvalidHttpResponseException) cause; + if (exception.response().status() == HttpStatus.FORBIDDEN) { + throw new AccessDeniedException( + "Failed to obtain an Athenz role token. (domain: " + domainName + + ", roles: " + roleNames + ')', exception); + } + } + } + return response.content(); + }); + } + + @Nullable + private CompletableFuture errorHandler(Throwable cause, @Nullable RoleToken cache) { + if (cause instanceof InvalidHttpResponseException) { + final InvalidHttpResponseException exception = (InvalidHttpResponseException) cause; + if (exception.response().status() == HttpStatus.FORBIDDEN) { + return UnmodifiableFuture.exceptionallyCompletedFuture( + new AccessDeniedException("Failed to obtain an Athenz role token. " + + "(domain: " + domainName + ", roles: " + roleNames + ')', + exception)); + } + } + return null; + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/TokenClient.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/TokenClient.java new file mode 100644 index 00000000000..880a8df707b --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/TokenClient.java @@ -0,0 +1,25 @@ +/* + * 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.armeria.client.athenz; + +import java.util.concurrent.CompletableFuture; + +@FunctionalInterface +interface TokenClient { + + CompletableFuture getToken(); +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/ZtsBaseClient.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/ZtsBaseClient.java new file mode 100644 index 00000000000..b3aaef8b28b --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/ZtsBaseClient.java @@ -0,0 +1,214 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client.athenz; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.net.URL; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ClientFactoryBuilder; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.logging.LoggingClient; +import com.linecorp.armeria.client.retry.RetryRule; +import com.linecorp.armeria.client.retry.RetryingClient; +import com.linecorp.armeria.common.CommonPools; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.logging.LogLevel; +import com.linecorp.armeria.common.logging.LogWriter; +import com.linecorp.armeria.common.util.AbstractListenable; +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.server.athenz.AthenzService; + +/** + * A base client for Athenz ZTS that provides common functionality such as {@link TlsKeyPair} management + * and {@link WebClient} configuration. It is recommended to create a new instance and share it across multiple + * {@link AthenzClient} and {@link AthenzService}. + * + *

Example: + *

{@code
+ * ZtsBaseClient ztsBaseClient =
+ *   ZtsBaseClient
+ *     .builder("https://athenz.example.com:8443/zts/v1")
+ *     .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem")
+ *     .build();
+ * }
+ */ +@UnstableApi +public final class ZtsBaseClient implements SafeCloseable { + + /** + * Returns a new {@link ZtsBaseClientBuilder} for the specified ZTS URI. + */ + public static ZtsBaseClientBuilder builder(String ztsUri) { + requireNonNull(ztsUri, "ztsUri"); + return builder(URI.create(ztsUri)); + } + + /** + * Returns a new {@link ZtsBaseClientBuilder} for the specified ZTS {@link URI}. + */ + public static ZtsBaseClientBuilder builder(URI ztsUri) { + requireNonNull(ztsUri, "ztsUri"); + return new ZtsBaseClientBuilder(normalizeZtsUri(ztsUri)); + } + + private static URI normalizeZtsUri(URI ztsUri) { + // Respect the upstream behavior of ZTS, which requires the URI to end with "/zts/v1". + String rawUri = ztsUri.toString(); + if (!rawUri.endsWith("/zts/v1")) { + if (rawUri.charAt(rawUri.length() - 1) != '/') { + rawUri += "/"; + } + rawUri += "zts/v1"; + } + return URI.create(rawUri); + } + + private final TlsKeyPairListener tlsKeyPairListener = new TlsKeyPairListener(); + private final URI ztsUri; + @Nullable + private final URI proxyUri; + private final ClientFactory clientFactory; + @Nullable + private final Consumer webClientConfigurer; + private final WebClient defaultWebClient; + + ZtsBaseClient(URI ztsUri, @Nullable URI proxyUri, Supplier keyPairSupplier, + List trustedCertificates, int autoKeyRefreshIntervalMillis, + @Nullable Consumer clientFactoryConfigurer, + @Nullable Consumer webClientConfigurer) { + + this.ztsUri = ztsUri; + this.proxyUri = proxyUri; + final TlsProvider tlsProvider = + TlsProvider.ofScheduled(keyPairSupplier, + trustedCertificates, + tlsKeyPairListener::onTlsKeyPairUpdated, + Duration.ofMillis(autoKeyRefreshIntervalMillis), + CommonPools.blockingTaskExecutor()); + + final ClientFactoryBuilder factoryBuilder = ClientFactory.builder().tlsProvider(tlsProvider); + if (clientFactoryConfigurer != null) { + clientFactoryConfigurer.accept(factoryBuilder); + } + clientFactory = factoryBuilder.build(); + this.webClientConfigurer = webClientConfigurer; + defaultWebClient = webClient(builder -> { + if (webClientConfigurer != null) { + webClientConfigurer.accept(builder); + } + }); + } + + /** + * Returns the ZTS {@link URL} that this client connects to. + */ + public URI ztsUri() { + return ztsUri; + } + + /** + * Returns the proxy {@link URL} if it was configured, or {@code null} if no proxy is set. + */ + @Nullable + public URI proxyUri() { + return proxyUri; + } + + /** + * Returns the {@link ClientFactory} that is used to create the {@link WebClient} instances. + */ + public ClientFactory clientFactory() { + return clientFactory; + } + + /** + * Returns the {@link WebClient} that can connect to the ZTS server. + */ + public WebClient webClient() { + return defaultWebClient; + } + + /** + * Returns a new {@link WebClient} that can connect to the ZTS server with the specified configurer. + */ + public WebClient webClient(Consumer configurer) { + requireNonNull(configurer, "configurer"); + + final WebClientBuilder clientBuilder = + WebClient.builder(ztsUri) + .decorator(RetryingClient.newDecorator(RetryRule.failsafe())); + + clientBuilder.factory(clientFactory); + + if (LoggerFactory.getLogger(ZtsBaseClient.class).isTraceEnabled()) { + final LogWriter logWriter = LogWriter.builder() + .logger(ZtsBaseClient.class.getName()) + .requestLogLevel(LogLevel.TRACE) + .successfulResponseLogLevel(LogLevel.TRACE) + .build(); + clientBuilder.decorator(LoggingClient.builder() + .logWriter(logWriter) + .newDecorator()); + } + if (webClientConfigurer != null) { + webClientConfigurer.accept(clientBuilder); + } + configurer.accept(clientBuilder); + return clientBuilder.build(); + } + + /** + * Adds a listener that will be notified when the {@link TlsKeyPair} is updated. + */ + public void addTlsKeyPairListener(Consumer listener) { + requireNonNull(listener, "listener"); + tlsKeyPairListener.addListener(listener); + } + + /** + * Removes a listener that was previously added with {@link #addTlsKeyPairListener(Consumer)}. + */ + public void removeTlsKeyPairListener(Consumer listener) { + requireNonNull(listener, "listener"); + tlsKeyPairListener.removeListener(listener); + } + + @Override + public void close() { + defaultWebClient.options().factory().closeAsync(); + } + + private static final class TlsKeyPairListener extends AbstractListenable { + void onTlsKeyPairUpdated(TlsKeyPair newTlsKeyPair) { + notifyListeners(newTlsKeyPair); + } + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/ZtsBaseClientBuilder.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/ZtsBaseClientBuilder.java new file mode 100644 index 00000000000..78de3652e9a --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/ZtsBaseClientBuilder.java @@ -0,0 +1,200 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client.athenz; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ClientFactoryBuilder; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.proxy.ConnectProxyConfig; +import com.linecorp.armeria.client.proxy.ProxyConfig; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.common.util.CertificateUtil; + +/** + * A builder for creating a {@link ZtsBaseClient} instance. + */ +@UnstableApi +public final class ZtsBaseClientBuilder { + + private final URI ztsUri; + @Nullable + private URI proxyUri; + + @Nullable + private Supplier athenzKeyPairSupplier; + private int autoKeyRefreshIntervalMillis = 60000; // Default to 60 seconds + @Nullable + private Consumer clientFactoryConfigurer; + @Nullable + private Consumer webClientConfigurer; + private List trustedCertificates = ImmutableList.of(); + + ZtsBaseClientBuilder(URI ztsUri) { + this.ztsUri = ztsUri; + } + + /** + * Sets the Athenz private and public key files for mutual TLS authentication. + */ + public ZtsBaseClientBuilder keyPair(String athenzPrivateKeyPath, String athenzPublicKeyPath) { + requireNonNull(athenzPrivateKeyPath, "athenzPrivateKeyPath"); + requireNonNull(athenzPublicKeyPath, "athenzPublicKeyPath"); + return keyPair(new File(athenzPrivateKeyPath), new File(athenzPublicKeyPath)); + } + + /** + * Sets the Athenz private and public key files for mutual TLS authentication. + */ + public ZtsBaseClientBuilder keyPair(File athenzPrivateKeyFile, File athenzPublicKeyFile) { + requireNonNull(athenzPrivateKeyFile, "athenzPrivateKeyFile"); + requireNonNull(athenzPublicKeyFile, "athenzPublicKeyFile"); + athenzKeyPairSupplier = () -> TlsKeyPair.of(athenzPrivateKeyFile, athenzPublicKeyFile); + return this; + } + + /** + * Sets the Athenz private and public key files for mutual TLS authentication. + */ + public ZtsBaseClientBuilder keyPair(Supplier athenzKeyPairSupplier) { + requireNonNull(athenzKeyPairSupplier, "keyPairSupplier"); + this.athenzKeyPairSupplier = athenzKeyPairSupplier; + return this; + } + + /** + * Sets the trusted certificate file for verifying the ZTS server's certificate. + */ + public ZtsBaseClientBuilder trustedCertificate(String trustedCertificateFile) { + requireNonNull(trustedCertificateFile, "trustedCertificateFile"); + return trustedCertificate(new File(trustedCertificateFile)); + } + + /** + * Sets the trusted certificate file for verifying the ZTS server's certificate. + */ + public ZtsBaseClientBuilder trustedCertificate(File trustedCertificateFile) { + requireNonNull(trustedCertificateFile, "trustedCertificateFile"); + try { + trustedCertificates = CertificateUtil.toX509Certificates(trustedCertificateFile); + } catch (CertificateException e) { + throw new IllegalArgumentException(e); + } + return this; + } + + /** + * Sets the trusted certificate input stream for verifying the ZTS server's certificate. + */ + public ZtsBaseClientBuilder trustedCertificate(InputStream trustedCertificateInputStream) { + requireNonNull(trustedCertificateInputStream, "trustedCertificateInputStream"); + try { + trustedCertificates = CertificateUtil.toX509Certificates(trustedCertificateInputStream); + } catch (CertificateException e) { + throw new IllegalArgumentException(e); + } + return this; + } + + /** + * Sets the interval in milliseconds for automatically refreshing the Athenz key pair. + * If not specified, defaults to 60 seconds. + */ + public ZtsBaseClientBuilder autoKeyRefreshIntervalMillis(int autoKeyRefreshIntervalMillis) { + checkArgument(autoKeyRefreshIntervalMillis > 0, "autoKeyRefreshIntervalMillis: %s (expected > 0)", + autoKeyRefreshIntervalMillis); + this.autoKeyRefreshIntervalMillis = autoKeyRefreshIntervalMillis; + return this; + } + + /** + * Sets the proxy URI for the ZTS client. + */ + public ZtsBaseClientBuilder proxyUri(String proxyUrl) { + requireNonNull(proxyUrl, "proxyUrl"); + return proxyUri(URI.create(proxyUrl)); + } + + /** + * Sets the proxy {@link URI} for the ZTS client. + */ + public ZtsBaseClientBuilder proxyUri(URI proxyUri) { + requireNonNull(proxyUri, "proxyUri"); + this.proxyUri = proxyUri; + final boolean isTls = "https".equalsIgnoreCase(proxyUri.getScheme()); + final int port = proxyUri.getPort() == -1 ? (isTls ? 443 : 80) : proxyUri.getPort(); + final InetSocketAddress proxyAddress = InetSocketAddress.createUnresolved(proxyUri.getHost(), port); + final ConnectProxyConfig proxyConfig = ProxyConfig.connect(proxyAddress, isTls); + return configureClientFactory(factoryBuilder -> factoryBuilder.proxyConfig(proxyConfig)); + } + + /** + * Configures the {@link ClientFactory} used by this client. + */ + public ZtsBaseClientBuilder configureClientFactory(Consumer configurer) { + requireNonNull(configurer, "configurer"); + if (clientFactoryConfigurer == null) { + //noinspection unchecked + clientFactoryConfigurer = (Consumer) configurer; + } else { + clientFactoryConfigurer = clientFactoryConfigurer.andThen(configurer); + } + return this; + } + + /** + * Configures the {@link WebClient} used by this client. + */ + public ZtsBaseClientBuilder configureWebClient(Consumer configurer) { + requireNonNull(configurer, "configurer"); + if (webClientConfigurer == null) { + //noinspection unchecked + webClientConfigurer = (Consumer) configurer; + } else { + webClientConfigurer = webClientConfigurer.andThen(configurer); + } + return this; + } + + /** + * Builds a new {@link ZtsBaseClient} instance with the configured settings. + */ + public ZtsBaseClient build() { + if (athenzKeyPairSupplier == null) { + throw new IllegalStateException("Athenz key pair must be set"); + } + return new ZtsBaseClient(ztsUri, proxyUri, athenzKeyPairSupplier, trustedCertificates, + autoKeyRefreshIntervalMillis, clientFactoryConfigurer, webClientConfigurer); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/client/athenz/package-info.java b/athenz/src/main/java/com/linecorp/armeria/client/athenz/package-info.java new file mode 100644 index 00000000000..252c2c60321 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/client/athenz/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Provides the client-side classes and interfaces for Athenz integration. + */ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.client.athenz; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/athenz/src/main/java/com/linecorp/armeria/common/athenz/AccessDeniedException.java b/athenz/src/main/java/com/linecorp/armeria/common/athenz/AccessDeniedException.java new file mode 100644 index 00000000000..761ea9eb000 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/common/athenz/AccessDeniedException.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common.athenz; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * An exception that is thrown when access to an Athenz resource is denied. + */ +@UnstableApi +public final class AccessDeniedException extends RuntimeException { + + private static final long serialVersionUID = 3728934453363905137L; + + /** + * Creates a new instance with the specified message. + */ + public AccessDeniedException(String message) { + super(message); + } + + /** + * Creates a new instance with the specified message and cause. + */ + public AccessDeniedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/common/athenz/TokenType.java b/athenz/src/main/java/com/linecorp/armeria/common/athenz/TokenType.java new file mode 100644 index 00000000000..584bfc6427f --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/common/athenz/TokenType.java @@ -0,0 +1,51 @@ +/* + * 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.armeria.common.athenz; + +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.common.athenz.AthenzHeaderNames; + +import io.netty.util.AsciiString; + +/** + * The type of Athenz token. + */ +@UnstableApi +public enum TokenType { + /** + * Athenz role token. + */ + ROLE_TOKEN(AthenzHeaderNames.YAHOO_ROLE_AUTH), + /** + * Athenz access token. + */ + ACCESS_TOKEN(HttpHeaderNames.AUTHORIZATION); + + TokenType(AsciiString headerName) { + this.headerName = headerName; + } + + private final AsciiString headerName; + + /** + * Returns the header name used to pass the token. + */ + public AsciiString headerName() { + return headerName; + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/common/athenz/package-info.java b/athenz/src/main/java/com/linecorp/armeria/common/athenz/package-info.java new file mode 100644 index 00000000000..f5f99353da2 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/common/athenz/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Provides common classes and interfaces for Athenz integration. + */ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.common.athenz; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/athenz/src/main/java/com/linecorp/armeria/internal/common/athenz/AthenzHeaderNames.java b/athenz/src/main/java/com/linecorp/armeria/internal/common/athenz/AthenzHeaderNames.java new file mode 100644 index 00000000000..9dfacdedde4 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/internal/common/athenz/AthenzHeaderNames.java @@ -0,0 +1,28 @@ +/* + * 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.armeria.internal.common.athenz; + +import com.linecorp.armeria.common.HttpHeaderNames; + +import io.netty.util.AsciiString; + +public final class AthenzHeaderNames { + + public static final AsciiString YAHOO_ROLE_AUTH = HttpHeaderNames.of("yahoo-role-auth"); + + private AthenzHeaderNames() {} +} diff --git a/athenz/src/main/java/com/linecorp/armeria/internal/common/athenz/package-info.java b/athenz/src/main/java/com/linecorp/armeria/internal/common/athenz/package-info.java new file mode 100644 index 00000000000..b0701c72307 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/internal/common/athenz/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Internal classes for Athenz integration. + */ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.internal.common.athenz; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AbstractAthenzServiceBuilder.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AbstractAthenzServiceBuilder.java new file mode 100644 index 00000000000..347355d6d67 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AbstractAthenzServiceBuilder.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; + +import java.time.Duration; + +import com.yahoo.athenz.zpe.ZpeClient; +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A base builder for creating an Athenz service that checks access permissions using Athenz policies. + */ +@UnstableApi +public abstract class AbstractAthenzServiceBuilder> { + + private static final Duration DEFAULT_OAUTH2_KEYS_REFRESH_INTERVAL = Duration.ofHours(1); + private static final int MAX_TOKEN_CACHE_SIZE = 10240; + + private Duration oauth2KeysRefreshInterval = DEFAULT_OAUTH2_KEYS_REFRESH_INTERVAL; + + private final ZtsBaseClient ztsBaseClient; + @Nullable + private AthenzPolicyConfig policyConfig; + private int maxTokenCacheSize = MAX_TOKEN_CACHE_SIZE; + + AbstractAthenzServiceBuilder(ZtsBaseClient ztsBaseClient) { + this.ztsBaseClient = ztsBaseClient; + } + + /** + * Sets the {@link AthenzPolicyConfig} to fetch Athenz policies from the ZTS server. + * + *

Mandatory: This field must be set before building the service. + */ + public SELF policyConfig(AthenzPolicyConfig policyConfig) { + requireNonNull(policyConfig, "policyConfig"); + this.policyConfig = policyConfig; + return self(); + } + + /** + * Sets the interval for refreshing OAuth2 keys from the ZTS server. + * If not set, defaults to 1 hour. + */ + public SELF oauth2KeysRefreshInterval(Duration oauth2KeysRefreshInterval) { + requireNonNull(oauth2KeysRefreshInterval, "oauth2KeysRefreshInterval"); + this.oauth2KeysRefreshInterval = oauth2KeysRefreshInterval; + return self(); + } + + /** + * Set the limit of role and access tokens that are cached to improve the performance of validating + * signatures since the tokens must be re-used by clients until they're about to be expired. + * If not set, defaults to {@value MAX_TOKEN_CACHE_SIZE}. + */ + public SELF maxTokenCacheSize(int maxTokenCacheSize) { + checkArgument(maxTokenCacheSize > 0, "maxTokenCacheSize: %s (expected > 0)", maxTokenCacheSize); + this.maxTokenCacheSize = maxTokenCacheSize; + return self(); + } + + MinifiedAuthZpeClient buildAuthZpeClient() { + checkState(policyConfig != null, "policyConfig must be set before building the service"); + final PublicKeyStore publicKeyStore = new AthenzPublicKeyProvider(ztsBaseClient, + oauth2KeysRefreshInterval); + final ZpeClient zpeClient = new AthenzPolicyClient(ztsBaseClient, policyConfig, publicKeyStore, + maxTokenCacheSize); + // NB: zpeClient.init() will block until the initial policy data is loaded. + zpeClient.init(null); + return new MinifiedAuthZpeClient(ztsBaseClient, publicKeyStore, zpeClient); + } + + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzAssertions.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzAssertions.java new file mode 100644 index 00000000000..adeb7e57690 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzAssertions.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.common.base.MoreObjects; +import com.yahoo.rdl.Struct; + +final class AthenzAssertions { + + private final Map> roleStandardAllowMap = new HashMap<>(); + private final Map> roleWildcardAllowMap = new HashMap<>(); + private final Map> roleStandardDenyMap = new HashMap<>(); + private final Map> roleWildcardDenyMap = new HashMap<>(); + + Map> roleStandardAllowMap() { + return roleStandardAllowMap; + } + + Map> roleWildcardAllowMap() { + return roleWildcardAllowMap; + } + + Map> roleStandardDenyMap() { + return roleStandardDenyMap; + } + + Map> roleWildcardDenyMap() { + return roleWildcardDenyMap; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof AthenzAssertions)) { + return false; + } + final AthenzAssertions that = (AthenzAssertions) o; + return roleStandardAllowMap.equals(that.roleStandardAllowMap) && + roleWildcardAllowMap.equals(that.roleWildcardAllowMap) && + roleStandardDenyMap.equals(that.roleStandardDenyMap) && + roleWildcardDenyMap.equals(that.roleWildcardDenyMap); + } + + @Override + public int hashCode() { + return Objects.hash(roleStandardAllowMap, roleWildcardAllowMap, roleStandardDenyMap, + roleWildcardDenyMap); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("roleStandardAllowMap", roleStandardAllowMap) + .add("roleWildcardAllowMap", roleWildcardAllowMap) + .add("roleStandardDenyMap", roleStandardDenyMap) + .add("roleWildcardDenyMap", roleWildcardDenyMap) + .toString(); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyClient.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyClient.java new file mode 100644 index 00000000000..3bab99f7487 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyClient.java @@ -0,0 +1,159 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.function.ToLongFunction; + +import org.checkerframework.checker.index.qual.NonNegative; +import org.checkerframework.checker.nullness.qual.NonNull; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.google.common.collect.ImmutableMap; +import com.yahoo.athenz.auth.token.AccessToken; +import com.yahoo.athenz.auth.token.RoleToken; +import com.yahoo.athenz.zpe.ZpeClient; +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; +import com.yahoo.rdl.Struct; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.CommonPools; + +final class AthenzPolicyClient implements ZpeClient { + + private final Cache roleTokenCache; + private final Cache accessTokenCache; + private final Map policyLoaders; + + AthenzPolicyClient(ZtsBaseClient baseClient, AthenzPolicyConfig policyConfig, PublicKeyStore publicKeyStore, + int maxTokenCacheSize) { + final Executor executor = CommonPools.blockingTaskExecutor(); + roleTokenCache = Caffeine.newBuilder() + .maximumSize(maxTokenCacheSize) + .expireAfter(new TokenExpiry<>(RoleToken::getExpiryTime)) + .executor(executor) + .build(); + accessTokenCache = Caffeine.newBuilder() + .maximumSize(maxTokenCacheSize) + .expireAfter(new TokenExpiry<>(AccessToken::getExpiryTime)) + .executor(executor) + .build(); + + final ImmutableMap.Builder builder = + ImmutableMap.builderWithExpectedSize(policyConfig.domains().size()); + for (String domain : policyConfig.domains()) { + builder.put(domain, new AthenzPolicyLoader(baseClient, domain, policyConfig, publicKeyStore)); + } + policyLoaders = builder.buildKeepingLast(); + } + + @Override + public void init(String domain) { + for (AthenzPolicyLoader loader : policyLoaders.values()) { + try { + loader.init(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + } + + @Override + public void close() {} + + @Override + public Map getRoleTokenCacheMap() { + return roleTokenCache.asMap(); + } + + @Override + public Map getAccessTokenCacheMap() { + return accessTokenCache.asMap(); + } + + private AthenzAssertions assertionGroup(String domain) { + final AthenzPolicyLoader policyLoader = policyLoaders.get(domain); + if (policyLoader == null) { + throw new AthenzPolicyException("No policy loader found for domain: " + domain); + } else { + return policyLoader.getNow(); + } + } + + @Override + public Map> getRoleAllowAssertions(String domain) { + return assertionGroup(domain).roleStandardAllowMap(); + } + + @Override + public Map> getWildcardAllowAssertions(String domain) { + return assertionGroup(domain).roleWildcardAllowMap(); + } + + @Override + public Map> getRoleDenyAssertions(String domain) { + return assertionGroup(domain).roleStandardDenyMap(); + } + + @Override + public Map> getWildcardDenyAssertions(String domain) { + return assertionGroup(domain).roleWildcardDenyMap(); + } + + @Override + public int getDomainCount() { + return policyLoaders.size(); + } + + private static class TokenExpiry implements Expiry { + + private final ToLongFunction expiryTimeFunction; + + TokenExpiry(ToLongFunction expiryTimeFunction) { + this.expiryTimeFunction = expiryTimeFunction; + } + + @Override + public long expireAfterCreate(@NonNull String key, @NonNull V value, + long currentTime) { + final long expiryTime = expiryTimeFunction.applyAsLong(value); + if (expiryTime <= 0) { + // If the expiry time is not set, we assume it never expires. + return Long.MAX_VALUE; + } + final long now = System.currentTimeMillis() / 1000; + return TimeUnit.SECONDS.toNanos(expiryTime - now); + } + + @Override + public long expireAfterUpdate(@NonNull String key, @NonNull V value, + long currentTime, @NonNegative long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(@NonNull String key, @NonNull V value, long currentTime, + @NonNegative long currentDuration) { + return currentDuration; + } + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyConfig.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyConfig.java new file mode 100644 index 00000000000..59b59e2f7fd --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyConfig.java @@ -0,0 +1,131 @@ +/* + * 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.armeria.server.athenz; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A configuration class to download and refresh the Athenz policy data. + */ +@UnstableApi +public final class AthenzPolicyConfig { + + private static final Duration DEFAULT_REFRESH_INTERVAL = Duration.ofHours(1); + + private final List domains; + private final Map policyVersions; + private final boolean jwsPolicySupport; + private final Duration refreshInterval; + + /** + * Creates a new {@link AthenzPolicyConfig} with the specified domain. + */ + public AthenzPolicyConfig(String domain) { + this(ImmutableList.of(requireNonNull(domain, "domain")), ImmutableMap.of(), true, + DEFAULT_REFRESH_INTERVAL); + } + + /** + * Creates a new {@link AthenzPolicyConfig} with the specified domains, policy versions, + * and JWS policy support. + * + * @param domains the list of domains + * @param policyVersions the map of policy versions + * @param jwsPolicySupport whether JWS policy support is enabled + * @param refreshInterval the interval for refreshing the policy data + */ + public AthenzPolicyConfig(List domains, Map policyVersions, + boolean jwsPolicySupport, Duration refreshInterval) { + requireNonNull(domains, "domains"); + checkArgument(!domains.isEmpty(), "domains must not be empty"); + requireNonNull(policyVersions, "policyVersions"); + requireNonNull(refreshInterval, "refreshInterval"); + checkArgument(refreshInterval.toMillis() > 0, "refreshInterval must be greater than 0"); + + this.domains = domains; + this.policyVersions = policyVersions; + this.jwsPolicySupport = jwsPolicySupport; + this.refreshInterval = refreshInterval; + } + + /** + * Returns the list of domains for which the ZPU configuration is applicable. + */ + public List domains() { + return domains; + } + + /** + * Returns the map of policy versions. + */ + public Map policyVersions() { + return policyVersions; + } + + /** + * Returns whether JWS policy support is enabled. + */ + public boolean jwsPolicySupport() { + return jwsPolicySupport; + } + + /** + * Returns the interval for refreshing the policy data. + */ + public Duration refreshInterval() { + return refreshInterval; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof AthenzPolicyConfig)) { + return false; + } + final AthenzPolicyConfig zpuConfig = (AthenzPolicyConfig) o; + return jwsPolicySupport == zpuConfig.jwsPolicySupport && + domains.equals(zpuConfig.domains) && + policyVersions.equals(zpuConfig.policyVersions) && + refreshInterval.equals(zpuConfig.refreshInterval); + } + + @Override + public int hashCode() { + return Objects.hash(domains, policyVersions, jwsPolicySupport, refreshInterval); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("domains", domains) + .add("policyVersions", policyVersions) + .add("jwsPolicySupport", jwsPolicySupport) + .add("refreshInterval", refreshInterval) + .toString(); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyException.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyException.java new file mode 100644 index 00000000000..1dc56d89ecf --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyException.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * An exception that indicates an error occurred while fetching or processing Athenz policies. + */ +@UnstableApi +public class AthenzPolicyException extends RuntimeException { + private static final long serialVersionUID = 1002774467778087701L; + + /** + * Creates a new {@link AthenzPolicyException} with the specified message. + */ + public AthenzPolicyException(String message) { + super(message); + } + + /** + * Creates a new {@link AthenzPolicyException} with the specified message and cause. + */ + public AthenzPolicyException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyHandler.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyHandler.java new file mode 100644 index 00000000000..73ff255f6e0 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyHandler.java @@ -0,0 +1,285 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright The Athenz 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 + * + * http://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.armeria.server.athenz; + +import static com.linecorp.armeria.server.athenz.MinifiedAuthZpeClient.stripDomainPrefix; + +import java.io.IOException; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.athenz.common.utils.SignUtils; +import com.yahoo.athenz.zpe.ZpeConsts; +import com.yahoo.athenz.zpe.match.ZpeMatch; +import com.yahoo.athenz.zpe.match.impl.ZpeMatchAll; +import com.yahoo.athenz.zpe.match.impl.ZpeMatchEqual; +import com.yahoo.athenz.zpe.match.impl.ZpeMatchRegex; +import com.yahoo.athenz.zpe.match.impl.ZpeMatchStartsWith; +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; +import com.yahoo.athenz.zts.Assertion; +import com.yahoo.athenz.zts.AssertionEffect; +import com.yahoo.athenz.zts.DomainSignedPolicyData; +import com.yahoo.athenz.zts.JWSPolicyData; +import com.yahoo.athenz.zts.Policy; +import com.yahoo.athenz.zts.PolicyData; +import com.yahoo.athenz.zts.SignedPolicyData; +import com.yahoo.rdl.Struct; + +import com.linecorp.armeria.common.annotation.Nullable; + +final class AthenzPolicyHandler { + + // Forked from: https://github.com/AthenZ/athenz/blob/7e326fa655fef997ce913267f9dd561a9f4c82dd/clients/java/zpe/src/main/java/com/yahoo/athenz/zpe/ZpeUpdPolLoader.java#L333 + // Modified to use PublicKeyStore to fetch ZTS/ZMS keys instead of reading from file system. + + private static final Logger logger = LoggerFactory.getLogger(AthenzPolicyHandler.class); + + private static final boolean checkPolicyZMSSignature = Boolean.parseBoolean( + System.getProperty(ZpeConsts.ZPE_PROP_CHECK_POLICY_ZMS_SIGNATURE, "false")); + + private final PublicKeyStore publicKeyStore; + private final ObjectMapper mapper; + + AthenzPolicyHandler(PublicKeyStore publicKeyStore) { + this.publicKeyStore = publicKeyStore; + mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + private static ZpeMatch getMatchObject(String value) { + final ZpeMatch match; + if ("*".equals(value)) { + match = new ZpeMatchAll(); + } else { + final int anyCharMatch = value.indexOf('*'); + final int singleCharMatch = value.indexOf('?'); + + if (anyCharMatch == -1 && singleCharMatch == -1) { + match = new ZpeMatchEqual(value); + } else if (anyCharMatch == value.length() - 1 && singleCharMatch == -1) { + match = new ZpeMatchStartsWith(value.substring(0, value.length() - 1)); + } else { + match = new ZpeMatchRegex(value); + } + } + + return match; + } + + PolicyData getJWSPolicyData(JWSPolicyData jwsPolicyData) { + + // first we're going to assume that our signature was provided in P1363 format + // since that's what zpu is asking for by default. + + final String derSignature = getDERSignature(jwsPolicyData.getProtectedHeader(), + jwsPolicyData.getSignature()); + if (derSignature == null || !Crypto.validateJWSDocument(jwsPolicyData.getProtectedHeader(), + jwsPolicyData.getPayload(), + derSignature, publicKeyStore::getZtsKey)) { + + // assume the signature was already in DER format, so we'll use it directly + + if (!Crypto.validateJWSDocument(jwsPolicyData.getProtectedHeader(), jwsPolicyData.getPayload(), + jwsPolicyData.getSignature(), publicKeyStore::getZtsKey)) { + throw new AthenzPolicyException("ZTS signature validation failed"); + } + } + + final Base64.Decoder base64Decoder = Base64.getUrlDecoder(); + final byte[] payload = base64Decoder.decode(jwsPolicyData.getPayload()); + try { + final SignedPolicyData signedPolicyData = mapper.readValue(payload, SignedPolicyData.class); + return signedPolicyData.getPolicyData(); + } catch (IOException e) { + throw new AthenzPolicyException("Unable to parse jws policy data payload, ", e); + } + } + + private static boolean isESAlgorithm(@Nullable String algorithm) { + if (algorithm != null) { + switch (algorithm) { + case "ES256": + case "ES384": + case "ES512": + return true; + } + } + return false; + } + + @Nullable + private static String getDERSignature(final String protectedHeader, final String signature) { + + final Map header = Crypto.parseJWSProtectedHeader(protectedHeader); + if (header == null) { + return null; + } + final String algorithm = header.get("alg"); + if (!isESAlgorithm(algorithm)) { + return null; + } + try { + final Base64.Decoder base64Decoder = Base64.getUrlDecoder(); + final byte[] signatureBytes = base64Decoder.decode(signature); + final byte[] convertedSignature = Crypto.convertSignatureFromP1363ToDERFormat( + signatureBytes, Crypto.getDigestAlgorithm(algorithm)); + final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding(); + return base64Encoder.encodeToString(convertedSignature); + } catch (Exception ex) { + return null; + } + } + + PolicyData getSignedPolicyData(DomainSignedPolicyData domainSignedPolicyData) { + // we already verified that the object has policy data present + + final SignedPolicyData signedPolicyData = domainSignedPolicyData.getSignedPolicyData(); + + final String ztsSignature = domainSignedPolicyData.getSignature(); + final String ztsKeyId = domainSignedPolicyData.getKeyId(); + + // first let's verify the ZTS signature for our policy file + + final PublicKey ztsPublicKey = publicKeyStore.getZtsKey(ztsKeyId); + if (ztsPublicKey == null) { + throw new AthenzPolicyException("Unable to fetch zts public key for id: " + ztsKeyId); + } + + if (!Crypto.verify(SignUtils.asCanonicalString(signedPolicyData), ztsPublicKey, ztsSignature)) { + throw new AthenzPolicyException("ZTS signature validation failed"); + } + + final PolicyData policyData = signedPolicyData.getPolicyData(); + if (policyData == null) { + throw new AthenzPolicyException("Missing policy data"); + } + + // now let's verify that the ZMS signature for our policy file + // by default we're skipping this check because with multi-policy + // support we'll be returning different versions of the policy + // data from ZTS which cannot be signed by ZMS + + if (checkPolicyZMSSignature) { + + final String zmsSignature = signedPolicyData.getZmsSignature(); + final String zmsKeyId = signedPolicyData.getZmsKeyId(); + + final PublicKey zmsPublicKey = publicKeyStore.getZmsKey(zmsKeyId); + if (zmsPublicKey == null) { + throw new AthenzPolicyException("unable to fetch zms public key for id: " + zmsKeyId); + } + + if (!Crypto.verify(SignUtils.asCanonicalString(policyData), zmsPublicKey, zmsSignature)) { + throw new AthenzPolicyException("ZMS signature validation failed"); + } + } + return policyData; + } + + /** + * Process the policies into assertions, process the assertions: action, resource, role. + * If there is a wildcard in the action or resource, compile the regexpr and place it into the assertion + * Struct. This is a performance enhancement for AuthZpeClient when it performs the authorization checks. + */ + static AthenzAssertions toAssertions(PolicyData policyData) { + final AthenzAssertions athenzAssertions = new AthenzAssertions(); + final String domainName = policyData.getDomain(); + final List policies = policyData.getPolicies(); + for (Policy policy : policies) { + final String pname = policy.getName(); + logger.debug("Process policy {}. domain({}) ", pname, domainName); + final List assertions = policy.getAssertions(); + if (assertions == null) { + continue; + } + for (Assertion assertion : assertions) { + final Struct strAssert = new Struct(); + strAssert.put(ZpeConsts.ZPE_FIELD_POLICY_NAME, pname); + + // It is possible for action and resource to retain case. Need to lower them both. + final String passertAction = assertion.getAction().toLowerCase(); + + ZpeMatch matchStruct = getMatchObject(passertAction); + strAssert.put(ZpeConsts.ZPE_ACTION_MATCH_STRUCT, matchStruct); + + final String passertResource = assertion.getResource().toLowerCase(); + final String rsrc = stripDomainPrefix(passertResource, domainName, passertResource); + assert rsrc != null; + strAssert.put(ZpeConsts.ZPE_FIELD_RESOURCE, rsrc); + matchStruct = getMatchObject(rsrc); + strAssert.put(ZpeConsts.ZPE_RESOURCE_MATCH_STRUCT, matchStruct); + + final String passertRole = assertion.getRole(); + String pRoleName = stripDomainPrefix(passertRole, domainName, passertRole); + assert pRoleName != null; + // strip the prefix "role." too + pRoleName = pRoleName.replaceFirst("^role.", ""); + strAssert.put(ZpeConsts.ZPE_FIELD_ROLE, pRoleName); + + // based on the effect and role name determine what + // map we're going to use + + final Map> roleMap; + final AssertionEffect passertEffect = assertion.getEffect(); + matchStruct = getMatchObject(pRoleName); + strAssert.put(ZpeConsts.ZPE_ROLE_MATCH_STRUCT, matchStruct); + + if (passertEffect != null && passertEffect.toString().compareTo("DENY") == 0) { + if (matchStruct instanceof ZpeMatchEqual) { + roleMap = athenzAssertions.roleStandardDenyMap(); + } else { + roleMap = athenzAssertions.roleWildcardDenyMap(); + } + } else { + if (matchStruct instanceof ZpeMatchEqual) { + roleMap = athenzAssertions.roleStandardAllowMap(); + } else { + roleMap = athenzAssertions.roleWildcardAllowMap(); + } + } + + final List assertList = roleMap.computeIfAbsent(pRoleName, k -> new ArrayList<>()); + assertList.add(strAssert); + } + } + return athenzAssertions; + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyLoader.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyLoader.java new file mode 100644 index 00000000000..b0849d2243f --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPolicyLoader.java @@ -0,0 +1,109 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.google.common.base.Preconditions.checkState; +import static com.linecorp.armeria.server.athenz.AthenzPolicyHandler.toAssertions; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.google.common.collect.ImmutableMap; +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; +import com.yahoo.athenz.zts.DomainSignedPolicyData; +import com.yahoo.athenz.zts.JWSPolicyData; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.CommonPools; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.AsyncLoader; + +final class AthenzPolicyLoader { + + private final WebClient client; + private final String targetDomain; + private final AthenzPolicyConfig updaterConfig; + @Nullable + private final Map jwsPolicyParams; + private final AthenzPolicyHandler policyHandler; + private final AsyncLoader policyLoader; + private final CompletableFuture initialPolicyData; + + AthenzPolicyLoader(ZtsBaseClient baseClient, String targetDomain, + AthenzPolicyConfig updaterConfig, PublicKeyStore publicKeyStore) { + client = baseClient.webClient(); + this.targetDomain = targetDomain; + this.updaterConfig = updaterConfig; + if (updaterConfig.jwsPolicySupport()) { + jwsPolicyParams = ImmutableMap.of( + "policyVersions", updaterConfig.policyVersions(), + "signatureP1363Format", true); + } else { + jwsPolicyParams = null; + } + + policyHandler = new AthenzPolicyHandler(publicKeyStore); + policyLoader = AsyncLoader.builder(unused -> loadPolicyData()) + .name("athenz-policy-loader/" + targetDomain) + .refreshAfterLoad(updaterConfig.refreshInterval()) + .build(); + initialPolicyData = policyLoader.load(); + } + + void init() throws Exception { + initialPolicyData.get(20, TimeUnit.SECONDS); + } + + AthenzAssertions getNow() { + checkState(initialPolicyData.isDone(), "Policy data is not initialized yet"); + return policyLoader.load().join(); + } + + private CompletableFuture loadPolicyData() { + if (updaterConfig.jwsPolicySupport()) { + return loadJwsPolicyData(); + } else { + return loadSignedPolicyData(); + } + } + + private CompletableFuture loadJwsPolicyData() { + assert jwsPolicyParams != null; + return client.prepare() + .post("/domain/{domain}/policy/signed") + .pathParam("domain", targetDomain) + .contentJson(jwsPolicyParams) + .asJson(JWSPolicyData.class) + .execute() + .thenApplyAsync(entity -> { + return toAssertions(policyHandler.getJWSPolicyData(entity.content())); + }, CommonPools.blockingTaskExecutor()); + } + + private CompletableFuture loadSignedPolicyData() { + return client.prepare() + .get("/domain/{domain}/signed_policy_data") + .pathParam("domain", targetDomain) + .asJson(DomainSignedPolicyData.class) + .execute() + .thenApplyAsync(entity -> { + return toAssertions(policyHandler.getSignedPolicyData(entity.content())); + }, CommonPools.blockingTaskExecutor()); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPublicKeyProvider.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPublicKeyProvider.java new file mode 100644 index 00000000000..47ba59d01cc --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzPublicKeyProvider.java @@ -0,0 +1,180 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + */ + +package com.linecorp.armeria.server.athenz; + +import static com.linecorp.armeria.common.util.UnmodifiableFuture.completedFuture; + +import java.security.PublicKey; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMap.Builder; +import com.yahoo.athenz.auth.token.jwts.Key; +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; +import com.yahoo.athenz.zts.PublicKeyEntry; +import com.yahoo.athenz.zts.ServiceIdentity; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.util.AsyncLoader; + +final class AthenzPublicKeyProvider implements PublicKeyStore { + + private static final Logger logger = LoggerFactory.getLogger(AthenzPublicKeyProvider.class); + + private final WebClient webClient; + private final long minRetryInterval; + private final AsyncLoader>> ztsKeyLoader; + private final AsyncLoader>> zmsKeyLoader; + private volatile long lastReloadZtsJwkTime; + private volatile long lastReloadZmsJwkTime; + + AthenzPublicKeyProvider(ZtsBaseClient ztsBaseClient, Duration refreshInterval) { + // TODO(ikhoon): Make minRetryInterval configurable. + minRetryInterval = refreshInterval.toMillis() / 4; + webClient = ztsBaseClient.webClient(); + ztsKeyLoader = AsyncLoader.>>builder(k -> fetchZtsKeys()) + .name("athenz-zts-key-loader") + .refreshAfterLoad(refreshInterval) + .build(); + zmsKeyLoader = AsyncLoader.>>builder(k -> fetchZmsKeys()) + .name("athenz-zms-key-loader") + .refreshAfterLoad(refreshInterval) + .build(); + ztsKeyLoader.load(); + zmsKeyLoader.load(); + } + + @Override + public PublicKey getZtsKey(String keyId) { + return getKey(ztsKeyLoader, keyId, true).join(); + } + + @Override + public PublicKey getZmsKey(String keyId) { + return getKey(zmsKeyLoader, keyId, false).join(); + } + + private CompletableFuture getKey( + AsyncLoader>> keyLoader, String keyId, boolean zts) { + return keyLoader.load().thenCompose(keys -> { + final CompletableFuture publicKey = keys.get(keyId); + if (publicKey != null) { + return publicKey; + } + + final long lastReloadJwkTime = zts ? lastReloadZtsJwkTime : lastReloadZmsJwkTime; + if (!canReload(lastReloadJwkTime)) { + return completedFuture(null); + } + + // The keys may be rotated, so we need to reload the keys. + return keyLoader.load(true).thenApply(keys0 -> { + final CompletableFuture publicKey0 = keys.get(keyId); + if (publicKey0 != null) { + return publicKey0.join(); + } else { + return null; + } + }); + }); + } + + private CompletableFuture>> fetchZtsKeys() { + return webClient + .prepare() + .get("/oauth2/keys") + .asJson(Keys.class) + .execute() + .thenApply(res -> { + final List keys = res.content().getKeys(); + final Builder> builder = + ImmutableMap.builderWithExpectedSize(keys.size()); + for (Key key : keys) { + try { + builder.put(key.getKid(), completedFuture(key.getPublicKey())); + } catch (Exception ex) { + logger.warn("Unable to generate JSON Web Key for key-id {}", key.getKid(), ex); + } + } + lastReloadZtsJwkTime = System.currentTimeMillis(); + return builder.buildKeepingLast(); + }); + } + + private CompletableFuture>> fetchZmsKeys() { + return webClient + .prepare() + .get("/domain/sys.auth/service/zms") + .asJson(ServiceIdentity.class) + .execute() + .thenApply(res -> { + final List keys = res.content().getPublicKeys(); + final Builder> builder = + ImmutableMap.builderWithExpectedSize(keys.size()); + for (PublicKeyEntry key : keys) { + try { + final PublicKey publicKey = + Crypto.loadPublicKey(Crypto.ybase64DecodeString(key.getKey())); + builder.put(key.getId(), completedFuture(publicKey)); + } catch (Exception ex) { + logger.warn("Unable to generate zms proprietary key for key-id {}", + key.getId(), ex); + } + } + lastReloadZmsJwkTime = System.currentTimeMillis(); + return builder.buildKeepingLast(); + }); + } + + private boolean canReload(long lastReloadJwkTime) { + final long now = System.currentTimeMillis(); + final long millisDiff = now - lastReloadJwkTime; + return millisDiff > minRetryInterval; + } + + private static class Keys { + private final List keys; + + @JsonCreator + Keys(@JsonProperty("keys") List keys) { + this.keys = keys; + } + + List getKeys() { + return keys; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("keys", keys) + .toString(); + } + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzService.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzService.java new file mode 100644 index 00000000000..52a432e53f2 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzService.java @@ -0,0 +1,124 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + */ + +package com.linecorp.armeria.server.athenz; + +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.server.HttpService; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.SimpleDecoratingHttpService; +import com.linecorp.armeria.server.athenz.MinifiedAuthZpeClient.AccessCheckStatus; + +/** + * Decorates an {@link HttpService} to check access permissions using Athenz policies. + * + *

Example: + *

{@code
+ *  import com.linecorp.armeria.client.athenz.ZtsBaseClient;
+ *  import com.linecorp.armeria.server.athenz.AthenzService;
+ *
+ *  ZtsBaseClient ztsBaseClient =
+ *    ZtsBaseClient
+ *      .builder("https://athenz.example.com:8443/zts/v1")
+ *      .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem")
+ *      .build();
+ *
+ *  ServerBuilder sb = Server.builder();
+ *  // Decorate the service to check access permissions for the "/users" resource.
+ *  sb.decorator("/users",
+ *               AthenzService
+ *                 .builder(ztsBaseClient)
+ *                 .action("read")
+ *                 .resource("users")
+ *                 .policyConfig(new AthenzPolicyConfig("my-domain"))
+ *                 .newDecorator());
+ * }
+ */ +@UnstableApi +public final class AthenzService extends SimpleDecoratingHttpService { + + /** + * Returns a new {@link AthenzServiceBuilder} with the specified {@link ZtsBaseClient}. + */ + public static AthenzServiceBuilder builder(ZtsBaseClient ztsBaseClient) { + requireNonNull(ztsBaseClient, "ztsBaseClient"); + return new AthenzServiceBuilder(ztsBaseClient); + } + + private final MinifiedAuthZpeClient authZpeClient; + private final String athenzResource; + private final String athenzAction; + private final List tokenTypes; + + AthenzService(HttpService delegate, MinifiedAuthZpeClient authZpeClient, + String athenzResource, String athenzAction, List tokenTypes) { + super(delegate); + + this.authZpeClient = authZpeClient; + this.athenzResource = athenzResource; + this.athenzAction = athenzAction; + this.tokenTypes = tokenTypes; + } + + @Override + public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { + final String token = extractToken(req.headers()); + if (token == null) { + return HttpResponse.of(HttpStatus.UNAUTHORIZED, MediaType.PLAIN_TEXT, "Missing token"); + } + + final CompletableFuture future = CompletableFuture.supplyAsync(() -> { + final AccessCheckStatus status = authZpeClient.allowAccess(token, athenzResource, athenzAction); + if (status == AccessCheckStatus.ALLOW) { + try { + return unwrap().serve(ctx, req); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + } else { + return HttpResponse.of(HttpStatus.UNAUTHORIZED, MediaType.PLAIN_TEXT, status.toString()); + } + }, ctx.blockingTaskExecutor()); + return HttpResponse.of(future); + } + + @Nullable + private String extractToken(RequestHeaders headers) { + for (TokenType tokenType : tokenTypes) { + final String token = headers.get(tokenType.headerName(), ""); + if (token.isEmpty()) { + continue; + } + return token; + } + return null; + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceBuilder.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceBuilder.java new file mode 100644 index 00000000000..d48e3579e2a --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceBuilder.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.function.Function; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; + +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.server.HttpService; + +/** + * A builder for creating an {@link AthenzService} that checks access permissions using Athenz policies. + */ +@UnstableApi +public final class AthenzServiceBuilder extends AbstractAthenzServiceBuilder { + + private static final List DEFAULT_TOKEN_TYPES = ImmutableList.copyOf(TokenType.values()); + + private List tokenTypes = DEFAULT_TOKEN_TYPES; + + @Nullable + private String athenzResource; + @Nullable + private String athenzAction; + + AthenzServiceBuilder(ZtsBaseClient ztsBaseClient) { + super(ztsBaseClient); + } + + /** + * Sets the Athenz resource to check access permissions against. + * + *

Mandatory: This field must be set before calling {@link #newDecorator()}. + */ + public AthenzServiceBuilder resource(String athenzResource) { + requireNonNull(athenzResource, "athenzResource"); + checkArgument(!athenzResource.isEmpty(), "athenzResource must not be empty"); + this.athenzResource = athenzResource; + return this; + } + + /** + * Sets the Athenz action to check access permissions against. + * + *

Mandatory: This field must be set before calling {@link #newDecorator()}. + */ + public AthenzServiceBuilder action(String athenzAction) { + this.athenzAction = athenzAction; + requireNonNull(athenzAction, "athenzAction"); + checkArgument(!athenzAction.isEmpty(), "athenzAction must not be empty"); + return this; + } + + /** + * Sets the {@link TokenType}s to be used for access checks. + * If not set, all token types are checked by default. + */ + public AthenzServiceBuilder tokenType(TokenType... tokenTypes) { + requireNonNull(tokenTypes, "tokenTypes"); + checkArgument(tokenTypes.length > 0, "tokenTypes must not be empty"); + return this; + } + + /** + * Sets the {@link TokenType}s to be used for access checks. + * If not set, all token types are checked by default. + */ + public AthenzServiceBuilder tokenType(Iterable tokenTypes) { + requireNonNull(tokenTypes, "tokenTypes"); + checkArgument(!Iterables.isEmpty(tokenTypes), "tokenTypes must not be empty"); + this.tokenTypes = ImmutableList.copyOf(tokenTypes); + return this; + } + + /** + * Returns a new {@link HttpClient} decorator that performs access checks using Athenz policies. + */ + public Function newDecorator() { + final String athenzResource = this.athenzResource; + final String athenzAction = this.athenzAction; + final List tokenTypes = this.tokenTypes; + + checkState(athenzResource != null, "resource is not set"); + checkState(athenzAction != null, "action is not set"); + + final MinifiedAuthZpeClient authZpeClient = buildAuthZpeClient(); + return delegate -> new AthenzService(delegate, authZpeClient, + athenzResource, athenzAction, tokenTypes); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceDecoratorFactory.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceDecoratorFactory.java new file mode 100644 index 00000000000..43cfe4428d8 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceDecoratorFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.function.Function; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.server.HttpService; +import com.linecorp.armeria.server.annotation.DecoratorFactoryFunction; + +/** + * A factory for creating a decorator that checks access permissions using Athenz policies. + * This factory is used in conjunction with the {@link RequiresAthenzRole} annotation. + * + * @see RequiresAthenzRole + */ +@UnstableApi +public final class AthenzServiceDecoratorFactory implements DecoratorFactoryFunction { + + /** + * Returns a new {@link AthenzServiceDecoratorFactoryBuilder} with the specified {@link ZtsBaseClient}. + */ + public static AthenzServiceDecoratorFactoryBuilder builder(ZtsBaseClient ztsBaseClient) { + requireNonNull(ztsBaseClient, "ztsBaseClient"); + return new AthenzServiceDecoratorFactoryBuilder(ztsBaseClient); + } + + private final MinifiedAuthZpeClient authZpeClient; + + AthenzServiceDecoratorFactory(MinifiedAuthZpeClient authZpeClient) { + this.authZpeClient = authZpeClient; + } + + @Override + public Function newDecorator(RequiresAthenzRole parameter) { + final String resource = parameter.resource(); + final String action = parameter.action(); + final List tokenTypes = ImmutableList.copyOf(parameter.tokenType()); + + requireNonNull(resource, "resource"); + requireNonNull(action, "action"); + checkArgument(!resource.isEmpty(), "resource must not be empty"); + checkArgument(!action.isEmpty(), "action must not be empty"); + checkArgument(!tokenTypes.isEmpty(), "tokenType must not be empty"); + + return delegate -> new AthenzService(delegate, authZpeClient, resource, action, tokenTypes); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceDecoratorFactoryBuilder.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceDecoratorFactoryBuilder.java new file mode 100644 index 00000000000..914b29fda5b --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/AthenzServiceDecoratorFactoryBuilder.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A builder for creating an {@link AthenzServiceDecoratorFactory} that checks access permissions using + * Athenz policies. + */ +@UnstableApi +public final class AthenzServiceDecoratorFactoryBuilder + extends AbstractAthenzServiceBuilder { + + AthenzServiceDecoratorFactoryBuilder(ZtsBaseClient ztsBaseClient) { + super(ztsBaseClient); + } + + /** + * Returns a new {@link AthenzServiceDecoratorFactory} instance. + */ + public AthenzServiceDecoratorFactory build() { + final MinifiedAuthZpeClient authZpeClient = buildAuthZpeClient(); + return new AthenzServiceDecoratorFactory(authZpeClient); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/MinifiedAuthZpeClient.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/MinifiedAuthZpeClient.java new file mode 100644 index 00000000000..3a782ab3d83 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/MinifiedAuthZpeClient.java @@ -0,0 +1,849 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright The Athenz 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 + * + * http://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.armeria.server.athenz; + +import static com.yahoo.athenz.zpe.ZpeConsts.ZPE_PROP_MILLIS_BETWEEN_ZTS_CALLS; + +import java.net.URI; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.net.ssl.SSLContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.oath.auth.KeyRefresher; +import com.oath.auth.Utils; +import com.yahoo.athenz.auth.token.AccessToken; +import com.yahoo.athenz.auth.token.RoleToken; +import com.yahoo.athenz.auth.token.jwts.JwtsSigningKeyResolver; +import com.yahoo.athenz.auth.util.CryptoException; +import com.yahoo.athenz.zpe.ZpeClient; +import com.yahoo.athenz.zpe.ZpeConsts; +import com.yahoo.athenz.zpe.match.ZpeMatch; +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; +import com.yahoo.rdl.Struct; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.common.SslContextFactory.SslContextMode; + +import io.netty.handler.ssl.JdkSslContext; + +final class MinifiedAuthZpeClient { + + // Forked from https://github.com/AthenZ/athenz/blob/3acc0ea0e0f44adc0fb69bb442a0a449b655ad10/clients/java/zpe/src/main/java/com/yahoo/athenz/zpe/AuthZpeClient.java + // Changes made: + // - Added the constructor to configure a custom PublicKeyStore and ZpeClient per instance + // - Removed unused methods and fields + // - Changed static methods to instance methods + // - Changed access modifiers to package-private or private as appropriate + // - Lowered the logging level from error to warn + + // TODO(ikhoon): Refactor MinifiedAuthZpeClient to perform access check asynchronously. + + private static final Logger logger = LoggerFactory.getLogger(MinifiedAuthZpeClient.class); + + private static final String BEARER_TOKEN = "Bearer "; + + private int allowedOffset = 300; + private JwtsSigningKeyResolver accessSignKeyResolver; + private final ZpeClient zpeClt; + private final PublicKeyStore publicKeyStore; + + public enum AccessCheckStatus { + ALLOW { + @Override + public String toString() { + return "Access Check was explicitly allowed"; + } + }, + DENY { + @Override + public String toString() { + return "Access Check was explicitly denied"; + } + }, + DENY_NO_MATCH { + @Override + public String toString() { + return "Access denied due to no match to any of the assertions defined in domain policy file"; + } + }, + DENY_ROLETOKEN_EXPIRED { + @Override + public String toString() { + return "Access denied due to expired Token"; + } + }, + DENY_ROLETOKEN_INVALID { + @Override + public String toString() { + return "Access denied due to invalid Token"; + } + }, + DENY_DOMAIN_MISMATCH { + @Override + public String toString() { + return "Access denied due to domain mismatch between Resource and Token"; + } + }, + DENY_DOMAIN_NOT_FOUND { + @Override + public String toString() { + return "Access denied due to domain not found in library cache"; + } + }, + DENY_DOMAIN_EXPIRED { + @Override + public String toString() { + return "Access denied due to expired domain policy file"; + } + }, + DENY_DOMAIN_EMPTY { + @Override + public String toString() { + return "Access denied due to no policies in the domain file"; + } + }, + DENY_INVALID_PARAMETERS { + @Override + public String toString() { + return "Access denied due to invalid/empty action/resource values"; + } + }, + DENY_CERT_MISMATCH_ISSUER { + @Override + public String toString() { + return "Access denied due to certificate mismatch in issuer"; + } + }, + DENY_CERT_MISSING_SUBJECT { + @Override + public String toString() { + return "Access denied due to missing subject in certificate"; + } + }, + DENY_CERT_MISSING_DOMAIN { + @Override + public String toString() { + return "Access denied due to missing domain name in certificate"; + } + }, + DENY_CERT_MISSING_ROLE_NAME { + @Override + public String toString() { + return "Access denied due to missing role name in certificate"; + } + }, + DENY_CERT_HASH_MISMATCH { + @Override + public String toString() { + return "Access denied due to access token certificate hash mismatch"; + } + } + } + + MinifiedAuthZpeClient(ZtsBaseClient ztsBaseClient, PublicKeyStore publicKeyStore, ZpeClient zpeClt) { + this.publicKeyStore = publicKeyStore; + this.zpeClt = zpeClt; + + // set the allowed offset + + setTokenAllowedOffset(Integer.parseInt(System.getProperty(ZpeConsts.ZPE_PROP_TOKEN_OFFSET, "300"))); + + // initialize the access token signing key resolver + + initializeAccessTokenSignKeyResolver(ztsBaseClient); + + // save the last zts api call time, and the allowed interval between api calls + + setMillisBetweenZtsCalls(Long.parseLong( + System.getProperty(ZPE_PROP_MILLIS_BETWEEN_ZTS_CALLS, Long.toString(30 * 1000 * 60)))); + } + + private void initializeAccessTokenSignKeyResolver(ZtsBaseClient ztsBaseClient) { + final String serverUrl = System.getProperty(ZpeConsts.ZPE_PROP_JWK_URI); + if (serverUrl == null || serverUrl.isEmpty()) { + accessSignKeyResolver = newDefaultJwtsSigningKeyResolver(ztsBaseClient); + ztsBaseClient.addTlsKeyPairListener(tlsKeyPair -> { + // Refresh the JwtsSigningKeyResolver when the TLS key pair changes + accessSignKeyResolver = newDefaultJwtsSigningKeyResolver(ztsBaseClient); + }); + return; + } + + final String keyPath = System.getProperty(ZpeConsts.ZPE_PROP_JWK_PRIVATE_KEY_PATH); + final String certPath = System.getProperty(ZpeConsts.ZPE_PROP_JWK_X509_CERT_PATH); + SSLContext sslContext = null; + if (keyPath != null && !keyPath.isEmpty() && certPath != null && !certPath.isEmpty()) { + try { + final KeyRefresher keyRefresher = Utils.generateKeyRefresher(null, certPath, keyPath); + keyRefresher.startup(); + sslContext = Utils.buildSSLContext(keyRefresher.getKeyManagerProxy(), + keyRefresher.getTrustManagerProxy()); + } catch (Exception ex) { + logger.warn("Unable to initialize key refresher: {}", ex.getMessage()); + } + } + accessSignKeyResolver = new JwtsSigningKeyResolver(serverUrl, sslContext, null); + } + + private static JwtsSigningKeyResolver newDefaultJwtsSigningKeyResolver(ZtsBaseClient ztsBaseClient) { + final URI ztsUri = ztsBaseClient.ztsUri(); + final URI proxyUri = ztsBaseClient.proxyUri(); + logger.debug("No JWK URI specified, using {}", ztsUri); + String proxyUriStr = null; + if (proxyUri != null) { + proxyUriStr = proxyUri.toString(); + } + final ClientFactory clientFactory = ztsBaseClient.clientFactory(); + final TlsProvider tlsProvider = clientFactory.options().tlsProvider(); + final SslContextFactory sslContextFactory = new SslContextFactory(tlsProvider, TlsEngineType.JDK, + null, clientFactory.meterRegistry()); + final JdkSslContext sslContext = (JdkSslContext) sslContextFactory.getOrCreate(SslContextMode.CLIENT, + "*"); + return new JwtsSigningKeyResolver(ztsUri + "/oauth2/keys", sslContext.context(), proxyUriStr); + } + + /** + * Set the role token allowed offset. this might be necessary + * if the client and server are not ntp synchronized, and we + * don't want the server to reject valid role tokens + * @param offset value in seconds + */ + void setTokenAllowedOffset(int offset) { + // skip any invalid values + if (offset > 0) { + allowedOffset = offset; + } + } + + PublicKey getZtsPublicKey(String keyId) { + PublicKey publicKey = publicKeyStore.getZtsKey(keyId); + if (publicKey == null) { + // fetch all zts jwk keys and update config and try again + publicKey = accessSignKeyResolver.getPublicKey(keyId); + } + return publicKey; + } + + private void setMillisBetweenZtsCalls(long millis) { + accessSignKeyResolver.setMillisBetweenZtsCalls(millis); + } + + /** + * Determine if access(action) is allowed against the specified resource by + * a user represented by the user (cltToken, cltTokenName). + * + * @param token - either role or access token. For role tokens: + * value for the HTTP header: Athenz-Role-Auth + * ex: "v=Z1;d=angler;r=admin;a=aAkjbbDMhnLX;t=1431974053;e=1431974153;k=0" + * For access tokens: value for HTTP header: Authorization: Bearer access-token + * @param resource is a domain qualified resource the calling service + * will check access for. ex: my_domain:my_resource + * ex: "angler:pondsKernCounty" + * ex: "sports:service.storage.tenant.Activator.ActionMap" + * @param action is the type of access attempted by a client + * ex: "read" + * ex: "scan" + * @return AccessCheckStatus if the user can access the resource via the specified action + * the result is ALLOW otherwise one of the DENY_* values specifies the exact + * reason why the access was denied + */ + AccessCheckStatus allowAccess(String token, String resource, String action) { + final StringBuilder matchRoleName = new StringBuilder(256); + return allowAccess(token, null, null, resource, action, matchRoleName); + } + + /** + * Determine if access(action) is allowed against the specified resource by + * a user represented by the user (cltToken, cltTokenName). + * @param token either role or access token. For role tokens: + * value for the HTTP header: Athenz-Role-Auth + * ex: "v=Z1;d=angler;r=admin;a=aAkjbbDMhnLX;t=1431974053;e=1431974153;k=0" + * For access tokens: value for HTTP header: Authorization: Bearer access-token + * @param cert X509 Client Certificate used to establish the mTLS connection + * submitting this request + * @param certHash If the connection is coming through a proxy, this includes + * the certificate hash of the client certificate that was calculated + * by the proxy and forwarded in a http header + * @param resource is a domain qualified resource the calling service + * will check access for. ex: my_domain:my_resource + * ex: "angler:pondsKernCounty" + * ex: "sports:service.storage.tenant.Activator.ActionMap" + * @param action is the type of access attempted by a client + * ex: "read" + * ex: "scan" + * @param matchRoleName - [out] will include the role name that the result was based on + * it will be not be set if the failure is due to expired/invalid tokens or + * there were no matches thus a default value of DENY_NO_MATCH is returned + * @return AccessCheckStatus if the user can access the resource via the specified action + * the result is ALLOW otherwise one of the DENY_* values specifies the exact + * reason why the access was denied + */ + private AccessCheckStatus allowAccess(String token, @Nullable X509Certificate cert, + @Nullable String certHash, + String resource, String action, StringBuilder matchRoleName) { + + if (logger.isDebugEnabled()) { + logger.debug("allowAccess: action={} resource={}", action, resource); + } + + // check if we're given role or access token + + if (token.startsWith("v=Z1;")) { + return allowRoleTokenAccess(token, resource, action, matchRoleName); + } else { + return allowAccessTokenAccess(token, cert, certHash, resource, action, matchRoleName); + } + } + + private AccessCheckStatus allowRoleTokenAccess(String roleToken, String resource, String action, + StringBuilder matchRoleName) { + + final Map tokenCache = zpeClt.getRoleTokenCacheMap(); + RoleToken rToken = tokenCache.get(roleToken); + + if (rToken == null) { + + rToken = new RoleToken(roleToken); + + // validate the token. validation also verifies that + // the token is not expired + + if (!rToken.validate(getZtsPublicKey(rToken.getKeyId()), allowedOffset, false, null)) { + + // check the token expiration and provide a more specific + // status code to the caller + + if (isTokenExpired(rToken)) { + return AccessCheckStatus.DENY_ROLETOKEN_EXPIRED; + } + + logger.warn("allowAccess: Authorization denied. Authentication failed for token={}", + rToken.getUnsignedToken()); + return AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } + + addTokenToCache(tokenCache, roleToken, rToken); + } + + return allowAccess(rToken, resource, action, matchRoleName); + } + + private AccessCheckStatus allowAccessTokenAccess(String accessToken, @Nullable X509Certificate cert, + @Nullable String certHash, + String resource, String action, + StringBuilder matchRoleName) { + + // if our client sent the full header including Bearer part + // we're going to strip that out + + if (accessToken.startsWith(BEARER_TOKEN)) { + accessToken = accessToken.substring(BEARER_TOKEN.length()); + } + + final Map tokenCache = zpeClt.getAccessTokenCacheMap(); + AccessToken acsToken = tokenCache.get(accessToken); + + // if we have an x.509 certificate provided then we need to + // validate our mtls client certificate confirmation value + // before accepting the token from the cache + + if (acsToken != null && cert != null && !acsToken.confirmMTLSBoundToken(cert, certHash)) { + logger.warn("allowAccess: mTLS Client certificate confirmation failed"); + return AccessCheckStatus.DENY_CERT_HASH_MISMATCH; + } + + if (acsToken == null) { + + try { + if (cert == null && certHash == null) { + acsToken = new AccessToken(accessToken, accessSignKeyResolver); + } else { + acsToken = new AccessToken(accessToken, accessSignKeyResolver, cert, certHash); + } + } catch (CryptoException ex) { + + logger.warn("allowAccess: Authorization denied. Authentication failed for token={}", + ex.getMessage()); + return (ex.getCode() == CryptoException.CERT_HASH_MISMATCH) ? + AccessCheckStatus.DENY_CERT_HASH_MISMATCH : AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } catch (Exception ex) { + + logger.warn("allowAccess: Authorization denied. Authentication failed for token={}", + ex.getMessage()); + return AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } + + addTokenToCache(tokenCache, accessToken, acsToken); + } + + return allowAccess(acsToken, resource, action, matchRoleName); + } + + /** + * Determine if access(action) is allowed against the specified resource by + * a user represented by the RoleToken. + * @param rToken represents the role token sent by the client that wants access to the resource + * @param resource is a domain qualified resource the calling service + * will check access for. ex: my_domain:my_resource + * ex: "angler:pondsKernCounty" + * ex: "sports:service.storage.tenant.Activator.ActionMap" + * @param action is the type of access attempted by a client + * ex: "read" + * ex: "scan" + * @param matchRoleName - [out] will include the role name that the result was based on + * it will be not be set if the failure is due to expired/invalid tokens or + * there were no matches thus a default value of DENY_NO_MATCH is returned + * @return AccessCheckStatus if the user can access the resource via the specified action + * the result is ALLOW otherwise one of the DENY_* values specifies the exact + * reason why the access was denied + **/ + @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder") + private AccessCheckStatus allowAccess(RoleToken rToken, String resource, String action, + StringBuilder matchRoleName) { + + // check the token expiration + + if (rToken == null) { + logger.warn("allowAccess: Authorization denied. Token is null"); + return AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } + + if (isTokenExpired(rToken)) { + return AccessCheckStatus.DENY_ROLETOKEN_EXPIRED; + } + + final String tokenDomain = rToken.getDomain(); // ZToken contains the domain + final List roles = rToken.getRoles(); // ZToken contains roles + + return allowActionZPE(action, tokenDomain, resource, roles, matchRoleName); + } + + /** + * Determine if access(action) is allowed against the specified resource by + * a user represented by the AccessToken. + * @param accessToken represents the access token sent by the client that wants access to the resource + * @param resource is a domain qualified resource the calling service + * will check access for. ex: my_domain:my_resource + * ex: "angler:pondsKernCounty" + * ex: "sports:service.storage.tenant.Activator.ActionMap" + * @param action is the type of access attempted by a client + * ex: "read" + * ex: "scan" + * @param matchRoleName - [out] will include the role name that the result was based on + * it will be not be set if the failure is due to expired/invalid tokens or + * there were no matches thus a default value of DENY_NO_MATCH is returned + * @return AccessCheckStatus if the user can access the resource via the specified action + * the result is ALLOW otherwise one of the DENY_* values specifies the exact + * reason why the access was denied + **/ + private AccessCheckStatus allowAccess(AccessToken accessToken, String resource, String action, + StringBuilder matchRoleName) { + + // check the token expiration + + if (accessToken == null) { + logger.warn("allowAccess: Authorization denied. Token is null"); + return AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } + + if (isTokenExpired(accessToken)) { + return AccessCheckStatus.DENY_ROLETOKEN_EXPIRED; + } + + final String tokenDomain = accessToken.getAudience(); + final List roles = accessToken.getScope(); + + return allowActionZPE(action, tokenDomain, resource, roles, matchRoleName); + } + + private static boolean isTokenExpired(RoleToken roleToken) { + + final long now = System.currentTimeMillis() / 1000; + final long expiry = roleToken.getExpiryTime(); + if (expiry != 0 && expiry < now) { + logger.warn("ExpiryCheck: Token expired. now={} expiry={} token={}", + now, expiry, roleToken.getUnsignedToken()); + return true; + } + return false; + } + + private static boolean isTokenExpired(AccessToken accessToken) { + + final long now = System.currentTimeMillis() / 1000; + final long expiry = accessToken.getExpiryTime(); + if (expiry != 0 && expiry < now) { + logger.warn("ExpiryCheck: Token expired. now={} expiry={} token={}", + now, expiry, accessToken.getClientId()); + return true; + } + return false; + } + + /* + * Peel off domain name from the assertion string if it matches + * domain and return the string without the domain prefix. + * Else, return default value + */ + @Nullable + static String stripDomainPrefix(String assertString, String domain, @Nullable String defaultValue) { + final int index = assertString.indexOf(':'); + if (index == -1) { + return assertString; + } + + if (!assertString.substring(0, index).equals(domain)) { + return defaultValue; + } + + return assertString.substring(index + 1); + } + + // check action access in the domain to the resource with the given roles + + /** + * Determine if access(action) is allowed against the specified resource by + * a user represented by the given roles. The expected method for authorization + * check is the allowAccess methods. However, if the client is responsible for + * validating the role token (including expiration check), it may use this + * method directly by just specifying the tokenDomain and roles arguments + * which are directly extracted from the role token. + * @param action is the type of access attempted by a client + * ex: "read" + * ex: "scan" + * @param tokenDomain represents the domain the role token was issued for + * @param resource is a domain qualified resource the calling service + * will check access for. ex: my_domain:my_resource + * ex: "angler:pondsKernCounty" + * ex: "sports:service.storage.tenant.Activator.ActionMap" + * @param roles list of roles extracted from the role token + * @param matchRoleName - [out] will include the role name that the result was based on + * it will be not be set if the failure is due to expired/invalid tokens or + * there were no matches thus a default value of DENY_NO_MATCH is returned + * @return AccessCheckStatus if the user can access the resource via the specified action + * the result is ALLOW otherwise one of the DENY_* values specifies the exact + * reason why the access was denied + **/ + private AccessCheckStatus allowActionZPE(String action, String tokenDomain, String resource, + List roles, StringBuilder matchRoleName) { + + final String msgPrefix = "allowActionZPE: domain(" + tokenDomain + ") action(" + action + + ") resource(" + resource + ')'; + + if (roles == null || roles.isEmpty()) { + logger.warn("{} ERROR: No roles so access denied", msgPrefix); + return AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } + + if (logger.isDebugEnabled()) { + logger.debug("{} roles({}) starting...", msgPrefix, String.join(",", roles)); + } + + if (tokenDomain == null || tokenDomain.isEmpty()) { + logger.warn("{} ERROR: No domain so access denied", msgPrefix); + return AccessCheckStatus.DENY_ROLETOKEN_INVALID; + } + + if (action == null || action.isEmpty()) { + logger.warn("{} ERROR: No action so access denied", msgPrefix); + return AccessCheckStatus.DENY_INVALID_PARAMETERS; + } + action = action.toLowerCase(); + + if (resource == null || resource.isEmpty()) { + logger.warn("{} ERROR: No resource so access denied", msgPrefix); + return AccessCheckStatus.DENY_INVALID_PARAMETERS; + } + resource = resource.toLowerCase(); + + // Note: if domain in token doesn't match domain in resource then there + // will be no match of any resource in the assertions - so deny immediately + // special case - when we have a single domain being processed by ZPE + // the application will never have generated multiple domain values thus + // if the resource contains : for something else, we'll ignore it and don't + // assume it's part of the domain separator and thus reject the request. + // for multiple domains, if the resource might contain :, it's the responsibility + // of the caller to include the "domain-name:" prefix as part of the resource + + resource = stripDomainPrefix(resource, tokenDomain, zpeClt.getDomainCount() == 1 ? resource : null); + if (resource == null) { + logger.warn("{} ERROR: Domain mismatch in token({}) and resource so access denied", + msgPrefix, tokenDomain); + return AccessCheckStatus.DENY_DOMAIN_MISMATCH; + } + + // first hunt by role for deny assertions since deny takes precedence + // over allow assertions + + AccessCheckStatus status = AccessCheckStatus.DENY_DOMAIN_NOT_FOUND; + Map> roleMap = zpeClt.getRoleDenyAssertions(tokenDomain); + if (roleMap != null && !roleMap.isEmpty()) { + if (actionByRole(action, tokenDomain, resource, roles, roleMap, matchRoleName)) { + return AccessCheckStatus.DENY; + } else { + status = AccessCheckStatus.DENY_NO_MATCH; + } + } else if (roleMap != null) { + status = AccessCheckStatus.DENY_DOMAIN_EMPTY; + } + + // if the check was not explicitly denied by a standard role, then + // let's process our wildcard roles for deny assertions + + roleMap = zpeClt.getWildcardDenyAssertions(tokenDomain); + if (roleMap != null && !roleMap.isEmpty()) { + if (actionByWildCardRole(action, tokenDomain, resource, roles, roleMap, matchRoleName)) { + return AccessCheckStatus.DENY; + } else { + status = AccessCheckStatus.DENY_NO_MATCH; + } + } else if (status != AccessCheckStatus.DENY_NO_MATCH && roleMap != null) { + status = AccessCheckStatus.DENY_DOMAIN_EMPTY; + } + + // so far it did not match any deny assertions so now let's + // process our allow assertions + + roleMap = zpeClt.getRoleAllowAssertions(tokenDomain); + if (roleMap != null && !roleMap.isEmpty()) { + if (actionByRole(action, tokenDomain, resource, roles, roleMap, matchRoleName)) { + return AccessCheckStatus.ALLOW; + } else { + status = AccessCheckStatus.DENY_NO_MATCH; + } + } else if (status != AccessCheckStatus.DENY_NO_MATCH && roleMap != null) { + status = AccessCheckStatus.DENY_DOMAIN_EMPTY; + } + + // at this point we either got an allow or didn't match anything so we're + // going to try the wildcard roles + + roleMap = zpeClt.getWildcardAllowAssertions(tokenDomain); + if (roleMap != null && !roleMap.isEmpty()) { + if (actionByWildCardRole(action, tokenDomain, resource, roles, roleMap, matchRoleName)) { + return AccessCheckStatus.ALLOW; + } else { + status = AccessCheckStatus.DENY_NO_MATCH; + } + } else if (status != AccessCheckStatus.DENY_NO_MATCH && roleMap != null) { + status = AccessCheckStatus.DENY_DOMAIN_EMPTY; + } + + if (status == AccessCheckStatus.DENY_DOMAIN_NOT_FOUND) { + logger.warn("{}: No role map found for domain={} so access denied", msgPrefix, tokenDomain); + } else if (status == AccessCheckStatus.DENY_DOMAIN_EMPTY) { + logger.warn("{}: No policy assertions for domain={} so access denied", msgPrefix, tokenDomain); + } + + return status; + } + + private static boolean matchAssertions(List asserts, String role, String action, + String resource, StringBuilder matchRoleName, + @Nullable String msgPrefix) { + + ZpeMatch matchStruct; + String passertAction = null; + String passertResource = null; + String polName = null; + + for (Struct strAssert : asserts) { + + if (logger.isDebugEnabled()) { + assert msgPrefix != null; + // this strings are only used for debug statements so we'll + // only retrieve them if debug option is enabled + + passertAction = strAssert.getString(ZpeConsts.ZPE_FIELD_ACTION); + passertResource = strAssert.getString(ZpeConsts.ZPE_FIELD_RESOURCE); + polName = strAssert.getString(ZpeConsts.ZPE_FIELD_POLICY_NAME); + + final String passertRole = strAssert.getString(ZpeConsts.ZPE_FIELD_ROLE); + + logger.debug( + "{}: Process Assertion: policy({}) assert-action={} assert-resource={} assert-role={}", + msgPrefix, polName, passertAction, passertResource, passertRole); + } + + // ex: "mod* + + matchStruct = (ZpeMatch) strAssert.get(ZpeConsts.ZPE_ACTION_MATCH_STRUCT); + if (!matchStruct.matches(action)) { + if (logger.isDebugEnabled()) { + logger.debug( + "{}: policy({}) regexpr-match: FAILed: assert-action({}) doesn't match action({})", + msgPrefix, polName, passertAction, action); + } + continue; + } + + // ex: "weather:service.storage.tenant.sports.*" + matchStruct = (ZpeMatch) strAssert.get(ZpeConsts.ZPE_RESOURCE_MATCH_STRUCT); + if (!matchStruct.matches(resource)) { + if (logger.isDebugEnabled()) { + logger.debug( + "{}: policy({}) regexpr-match: FAILed: assert-resource({}) " + + "doesn't match resource({})", msgPrefix, polName, passertResource, resource); + } + continue; + } + + // update the match role name + + matchRoleName.setLength(0); + matchRoleName.append(role); + + return true; + } + + return false; + } + + private static boolean actionByRole(String action, String domain, String resource, + List roles, Map> roleMap, + StringBuilder matchRoleName) { + + // msgPrefix is only used in our debug statements so we're only + // going to generate the value if debug is enabled + + String msgPrefix = null; + if (logger.isDebugEnabled()) { + msgPrefix = "allowActionByRole: domain(" + domain + ") action(" + action + + ") resource(" + resource + ')'; + } + + for (String role : roles) { + if (logger.isDebugEnabled()) { + logger.debug("{}: Process role ({})", msgPrefix, role); + } + + final List asserts = roleMap.get(role); + if (asserts == null || asserts.isEmpty()) { + if (logger.isDebugEnabled()) { + logger.debug("{}: No policy assertions in domain={} for role={} so access denied", + msgPrefix, domain, role); + } + continue; + } + + // see if any of its assertions match the action and resource + // the assert action value does not have the domain prefix + // ex: "Modify" + // the assert resource value has the domain prefix + // ex: "angler:angler.stuff" + + if (matchAssertions(asserts, role, action, resource, matchRoleName, msgPrefix)) { + return true; + } + } + + return false; + } + + private static boolean actionByWildCardRole(String action, String domain, String resource, + List roles, Map> roleMap, + StringBuilder matchRoleName) { + + String msgPrefix = null; + if (logger.isDebugEnabled()) { + msgPrefix = "allowActionByWildCardRole: domain(" + domain + ") action(" + action + + ") resource(" + resource + ')'; + } + + // find policy matching resource and action + // get assertions for given domain+role + // then cycle thru those assertions looking for matching action and resource + + // we will visit each of the wildcard roles + // + final Set keys = roleMap.keySet(); + + for (String role : roles) { + + if (logger.isDebugEnabled()) { + logger.debug("{}: Process role ({})", msgPrefix, role); + } + + for (String roleName : keys) { + final List asserts = roleMap.get(roleName); + if (asserts == null || asserts.isEmpty()) { + if (logger.isDebugEnabled()) { + logger.debug("{}: No policy assertions in domain={} for role={} so access denied", + msgPrefix, domain, role); + } + continue; + } + + final Struct structAssert = asserts.get(0); + final ZpeMatch matchStruct = (ZpeMatch) structAssert.get(ZpeConsts.ZPE_ROLE_MATCH_STRUCT); + if (!matchStruct.matches(role)) { + if (logger.isDebugEnabled()) { + final String polName = structAssert.getString(ZpeConsts.ZPE_FIELD_POLICY_NAME); + logger.debug( + "{}: policy({}) regexpr-match: FAILed: assert-role({}) doesnt match role({})", + msgPrefix, polName, roleName, role); + } + continue; + } + + // HAVE: matched the role with the wildcard + + // see if any of its assertions match the action and resource + // the assert action value does not have the domain prefix + // ex: "Modify" + // the assert resource value has the domain prefix + // ex: "angler:angler.stuff" + + if (matchAssertions(asserts, roleName, action, resource, matchRoleName, msgPrefix)) { + return true; + } + } + } + + return false; + } + + private static void addTokenToCache(Map tokenCache, final String tokenKey, T tokenValue) { + tokenCache.put(tokenKey, tokenValue); + } +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/RequiresAthenzRole.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/RequiresAthenzRole.java new file mode 100644 index 00000000000..f929bc4b5d9 --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/RequiresAthenzRole.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.server.annotation.Decorator; +import com.linecorp.armeria.server.annotation.DecoratorFactory; + +/** + * A {@link Decorator} which allows a request from a user granted the specified Athenz role. + * + *

Example: + *

{@code
+ * class MyService {
+ *   // 1. Decorate the method with `RequiresAthenzRole` to check Athenz role.
+ *   @RequiresAthenzRole(resource = "user", action = "get")
+ *   @ProducesJson
+ *   @Get("/user")
+ *   public CompletableFuture getUser() {
+ *      ...
+ *   }
+ * }
+ *
+ * // 2. Create a `ZtsBaseClient` and `AthenzServiceDecoratorFactory` to use Athenz.
+ * ZtsBaseClient ztsBaseClient =
+ *   ZtsBaseClient
+ *     .builder("https://athenz.example.com:8443/zts/v1")
+ *     .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem")
+ *     .build();
+ * final AthenzServiceDecoratorFactory athenzDecoratorFactory =
+ *   AthenzServiceDecoratorFactory
+ *     .builder(ztsBaseClient)
+ *     .policyConfig(new AthenzPolicyConfig("my-domain"))
+ *     .build();
+ *
+ * // 3. Create a `DependencyInjector` with the `AthenzServiceDecoratorFactory`
+ * //    and set it to the server. `AthenzServiceDecoratorFactory` is required to
+ * //    create the `RequiresAthenzRole` decorator.
+ * final DependencyInjector di =
+ *   DependencyInjector.ofSingletons(athenzDecoratorFactory)
+ *                     .orElse(DependencyInjector.ofReflective());
+ * serverBuilder.dependencyInjector(di, true);
+ * }
+ */ +@UnstableApi +@DecoratorFactory(AthenzServiceDecoratorFactory.class) +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface RequiresAthenzRole { + + /** + * The required Athenz resource. + */ + String resource(); + + /** + * The required Athenz action. + */ + String action(); + + /** + * The required {@link TokenType}. + */ + TokenType[] tokenType() default { TokenType.ROLE_TOKEN, TokenType.ACCESS_TOKEN }; + + /** + * A special parameter in order to specify the order of a {@link Decorator}. + */ + int order() default 0; +} diff --git a/athenz/src/main/java/com/linecorp/armeria/server/athenz/package-info.java b/athenz/src/main/java/com/linecorp/armeria/server/athenz/package-info.java new file mode 100644 index 00000000000..a8e6c430f4b --- /dev/null +++ b/athenz/src/main/java/com/linecorp/armeria/server/athenz/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 LY 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. + * + */ + +/** + * Provides the server-side classes and interfaces for Athenz integration. + */ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.server.athenz; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/athenz/src/test/java/com/linecorp/armeria/client/athenz/AccessTokenClientTest.java b/athenz/src/test/java/com/linecorp/armeria/client/athenz/AccessTokenClientTest.java new file mode 100644 index 00000000000..5d0254808f9 --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/client/athenz/AccessTokenClientTest.java @@ -0,0 +1,108 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client.athenz; + +import static com.linecorp.armeria.server.athenz.AthenzExtension.ATHENZ_CERTS; +import static com.linecorp.armeria.server.athenz.AthenzExtension.CA_CERT_FILE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_DOMAIN_NAME; +import static com.linecorp.armeria.server.athenz.AthenzExtension.USER_ROLE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; +import org.testcontainers.shaded.com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.server.athenz.AthenzExtension; + +@EnabledIfDockerAvailable +class AccessTokenClientTest { + + @RegisterExtension + static AthenzExtension athenzExtension = new AthenzExtension(); + + @Test + void shouldReturnCachedToken() throws Exception { + final URI ztsUri = athenzExtension.ztsUri(); + + final String serviceKeyFile = ATHENZ_CERTS + "foo-service/key.pem"; + final String serviceCertFile = ATHENZ_CERTS + "foo-service/cert.pem"; + final AtomicReference keyPairRef = new AtomicReference<>(); + final InputStream serviceKey = AccessTokenClientTest.class.getResourceAsStream(serviceKeyFile); + final InputStream serviceCert = AccessTokenClientTest.class.getResourceAsStream(serviceCertFile); + final InputStream caCert = AthenzExtension.class.getResourceAsStream(CA_CERT_FILE); + keyPairRef.set(TlsKeyPair.of(serviceKey, serviceCert)); + final ZtsBaseClient ztsBaseClient = + ZtsBaseClient.builder(ztsUri) + .keyPair(keyPairRef::get) + .trustedCertificate(caCert) + .build(); + + final AccessTokenClient tokenClient = new AccessTokenClient(ztsBaseClient, TEST_DOMAIN_NAME, + ImmutableList.of(USER_ROLE), + Duration.ofSeconds(10)); + final String token0 = tokenClient.getToken().join(); + assertThat(token0).isNotEmpty(); + final String token1 = tokenClient.getToken().join(); + // AccessTokenClient caches the token, so the two tokens should be equal. + assertThat(token1).isEqualTo(token0); + Thread.sleep(1000); + final String token2 = tokenClient.getToken().join(); + assertThat(token2).isEqualTo(token1); + } + + @Test + void refreshTokenOnKeyPairUpdates() throws Exception { + final URI ztsUri = athenzExtension.ztsUri(); + + final String serviceKeyFile = ATHENZ_CERTS + "foo-service/key.pem"; + final String serviceCertFile = ATHENZ_CERTS + "foo-service/cert.pem"; + final AtomicReference keyPairRef = new AtomicReference<>(); + final InputStream serviceKey = AccessTokenClientTest.class.getResourceAsStream(serviceKeyFile); + final InputStream serviceCert = AccessTokenClientTest.class.getResourceAsStream(serviceCertFile); + final InputStream caCert = AthenzExtension.class.getResourceAsStream(CA_CERT_FILE); + keyPairRef.set(TlsKeyPair.of(serviceKey, serviceCert)); + final ZtsBaseClient ztsBaseClient = + ZtsBaseClient.builder(ztsUri) + .keyPair(keyPairRef::get) + .trustedCertificate(caCert) + .build(); + + final AccessTokenClient tokenClient = new AccessTokenClient(ztsBaseClient, TEST_DOMAIN_NAME, + ImmutableList.of(USER_ROLE), + Duration.ofSeconds(10)); + final String token0 = tokenClient.getToken().join(); + assertThat(token0).isNotEmpty(); + final String token1 = tokenClient.getToken().join(); + // AccessTokenClient caches the token, so the two tokens should be equal. + assertThat(token1).isEqualTo(token0); + final String newServiceKeyFile = ATHENZ_CERTS + "foo-service-new/key.pem"; + final String newServiceCertFile = ATHENZ_CERTS + "foo-service-new/cert.pem"; + final InputStream newServiceKey = AccessTokenClientTest.class.getResourceAsStream(newServiceKeyFile); + final InputStream newServiceCert = AccessTokenClientTest.class.getResourceAsStream(newServiceCertFile); + keyPairRef.set(TlsKeyPair.of(newServiceKey, newServiceCert)); + // After updating the key pair, the token should be refreshed. + final String token2 = tokenClient.getToken().join(); + assertThat(token2).isEqualTo(token1); + } +} diff --git a/athenz/src/test/java/com/linecorp/armeria/client/athenz/RoleTokenClientTest.java b/athenz/src/test/java/com/linecorp/armeria/client/athenz/RoleTokenClientTest.java new file mode 100644 index 00000000000..2ca6b379b07 --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/client/athenz/RoleTokenClientTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client.athenz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; +import com.yahoo.athenz.zts.RoleToken; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class RoleTokenClientTest { + + private static final AtomicReference roleTokenRef = new AtomicReference<>(); + private static final AtomicInteger requestCount = new AtomicInteger(); + + @RegisterExtension + static ServerExtension mockServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.service("/zts/v1/domain/{domainName}/token", (ctx, req) -> { + requestCount.incrementAndGet(); + return HttpResponse.ofJson(roleTokenRef.get()); + }); + } + }; + private static ZtsBaseClient ztsBaseClient; + + @BeforeAll + static void beforeAll() { + final TlsKeyPair tlsKeyPair = TlsKeyPair.ofSelfSigned(); + ztsBaseClient = ZtsBaseClient.builder(mockServer.httpUri()) + .keyPair(() -> tlsKeyPair) + .build(); + } + + @AfterAll + static void afterAll() { + ztsBaseClient.close(); + } + + @BeforeEach + void setUp() { + requestCount.set(0); + } + + @Test + void shouldCacheTokenBeforeExpiry() throws Exception { + final RoleTokenClient roleTokenClient = new RoleTokenClient(ztsBaseClient, "test", + ImmutableList.of("role1", "role2"), + Duration.ofSeconds(10)); + final RoleToken roleToken = new RoleToken(); + roleToken.setToken("test-token"); + roleToken.setExpiryTime(Instant.now().plusSeconds(100).toEpochMilli()); + roleTokenRef.set(roleToken); + final String token0 = roleTokenClient.getToken().join(); + assertThat(token0).isEqualTo("test-token"); + final String token1 = roleTokenClient.getToken().join(); + assertThat(token1).isEqualTo(token0); + Thread.sleep(1000); + final String token2 = roleTokenClient.getToken().join(); + assertThat(token2).isEqualTo(token1); + assertThat(requestCount).hasValue(1); + } + + @Test + void shouldRefreshTokenBeforeExpiry() throws Exception { + final TlsKeyPair tlsKeyPair = TlsKeyPair.ofSelfSigned(); + final ZtsBaseClient ztsBaseClient = ZtsBaseClient.builder(mockServer.httpUri()) + .keyPair(() -> tlsKeyPair) + .build(); + final RoleTokenClient roleTokenClient = new RoleTokenClient(ztsBaseClient, "test", + ImmutableList.of("role1", "role2"), + Duration.ofSeconds(10)); + final RoleToken roleToken = new RoleToken(); + roleToken.setToken("test-token"); + roleToken.setExpiryTime(Instant.now().plusSeconds(5).getEpochSecond()); + roleTokenRef.set(roleToken); + final String token0 = roleTokenClient.getToken().join(); + assertThat(token0).isEqualTo("test-token"); + roleToken.setToken("test-token1"); + roleToken.setExpiryTime(Instant.now().plusSeconds(5).getEpochSecond()); + // Should return the cached token immediately and refresh it in the background. + assertThat(roleTokenClient.getToken().join()).isEqualTo(token0); + await().untilAtomic(requestCount, Matchers.is(2)); + } +} diff --git a/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzAnnotatedServiceTest.java b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzAnnotatedServiceTest.java new file mode 100644 index 00000000000..e447d7f0c78 --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzAnnotatedServiceTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.linecorp.armeria.server.athenz.AthenzExtension.ADMIN_ROLE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_DOMAIN_NAME; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_SERVICE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.USER_ROLE; +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; +import org.testcontainers.shaded.com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.athenz.AthenzClient; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.DependencyInjector; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.athenz.AccessDeniedException; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerListener; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.ProducesJson; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +@EnabledIfDockerAvailable +class AthenzAnnotatedServiceTest { + + @Order(1) + @RegisterExtension + static final AthenzExtension athenzExtension = new AthenzExtension(); + + @Order(2) + @RegisterExtension + static ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final ZtsBaseClient baseClient = athenzExtension.newZtsBaseClient(TEST_SERVICE); + final AthenzServiceDecoratorFactory decoratorFactory = + AthenzServiceDecoratorFactory.builder(baseClient) + .policyConfig(new AthenzPolicyConfig(TEST_DOMAIN_NAME)) + .build(); + sb.annotatedService(new AthenzAnnotatedService()); + final DependencyInjector di = DependencyInjector.ofSingletons(decoratorFactory) + .orElse(DependencyInjector.ofReflective()); + sb.dependencyInjector(di, true); + sb.serverListener(ServerListener.builder() + .whenStopped(server -> baseClient.close()) + .build()); + } + }; + + @CsvSource({ + "foo-service, ROLE_TOKEN", + "foo-service, ACCESS_TOKEN", + "test-service, ROLE_TOKEN", + "test-service, ACCESS_TOKEN" + }) + @ParameterizedTest + void testUserRole(String serviceName, TokenType tokenType) { + try (ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(serviceName)) { + final BlockingWebClient client = + WebClient.builder(server.httpUri()) + .decorator(AthenzClient.newDecorator(ztsBaseClient, TEST_DOMAIN_NAME, + USER_ROLE, tokenType)) + .build() + .blocking(); + + final AggregatedHttpResponse response = client.get("/files"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThatJson(response.contentUtf8()).isEqualTo(ImmutableList.of("foo.txt", "bar.txt")); + } + } + + @CsvSource({ + "foo-service, ROLE_TOKEN, false", + "foo-service, ACCESS_TOKEN, false", + "test-service, ROLE_TOKEN, true", + "test-service, ACCESS_TOKEN, true" + }) + @ParameterizedTest + void testAdminRole(String serviceName, TokenType tokenType, boolean shouldSucceed) { + try (ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(serviceName)) { + final BlockingWebClient client = + WebClient.builder(server.httpUri()) + .decorator(AthenzClient.newDecorator(ztsBaseClient, TEST_DOMAIN_NAME, + ADMIN_ROLE, tokenType)) + .build() + .blocking(); + + if (shouldSucceed) { + final AggregatedHttpResponse response = client.get("/secrets"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThatJson(response.contentUtf8()).isEqualTo(ImmutableList.of("Armeria", "Athenz")); + } else { + assertThatThrownBy(() -> client.get("/secrets")) + .isInstanceOf(AccessDeniedException.class) + .hasMessage("Failed to obtain an Athenz %s token. " + + "(domain: testing, roles: test_role_admin)", + tokenType == TokenType.ROLE_TOKEN ? "role" : "access"); + } + } + } + + private static final class AthenzAnnotatedService { + + @RequiresAthenzRole(action = "obtain", resource = "secrets") + @Get("/secrets") + @ProducesJson + public List getSecrets() { + return ImmutableList.of("Armeria", "Athenz"); + } + + @RequiresAthenzRole(action = "obtain", resource = "files") + @Get("/files") + @ProducesJson + public List getFiles() { + return ImmutableList.of("foo.txt", "bar.txt"); + } + } +} diff --git a/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzExtension.java b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzExtension.java new file mode 100644 index 00000000000..1d640b66c8c --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzExtension.java @@ -0,0 +1,274 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import javax.net.ssl.SSLContext; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import com.google.common.collect.ImmutableList; +import com.oath.auth.Utils; +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.athenz.zms.Assertion; +import com.yahoo.athenz.zms.AssertionEffect; +import com.yahoo.athenz.zms.Policy; +import com.yahoo.athenz.zms.PublicKeyEntry; +import com.yahoo.athenz.zms.Role; +import com.yahoo.athenz.zms.ServiceIdentity; +import com.yahoo.athenz.zms.TopLevelDomain; +import com.yahoo.athenz.zms.ZMSClient; +import com.yahoo.athenz.zts.ZTSClient; + +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.testing.junit5.common.AbstractAllOrEachExtension; + +import io.micrometer.core.instrument.util.IOUtils; + +public class AthenzExtension extends AbstractAllOrEachExtension { + + private static final Logger logger = LoggerFactory.getLogger(AthenzExtension.class); + + public static final String ZMS_SERVICE_NAME = "zms-server"; + public static final String ZTS_SERVICE_NAME = "zts-server"; + public static final int ZMS_PORT = 4443; + public static final int ZTS_PORT = 8443; + + public static final String ATHENZ_CERTS = "/docker/certs/"; + public static final String CA_CERT_FILE = ATHENZ_CERTS + "CAs/athenz_ca_cert.pem"; + public static final String TEST_DOMAIN_NAME = "testing"; + public static final String TEST_SERVICE = "test-service"; + public static final String FOO_SERVICE = "foo-service"; + + public static final String ADMIN_ROLE = "test_role_admin"; + public static final String USER_ROLE = "test_role_users"; + public static final String ADMIN_POLICY = "admin-policy"; + public static final String USER_POLICY = "user-policy"; + + private final ComposeContainer composeContainer; + + @Nullable + private URI ztsUri; + @Nullable + private ZMSClient zmsClient; + + public AthenzExtension() { + composeContainer = + new ComposeContainer(new File("src/test/resources/docker/docker-compose.yml")) + .withLocalCompose(true) + .withExposedService(ZMS_SERVICE_NAME, ZMS_PORT, Wait.forHealthcheck()) + .withExposedService(ZTS_SERVICE_NAME, ZTS_PORT, Wait.forHealthcheck()); + } + + private ZMSClient zmsClient() { + if (zmsClient == null) { + zmsClient = newZmsClient(); + } + return zmsClient; + } + + @Override + protected void before(ExtensionContext context) throws Exception { + composeContainer.start(); + logger.info("Starting Docker compose container for Athenz tests"); + defaultScaffold(); + scaffold(zmsClient()); + } + + @Override + public void after(ExtensionContext context) throws Exception { + composeContainer.stop(); + } + + /** + * Override this method to create your own test domain, services, roles, and policies. + */ + protected void scaffold(ZMSClient zmsClient) {} + + private void defaultScaffold() { + // Create test domain + createDomain(); + // Create test service + final String testServicePublicKeyId = createService(TEST_SERVICE); + final String fooServicePublicKeyId = createService(FOO_SERVICE); + + createRole(USER_ROLE, ImmutableList.of(TEST_DOMAIN_NAME + '.' + TEST_SERVICE, + TEST_DOMAIN_NAME + '.' + FOO_SERVICE)); + // Admin role is only granted to the test service + createRole(ADMIN_ROLE, ImmutableList.of(TEST_DOMAIN_NAME + '.' + TEST_SERVICE)); + + createPolicy(USER_POLICY, USER_ROLE, "files"); + createPolicy(ADMIN_POLICY, ADMIN_ROLE, "secrets"); + + try (ZTSClient ztsAdminClient = newZtsClient("domain-admin")) { + // Wait for ZTS to sync + await().untilAsserted(() -> { + try { + final com.yahoo.athenz.zts.ServiceIdentity testServiceIdentity = + ztsAdminClient.getServiceIdentity(TEST_DOMAIN_NAME, TEST_SERVICE); + assertThat(testServiceIdentity.getPublicKeys()).anyMatch(publicKey -> { + return publicKey.getId().equals(testServicePublicKeyId); + }); + + final com.yahoo.athenz.zts.ServiceIdentity fooServiceIdentity = + ztsAdminClient.getServiceIdentity(TEST_DOMAIN_NAME, FOO_SERVICE); + assertThat(fooServiceIdentity.getPublicKeys()).anyMatch(publicKey -> { + return publicKey.getId().equals(fooServicePublicKeyId); + }); + } catch (Exception e) { + throw new AssertionError(e); + } + }); + } + } + + private void createPolicy(String policyName, String roleName, String resourceName) { + final String assertionRole = TEST_DOMAIN_NAME + ":role." + roleName; + final String assertionAction = "obtain"; + final String assertionResource = TEST_DOMAIN_NAME + ':' + resourceName; + final Assertion assertion = new Assertion(); + assertion.setRole(assertionRole); + assertion.setAction(assertionAction); + assertion.setResource(assertionResource); + assertion.setEffect(AssertionEffect.ALLOW); + + final Policy policyToCreate = new Policy(); + policyToCreate.setName(TEST_DOMAIN_NAME + ":policy." + policyName); + policyToCreate.setAssertions(ImmutableList.of(assertion)); + + zmsClient().putPolicy(TEST_DOMAIN_NAME, policyName, "create-policy-audit-ref", policyToCreate); + } + + private void createRole(String roleName, List members) { + final Role role = new Role().setName(TEST_DOMAIN_NAME + ":role." + roleName) + .setMembers(members); + zmsClient().putRole(TEST_DOMAIN_NAME, roleName, "create-role-audit-ref", role); + } + + private String createService(String serviceName) { + final String publicCert = readFile(ATHENZ_CERTS + serviceName + "/public.pem"); + final String ybase64PublicKey = Crypto.ybase64EncodeString(publicCert); + final String publicKeyId = serviceName + "_public_key"; + + final PublicKeyEntry publicKeyEntry = new PublicKeyEntry(); + publicKeyEntry.setId(publicKeyId); + publicKeyEntry.setKey(ybase64PublicKey); + final ServiceIdentity serviceIdentity = new ServiceIdentity(); + serviceIdentity.setName(TEST_DOMAIN_NAME + '.' + serviceName); + serviceIdentity.setPublicKeys(Collections.singletonList(publicKeyEntry)); + + zmsClient().putServiceIdentity(TEST_DOMAIN_NAME, serviceName, "create-service-audit-ref", + serviceIdentity); + return publicKeyId; + } + + private void createDomain() { + final TopLevelDomain domain = new TopLevelDomain(); + domain.setName(TEST_DOMAIN_NAME); + domain.setDescription("A test domain created by the Java client."); + domain.setAdminUsers(ImmutableList.of("user.github-7654321")); + zmsClient().postTopLevelDomain("create-domain-audit-ref", domain); + } + + public URI ztsUri() { + if (ztsUri == null) { + final String serviceHost = composeContainer.getServiceHost(ZTS_SERVICE_NAME, ZTS_PORT); + final int servicePort = composeContainer.getServicePort(ZTS_SERVICE_NAME, ZTS_PORT); + ztsUri = URI.create("https://" + serviceHost + ':' + servicePort); + } + return ztsUri; + } + + public ZtsBaseClient newZtsBaseClient(String serviceName) { + return newZtsBaseClient(serviceName, webClientBuilder -> {}); + } + + public ZtsBaseClient newZtsBaseClient(String serviceName, + Consumer webClientConfigurer) { + final String serviceKeyFile = ATHENZ_CERTS + serviceName + "/key.pem"; + final String serviceCertFile = ATHENZ_CERTS + serviceName + "/cert.pem"; + try (InputStream serviceKey = AthenzExtension.class.getResourceAsStream(serviceKeyFile); + InputStream serviceCert = AthenzExtension.class.getResourceAsStream(serviceCertFile); + InputStream caCert = AthenzExtension.class.getResourceAsStream(CA_CERT_FILE)) { + final TlsKeyPair tlsKeyPair = TlsKeyPair.of(serviceKey, serviceCert); + return ZtsBaseClient.builder(ztsUri()) + .keyPair(() -> tlsKeyPair) + .trustedCertificate(caCert) + .configureWebClient(webClientConfigurer) + .build(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private ZMSClient newZmsClient() { + final String serviceHost = composeContainer.getServiceHost(ZMS_SERVICE_NAME, ZMS_PORT); + final int servicePort = composeContainer.getServicePort(ZMS_SERVICE_NAME, ZMS_PORT); + final String zmsUrl = "https://" + serviceHost + ':' + servicePort; + return new ZMSClient(zmsUrl, getSslContext("domain-admin")); + } + + private ZTSClient newZtsClient(String serviceName) { + final String serviceHost = composeContainer.getServiceHost(ZTS_SERVICE_NAME, ZTS_PORT); + final Integer servicePort = composeContainer.getServicePort(ZTS_SERVICE_NAME, ZTS_PORT); + final String ztsUrl = "https://" + serviceHost + ':' + servicePort; + return new ZTSClient(ztsUrl, getSslContext(serviceName)); + } + + private static SSLContext getSslContext(String serviceName) { + final String domainAdminCertFile = ATHENZ_CERTS + serviceName + "/cert.pem"; + final String domainAdminKeyFile = ATHENZ_CERTS + serviceName + "/key.pem"; + return getSSLContext(CA_CERT_FILE, domainAdminKeyFile, domainAdminCertFile); + } + + private static SSLContext getSSLContext(String caCertFile, + String athenzPrivateKeyFile, String athenzPublicCertFile) { + final String caCert = readFile(caCertFile); + final String athenzPublicCert = readFile(athenzPublicCertFile); + final String athenzPrivateKey = readFile(athenzPrivateKeyFile); + try { + return Utils.buildSSLContext(caCert, athenzPublicCert, athenzPrivateKey); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String readFile(String fileName) { + try (InputStream is = AthenzIntegrationTest.class.getResourceAsStream(fileName)) { + return IOUtils.toString(is); + } catch (Exception e) { + throw new IllegalStateException("Failed to read file: " + fileName, e); + } + } +} diff --git a/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzIntegrationTest.java b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzIntegrationTest.java new file mode 100644 index 00000000000..e847fe392e3 --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzIntegrationTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.linecorp.armeria.server.athenz.AthenzExtension.ADMIN_ROLE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.FOO_SERVICE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_DOMAIN_NAME; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_SERVICE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.USER_ROLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.ClientRequestContextCaptor; +import com.linecorp.armeria.client.Clients; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.athenz.AthenzClient; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.athenz.AccessDeniedException; +import com.linecorp.armeria.common.athenz.TokenType; +import com.linecorp.armeria.internal.common.athenz.AthenzHeaderNames; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerListener; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +@EnabledIfDockerAvailable +class AthenzIntegrationTest { + + @Order(1) + @RegisterExtension + static final AthenzExtension athenzExtension = new AthenzExtension(); + + @Order(2) + @RegisterExtension + static ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + // test-service acts as the provider application. + final ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(TEST_SERVICE); + sb.serverListener(ServerListener.builder() + .whenStopped(s -> ztsBaseClient.close()) + .build()); + sb.service("/admin", (ctx, req) -> { + String authorization = req.headers().get(HttpHeaderNames.AUTHORIZATION, ""); + if (!authorization.isEmpty()) { + return HttpResponse.of("Authorization " + authorization); + } + authorization = req.headers().get(AthenzHeaderNames.YAHOO_ROLE_AUTH, ""); + if (!authorization.isEmpty()) { + return HttpResponse.of("YahooRoleAuth " + authorization); + } + // Should not reach here. + return HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR); + }); + + sb.decorator("/admin", AthenzService.builder(ztsBaseClient) + .action("obtain") + .resource("secrets") + .policyConfig(new AthenzPolicyConfig(TEST_DOMAIN_NAME)) + .newDecorator()); + + sb.service("/users", (ctx, req) -> { + String authorization = req.headers().get(HttpHeaderNames.AUTHORIZATION, ""); + if (!authorization.isEmpty()) { + return HttpResponse.of("Authorization " + authorization); + } + authorization = req.headers().get(AthenzHeaderNames.YAHOO_ROLE_AUTH, ""); + if (!authorization.isEmpty()) { + return HttpResponse.of("YahooRoleAuth " + authorization); + } + // Should not reach here. + return HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR); + }); + sb.decorator("/users", AthenzService.builder(ztsBaseClient) + .action("obtain") + .resource("files") + .policyConfig(new AthenzPolicyConfig(TEST_DOMAIN_NAME)) + .newDecorator()); + } + }; + + @EnumSource(TokenType.class) + @ParameterizedTest + void shouldObtainAdminRole(TokenType tokenType) { + try (ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(TEST_SERVICE)) { + final BlockingWebClient client = + WebClient.builder(server.httpUri()) + .decorator(AthenzClient.newDecorator(ztsBaseClient, TEST_DOMAIN_NAME, + ADMIN_ROLE, tokenType)) + .responseTimeoutMillis(0) + .build() + .blocking(); + + final AggregatedHttpResponse response = client.get("/admin"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + switch (tokenType) { + case ROLE_TOKEN: + assertThat(response.contentUtf8()).startsWith("YahooRoleAuth "); + break; + case ACCESS_TOKEN: + assertThat(response.contentUtf8()).startsWith("Authorization "); + break; + } + } + } + + @EnumSource(TokenType.class) + @ParameterizedTest + void shouldNotObtainAdminRole(TokenType tokenType) { + try (ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(FOO_SERVICE)) { + final BlockingWebClient client = + WebClient.builder(server.httpUri()) + .decorator(AthenzClient.newDecorator(ztsBaseClient, TEST_DOMAIN_NAME, + ADMIN_ROLE, tokenType)) + .build() + .blocking(); + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + assertThatThrownBy(() -> { + client.get("/admin"); + }).isInstanceOf(AccessDeniedException.class) + .hasMessage("Failed to obtain an Athenz " + + (tokenType == TokenType.ROLE_TOKEN ? "role" : "access") + + " token. (domain: testing, roles: test_role_admin)"); + final ClientRequestContext ctx = captor.get(); + // Make sure the RequestLog is completed when the request was rejected by the decorator. + ctx.log().whenComplete().join(); + } + } + } + + @EnumSource(TokenType.class) + @ParameterizedTest + void shouldRejectImproperRole(TokenType tokenType) { + try (ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(FOO_SERVICE)) { + final BlockingWebClient client = + WebClient.builder(server.httpUri()) + .decorator(AthenzClient.newDecorator(ztsBaseClient, TEST_DOMAIN_NAME, + USER_ROLE, tokenType)) + .build() + .blocking(); + + final AggregatedHttpResponse response = client.get("/admin"); + assertThat(response.status()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzInvalidPolicyDataTest.java b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzInvalidPolicyDataTest.java new file mode 100644 index 00000000000..4db5db9e823 --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzInvalidPolicyDataTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_DOMAIN_NAME; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_SERVICE; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; + +@EnabledIfDockerAvailable +class AthenzInvalidPolicyDataTest { + + @RegisterExtension + static final AthenzExtension athenzExtension = new AthenzExtension(); + + @Test + void shouldFailWithInvalidPolicyData() { + try (ZtsBaseClient ztsBaseClient = athenzExtension.newZtsBaseClient(TEST_SERVICE, cb -> { + cb.decorator((delegate, ctx, req) -> { + if (ctx.path().equals("/zts/v1/domain/" + TEST_DOMAIN_NAME + "/policy/signed")) { + // Simulate an invalid policy data response. + return HttpResponse.of(HttpStatus.OK, + MediaType.JSON_UTF_8, + "{\"name\":\"invalid_policy\",\"data\":\"invalid_data\"}"); + } else { + return delegate.execute(ctx, req); + } + }); + })) { + assertThatThrownBy(() -> { + AthenzService.builder(ztsBaseClient) + .action("obtain") + .resource("secrets") + .policyConfig(new AthenzPolicyConfig(TEST_DOMAIN_NAME)) + .buildAuthZpeClient(); + }).isInstanceOf(IllegalStateException.class) + .hasRootCauseInstanceOf(AthenzPolicyException.class) + .hasMessageContaining("ZTS signature validation failed"); + } + } +} diff --git a/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzPolicyLoaderTest.java b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzPolicyLoaderTest.java new file mode 100644 index 00000000000..741831e996c --- /dev/null +++ b/athenz/src/test/java/com/linecorp/armeria/server/athenz/AthenzPolicyLoaderTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.athenz; + +import static com.linecorp.armeria.server.athenz.AthenzExtension.ADMIN_POLICY; +import static com.linecorp.armeria.server.athenz.AthenzExtension.ADMIN_ROLE; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_DOMAIN_NAME; +import static com.linecorp.armeria.server.athenz.AthenzExtension.TEST_SERVICE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; +import org.testcontainers.shaded.com.google.common.collect.ImmutableList; +import org.testcontainers.shaded.com.google.common.collect.ImmutableMap; + +import com.yahoo.athenz.zpe.pkey.PublicKeyStore; +import com.yahoo.rdl.Struct; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; + +@EnabledIfDockerAvailable +class AthenzPolicyLoaderTest { + + @RegisterExtension + static AthenzExtension athenzExtension = new AthenzExtension(); + + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void loadPolicyFiles(boolean jwsPolicySupport) throws Exception { + try (ZtsBaseClient baseClient = athenzExtension.newZtsBaseClient(TEST_SERVICE)) { + final PublicKeyStore publicKeyStore = new AthenzPublicKeyProvider(baseClient, + Duration.ofSeconds(10)); + final AthenzPolicyConfig policyConfig = new AthenzPolicyConfig(ImmutableList.of(TEST_DOMAIN_NAME), + ImmutableMap.of(), jwsPolicySupport, + Duration.ofSeconds(10)); + + final AthenzPolicyLoader policyLoader = new AthenzPolicyLoader(baseClient, TEST_DOMAIN_NAME, + policyConfig, publicKeyStore); + + assertThatThrownBy(policyLoader::getNow) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Policy data is not initialized yet"); + policyLoader.init(); + final AthenzAssertions assertions = policyLoader.getNow(); + final List adminPolicy = assertions.roleStandardAllowMap().get(ADMIN_ROLE); + assertThat(adminPolicy).satisfiesOnlyOnce(struct -> { + assertThat(struct.get("polname")).isEqualTo(TEST_DOMAIN_NAME + ":policy." + ADMIN_POLICY); + }); + } + } +} diff --git a/athenz/src/test/resources/docker/.gitignore b/athenz/src/test/resources/docker/.gitignore new file mode 100644 index 00000000000..0ca65d2efd2 --- /dev/null +++ b/athenz/src/test/resources/docker/.gitignore @@ -0,0 +1,2 @@ +logs +zts/var/zts_store diff --git a/athenz/src/test/resources/docker/README.md b/athenz/src/test/resources/docker/README.md new file mode 100644 index 00000000000..730c648c76c --- /dev/null +++ b/athenz/src/test/resources/docker/README.md @@ -0,0 +1,6 @@ +# Docker configurations for Athenz integration tests + +The test certificate authority (CA) and key pairs are generated by [`create-self-signed-certs.sh`](https://github.com/AthenZ/athenz/blob/a76b62563337ed579a990becb61f664cc3121e1f/docker/setup-scripts/self-signed-certificates.sh) +and [`acceptance-test.sh`](https://github.com/AthenZ/athenz/blob/a76b62563337ed579a990becb61f664cc3121e1f/docker/deploy-scripts/acceptance-test.sh) scripts. +Please refer to [Athenz Docker documentation](https://github.com/AthenZ/athenz/blob/master/docker/docs/try-out-Athenz-with-self-signed-CA.md) +for more details on how to set up a self-signed CA for Athenz. diff --git a/athenz/src/test/resources/docker/certs/CAs/athenz_ca.pem b/athenz/src/test/resources/docker/certs/CAs/athenz_ca.pem new file mode 100644 index 00000000000..0b4c6cc6e5f --- /dev/null +++ b/athenz/src/test/resources/docker/certs/CAs/athenz_ca.pem @@ -0,0 +1,84 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCy9SlZOf5VdwLq +Hem6ah9kNHfylrD18x1OTp/b2wrjSdg2EC1cNQMPmV0JA23tB5+VUwje/LumE2jO +gCXM2l2SQJp59iGoc+MnT0FF2i3OvjEtxCXaDX1BmOrRe+f1TYqZd4R9fRLHIbhZ +z2Vp/CqggYeDF3XjcV/xYkRFHLs0fnuD51bC09XbStdlynfm5qC2dVbZFRCmWu2n +9uO62CwInxL3gpj/HoIfUSdTiC8aYJirpIoZ7GOHD3FBhSotkUwxKzVz1EORV//x +Cx2rvZ5sANNDOtC6DKwGA7Q0unq7gqqi9K8YyNRpYAoO4mgc/ZlX64vbY69FI1+R +aQJynGELvU+hphjWAPC91jzB114+E/Ao9+PY1oKtRzoxa+PyII1mxz1SahtoTxcD +LLIKUmcHsjosqmTroHZ7cX3LpBI0KlbwTbnMkBlHl69sejks7g7MFEXg6rQrHGmz +jC2s2gcD0luPOEpkW0H+urmhw4AGksQBPzbsxrBdqNK3zTyrinJHWsef1TD3cZNm +JU5ez9y/sN97nZvHR7DurVbXYMVPgHqsXJ48HuP+ySk/gnc3hE19o5lnN4yCwPxj +gydsrIA566fDNUD4koAOCRUf7qgUrXuf/rGtWoanJyHEBRjoPXcPttip/Obx2efs +HEJsoflTkNL4QIXEUP0Fe7kEgZev9wIDAQABAoICAHr5Y1eQNYivRo5wzcsu37Fx +KLfH+4SXcpz8BHgFFn9HoE7OYQ1K1Haksbze4WwPYAcxzxfEXoJqNgwpHVCfAcvd +nwmOLGTgdcjuenEw02eNZKgJcOVdfaRVG4+mcKgth7+b1KyTBRNXxaHZldv1z6kK +OX/I4FbR8tch6r3/V1lkTSZAE2vAO+X8kPW+4xQFiYy4J1z3T8CxHTzduHIN2Hx4 +K4Vv9gjiAxLYJokNPSkIFgFlCQ11VpNZ9j7K7kfv34auJYYZ5/B5o0QVmmcI31Ni +YcqCISqoSJG9e9j8CBjQWseuEqCaaPScbWz7AgZh8jzrfOjeoHOHMkXu/iKZ+Uj7 +oJSrdHkwwvAySEvrDcrdhN9WUmx2Fk0tu+iT/qqTm3VPiRMdx0K+ovEFxk0P2h0v +/jE67zoRojykre7gbxBFa6Zc9t1rhWwaul84f43u8BZZCpjn2vFaGRMYHFUcThcd +vgDNOHSPsQyIIzbBmMdUNsZdbj6i3Jj84kXhmMH9R2jcq2FNFslgEsBtLXAEGKmj +W1sLhszmQBg2XpcUsG47jBwsLiAZ5NgX3QgJtmJVtreEb/aglzZb0j1KVHoth3lI +/fUfUc2ke3R+6YvBKvJ8vDeE4O6/JJHdAMVkpkAUZMnw01nC2A8ewRSlwOePhP4O +9hdIob4Qr6JFv9w8ndkRAoIBAQDpcP2a9h9LzJjUS/VNl3Xt0vsXdgVFdBpj+CNG +7AVBgNKQRqIwaKdQzZUwi1QqOqKdWk5CtmHtLOJU+qziKtpF4pBFZQljtON3aGlP +R47jHEdM6K9qrbbBptyuFPIFJalMI3GjlsKTVT4CoSMIixOqJ3cx1r72UR6bQYc8 +I8Q2oDbtHA4e+3rP+Pt7+WTNPln/0/uYg4BF5GLwuNQhq2eN16DkFHveEcMLgHnG +7yMcvXmfYAjJnj8J111LZTZVMMYaAVJW+sQoWzHGPKgutgc14Wf3qs4FghMmc0mi +7dHHRr2vEUVFThb9/8MQl/Zww2ZFQtNUdquxArXGMU9I77bfAoIBAQDEQFIs86Gb +Aks2LZZooFaWqBR6NOLn9mERoDAx5jQbg74wWD9IEndKAXdZUVt7rWAM2IpMAdf/ +vel+89h9WXCpYFmYH8UExyqjYZlS3fsSbBuZLCAWBDU9WliqZ8J8I5BbbnkYBdYW +rf1E3gZ0J1vwynSBx1zwBZd7yCSOZjKbS3dvqgAc4ZwhM6N9Tnd/XEtIDBJppH/Y +gc88uTi/QqK9UgDBBN/9Tp73A8xfmwr2SycZo/PHrB1ZynhXdOC9FkjnfQ6sViwx +eZfbmqJPn0QXTyjiK//xhsrNqOwNqkA13qfMwWGxIob5cTXnn4pAE/zEv9dZBj3T +yx0MBL5XmKHpAoIBAHSxqATjzgc9MpTFl81+zlPhqOU0eTQb8ulQbIoSiBoThK7c +sE0Us7cf3dlTfPJTN2EDeKW+uDswub+TRAMXOt++fzJBbK0sCp1xU1tvQQ1k4Hug +wbfJONebSyu2irlp9zR5b2BAh+nQrdvwoyutIlpnuhzu5a0KJgukeS7mDjrkYjCr +Wp/ARMdcBpMVu38iESli1Z2K9v95SMBfFKP0JCLyzJnXZ4g+RsskITwB5QKD8R1r +i1kGQyrvPtLmuH9jj1QGnxud5Zrq43nebpBTLE2R7UAQYUa9nEcXUJMi4UAFq+Ks +9nzLqZC1XtSXsnjyJNiwrCXH4NHuseFuWtzx/+8CggEBAJTQsX+kv0br1lye9Q8h +hod3rQ9+SxHaooDbF7C+/4RdwjqmsFXWqt5Tfk0chGKkddFH4pcATup++DBseB2V +VPkbNtVEQgH9hTefKsTLzzeJAuSDsVEAn96GQ/Xz+GZqEW1DuE+fHVjVVtiCBCLQ +NcfBvdcrdi4MD3KtsgrJNFbOQyNJpgxAiEQlBDRg6/yH4A4iGVRCiS+EP8GsMnvV +69Hj/O5g4/kRRTnKh4934jghhOTMjm869IbCIb8vwbugyme/TQEh1yEtMxoziCEY +hGmofTgEZXCg5ehNz7INB+PRkyyQ96N6qx872cRqFkvA3EvVrVa0hO21d7FFZ5DF +DQECggEBANLyWOYskWba+Mol7JjtNUEXcGkKiJkQ+2VqH0SJqx5SJjEp2qJkNGqK +YeDCp2OpynPvwNFAbwSuZfK1VV7842GnOGgDOk0n502upytZojWca3qwx0rUBwJ3 +wDTO/M12l3+3uUv6762LFExCctGBAAEuOVvxnYtoKXT8I/ELvEShF2AsIfNrLikS +gV+0j+CswU/OUIei0qS0k819SJ280TvU3To7DsmoGRrG0WjvQPi2skfL/3d7suO5 +LtO6jy/+si1qmKzNu7JVYpYRGo0ZBvmshBT7EZlw8ZlwgyVy8BkoFGZMRKi1Vxs5 +zHX47PRQylh4LJUbthROr3Lu6GQvGrg= +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFfTCCA2WgAwIBAgIUTMBy2X4eyE40K2iz+BpLSX9wj38wDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkF0aGVuejElMCMGA1UEAwwcU2Ft +cGxlIFNlbGYgU2lnbmVkIEF0aGVueiBDQTAgFw0yNTA3MTcxMjE1MTlaGA8yMTI1 +MDYyMzEyMTUxOVowRTELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkF0aGVuejElMCMG +A1UEAwwcU2FtcGxlIFNlbGYgU2lnbmVkIEF0aGVueiBDQTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBALL1KVk5/lV3Auod6bpqH2Q0d/KWsPXzHU5On9vb +CuNJ2DYQLVw1Aw+ZXQkDbe0Hn5VTCN78u6YTaM6AJczaXZJAmnn2Iahz4ydPQUXa +Lc6+MS3EJdoNfUGY6tF75/VNipl3hH19EschuFnPZWn8KqCBh4MXdeNxX/FiREUc +uzR+e4PnVsLT1dtK12XKd+bmoLZ1VtkVEKZa7af247rYLAifEveCmP8egh9RJ1OI +LxpgmKukihnsY4cPcUGFKi2RTDErNXPUQ5FX//ELHau9nmwA00M60LoMrAYDtDS6 +eruCqqL0rxjI1GlgCg7iaBz9mVfri9tjr0UjX5FpAnKcYQu9T6GmGNYA8L3WPMHX +Xj4T8Cj349jWgq1HOjFr4/IgjWbHPVJqG2hPFwMssgpSZweyOiyqZOugdntxfcuk +EjQqVvBNucyQGUeXr2x6OSzuDswUReDqtCscabOMLazaBwPSW484SmRbQf66uaHD +gAaSxAE/NuzGsF2o0rfNPKuKckdax5/VMPdxk2YlTl7P3L+w33udm8dHsO6tVtdg +xU+Aeqxcnjwe4/7JKT+CdzeETX2jmWc3jILA/GODJ2ysgDnrp8M1QPiSgA4JFR/u +qBSte5/+sa1ahqcnIcQFGOg9dw+22Kn85vHZ5+wcQmyh+VOQ0vhAhcRQ/QV7uQSB +l6/3AgMBAAGjYzBhMB0GA1UdDgQWBBQQ2wsDbHjTvQdW6LlhrOiQjawBljAfBgNV +HSMEGDAWgBQQ2wsDbHjTvQdW6LlhrOiQjawBljAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAAjAwK/WR8NJVxAtSZ0q1 +o+31Ded5sVkLphTT+k6D6Dp9Y4S75c+Z1XtBnQYoG7wbCAM1GJys8kj3DqElWiHk +9gMPBSypi33A7VYfeDOksI3vLRNpnMrPMiG/StemAG9H3jSKX44JNd29PEUjzgg4 +70Y4W7mTrSal1ZffkEW+86mSpiBrxG6YqIYwlQPUFPYxf19nWLl9nKJSXU9KPbdA +BMQrTFpMyCSXWl4CNPd23gY5l6PX+2dP+8vGRjD1BeJzdfPUyVsIAF56sYqJ00UQ +C2QoEqntQcWuthGk4hfSvzsjk828ftWz/sSbTgo4dOPLA235/4bglCWs4sAW3ZfN +QZHn0x1eQOG35tDlhlDekRzwU1yH2E0cweaOdnVZ+iO6/u2hkDHgjM9AE4o/NM/0 +8ggd0qDSfPrK7atzyZVGl7NkCTC2FIFuhVKqOzUJu5sKrDTshJ3akxU8aBldVfkG +CJPMcL/RcQNYhwgzFaheRJIXsz7wirJrNWLt3RVC9KfM5FLprVLZ0/zAHDqec4hE +uevlFEmX/SRWJKDCyyz9gWIbclMmOGinuLTUWIfYkBvTH4OUc9TxWuFvSj7tDohP +fwEQVmfkHIWcU+6vqtV8eMWsjH2+XPozww/s/ROTS5ld0Tn7jPFFxFM3f+yr7DwJ +9KTTVoi2s78S/uo9uY4wSN8= +-----END CERTIFICATE----- diff --git a/athenz/src/test/resources/docker/certs/CAs/athenz_ca_cert.pem b/athenz/src/test/resources/docker/certs/CAs/athenz_ca_cert.pem new file mode 100644 index 00000000000..8fb65eaaa47 --- /dev/null +++ b/athenz/src/test/resources/docker/certs/CAs/athenz_ca_cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFfTCCA2WgAwIBAgIUTMBy2X4eyE40K2iz+BpLSX9wj38wDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkF0aGVuejElMCMGA1UEAwwcU2Ft +cGxlIFNlbGYgU2lnbmVkIEF0aGVueiBDQTAgFw0yNTA3MTcxMjE1MTlaGA8yMTI1 +MDYyMzEyMTUxOVowRTELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkF0aGVuejElMCMG +A1UEAwwcU2FtcGxlIFNlbGYgU2lnbmVkIEF0aGVueiBDQTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBALL1KVk5/lV3Auod6bpqH2Q0d/KWsPXzHU5On9vb +CuNJ2DYQLVw1Aw+ZXQkDbe0Hn5VTCN78u6YTaM6AJczaXZJAmnn2Iahz4ydPQUXa +Lc6+MS3EJdoNfUGY6tF75/VNipl3hH19EschuFnPZWn8KqCBh4MXdeNxX/FiREUc +uzR+e4PnVsLT1dtK12XKd+bmoLZ1VtkVEKZa7af247rYLAifEveCmP8egh9RJ1OI +LxpgmKukihnsY4cPcUGFKi2RTDErNXPUQ5FX//ELHau9nmwA00M60LoMrAYDtDS6 +eruCqqL0rxjI1GlgCg7iaBz9mVfri9tjr0UjX5FpAnKcYQu9T6GmGNYA8L3WPMHX +Xj4T8Cj349jWgq1HOjFr4/IgjWbHPVJqG2hPFwMssgpSZweyOiyqZOugdntxfcuk +EjQqVvBNucyQGUeXr2x6OSzuDswUReDqtCscabOMLazaBwPSW484SmRbQf66uaHD +gAaSxAE/NuzGsF2o0rfNPKuKckdax5/VMPdxk2YlTl7P3L+w33udm8dHsO6tVtdg +xU+Aeqxcnjwe4/7JKT+CdzeETX2jmWc3jILA/GODJ2ysgDnrp8M1QPiSgA4JFR/u +qBSte5/+sa1ahqcnIcQFGOg9dw+22Kn85vHZ5+wcQmyh+VOQ0vhAhcRQ/QV7uQSB +l6/3AgMBAAGjYzBhMB0GA1UdDgQWBBQQ2wsDbHjTvQdW6LlhrOiQjawBljAfBgNV +HSMEGDAWgBQQ2wsDbHjTvQdW6LlhrOiQjawBljAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAAjAwK/WR8NJVxAtSZ0q1 +o+31Ded5sVkLphTT+k6D6Dp9Y4S75c+Z1XtBnQYoG7wbCAM1GJys8kj3DqElWiHk +9gMPBSypi33A7VYfeDOksI3vLRNpnMrPMiG/StemAG9H3jSKX44JNd29PEUjzgg4 +70Y4W7mTrSal1ZffkEW+86mSpiBrxG6YqIYwlQPUFPYxf19nWLl9nKJSXU9KPbdA +BMQrTFpMyCSXWl4CNPd23gY5l6PX+2dP+8vGRjD1BeJzdfPUyVsIAF56sYqJ00UQ +C2QoEqntQcWuthGk4hfSvzsjk828ftWz/sSbTgo4dOPLA235/4bglCWs4sAW3ZfN +QZHn0x1eQOG35tDlhlDekRzwU1yH2E0cweaOdnVZ+iO6/u2hkDHgjM9AE4o/NM/0 +8ggd0qDSfPrK7atzyZVGl7NkCTC2FIFuhVKqOzUJu5sKrDTshJ3akxU8aBldVfkG +CJPMcL/RcQNYhwgzFaheRJIXsz7wirJrNWLt3RVC9KfM5FLprVLZ0/zAHDqec4hE +uevlFEmX/SRWJKDCyyz9gWIbclMmOGinuLTUWIfYkBvTH4OUc9TxWuFvSj7tDohP +fwEQVmfkHIWcU+6vqtV8eMWsjH2+XPozww/s/ROTS5ld0Tn7jPFFxFM3f+yr7DwJ +9KTTVoi2s78S/uo9uY4wSN8= +-----END CERTIFICATE----- diff --git a/athenz/src/test/resources/docker/certs/CAs/create-self-signed-ca.sh b/athenz/src/test/resources/docker/certs/CAs/create-self-signed-ca.sh new file mode 100755 index 00000000000..fd0f7128e89 --- /dev/null +++ b/athenz/src/test/resources/docker/certs/CAs/create-self-signed-ca.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -eu +set -o pipefail + +# to script directory +cd "$(dirname "$0")" +echo 'running...' + +FILENAME='athenz_ca' +CN='Sample Self Signed Athenz CA' \ + openssl req -x509 -nodes \ + -newkey rsa:4096 -days 36500 \ + -config "${SELF_SIGN_CNF_PATH}" \ + -keyout "${DEV_ATHENZ_CA_KEY_PATH}" \ + -out "${DEV_ATHENZ_CA_PATH}" 2> /dev/null + +FILENAME='user_ca' +CN='Sample Self Signed User CA' \ + openssl req -x509 -nodes \ + -newkey rsa:4096 -days 36500 \ + -config "${SELF_SIGN_CNF_PATH}" \ + -keyout "${DEV_USER_CA_KEY_PATH}" \ + -out "${DEV_USER_CA_PATH}" 2> /dev/null + +FILENAME='service_ca' +CN='Sample Self Signed Service CA' \ + openssl req -x509 -nodes \ + -newkey rsa:4096 -days 36500 \ + -config "${SELF_SIGN_CNF_PATH}" \ + -keyout "${DEV_SERVICE_CA_KEY_PATH}" \ + -out "${DEV_SERVICE_CA_PATH}" 2> /dev/null + +# convert pem cert to der format so that it can be imported into OS ( optional step ) +openssl x509 -outform der -in "${DEV_ATHENZ_CA_PATH}" -out "${DEV_ATHENZ_CA_DER_PATH}" + +# print result +cat < + + + + + + + + ${LOG_DIR}/server.log + true + + + ${LOG_DIR}/server.%d.log + 7 + true + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{35} - %msg%n + + + + + + + ${LOG_DIR}/audit.log + true + + + ${LOG_DIR}/audit.%d.log + 30 + true + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{35} - %msg%n + + + + + + + + + + + + + diff --git a/athenz/src/test/resources/docker/zms/conf/solution_templates.json b/athenz/src/test/resources/docker/zms/conf/solution_templates.json new file mode 100644 index 00000000000..38f050984ca --- /dev/null +++ b/athenz/src/test/resources/docker/zms/conf/solution_templates.json @@ -0,0 +1,216 @@ +{ + "templates" : { + "user_provisioning": { + "roles": [ + { + "name": "_domain_:role.user", + "modified": "1970-01-01T00:00:00.000Z" + }, + { + "name": "_domain_:role.superuser", + "modified": "1970-01-01T00:00:00.000Z" + }, + { + "name": "_domain_:role.builders", + "roleMembers": [ + { + "memberName": "sys.builder" + } + ], + "modified": "1970-01-01T00:00:00.000Z" + } + ], + "policies": [ + { + "name": "_domain_:policy.user", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:node.*", + "role": "_domain_:role.user", + "action": "node_user" + } + ] + }, + { + "name": "_domain_:policy.superuser", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:node.*", + "role": "_domain_:role.superuser", + "action": "node_sudo" + } + ] + }, + { + "name": "_domain_:policy.builders", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:build", + "role": "_domain_:role.builders", + "action": "read" + }, + { + "resource": "_domain_:build", + "role": "_domain_:role.builders", + "action": "delete" + } + ] + } + ] + }, + "zts_instance_launch_provider": { + "metadata": + { + "latestVersion": 1, + "timestamp": "2020-06-16T00:00:00.000Z", + "description": "ZTS instance launch provider template", + "keywordsToReplace": "_service_", + "autoUpdate": false + }, + "roles": [ + { + "name": "_domain_:role.zts_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "roleMembers": [ + { + "memberName": "sys.auth.zts" + } + ] + } + ], + "policies": [ + { + "name": "_domain_:policy.zts_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:service._service_", + "role": "_domain_:role.zts_instance_launch_provider", + "action": "launch" + } + ] + } + ] + }, + "aws_instance_launch_provider": { + "metadata": + { + "latestVersion": 1, + "timestamp": "2020-06-16T00:00:00.000Z", + "description": "AWS instance launch provider template", + "keywordsToReplace": "_service_", + "autoUpdate": false + }, + "roles": [ + { + "name": "_domain_:role.aws_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "roleMembers": [ + { + "memberName": "athenz.aws.*" + } + ] + }, + { + "name": "_domain_:role.aws.ssh_login", + "modified": "1970-01-01T00:00:00.000Z" + } + ], + "policies": [ + { + "name": "_domain_:policy.aws_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:service._service_", + "role": "_domain_:role.aws_instance_launch_provider", + "action": "launch" + } + ] + }, + { + "name": "_domain_:policy.aws.ssh_login", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:aws._service_.*", + "role": "_domain_:role.aws.ssh_login", + "action": "ssh_login" + } + ] + } + ] + }, + "aws_ecs_instance_launch_provider": { + "metadata": + { + "latestVersion": 1, + "timestamp": "2020-06-16T00:00:00.000Z", + "description": "AWS ECS instance launch provider template", + "keywordsToReplace": "_service_", + "autoUpdate": false + }, + "roles": [ + { + "name": "_domain_:role.aws_ecs_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "roleMembers": [ + { + "memberName": "athenz.aws-ecs.*" + } + ] + } + ], + "policies": [ + { + "name": "_domain_:policy.aws_ecs_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:service._service_", + "role": "_domain_:role.aws_ecs_instance_launch_provider", + "action": "launch" + } + ] + } + ] + }, + "aws_lambda_instance_launch_provider": { + "metadata": + { + "latestVersion": 1, + "timestamp": "2020-06-16T00:00:00.000Z", + "description": "AWS lambda instance launch provider template", + "keywordsToReplace": "_service_", + "autoUpdate": false + }, + "roles": [ + { + "name": "_domain_:role.aws_lambda_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "roleMembers": [ + { + "memberName": "athenz.aws-lambda.*" + } + ] + } + ], + "policies": [ + { + "name": "_domain_:policy.aws_lambda_instance_launch_provider", + "modified": "1970-01-01T00:00:00.000Z", + "assertions": [ + { + "resource": "_domain_:service._service_", + "role": "_domain_:role.aws_lambda_instance_launch_provider", + "action": "launch" + } + ] + } + ] + } + } +} diff --git a/athenz/src/test/resources/docker/zms/conf/zms.properties b/athenz/src/test/resources/docker/zms/conf/zms.properties new file mode 100644 index 00000000000..b8d8228fdc1 --- /dev/null +++ b/athenz/src/test/resources/docker/zms/conf/zms.properties @@ -0,0 +1,436 @@ +# Athenz ZMS Servlet properties file. +# If there is a value specified in the commented property line, +# then it indicates the default value + +# Default root directory for ZMS Server. This must be passed as +# part of the startup script since it is used before the +# properties file is accessed. +#athenz.zms.root_dir=/opt/athenz/zms + +# Comma separated list of authority implementation classes to support +# authenticating principals in ZMS +athenz.zms.authority_classes=com.yahoo.athenz.auth.impl.PrincipalAuthority,com.yahoo.athenz.auth.impl.TestUserAuthority,com.yahoo.athenz.auth.oauth.OAuthCertBoundJwtAccessTokenAuthority,com.yahoo.athenz.auth.impl.CertificateAuthority + +# Principal Authority class. If defined and the caller asks for the header +# name for the getUserToken api, the header from this authority will be +# returned in the response. This class must be one of the classes listed +# in the athenz.zms.authority_classes setting +#athenz.zms.principal_authority_class= + +# User Authority class. If defined and the server is configured to validate +# all user principals when adding them as members in a role (this is configured +# with athenz.zms.validate_user_members property), the ZMS Server will call +# the authority isValidUser api method for validation. This class must be one of +# the classes listed in the athenz.zms.authority_classes setting +athenz.zms.user_authority_class=com.yahoo.athenz.auth.impl.TestUserAuthority + +# Specifies the user domain name for the current installation +athenz.user_domain=user + +# Specifies the factory class that implements the Metrics interface +# used by the ZMS Server to report stats +#athenz.zms.metric_factory_class=com.yahoo.athenz.common.metrics.impl.NoOpMetricFactory + +# Specifies the factory class that implements the AuditLoggerFactory +# interface used by the ZMS Server to log all changes to domain +# data for auditing purposes +#athenz.zms.audit_logger_factory_class=com.yahoo.athenz.common.server.log.impl.DefaultAuditLoggerFactory + +# Specifies the factory class that implements the PrivateKeyStoreFactory +# interface used by the ZMS Server to get access to its host specific +# private key +#athenz.zms.private_key_store_factory_class=com.yahoo.athenz.auth.impl.FilePrivateKeyStoreFactory + +# If the datastore does not contain any domains during startup, +# the server will automatically create sys, sys.auth and user +# domains and assign the specified users (comma separated list) +# as the administrator for those domains +athenz.zms.domain_admin=user.github-7654321 + +# If File Private Key store implementation is used in the Server, +# this setting specifies the path to the PEM encoded ZMS Server +# private key file (both RSA and EC privates keys are supported) +athenz.auth.private_key_store.private_key=/opt/athenz/zms/var/keys/zms_private.pem + +# If File Private Key store implementation is used in the Server, +# this setting specifies the key identifier for the private key +# configured by the athenz.auth.private_key_store.private_key +# property +athenz.auth.private_key_store.private_key_id=0 + +# Specify the FQDN/hostname of the server. This value will be used as the +# h parameter in the ZMS generated UserTokens +#athenz.zms.hostname= + +# If enabled, ZMS will be in maintenance read only mode where only +# get operations will succeed and all other put, post and delete +# operations will be rejected with invalid request error. +athenz.zms.read_only_mode=false + +# Specifies the authorized service json configuration file path. +athenz.zms.authz_service_fname=/opt/athenz/zms/conf/zms_server/authorized_services.json + +# Specifies the path to the solution templates json document +athenz.zms.solution_templates_fname=/opt/athenz/zms/conf/zms_server/solution_templates.json + +# In case there is a concurrent update conflict, the server will retry +# the operation multiple times until this timeout is reached before +# returning a conflict status code back to the client +#athenz.zms.conflict_retry_timeout=60 + +# When ZMS determines that updating a domain data tables will cause a +# concurrent update issue and needs to retry the operation, it will sleep +# configured number of milliseconds before retrying +#athenz.zms.retry_delay_timeout=50 + +# This setting specifies the number of seconds how long the signed +# policy documents are valid for +#athenz.zms.signed_policy_timeout=604800 + +# The number of milliseconds to sleep between runs of the idle object +# evictor thread. When non-positive, no idle object evictor thread +# will be run. The pool default is -1, but we're using 30 minutes to +# make sure the evictor thread is running | +#athenz.db.pool_evict_idle_interval=1800000 + +# The minimum amount of time (in milliseconds) an object may sit +# idle in the pool before it is eligible for eviction by the idle +# object evictor (if any) +#athenz.db.pool_evict_idle_timeout=1800000 + +# The maximum number of connections that can remain idle in the pool, +# without extra ones being released, or negative for no limit +#athenz.db.pool_max_idle=8 + +# The maximum number of active connections that can be allocated +# from this pool at the same time, or negative for no limit +#athenz.db.pool_max_total=8 + +# The maximum lifetime in milliseconds of a connection. After this +# time is exceeded the connection will fail the next activation, +# passivation or validation test. A value of zero or less means the +# connection has an infinite lifetime +#athenz.db.pool_max_ttl=600000 + +# The maximum number of milliseconds that the pool will wait +# (when there are no available connections) for a connection to be +# returned before throwing an exception, or -1 to wait indefinitely +#athenz.db.pool_max_wait=-1 + +# The minimum number of connections that can remain idle in the pool, +# without extra ones being created, or zero to create none +#athenz.db.pool_min_idle=0 + +# The validation query used by the pool to determine if the connection +# is valid before returning it to the caller. The default value +# is the recommended query for the Mysql/J Connector +#athenz.db.pool_validation_query=/* ping */ SELECT 1 + +# The maximum number of seconds that the server should wait +# for the store connection object to return its results +#athenz.zms.store_operation_timeout=60 + +# Specifies the factory class that implements the ObjectStoreFactory +# interface used by the ZMS Server to store its data. In production, +# this is typically the jdbc/mysql object store while for tests it's +# the file object store +athenz.zms.object_store_factory_class=com.yahoo.athenz.common.server.store.impl.JDBCObjectStoreFactory + +# If the athenz.zms.object_store_factory_class property is using +# the file object store factory, then this setting specifies +# the subdirectory name where domain files will be stored. +# The parent directory is identified by the athenz.zms.file_store_path +# property +#athenz.zms.file_store_path=/opt/athenz/zms/var + +# If the athenz.zms.object_store_factory_class property is using +# the file object store factory, then this setting specifies +# the directory name where file store subdirectory will +# be created to store domain files. The subdirectory is identified +# by the athenz.zms.file_store_name property +#athenz.zms.file_store_name=zms_root + +# If the athenz.zms.object_store_factory_class property is using +# the jdbc object store factory identified with +# com.yahoo.athenz.common.server.store.impl.JDBCObjectStoreFactory, then +# this setting specifies JDBC URL where the ZMS Server will store its data. +# The database server must be initialized with the ZMS +# server schema. For example, jdbc:mysql://localhost:3306/zms +# specifies a database called zms configured within a +# MySQL instance +athenz.zms.jdbc_store=jdbc:mysql://athenz-zms-db:3306/zms_server + +# If the athenz.zms.object_store_factory_class property is using +# the jdbc/mysql object store factory then this setting +# specifies the name of the user that has full access to the configured +# ZMS server database +athenz.zms.jdbc_user=zms_admin + +# If the athenz.zms.object_store_factory_class property is using +# the jdbc/mysql object store factory then this setting +# specifies the password key for the jdbc user that has been granted full +# access to the configured ZMS server database. The configured +# private key store will be called with the value of the key to +# retrieve the password to authenticate requests against the +# configured MySQL server. +#athenz.zms.jdbc_password=mariadb + +# If the athenz.zms.object_store_factory_class property is using +# the jdbc/mysql object store factory then this setting specifies +# JDBC URL for slave databases that replicate ZMS Server's +# domain data. If configured, ZMS Server will use this database +# instance for any read only operation. It has the same syntax +# as the athenz.zms.jdbc_store property. +#athenz.zms.jdbc_ro_store= + +# If the athenz.zms.jdbc_ro_store is configured then this property is +# the name of the user that has full access to the zms database +# if this property is not specified but athenz.zms.jdbc_ro_store +# is configured, the server will use the value of the +# athenz.zms.jdbc_user property. +#athenz.zms.jdbc_ro_user= + +# If the athenz.zms.jdbc_ro_store is configured then this property +# specifies the password key for the jdbc user that has been granted +# full access to the configured zms database. If this property is not +# specified but athenz.zms.jdbc_ro_store is configured, the server +# will use the value of the athenz.zms.jdbc_password property. +# The configured private key store will be called with the value of +# the key to retrieve the password to authenticate requests against +# the configured MySQL server. +#athenz.zms.jdbc_ro_password= + +# If using the jdbc connector (either mysql or aws) for zms +# data storage, this property specifies if the jdbc client +# should establish an SSL connection to the database server or not +#athenz.zms.jdbc_use_ssl=false + +# if using the jdbc connector (either mysql or aws) for zms +# data storage and the athenz.zms.jdbc_use_ssl property is set +# to true, this property specifies whether or not the jdbc client +# must verify the server certificate or not +#athenz.zms.jdbc_verify_server_certificate=false + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store factory identified with +# io.athenz.server.aws.common.store.impl.AWSObjectStoreFactory, then +# this setting specifies AWS RDS instance hostname. +# The database server must be initialized with the ZMS +# server schema. +#athenz.zms.aws_rds_master_instance= + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the database user configured with IAM Role AWS authentication +# and full access to the zms store database +#athenz.zms.aws_rds_user= + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the IMA role that has been enabled for authentication +#athenz.zms.aws_rds_iam_role= + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the port number for the RDL database instance +#athenz.zms.aws_rds_master_port=3306 + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the database engine used in rds +#athenz.zms.aws_rds_engine=mysql + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the database name in rds +#athenz.zms.aws_rds_database=zms_store + +# If the athenz.zms.object_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# in seconds how often to update the aws credentials for the IAM role +#athenz.zms.aws_rds_creds_refresh_time=300 + +# The number of seconds ZMS issued User Tokens are valid for +#athenz.zms.user_token_timeout=3600 + +# Boolean setting to configure whether or not virtual domains are +# supported or not. These are domains created in the user's own +# "user" namespace +#athenz.zms.virtual_domain_support=true + +# If virtual domain support is enabled, this setting specifies the +# number of sub domains in the user's virtual namespace that are +# allowed to be created. Value of 0 indicates no limit +#athenz.zms.virtual_domain_limit=5 + +# Number of bytes allowed to be specified in a domain name. +# This limit includes all subdomains as well. For example, +# athenz.storage.mysql domains length is 20. +#athenz.zms.domain_name_max_len=128 + +# Boolean setting to configure whether or not unique product +# IDs are required for top level domains +#athenz.zms.product_id_support=false + +# Number of seconds the authentication library will honor +# token's expiration timeout. +#athenz.token_max_expiry=2592000 + +# User Authority - if UserAuthority is enabled as one of the authenticating +# authorities in the ZMS Server, this setting provides the pam service +# name used when validating the user specified password +#athenz.auth.user.pam_service_name=login + +# Role Authority - when validating Role Tokens, this setting specifies the +# number seconds the library will allow the token to have a creation time +# in the future to accommodate time differences between server and client. +#athenz.auth.role.token_allowed_offset=300 + +# Role Authority - when authenticating principals based on the Role Token +# this setting specifies what HTTP header name in the request contains +# the token +#athenz.auth.role.header=Athenz-Role-Auth + +# Principal Authority - when validating User/Service Tokens, this setting +# specifies the number seconds the library will allow the token to have +# a creation time in the future to accommodate time differences between +# server and client. +#athenz.auth.principal.token_allowed_offset=300 + +# Principal Authority - when authenticating principals based on their +# User/Service Tokens, this setting specifies what HTTP header name in +# the request contains the token +#athenz.auth.principal.header=Athenz-Principal-Auth + +# Principal Authority - when authenticating principals based on their +# User/Service Tokens. this setting specifies whether or not to validate +# if the IP address of the incoming connection matches to the IP address +# in the token. The possible values are: OPS_ALL, OPS_NONE, OPS_WRITE +# OPS_WRITE indicates that only write/update operation will enforce +# this check. +#athenz.auth.principal.remote_ip_check_mode=OPS_WRITE + +# If the ZMS webapp is deployed along other webapps that may +# run on non-TLS ports, this setting forces that requests to +# ZMS are only accepted on secure TLS ports. +#athenz.zms.secure_requests_only=true + +# Quota Support: boolean value defining whether or not quota +# check is enabled. +#athenz.zms.quota_check=true + +# Quota Support: default number of roles allowed to be created +# in a given domain. +#athenz.zms.quota_role=1000 + +# Quota Support: default number of members a single role may have +#athenz.zms.quota_role_member=100 + +# Quota Support: default number of polices allowed to be created +# in a given domain. +#athenz.zms.quota_policy=1000 + +# Quota Support: default number of assertions each policy may have +#athenz.zms.quota_assertion=100 + +# Quota Support: default number of services allowed to be created +# in a given domain. +#athenz.zms.quota_service=250 + +# Quota Support: default number of hosts each service may have +#athenz.zms.quota_service_host=10 + +# Quota Support: default number of public keys each service may have +#athenz.zms.quota_public_key=100 + +# Quota Support: default number of entities allowed to be created +# in a given domain +#athenz.zms.quota_entity=100 + +# Quota Support: default number of sub-domains each top level +# domain allowed to have. +#athenz.zms.quota_subdomain=100 + +# Comma separated list of URIs that require authentication according to the RDL +# but we want the server to make the authentication as optional. The URI can +# include regex values based on + character to match resource URIs +# for example, /zms/v1/domain/.+/service +athenz.zms.no_auth_uri_list=/zms/v1/status + +# Comma separated list of http origin values that are whitelisted +# to request authorize service tokens. This is only used for the +# optionsUserToken method where we return CORS headers +#athenz.zms.cors_origin_list + +# Comma separated list of http header values that are allowed +# to be included in the Access-Control-Request-Headers CORS +# preflight request and returned back to the client as the value +# of the Access-Control-Allow-Headers header. +athenz.zms.cors_header_list=*,Accept,Accept-Language,Content-Language,Content-Type,Authorization + +# Comma separated list of service names that are reserved. The default +# list includes most common gTLDs so a service cannot obtain an identity +# x.509 certificate with a name that matches an actual domain e.g. yahoo.com +#athenz.zms.reserved_service_names=com,net,org,edu,biz,gov,mil,info,name,mobi,cloud + +# Integer value specifying the min length of any service names. The default +# value of 3 is configured to prevent a service from obtaining an identity +# x.509 certificate with a name that matches country gTLD - e.g. yahoo.us +#athenz.zms.service_name_min_length=3 + +# Athenz ZMS Service Health Check file path. If configured, the +# /zms/v1/status command would return failure if the file setting +# is configured but the file is not present. The idea is that once +# the server is started, an external process will verify that +# the server is running correctly by running some checks and if +# successful, it will create that file so that the server can +# now report that the server is ready to accept production traffic +#athenz.zms.health_check_path= + +# Boolean value indicating whether or not the ZMS server should +# call the configured user authority and verify if the given +# user is valid or not before adding the user to a role. The +# user authority is responsible for validating any usernames +# that might include wildcards (e.g. user.*). +#athenz.zms.validate_user_members=false + +# If the athenz.zms.validate_user_members property is enabled +# then this setting provides additional set of comma separated +# domains that the system might be using referencing accounts +# that can be validated with the user authority +#athenz.zms.addl_user_check_domains= + +# Boolean value indicating whether or not the ZMS server should +# verify if the given service exists in the given domain +# before adding the service to a role. The ZMS Service will +# automatically skip any service names that include wildcards +# (e.g. coretech.api*). +#athenz.zms.validate_service_members=false + +# If athenz.zms.validate_service_members property is enabled +# then this setting includes comma separated list of domains +# that should be skipped from the service member validation +# checks. These could include CI/CD domains, for example, +# that include dynamic services that are not registered. +#athenz.zms.validate_service_members_skip_domains= + +# Boolean value indicating whether or not the zms server +# should contact the master storage copy when returning +# data for signed domains api. In multi region environments +# this could generate large latency since the server +# needs to contact most likely a server (e.g. mysql instance) +# running in a different region. +#athenz.zms.master_copy_for_signed_domains=false + +# Set the timezone of the database +# when retrieving the modified domain. +#athenz.zms.athenz.zms.mysql_server_timezone= + +# Specifies the factory class that implements the StatusChecker interface +# Used to check the status of the ZMS server +#athenz.zms.status_checker_factory_class= + +# Boolean property to enable the periodic update of Principal state from Authority. +# Default value is false. +#athenz.zms.enable_principal_state_updater= +athenz.auth.oauth.jwt.parser.jwks_url=https://athenz.io diff --git a/athenz/src/test/resources/docker/zms/db/zms-db.cnf b/athenz/src/test/resources/docker/zms/db/zms-db.cnf new file mode 100644 index 00000000000..710fe047cfd --- /dev/null +++ b/athenz/src/test/resources/docker/zms/db/zms-db.cnf @@ -0,0 +1,10 @@ +# env. variable from command line will take priority + +[mysqld] +port = 3306 + +[client] +port = 3306 + +[mysqladmin] +port = 3306 diff --git a/athenz/src/test/resources/docker/zms/db/zms-init-db.sql b/athenz/src/test/resources/docker/zms/db/zms-init-db.sql new file mode 100644 index 00000000000..b58037fb4a9 --- /dev/null +++ b/athenz/src/test/resources/docker/zms/db/zms-init-db.sql @@ -0,0 +1,6 @@ +-- Create the 'zms_admin' user and grant it privileges on the 'zms_server' database. +CREATE USER 'zms_admin'@'%' IDENTIFIED BY 'mariadbmariadb'; +GRANT ALL PRIVILEGES ON zms_server.* TO 'zms_admin'@'%'; + +-- Apply the changes. +FLUSH PRIVILEGES; diff --git a/athenz/src/test/resources/docker/zms/var/certs/zms_keystore.pkcs12 b/athenz/src/test/resources/docker/zms/var/certs/zms_keystore.pkcs12 new file mode 100644 index 00000000000..25f9406b819 Binary files /dev/null and b/athenz/src/test/resources/docker/zms/var/certs/zms_keystore.pkcs12 differ diff --git a/athenz/src/test/resources/docker/zms/var/certs/zms_truststore.jks b/athenz/src/test/resources/docker/zms/var/certs/zms_truststore.jks new file mode 100644 index 00000000000..b00129435c2 Binary files /dev/null and b/athenz/src/test/resources/docker/zms/var/certs/zms_truststore.jks differ diff --git a/athenz/src/test/resources/docker/zms/var/keys/zms_private.pem b/athenz/src/test/resources/docker/zms/var/keys/zms_private.pem new file mode 100644 index 00000000000..07c41e18b24 --- /dev/null +++ b/athenz/src/test/resources/docker/zms/var/keys/zms_private.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEAxBAFCz0xRAfujaUc8Tk/NAAY6QuYzSZXt1422w2nUTjqyUo8 +2ybaoCe5qnZBm8bqPx2dCce9RBzKW+vl44dYyiwB83wikTRJ5loIlQkdCJxXW6gN +fRoaM6KKPVCVOJVSuzTMywvb5x3ahC8KFSdthPTAVyLbIVNa9Yu7X9agydF8fkyw +RvsMfibsOw1QjeGRH9b2M0vk6tM1VrOC31e/1J4QtHvaa8sRIhlGf//v/oNl4HPV +JTyJJpcl26f3gfbkb8pMN23VYKMUtshRf0Imu00sE77C279rPvV+N2QaNeS/xNP8 +Hmrx1FUB2rkozHuh61Tppql3vkvX1TAbaH1v9ptbzLx+TKYNQ4nGceESYHifwpTl +e0AFYqs5YYpfPFdA/OBr2qO5eMMyFwzJvUFBpN83OKA0nYQvqySukkzITl/Em641 +/r2aCDAfdB29yQUhePZE5SC/7d0kdxqwDu0a2hR9eioE2yewK17YQqlSNoZ7yu2C +Ciyb95kZMa+fb2AyYXaxnSYShK/Zf4t5HQKJT+PTXNb1C3WlAGTDA1s5i4O5Kq4Z +GYo4wsDWGZ0hMTuLRA69GuHIaxoaQm8sU0S2PrxvvtZaQNvUNYVcpGFlEnChULdg +mesfxCnaO9l/5JSc8i2O1b7v84ncNy/pwdLSVzt9y+CiEKiv9bD6wXvQBUsCAwEA +AQKCAgAzcOyR3VVOvzIE8fxdAiMmFpxykLtfoB7Fmq+T28o5klBvzc2XxmL4QFQD +eJGQOKl/M9rfFQwAkfk6EvO3NezC5rcAVmKPbIOzL44u5Dw85SA6iSY4259iStQw +cDMmrG8PdTQCVjM1OWz634iqsjSW/Mx2UEvkO4WTBT19UMfRC+i5Do/1n6rQco/q +kny/LR6xlunz3YbLYe0NuX2t8c0AERTa9eACBj6RjsYjkVg7k+gn8txKr85gVKn3 +LuPOsqOO/SnwJR4mA8PMjVRsqkOVQxM8fwGLPHb8Btc920LRPxpdnyz4IiEfD8qz +w38D37BFGXtQYghr4mRXqA6Y5Mh6GtzY0qyaQrdU35xvhq8aRvnLfrUSQKZxKwS3 +HBUxK5KtuXxD50se7VyoGheYBgjKzI7+AAnShvXZE/9LiihGfV69scReJMoPSAnK +8z8ufst+uVPGYurknICtKZ3gw/8WLiiYCkZ6d83gBui8gQ6JQC61xzdnKU9LS4rb +4yTRymtSia+8B+K/lzKdGuVg2Z6E3XpCfbF8jXJmvE3J/yFubXTlSOCizQJbtyRq +dPTh5Dk4vufQN1pJEfu+3g3hygDbfG5cjG24aTcykaHSlTs9eIpzoJg85MwEs0qC +kc0UqQobnBkem3J5GSzrE/7e3O/Tw/Jcp7zSITmKTo+yg7F6KQKCAQEA7Mk/VgNU +TuWb6pLtPpYUhMARkHgpYyfBv5blxKsZWGfjlc+YiO2xD4E3IRPo/A2lwLSBUx97 +53PQiyMaLzE5H0comtgktUWEgLOxAX+9bRuudF1Lp4yfchkee5D6tTT3ighMZDbB +LV2V/y3zJgBLtHnAJ0VrpsvkT5R11YFv52GSvyyIoU1HHP74UYIhxw9bMQ/mkvxk +or5SWTdxWCDGrQEW5eUZ3+Q6sFXf2X9SQIEr5oxiMH5Z+aRw++zPmg2K+MDYb9pS +Cpb6M9XlORJ+FWmNCcIL21RFlbN9NwBulHmDxl577ITK+dixRWu4m+0ca7WAo1cC +mz8OGv+VP4zxrwKCAQEA0/jSskixsSEj0Z9ZprCCeLQyTDGiWgi7cbA89m2BCCuS +++Htsi1l0hk31qIhbPyZwZFQGMDyIzJzl3hFQFOlOYENLJ2l93lNPPm0eYs2wTAv +Okz2K8dFUM6q36kV4z8qeeuj2guMW8ZPkRysp9rLHOzbYwUn1ua2B+eRtDOhmWdm +lvhzrH69D5FZVXh/DRg5NpDIlQ1MN1Yoqt6Jd7GOf8DONqxyotqh26V3dGhvaHM0 +VpK6SHhRWY8Czpra1n6i3LZYJfDvgM4NDIeAt1rNWd06RpTH9fcnWFP6BL/UenJ6 +JY0upy64QJnE/3hpEQor4d6WVVC+fXawK8jdbVkZJQKCAQB0o5HCcfNmxe4u999L +9Cv5VI+SZc6lXGExWkimv03F4a8XDeLqmIOeypz4e1FKUAK1UXVfF+Q2GDPDRjBQ +zOOBh9D1rcEvViY2K83mmiEajQc7pPVufHPShZMhdUI6XvQNF5dhyiMQkMghomXi +80RXi53e+nBUAp3doZkF6jPQe50I9Qv8wQ9ltidmJz8ySls2aMnA3/lGvY/dCNWZ +ftO/Rwkbh+ZeBpPuZu7UucYvpjGU6NQ5ZRQ3SAyr3HRLQ56QeJZlStsdAlAPlq7K +lVRsgckK5H5otTNt0bO9k0Xld5I09jf/Q06lElwIgU636G4WlJrgWy0agk4VeI7x +diFbAoIBAHCKW/sSA12Ctcy2qNeKQUsqiMXRWFwq5LIgms1PKEGZOOmIczW1Sqj4 +gJ/w5oRBvLR7nSX3UteDEsHptgLIJiHYtZDphlNU0MA95ybc1c5N8dUnRU/K9a9I +eopS8G79e2TNyembVgLn0BjAO57G2C3cR0JUW2mKhjy+Iqqk+vhCYMJo2KlNhL0+ +M0rgtbvTD5U723LfY4bBtMe7bQrid0C2sgVvG1IBeAvw1vIz/GpdN/1623Jchvxg +pGHi76sQAxHhsf065T3iqCTt0FYI3JwIi8creNrLRNVtiIF7A6mbG+TSB2H1uRd7 +TI9Cc637U38ROYnEW86q9C/a7kz/EA0CggEBAKvdUTQ+4KCwWG2QiO6yyckMM1Zb +T39uXyXonb0QP3w83jZ4wR23f0ghbXcp++R5cBCpvXwp7lXTN505fIEl2Ith4R9B +LpjZXTutURtcOF7vtpwYHtoE9IVcefregvTH7kZA4jqOTrvq1Y6JjYmTNgQeFPsD +SZuKNfxM8pRIrzWc7GAHtbpGW70nVK6Lx3GscMictI3ZK610zDrtwGiKNfWGJTSx +DFv9vDpkOvip5GfiFI3LEl5hhzsmBLCHTBGXsZxNuqOc/vwmFjTG86+hoKt7LFHC +5B3qdeBtiUZ4VCDX4LHJE2TG8qwq8zrvDmCAHangd54QDcH33hMDMDMtbuo= +-----END RSA PRIVATE KEY----- diff --git a/athenz/src/test/resources/docker/zms/var/keys/zms_public.pem b/athenz/src/test/resources/docker/zms/var/keys/zms_public.pem new file mode 100644 index 00000000000..b641587367b --- /dev/null +++ b/athenz/src/test/resources/docker/zms/var/keys/zms_public.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxBAFCz0xRAfujaUc8Tk/ +NAAY6QuYzSZXt1422w2nUTjqyUo82ybaoCe5qnZBm8bqPx2dCce9RBzKW+vl44dY +yiwB83wikTRJ5loIlQkdCJxXW6gNfRoaM6KKPVCVOJVSuzTMywvb5x3ahC8KFSdt +hPTAVyLbIVNa9Yu7X9agydF8fkywRvsMfibsOw1QjeGRH9b2M0vk6tM1VrOC31e/ +1J4QtHvaa8sRIhlGf//v/oNl4HPVJTyJJpcl26f3gfbkb8pMN23VYKMUtshRf0Im +u00sE77C279rPvV+N2QaNeS/xNP8Hmrx1FUB2rkozHuh61Tppql3vkvX1TAbaH1v +9ptbzLx+TKYNQ4nGceESYHifwpTle0AFYqs5YYpfPFdA/OBr2qO5eMMyFwzJvUFB +pN83OKA0nYQvqySukkzITl/Em641/r2aCDAfdB29yQUhePZE5SC/7d0kdxqwDu0a +2hR9eioE2yewK17YQqlSNoZ7yu2CCiyb95kZMa+fb2AyYXaxnSYShK/Zf4t5HQKJ +T+PTXNb1C3WlAGTDA1s5i4O5Kq4ZGYo4wsDWGZ0hMTuLRA69GuHIaxoaQm8sU0S2 +PrxvvtZaQNvUNYVcpGFlEnChULdgmesfxCnaO9l/5JSc8i2O1b7v84ncNy/pwdLS +Vzt9y+CiEKiv9bD6wXvQBUsCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/athenz/src/test/resources/docker/zts/conf/athenz.conf b/athenz/src/test/resources/docker/zts/conf/athenz.conf new file mode 100644 index 00000000000..ba6fef89dcc --- /dev/null +++ b/athenz/src/test/resources/docker/zts/conf/athenz.conf @@ -0,0 +1,16 @@ +{ + "zmsUrl": "https://athenz-zms-server:4443/", + "ztsUrl": "https://athenz-zts-server:8443/", + "zmsPublicKeys": [ + { + "id": "0", + "key": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUF4QkFGQ3oweFJBZnVqYVVjOFRrLwpOQUFZNlF1WXpTWlh0MTQyMncyblVUanF5VW84MnliYW9DZTVxblpCbThicVB4MmRDY2U5UkJ6S1crdmw0NGRZCnlpd0I4M3dpa1RSSjVsb0lsUWtkQ0p4WFc2Z05mUm9hTTZLS1BWQ1ZPSlZTdXpUTXl3dmI1eDNhaEM4S0ZTZHQKaFBUQVZ5TGJJVk5hOVl1N1g5YWd5ZEY4Zmt5d1J2c01maWJzT3cxUWplR1JIOWIyTTB2azZ0TTFWck9DMzFlLwoxSjRRdEh2YWE4c1JJaGxHZi8vdi9vTmw0SFBWSlR5SkpwY2wyNmYzZ2Zia2I4cE1OMjNWWUtNVXRzaFJmMEltCnUwMHNFNzdDMjc5clB2VitOMlFhTmVTL3hOUDhIbXJ4MUZVQjJya296SHVoNjFUcHBxbDN2a3ZYMVRBYmFIMXYKOXB0YnpMeCtUS1lOUTRuR2NlRVNZSGlmd3BUbGUwQUZZcXM1WVlwZlBGZEEvT0JyMnFPNWVNTXlGd3pKdlVGQgpwTjgzT0tBMG5ZUXZxeVN1a2t6SVRsL0VtNjQxL3IyYUNEQWZkQjI5eVFVaGVQWkU1U0MvN2Qwa2R4cXdEdTBhCjJoUjllaW9FMnlld0sxN1lRcWxTTm9aN3l1MkNDaXliOTVrWk1hK2ZiMkF5WVhheG5TWVNoSy9aZjR0NUhRS0oKVCtQVFhOYjFDM1dsQUdUREExczVpNE81S3E0WkdZbzR3c0RXR1owaE1UdUxSQTY5R3VISWF4b2FRbThzVTBTMgpQcnh2dnRaYVFOdlVOWVZjcEdGbEVuQ2hVTGRnbWVzZnhDbmFPOWwvNUpTYzhpMk8xYjd2ODRuY055L3B3ZExTClZ6dDl5K0NpRUtpdjliRDZ3WHZRQlVzQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo-" + } + ], + "ztsPublicKeys": [ + { + "id": "0", + "key": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFtdFZ5SEROQVVWbzV6N2Qwb09FSQpUcVNIZFNCZkdvNEh6SU5xazNuTEhJb0tvcUhCeXRhUHRjL2MwZEg3c2JkOHo1UGJIZjVqTXFNeGhGRWlWNkJLCjlIMWtvOEx2SVdSc3l4N216Q1hGVExFZjBJcUhua1dBSm5qYmFHNWxJVWkxdUNQWmQybjZ4a0JpOXFGUzFudkQKRDJ0ZnlGOEx2VnAzdFp1WnhSVHRKZU9ydmc5Q0N4TUVsaDc3dFluc3JUOWVFNnF4S3NlSG5za1l4bnJkdWVVQgpJWUhMN0twY1pjeXNjOGRSdmQ0MFFoZTJwMFd5ZURpK0R0SzQreUcxbWJUTksvMGhaSDc3K2RCWmo1L2Q1RDNRClFOWURmcWwrRGFSc1ZGdk5XL0NQNVFVNlZCQ3l6L1JIcE1NV3YvTktGb0duME9QTHFRYTUrRkIxUkl3a0FnQ2QKY2VzbS9KRXlPbHF1Nm9mVmxObG1reUZ3T0YxQmFQbWNaQmNmZVhZZFlGRmozMjZDM3ZuaFc4UVg4ekRvYlJSOApSNHJKd0lWVmFHcXJlZkg1aWczT2NQQm9QalNuMi9wT1JacWRpU1o3aURHVVJ2aDBvdWd0dlkxVHVzckJWK1UxCldCZjhuU2kvNGVMaFdzbTl4bGN1YWxqVXhVWDhoOWhEVlRQVDVWdEY3Y1RWOHJmb1hKMDVLNFB2SDhYMTRlMm8KUlFibVpxZzlaeEZHQ29ZT1E5Ui9SOVZUYU0vTDBodHdDQk0rRUtyUXlnVXd6RkVzWDNFK1UzUmtvMWtRcVNZMgpxWENBZ1dBVmlrQjI5a095WW1WbVgxVmJ6Z0E5YkFzdVQwTHkyd0tWcjdIK1I3TlZwNzBCOWZ6cW9NQmtBazN5CkI1TjZZNEMzZzduK2N4WWxySW5tMTEwQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo-" + } + ] +} diff --git a/athenz/src/test/resources/docker/zts/conf/athenz.properties b/athenz/src/test/resources/docker/zts/conf/athenz.properties new file mode 100644 index 00000000000..de4a61a7b67 --- /dev/null +++ b/athenz/src/test/resources/docker/zts/conf/athenz.properties @@ -0,0 +1,131 @@ +# Athenz Jetty Container properties file. +# If there is a value specified in the commented property line, +# then it indicates the default value + +# The TLS port that Jetty will listen on for HTTPS connection +athenz.tls_port=8443 + +# The standard HTTP port for Jetty - disabled by default +athenz.port=0 + +# Set the number of days before rotated access log files are deleted +#athenz.access_log_retain_days=31 + +# Format of the access log filename +#athenz.access_log_name=access.yyyy_MM_dd.log + +# If specified, the server will use SLF4J logger with the specified name +# to log events instead of using Jetty's NCSARequestLog class. +# The administrator then must configure the specified logger in the logback.xml +#athenz.access_slf4j_logger= + +# Directory to store access log files +athenz.access_log_dir=/opt/athenz/zts/logs/zts_server + +# Key Manager password +#athenz.ssl_key_manager_password= + +# The path to the keystore file that contains the server's certificate +athenz.ssl_key_store=/opt/athenz/zts/var/certs/zts_keystore.pkcs12 + +# Specifies the type for the keystore specified in the +# athenz.ssl_key_store property +athenz.ssl_key_store_type=PKCS12 + +# Password for the keystore specified in the athenz.ssl_key_store property +#athenz.ssl_key_store_password=athenz + +# The path to the trust store file that contains CA certificates +# trusted by this Jetty instance +athenz.ssl_trust_store=/opt/athenz/zts/var/certs/zts_truststore.jks + +# Specifies the type for the truststore specified in the +# athenz.ssl_trust_store property +athenz.ssl_trust_store_type=JKS + +# Password for the truststore specified in the athenz.ssl_trust_store property +#athenz.ssl_trust_store_password=athenz + +# List of excluded cipher suites from TLS negotiation +#athenz.ssl_excluded_cipher_suites= + +# List of cipher suites supported for TLS negotiation +#athenz.ssl_included_cipher_suites= + +# Comma separated list of excluded ssl protocols +#athenz.ssl_excluded_protocols=SSLv2,SSLv3 + +# Specifies whether or not for data requests the server +# would require TLS client authentication rather than +# just wanting it +#athenz.ssl_need_client_auth= + +# In milliseconds how long that connector will be allowed to +# remain idle with no traffic before it is shutdown +#athenz.http_idle_timeout=30000 + +# Boolean setting to specify whether or not the server should +# send the Server header in response +#athenz.http_send_server_version=false + +# Boolean setting to specify whether or not the server should +# include the Date in HTTP headers +#athenz.http_send_date_header=false + +# The size in bytes of the output buffer used to aggregate HTTP output +#athenz.http_output_buffer_size=32768 + +# The maximum allowed size in bytes for a HTTP request header +#athenz.http_request_header_size=8192 + +# The maximum allowed size in bytes for a HTTP response header +#athenz.http_response_header_size=8192 + +# For HTTP access specifies the IP address/Host for service to listen on. +# This could be necessary, for example, if the system administrator +# wants some proxy server (e.g. ATS) to handle TLS traffic and configure +# Jetty to listen on 127.0.0.1 loopback address only for HTTP connections +# from that proxy server +#athenz.listen_host= + +# Boolean flag to indicate whether or not the container should honor +# the Keep Alive connection option or just connections right away +#athenz.keep_alive=false + +# Max number of threads Jetty is allowed to spawn to handle incoming requests +#athenz.http_max_threads=1024 + +# Specify the FQDN/hostname of the server. This value will be used as the +# h parameter in the ZMS generated UserTokens. It is also reported as part +# of the server banner notification in logs +#athenz.hostname= + +# Default home directory for embedded Jetty Deployer. The container will look +# for any webapps in the webapps subdirectory of the configured directory +athenz.jetty_home=/opt/athenz/zts + +# Boolean flag to enable debug log entries when deploying webapps +#athenz.debug=false + +# Comma separated list of uris that are accessed by health check +# system. Used by the simple file based health check filter that +# returns 200/404 if the file exists or not +#athenz.health_check_uri_list= + +# Directory name where the files specified in the athenz.health_check_uri_list +# setting are checked for +#athenz.health_check_path= + +# Enable Proxy Protocol (used by HAProxy and environments such as Amazon Elastic Cloud) +# for the jetty container. +#athenz.proxy_protocol=false + +# Enable graceful shutdown in the Jetty +athenz.graceful_shutdown=true + +# How long to wait for the Jetty server to shutdown, in milliseconds +# If the athenz.graceful_shutdown is not true, this setting is invalid. +athenz.graceful_shutdown_timeout=30000 + +# Need this to start root-less container +athenz.jetty_temp=/tmp \ No newline at end of file diff --git a/athenz/src/test/resources/docker/zts/conf/authorized_client_ids.txt b/athenz/src/test/resources/docker/zts/conf/authorized_client_ids.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/athenz/src/test/resources/docker/zts/conf/logback.xml b/athenz/src/test/resources/docker/zts/conf/logback.xml new file mode 100755 index 00000000000..3d6967870c1 --- /dev/null +++ b/athenz/src/test/resources/docker/zts/conf/logback.xml @@ -0,0 +1,49 @@ + + + + + + + + + ${LOG_DIR}/server.log + true + + + ${LOG_DIR}/server.%d.log + 7 + true + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{35} - %msg%n + + + + + + + ${LOG_DIR}/audit.log + true + + + ${LOG_DIR}/audit.%d.log + 30 + true + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{35} - %msg%n + + + + + + + + + + + + + diff --git a/athenz/src/test/resources/docker/zts/conf/zts.properties b/athenz/src/test/resources/docker/zts/conf/zts.properties new file mode 100644 index 00000000000..0d4daee6b8a --- /dev/null +++ b/athenz/src/test/resources/docker/zts/conf/zts.properties @@ -0,0 +1,579 @@ +# Athenz ZTS Servlet properties file. +# If there is a value specified in the commented property line, +# then it indicates the default value + +# Default root directory for ZTS Server. This must be passed as +# part of the startup script since it is used before the +# properties file is accessed. +#athenz.zts.root_dir=/opt/athenz/zts + +# Comma separated list of authority implementation classes to support +# authenticating principals in ZTS +athenz.zts.authority_classes=com.yahoo.athenz.auth.oauth.OAuthCertBoundJwtAccessTokenAuthority,com.yahoo.athenz.auth.impl.CertificateAuthority + +# If File Private Key store implementation is used in the Server, +# this setting specifies the path to the PEM encoded ZTS Server +# private key file (both RSA and EC privates keys are supported) +athenz.auth.private_key_store.private_key=/opt/athenz/zts/var/keys/zts_private.pem + +# If File Private Key store implementation is used in the Server, +# this setting specifies the key identifier for the private key +# configured by the athenz.auth.private_key_store.private_key +# property +athenz.auth.private_key_store.private_key_id=0 + +# Key Manager password +#athenz.zts.ssl_key_manager_password= + +# The path to the keystore file that contains the client's private key +# and certificate. Currently this is only used by the HttpCertSigner +# class implementation. +athenz.zts.ssl_key_store=/opt/athenz/zts/var/certs/zts_signer_keystore.pkcs12 + +# Specifies the type for the keystore specified in the +# athenz.zts.ssl_key_store property +athenz.zts.ssl_key_store_type=PKCS12 + +# Password for the keystore specified in the athenz.zts.ssl_key_store property +#athenz.zts.ssl_key_store_password=athenz + +# The path to the trust store file that contains CA certificates +# trusted by the http client running within this ZTS instance +athenz.zts.ssl_trust_store=/opt/athenz/zts/var/certs/zts_signer_truststore.jks +javax.net.ssl.trustStore=/opt/athenz/zts/var/certs/zts_signer_truststore.jks + +# Type for the truststore specified in the athenz.zts.ssl_trust_store property +athenz.zts.ssl_trust_store_type=JKS +javax.net.ssl.trustStoreType=JKS + +# Password for the truststore specified in the athenz.zts.ssl_trust_store property +#athenz.zts.ssl_trust_store_password=athenz +#javax.net.ssl.trustStorePassword=athenz + +# Specifies the location for the athenz.conf file used by the ZMS Client +# library to determine what ZMS server to contact to. +athenz.athenz_conf=/opt/athenz/zts/conf/zts_server/athenz.conf + +# If specified, this setting overrides the ZMS Server url value for the +# ZMS Client as retrieved from the athenz.conf file +#athenz.zts.zms_url= + +# SelfCertSignerFactory implementation - if this factory class is used +# is used for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies the private key filename that is used to sign +# certificate requests. +# athenz.zts.self_signer_private_key_fname=/opt/athenz/zts/var/certs/zts_signer_key.pem + +# SelfCertSignerFactory implementation - if this factory class is used +# is used for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies the private key password that is used to sign +# certificate requests. +#athenz.zts.self_signer_private_key_password= + +# SelfCertSignerFactory implementation - if this factory class is used +# is used for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies the dn for the CA certificate that ZTS +# will use +# athenz.zts.self_signer_cert_dn=cn=Sample Self Signed Athenz CA,o=Athenz,c=US + +# HttpCertSignerFactory implementation - if this factory class is used +# for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies the base uri for the Certificate Signer Service +#athenz.zts.certsign_base_uri= + +# HttpCertSignerFactory implementation - if this factory class is used +# for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies in seconds the connect timeout +#athenz.zts.certsign_connect_timeout=10 + +# HttpCertSignerFactory implementation - if this factory class is used +# for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies in seconds the request timeout. +# We're setting the initial value to a small on so we know right away +# if our idle connection has been been closed by cert signer and we'll +# use our retry setting to retry with a max timeout of 30 seconds. +#athenz.zts.certsign_request_timeout=5 + +# HttpCertSignerFactory implementation - if this factory class is used +# for the CertSigner implementation (athenz.zts.cert_signer_factory_class +# property), this setting specifies the number of times the request +# should be retried if it's not completed with the requested timeout value +#athenz.zts.certsign_retry_count=3 + +# Specifies the factory class that implements the Metrics interface +# used by the ZTS Server to report stats +#athenz.zts.metric_factory_class=com.yahoo.athenz.common.metrics.impl.NoOpMetricFactory + +# Specifies the factory class that implements the AuditLoggerFactory +# interface used by the ZTS Server to log all changes to domain +# data for auditing purposes +#athenz.zts.audit_logger_factory_class=com.yahoo.athenz.common.server.log.impl.DefaultAuditLoggerFactory + +# Specifies the factory class that implements the HostnameResolverFactory +# interface used by the ZTS Server to validate that the hostname field +# requested to be added to the X.509 certificate SAN dnsName field is +# a valid hostname (A/AAAA type) and not other type of dns record. +#athenz.zts.hostname_resolver_factory_class= + +# Specifies the factory class that implements the PrivateKeyStoreFactory +# interface used by the ZTS Server to get access to its host specific +# private key +#athenz.zts.private_key_store_factory_class=com.yahoo.athenz.auth.impl.FilePrivateKeyStoreFactory + +# Specifies the factory class that implements CertSignerFactory +# interface used by the ZTS Server to sign any certificate requests +athenz.zts.cert_signer_factory_class=com.yahoo.athenz.zts.cert.impl.KeyStoreCertSignerFactory +# athenz.zts.keystore_signer.keystore_password=athenz +athenz.zts.keystore_signer.keystore_path=/opt/athenz/zts/var/certs/zts_signer_keystore.pkcs12 +athenz.zts.keystore_signer.keystore_type=PKCS12 +athenz.zts.keystore_signer.keystore_ca_alias=1 +athenz.zts.certsign_max_expiry_time=43200 + +# Specifies the factory class that implements ChangeLogStoreFactory +# interface used by the ZTS Server to retrieve the latest changes +# from the ZMS Server and save them locally +athenz.zts.change_log_store_factory_class=com.yahoo.athenz.common.server.store.impl.ZMSFileChangeLogStoreFactory + +# Specifies the directory for storing zms domain json documents when +# ZMSFileChangeLogStoreFactory is configured for the change log factory +# class (athenz.zts.change_log_store_factory_class property) +athenz.zts.change_log_store_dir=/opt/athenz/zts_store + +# Boolean setting to force users to request role tokens for specific +# roles rather than for domain which will includes all the roles the +# given principal has access in that domain +#athenz.zts.least_privilege_principle=false + +# Specifies the maximum expiry timeout that a client can ask for when +# requesting a role token. If the client asks for a longer timeout, the +# server will automatically replace the value with this one +#athenz.zts.role_token_max_timeout=2592000 + +# Specifies the default expiry timeout for role tokens when the client +# does not specify any timeout parameters +#athenz.zts.role_token_default_timeout=7200 + +# Specifies the maximum expiry timeout that a client can ask for when +# requesting a oauth2 id token. If the client asks for a longer timeout, the +# server will automatically replace the value with this one +#athenz.zts.id_token_max_timeout=43200 + +# Specifies the expiry timeout for signed policy documents that +# ZTS Server signs and returns to ZPU clients +#athenz.zts.signed_policy_timeout=604800 + +# Specifies timeout in seconds for NTokens issued by ZTS +# Server as part of the Instance bootstrap request +#athenz.zts.instance_token_timeout=86400 + +# Comma separated list of authorized proxy principals +#athenz.zts.authorized_proxy_users= + +# Specifies the service name that the ostk instance documents are +# signed with. +#athenz.zts.ostk_host_signer_service= + +# If the ZTS servlet is deployed along other servlets that may +# run on non-TLS ports, this setting forces that requests to +# ZTS are only accepted on secure TLS ports. +#athenz.zts.secure_requests_only=true + +# Comma separated list of hostname suffixes that providers +# are allowed to use for their verifiers +#athenz.zts.provider_endpoints= + +# Specifies in seconds how often to query ZMS Server for updates +# The default value is 60 seconds +athenz.zts.zms_domain_update_timeout=5 + +# Specifies in seconds how often to query ZMS Server for the full +# list of domains to determine the deleted domains +# The default value is 3600 seconds +#athenz.zts.zms_domain_delete_timeout + +# Specifies the factory class that implements the CertRecordStore +# interface used by the ZTS Server to store certificate data. In production, +# this is typically the jdbc/mysql cert record store while for tests it's +# the file cert record store +athenz.zts.cert_record_store_factory_class=com.yahoo.athenz.common.server.cert.impl.JDBCCertRecordStoreFactory + +# If the athenz.zts.cert_record_store_factory_class property is using +# the file cert record store factory, then this setting specifies +# the subdirectory name where record files will be stored. +#athenz.zts.cert_file_store_path=/opt/athenz/zts/var + +# If the athenz.zts.cert_record_store_factory_class property is using +# the file cert record store factory, then this setting specifies +# the directory name where file store subdirectory will +# be created to store cert record files. +#athenz.zts.cert_file_store_name=zts_cert_records + +# If the athenz.zts.cert_record_store_factory_class property is using +# the jdbc cert record store factory identified with +# com.yahoo.athenz.common.server.cert.impl.JDBCCertRecordStoreFactory, then +# this setting specifies the JDBC URL where the ZTS Server will store +# certificate records for revocation checks +# jdbc:mysql://localhost:3306/zts - specifies MySQL instance +athenz.zts.cert_jdbc_store=jdbc:mysql://athenz-zts-db:3306/zts_store + +# If the jdbcstore is pointing to a MySQL server then this specifies +# the name of the user that has full access to the zts database +athenz.zts.cert_jdbc_user=zts_admin + +# If the jdbcstore is pointing to a MySQL server then this specifies +# the password for the jdbc user that has been granted full access +# to the configured zts database +#athenz.zts.cert_jdbc_password=mariadb + +# If using the jdbc connector (either mysql or aws) for zts +# certificate data storage, this property specifies if the jdbc client +# should establish an SSL connection to the database server or not +#athenz.zts.cert_jdbc_use_ssl=false + +# if using the jdbc connector (either mysql or aws) for zms +# certificate data storage and the athenz.zts.cert_jdbc_use_ssl property +# is set to true, this property specifies whether or not the jdbc client +# must verify the server certificate or not +#athenz.zts.cert_jdbc_verify_server_certificate=false + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store factory identified with +# com.yahoo.athenz.zts.cert.impl.AWSObjectStoreFactory, then +# this setting specifies AWS RDS instance hostname. +# The database server must be initialized with the ZTS +# server schema. +#athenz.zts.aws_rds_master_instance= + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the database user configured with IAM Role AWS authentication +# and full access to the zms store database +#athenz.zts.aws_rds_user= + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the IMA role that has been enabled for authentication +#athenz.zts.aws_rds_iam_role= + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the port number for the RDL database instance +#athenz.zts.aws_rds_master_port=3306 + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the database engine used in rds +#athenz.zts.aws_rds_engine=mysql + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# the database name in rds +#athenz.zts.aws_rds_database=zts_store + +# If the athenz.zts.cert_record_store_factory_class property is using +# the aws rds mysql object store then this setting specifies +# in seconds how often to update the aws credentials for the IAM role +#athenz.zts.aws_rds_creds_refresh_time=300 + +# The maximum number of seconds that the server should wait +# for the certificate store connection object to return its results +#athenz.zts.cert_op_timeout=60 + +# When requesting TLS certificates for their corresponding NTokens, +# services must this dns suffix in their CSRs +#athenz.zts.cert_dns_suffix=.athenz.cloud +athenz.zts.aws_dns_suffix=aws.athenz.cloud +athenz.zts.aws_region_name=us-west-2 +athenz.zts.aws_boot_time_offset=1800 +athenz.zts.aws_public_cert=/opt/athenz/zts/conf/zts_server/aws_public.crt + +# Kerberos Authority Service Principal +#athenz.auth.kerberos.service_principal= + +# Kerberos Authority location of keytab file +#athenz.auth.kerberos.keytab_location= + +# Kerberos Authority debug boolean state +#athenz.auth.kerberos.debug=false + +# Kerberos Authority - if there is a jaas.conf whose path is specified by +# the system property java.security.auth.login.config then this setting +# specifies the config section name to be used for the authority +#athenz.auth.kerberos.jaas_cfg_section= + +# Kerberos Authority - login callback handler class +#athenz.auth.kerberos.login_callback_handler_class= + +# Kerberos Authority - boolean flag whether or not to renew TGT +#athenz.auth.kerberos.renewTGT=true + +# Kerberos Authority - boolean flag whether or not using ticket cache +#athenz.auth.kerberos.use_ticket_cache=true + +# Kerberos Authority - file path for the ticket cache data +#athenz.auth.kerberos.ticket_cache_name= + +# Kerberos Authority - in milliseconds the login window time for re-logins +#athenz.auth.kerberos.login_window=60000 + +# Kerberos Authority - privileged action class name +#athenz.auth.kerberos.krb_privileged_action_class= + +# Kerberos Authority - the realm for kerberos users (this could be a realm +# that regular users (also authenticated as part of UserAuthority) are part of +#athenz.auth.kerberos.user_realm= + +# Kerberos Authority - the domain name for users that are only authenticated +# by this authority +#athenz.auth.kerberos.krb_user_domain=krb + +# Kerberos Authority - the realm name for users that are only authenticated +# by this authority +#athenz.auth.kerberos.krb_user_realm= + +# ZTS is running within AWS so enable features such as getting temporary +# credentials, etc. +#athenz.zts.aws_enabled=false + +# If ZTS is running within AWS, this setting specifies path a file that includes +# the AWS Public certificate that is needed to verify host identity documents +# provided by AWS. +#athenz.zts.aws_public_cert + +# If ZTS is running within AWS and we need to validate the host identity document +# before we issue a TLS certificate for a service identified by its IAM role, +# the server verifies that the instance was booted within the configured number +# of seconds +#athenz.zts.aws_boot_time_offset=300 + +# Comma separated list of URIs that require authentication according to the RDL +# but we want the server to make the authentication as optional. The URI can +# include regex values based on + character to match resource URIs +# for example, /zts/v1/domain/.+/service +athenz.zts.no_auth_uri_list=/zts/v1/status,/zts/v1/instance + +# Boolean flag to control whether or not to include c=1 component in the issued +# role token when the rolename argument passed to the api is null. The presence +# of the c=1 in the role token then would indicate that the token contains all +# the roles that the principal has access in the domain +#athenz.zts.role_complete_flag=true + +# If configured, specifies whether or not the server should send +# back the x.509 signer certificate in the response. It's possible +# that the environment already has an external way of distributing +# signer certificates +#athenz.zts.resp_x509_signer_certs=true + +# If configured, specifies whether or not the server should send +# back the ssh signer certificate in the response. It's possible +# that the environment already has an external way of distributing +# signer certificates +#athenz.zts.resp_ssh_signer_certs=true + +# If configured, specifies a file name that contains the bundle of Athenz +# CA certificates. This is useful when there are multiple Athenz instances +# running in different regions/locations and each region/location has its own +# CA certificate and during instance register/refresh operation we want to +# return the full set of CA certs +#athenz.zts.x509_ca_cert_fname= + +# If configured, specifies a file name that contains the SSH Host certificate +# Signer certificates. This is useful when there are multiple Athenz instances +# running in different regions/locations and each region/location has its own +# Signer certificate and during instance register/refresh operation we want to +# return the full set of certs +#athenz.zts.ssh_host_ca_cert_fname= + +# If configured, specifies a file name that contains the SSH User certificate +# Signer certificates. This is useful when there are multiple Athenz instances +# running in different regions/locations and each region/location has its own +# Signer certificate and during instance register/refresh operation we want to +# return the full set of certs +#athenz.zts.ssh_user_ca_cert_fname= + +# The number of milliseconds to sleep between runs of the idle object +# evictor thread. When non-positive, no idle object evictor thread +# will be run. The pool default is -1, but we're using 30 minutes to +# make sure the evictor thread is running | +#athenz.db.pool_evict_idle_interval=1800000 + +# The minimum amount of time (in milliseconds) an object may sit +# idle in the pool before it is eligible for eviction by the idle +# object evictor (if any) +#athenz.db.pool_evict_idle_timeout=1800000 + +# The maximum number of connections that can remain idle in the pool, +# without extra ones being released, or negative for no limit +#athenz.db.pool_max_idle=8 + +# The maximum number of active connections that can be allocated +# from this pool at the same time, or negative for no limit +#athenz.db.pool_max_total=8 + +# The maximum lifetime in milliseconds of a connection. After this +# time is exceeded the connection will fail the next activation, +# passivation or validation test. A value of zero or less means the +# connection has an infinite lifetime +#athenz.db.pool_max_ttl=600000 + +# The maximum number of milliseconds that the pool will wait +# (when there are no available connections) for a connection to be +# returned before throwing an exception, or -1 to wait indefinitely +#athenz.db.pool_max_wait=-1 + +# The minimum number of connections that can remain idle in the pool, +# without extra ones being created, or zero to create none +#athenz.db.pool_min_idle=0 + +# The validation query used by the pool to determine if the connection +# is valid before returning it to the caller. The default value +# is the recommended query for the Mysql/J Connector +#athenz.db.pool_validation_query=/* ping */ SELECT 1 + +# List of valid values separated by | that a certificate +# request can include in the Subject O field. For example, if +# you allow to create certs with c=US,o=Company,cn=athenz.api +# and c=US,o=Company Inc.,cn=athenz.api, then the value for +# this property would be set to "Company|Company Inc.". If +# the property is not set, then no validation is carried out. +#athenz.zts.cert_allowed_o_values= + +# If enabled, ZTS server will validate the OU field in +# any certificate request if one is specified. If the +# certificate is requested from a Copper Argos provider, the provider +# service name is automatically allowed as one of the valid OU +# values. Otherwise, the list of values can be configured using +# the athenz.zts.cert_allowed_ou_values property. +#athenz.zts.cert_request_verify_subject_ou=false + +# List of valid values separated by | that a certificate +# request can include in the Subject OU field. For example, if +# you allow to create certs with c=US,o=Company,OU=Athenz,cn=athenz.api +# and c=US,o=Company Inc.,ou=Yahoo,cn=athenz.api, then the value for +# this property would be set to "Athenz|Yahoo". In case the +# certificate is requested from a Copper Argos provider, the provider +# service name is automatically allowed as one of the valid OU +# values. The validation is carried out only if the +# setting is enabled (set to true). +#athenz.zts.cert_allowed_ou_values= + +# During certificate refresh operations zts server looks up +# the original certificate details (serial number, timestamp, etc) +# to detect compromise. If this database is lost, then server +# will not be able to refresh any certs, so we provide an option +# to regenerate the db based on requests rather than rejecting +# all. So while the certs are refreshed, compromise will not be +# detected during the first refresh, but during the second one +# it will be detected. The value of the setting is the number +# of milliseconds since epoch. Any refresh request where the cert +# has a timestamp before this date will be handled successfully +# if the db record is not found. +#athenz.zts.cert_refresh_reset_time=0 + +# When requesting role and service certificates not through +# Copper Argos providers, the server can verify that the IP +# address in the request indeed matches to the connection +# IP address. Typically this should be enabled by default +# but keeping it false for now for backward compatibility +# reasons. +#athenz.zts.cert_request_verify_ip=false + +# If the athenz.zts.cert_record_store_factory_class property is using +# the dynamodb cert record store factory identified with +# io.athenz.server.aws.common.cert.impl.DynamoDBCertRecordStoreFactory, then +# this setting specifies the table name where the ZTS Server will store +# certificate records for revocation checks. The table must be created +# with the following requirements: primary field - primaryKey. +# Enable TTL and call the attribute as ttl. +#athenz.zts.cert_dynamodb_table_name= + +# If the athenz.zts.cert_record_store_factory_class property is using +# the dynamodb cert record store factory identified with +# io.athenz.server.aws.common.cert.impl.DynamoDBCertRecordStoreFactory, then +# this setting specifies the current-time index used to query records by time of update +# on the table specified in athenz.zts.cert_dynamodb_table_name +# The index must be created using the currentDate as partition key +# and project all attributes. +#athenz.zts.cert_dynamodb_index_current_time_name= + +# When using the DynamoDB certificate record store factory (see +# property athenz.zts.cert_dynamodb_table_name) this setting specifies +# the configured number of hours that DynamoDB will purge expired +# records. Default value is 30 days. +#athenz.zts.cert_dynamodb_item_ttl_hours=720 + +# Athenz ZTS Service Health Check file path. If configured, the +# /zts/v1/status command would return failure if the file setting +# is configured but the file is not present. The idea is that once +# the server is started, an external process will verify that +# the server is running correctly by running some checks and if +# successful, it will create that file so that the server can +# now report that the server is ready to accept production traffic +#athenz.zts.health_check_path= + +# Path to the json configuration file that specifies the certificate +# bundles that can be requested from the ZTS server. An example +# of the config file available in src/test/resources/ca-bundle-file.json +# If the bundle type is x509 then the server will parse all the +# certificates and generate their PEM representation again in order +# to remove any comments present in the file thus reducing the +# content size when requested +#athenz.zts.cert_authority_bundles_fname + +athenz.zms.client.keystore_path=/opt/athenz/zts/var/certs/zms_client_keystore.pkcs12 +athenz.zms.client.keystore_type=PKCS12 +# athenz.zms.client.keystore_password=athenz +athenz.zms.client.truststore_path=/opt/athenz/zts/var/certs/zms_client_truststore.jks +athenz.zms.client.truststore_type=JKS +# athenz.zms.client.truststore_password=athenz +athenz.zms.client.keymanager_password=dummy + +# Specifies the factory class that implements the StatusChecker interface +# Used to check the status of the ZTS server +#athenz.zts.status_checker_factory_class= + +# If the athenz.zts.change_log_store_factory_class change log factory class +# is set to com.yahoo.athenz.common.server.store.impl.ZMSFileChangeLogStoreFactory and +# the client wants to use mtls authentication when talking to ZMS Server +# instead of private key based service this tokens then this setting specifies +# the path to the zts server private key +# athenz.common.server.clog.zts_server_key_path= + +# If the athenz.zts.change_log_store_factory_class change log factory class +# is set to com.yahoo.athenz.common.server.store.impl.ZMSFileChangeLogStoreFactory and +# the client wants to use mtls authentication when talking to ZMS Server +# instead of private key based service this tokens then this setting specifies +# the path to the zts server x.509 certificate +#athenz.common.server.clog.zts_server_cert_path= + +# If the athenz.zts.change_log_store_factory_class change log factory class +# is set to com.yahoo.athenz.common.server.store.impl.ZMSFileChangeLogStoreFactory and +# the client wants to use mtls authentication when talking to ZMS Server +# instead of private key based service this tokens then this setting specifies +# the path to the trust store jks file that the client will use to validate +# the ZMS Server certificate +#athenz.common.server.clog.zts_server_trust_store_path= + +# If the athenz.zts.change_log_store_factory_class change log factory class +# is set to com.yahoo.athenz.common.server.store.impl.ZMSFileChangeLogStoreFactory and +# the client wants to use mtls authentication when talking to ZMS Server +# instead of private key based service this tokens then this setting specifies +# the key name that stores the password for the trust store. This requires that +# a private key store factory class is set and configured. If this value is +# not set, then the default jdk password of changeit will be used. +#athenz.common.server.clog.zts_server_trust_store_password_name= + +# If the athenz.zts.change_log_store_factory_class change log factory class +# is set to com.yahoo.athenz.common.server.store.impl.ZMSFileChangeLogStoreFactory and +# the client wants to use mtls authentication when talking to ZMS Server +# instead of private key based service this tokens then this setting specifies +# the app name that is used to retrieve the trust store password set by +# the athenz.common.server.clog.zts_server_trust_store_password_name setting. +#athenz.common.server.clog.zts_server_trust_store_password_app= + +# Comma separated list of domain that have dynamic services. For example, +# screwdriver domain has dynamic projects for CI/CD and we need to give +# identity without creating a service. These services will be automatically +# skipped from validation before certs are issued. +#athenz.zts.validate_service_skip_domains= +athenz.auth.oauth.jwt.parser.jwks_url=https://athenz.io diff --git a/athenz/src/test/resources/docker/zts/db/zts-db.cnf b/athenz/src/test/resources/docker/zts/db/zts-db.cnf new file mode 100644 index 00000000000..710fe047cfd --- /dev/null +++ b/athenz/src/test/resources/docker/zts/db/zts-db.cnf @@ -0,0 +1,10 @@ +# env. variable from command line will take priority + +[mysqld] +port = 3306 + +[client] +port = 3306 + +[mysqladmin] +port = 3306 diff --git a/athenz/src/test/resources/docker/zts/db/zts-init-db.sql b/athenz/src/test/resources/docker/zts/db/zts-init-db.sql new file mode 100644 index 00000000000..f5848fada8d --- /dev/null +++ b/athenz/src/test/resources/docker/zts/db/zts-init-db.sql @@ -0,0 +1,9 @@ +-- Create the 'zts_store' database if it doesn't exist. +CREATE DATABASE IF NOT EXISTS zts_store; + +-- Create the 'zts_admin' user and grant it privileges on the zts_store database. +CREATE USER 'zts_admin'@'%' IDENTIFIED BY 'mariadbmariadb'; +GRANT ALL PRIVILEGES ON zts_store.* TO 'zts_admin'@'%'; + +-- Apply the changes. +FLUSH PRIVILEGES; diff --git a/athenz/src/test/resources/docker/zts/var/certs/zms_client_keystore.pkcs12 b/athenz/src/test/resources/docker/zts/var/certs/zms_client_keystore.pkcs12 new file mode 100644 index 00000000000..011b7211615 Binary files /dev/null and b/athenz/src/test/resources/docker/zts/var/certs/zms_client_keystore.pkcs12 differ diff --git a/athenz/src/test/resources/docker/zts/var/certs/zms_client_truststore.jks b/athenz/src/test/resources/docker/zts/var/certs/zms_client_truststore.jks new file mode 100644 index 00000000000..29ffa45e45f Binary files /dev/null and b/athenz/src/test/resources/docker/zts/var/certs/zms_client_truststore.jks differ diff --git a/athenz/src/test/resources/docker/zts/var/certs/zts_keystore.pkcs12 b/athenz/src/test/resources/docker/zts/var/certs/zts_keystore.pkcs12 new file mode 100644 index 00000000000..1bab88053ba Binary files /dev/null and b/athenz/src/test/resources/docker/zts/var/certs/zts_keystore.pkcs12 differ diff --git a/athenz/src/test/resources/docker/zts/var/certs/zts_signer_keystore.pkcs12 b/athenz/src/test/resources/docker/zts/var/certs/zts_signer_keystore.pkcs12 new file mode 100644 index 00000000000..b1ded8a0c54 Binary files /dev/null and b/athenz/src/test/resources/docker/zts/var/certs/zts_signer_keystore.pkcs12 differ diff --git a/athenz/src/test/resources/docker/zts/var/certs/zts_signer_truststore.jks b/athenz/src/test/resources/docker/zts/var/certs/zts_signer_truststore.jks new file mode 100644 index 00000000000..6d0045d50a8 Binary files /dev/null and b/athenz/src/test/resources/docker/zts/var/certs/zts_signer_truststore.jks differ diff --git a/athenz/src/test/resources/docker/zts/var/certs/zts_truststore.jks b/athenz/src/test/resources/docker/zts/var/certs/zts_truststore.jks new file mode 100644 index 00000000000..34813a943eb Binary files /dev/null and b/athenz/src/test/resources/docker/zts/var/certs/zts_truststore.jks differ diff --git a/athenz/src/test/resources/docker/zts/var/keys/zts_private.pem b/athenz/src/test/resources/docker/zts/var/keys/zts_private.pem new file mode 100644 index 00000000000..5b4dd855c27 --- /dev/null +++ b/athenz/src/test/resources/docker/zts/var/keys/zts_private.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEAmtVyHDNAUVo5z7d0oOEITqSHdSBfGo4HzINqk3nLHIoKoqHB +ytaPtc/c0dH7sbd8z5PbHf5jMqMxhFEiV6BK9H1ko8LvIWRsyx7mzCXFTLEf0IqH +nkWAJnjbaG5lIUi1uCPZd2n6xkBi9qFS1nvDD2tfyF8LvVp3tZuZxRTtJeOrvg9C +CxMElh77tYnsrT9eE6qxKseHnskYxnrdueUBIYHL7KpcZcysc8dRvd40Qhe2p0Wy +eDi+DtK4+yG1mbTNK/0hZH77+dBZj5/d5D3QQNYDfql+DaRsVFvNW/CP5QU6VBCy +z/RHpMMWv/NKFoGn0OPLqQa5+FB1RIwkAgCdcesm/JEyOlqu6ofVlNlmkyFwOF1B +aPmcZBcfeXYdYFFj326C3vnhW8QX8zDobRR8R4rJwIVVaGqrefH5ig3OcPBoPjSn +2/pORZqdiSZ7iDGURvh0ougtvY1TusrBV+U1WBf8nSi/4eLhWsm9xlcualjUxUX8 +h9hDVTPT5VtF7cTV8rfoXJ05K4PvH8X14e2oRQbmZqg9ZxFGCoYOQ9R/R9VTaM/L +0htwCBM+EKrQygUwzFEsX3E+U3Rko1kQqSY2qXCAgWAVikB29kOyYmVmX1VbzgA9 +bAsuT0Ly2wKVr7H+R7NVp70B9fzqoMBkAk3yB5N6Y4C3g7n+cxYlrInm110CAwEA +AQKCAgAoMthwd+Hv/SPbSP8Psb0NZewBPclTRKLDty7ZYRqZq0E9ng+JURF4m6Hy +G7lkF4ZhAjs2W1B32iTF/KaU53nuZgvV9ItPORqB1eEICZpi4e2nn/+72MF/u4sD +Xv4EmTMMvsn8FdjU1eybqaCvLOykuTTh+cM7gxxnWyGeKGO1nZl+O8nixCpBq4Og +S1HWXQhYxM1AlRMh54X2KfodBFa501AVftmEWJJBcPNI/0LEEhIK9a5zRhAaGx2J +nePfJzDJhevAgrN61VwbTN84HdBOtQGtReYDNrglYrw7bYZ+aFAPeVJjaUGchkYG +yexnUXw3YJ0EoklwLbt0c4n5tx0pCgoatiu3Xv0O50ft1OOBEYaEIifW1xa7c7kO +a3RFp23JQMNApB5bslAia2zNpclUMu8V2Kc35kKMQBVUSSUFvx82odNUUbT2k+x8 +caFky54EywflDFnyVfS4CY0nVOZ8CFTRii0PfbpFOhvmCg7DIKWgFWiY0jAkaLOc +9eEHGZy+rc9nAzs6VKMD6F4ON4ns9Ms1lE6euXXTkc5wHHb2sZd10zG3FgPMcsq2 +dUvIzDFOmgKetdekxaBCqzFP0lxUJt4JMU0u+TNOqPWMUPrsttptEGNfZKeLLse6 ++QFLgnZlTj9qbyTx/FZr+bV/qrxTqu9KEEXIhzFfwaGIvYy/IQKCAQEAx28Vi+dO +4o4ikLlyCkV9rfKDxMXupwTmGrSV9MHYDP2QUGUY4bxSdLlGnuFRPi6j1781u7z2 +A/sl4bM2Oy4xCh1xy3YxoM9xUQcFA+EFK9MlcqLilmI+IK1RdcajHQNZ/77AFYXa +w8EJLkUFzviNBFMTZZimU5GYfgvQu8Nbd62fbhWgFNevSeJUHPCcMIeQ4EpljfFV ++VkqO9nGplxeB3BlphIBZsaaVMJqZpnQQ9y42TIuLnycN2rWHz4BUuYcNpGVY4gh +hkN0HFTZd1URbPzx1j8mYqtfqU0NjCuHfroUxX5Z1ez/lTl7FBw/ii2VnijW5Pin +/K6fubtLJjcfBQKCAQEAxr/wxBvhF56UHPtpA62j06NqK5VEFaI2IvyWby/p4x+h +PjdFGm590+S/vr1ibAWaAY9DISUCVa46t6XQ46ckYQymJkWg8hFtxM9eJLZeqD/H +N/aclSdmkhUhueMObC3yQbHC6TXhWGxcEsQVOV/t2+e839K/hMUjDWBclBsO9Yy0 +cbUss+tDkTf7rgveXe6zLVUPFlop+kMhvldtDYgTwgdPnVe+gTFX2+UGVQgylSNY +u8yfqHWTeIpmZdTWQszGaVbHxE50UlpEkX5YVwWO8BoSsSAz/o7TCfLuBm039OOU +da0qoKIHEj3/tgmpg8dboHJ/wENtfG/X477yJC/WeQKCAQEAkz7F9sRyLr1ocdgp +hcHm1/49IZRN7sykI8V/DfXs0TKJUYJDC6+iZYHBzV5oQHPpDkqjnpgWP7w3LNvH +R4yEMoao1OLQI33lmUCIiGOkEMZVWL1AdG3WlwPAKCffmHU4BqXMZlesvGkSoTHe +5wAGBdJceN9qtjrCDxYkJ+1F5CexlXKr/Zd9WRyco71WZFJDOgZT0qgDpRUbJJNW +os+BsRZgRmv20u4BVmEwc85OwTv+0oXFKRdhs1zS+MAr9AvnerakSJdiwSGeYaiX +4w5Qzlo3J8r6SVFkULiOLxaYdI1o9qOiKltQHNoSsDdnBls/o0Vd3DJmvR4k2dmZ +035RDQKCAQEAtW1nao2Ml5QR3rb9sPbkMwDcnTZf9WOjCaML7CmP1t37wiCP+0Xb +pP8Orh6RZsxiPoQ5olr3W6F1RkmeewILqm/yh8NN3UtdqagmZ1r24518zTBY1asm +7blOlTKY6tWybJfJtjuSHrakAhluynYwWmqbtrHaVGfkzIQnXqeoqywrWBUr3n2n +qzuwiruY3KRWec5IvH3IDgUUG34RNaX/a0JjQd1kMOkLK9dQRXT7P7sJeD2djRjv +arzkJpb4k/f8MxKdvyxi8P4n06zDFYUkazdR0tDzxa85JL7W25T93JWW4ykVXZcq +31MrR1BgpsPod3mt9qNWoZ4zNFoNDk2A+QKCAQBbUG2BqQj2cf74Us1VmO4620Su +n275a36MSnFCUZAvLT2usafu8iUYnElQR7U9g8YGeRc4ThX2TstCssyb2Qw2oyle +gFAcwXHT57DUmuGuN7xVmGouzGv6Ee75WJY77F5j3OpQyYKri8JvzZVZhzQsIsUQ +YhAQ50ImgTnWBa+8p1xte9tYngFQgpDrOhR9kbA1Wwc6M32/CzCf8Bg8/POxOPOH +4xVH7ldWlUL+c2hrjYjhT7aFai1/MxgzJTsCsXOXmvVykuHBrsmcEaI0bubALb5t +KpIgYB0VCPocfMyi3DcQvUMhgjHWdl1cbR/RGNH/EsbdRyzNkZXTfEKoW8e1 +-----END RSA PRIVATE KEY----- diff --git a/athenz/src/test/resources/docker/zts/var/keys/zts_public.pem b/athenz/src/test/resources/docker/zts/var/keys/zts_public.pem new file mode 100644 index 00000000000..84e290beb81 --- /dev/null +++ b/athenz/src/test/resources/docker/zts/var/keys/zts_public.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAmtVyHDNAUVo5z7d0oOEI +TqSHdSBfGo4HzINqk3nLHIoKoqHBytaPtc/c0dH7sbd8z5PbHf5jMqMxhFEiV6BK +9H1ko8LvIWRsyx7mzCXFTLEf0IqHnkWAJnjbaG5lIUi1uCPZd2n6xkBi9qFS1nvD +D2tfyF8LvVp3tZuZxRTtJeOrvg9CCxMElh77tYnsrT9eE6qxKseHnskYxnrdueUB +IYHL7KpcZcysc8dRvd40Qhe2p0WyeDi+DtK4+yG1mbTNK/0hZH77+dBZj5/d5D3Q +QNYDfql+DaRsVFvNW/CP5QU6VBCyz/RHpMMWv/NKFoGn0OPLqQa5+FB1RIwkAgCd +cesm/JEyOlqu6ofVlNlmkyFwOF1BaPmcZBcfeXYdYFFj326C3vnhW8QX8zDobRR8 +R4rJwIVVaGqrefH5ig3OcPBoPjSn2/pORZqdiSZ7iDGURvh0ougtvY1TusrBV+U1 +WBf8nSi/4eLhWsm9xlcualjUxUX8h9hDVTPT5VtF7cTV8rfoXJ05K4PvH8X14e2o +RQbmZqg9ZxFGCoYOQ9R/R9VTaM/L0htwCBM+EKrQygUwzFEsX3E+U3Rko1kQqSY2 +qXCAgWAVikB29kOyYmVmX1VbzgA9bAsuT0Ly2wKVr7H+R7NVp70B9fzqoMBkAk3y +B5N6Y4C3g7n+cxYlrInm110CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java index 13b0e1d2563..ca860ebe52a 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java @@ -1196,6 +1196,10 @@ public ServerBuilder tlsProvider(TlsProvider tlsProvider) { requireNonNull(tlsProvider, "tlsProvider"); this.tlsProvider = tlsProvider; tlsConfig = null; + + if (tlsProvider.autoClose()) { + shutdownSupports.add(ShutdownSupport.of(tlsProvider)); + } return this; } @@ -1229,6 +1233,10 @@ public ServerBuilder tlsProvider(TlsProvider tlsProvider) { public ServerBuilder tlsProvider(TlsProvider tlsProvider, ServerTlsConfig tlsConfig) { tlsProvider(tlsProvider); this.tlsConfig = requireNonNull(tlsConfig, "tlsConfig"); + + if (tlsProvider.autoClose()) { + shutdownSupports.add(ShutdownSupport.of(tlsProvider)); + } return this; } diff --git a/dependencies.toml b/dependencies.toml index 92c0bc851ce..39643f15d2d 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -6,6 +6,7 @@ apache-httpclient5 = "5.5" apache-httpclient4 = "4.5.14" asm = "9.8" assertj = "3.27.3" +athenz = "1.12.21" awaitility = "4.3.0" blockhound = "1.0.13.RELEASE" bouncycastle = "1.81" @@ -225,6 +226,16 @@ version.ref = "asm" module = "org.assertj:assertj-core" version.ref = "assertj" +[libraries.athenz-zms-client] +module = "com.yahoo.athenz:athenz-zms-java-client" +version.ref = "athenz" +[libraries.athenz-zpe-client] +module = "com.yahoo.athenz:athenz-zpe-java-client" +version.ref = "athenz" +[libraries.athenz-zts-client] +module = "com.yahoo.athenz:athenz-zts-java-client" +version.ref = "athenz" + [libraries.awaitility] module = "org.awaitility:awaitility" version.ref = "awaitility" diff --git a/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/DefaultOAuth2AuthorizationGrant.java b/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/DefaultOAuth2AuthorizationGrant.java index 3389c4fef2a..f1f7fecd721 100644 --- a/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/DefaultOAuth2AuthorizationGrant.java +++ b/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/DefaultOAuth2AuthorizationGrant.java @@ -18,12 +18,12 @@ import static java.util.Objects.requireNonNull; -import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.function.Supplier; import org.slf4j.Logger; @@ -50,7 +50,6 @@ class DefaultOAuth2AuthorizationGrant implements OAuth2AuthorizationGrant { private final OAuth2Endpoint oAuth2Endpoint; private final Supplier requestSupplier; - private final Duration refreshBefore; @Nullable private final Supplier> fallbackTokenProvider; @Nullable @@ -61,24 +60,47 @@ class DefaultOAuth2AuthorizationGrant implements OAuth2AuthorizationGrant { WebClient accessTokenEndpoint, String accessTokenEndpointPath, Supplier requestSupplier, OAuth2ResponseHandler responseHandler, - Duration refreshBefore, + @Nullable Predicate refreshIf, @Nullable Supplier> fallbackTokenProvider, @Nullable Consumer newTokenConsumer) { oAuth2Endpoint = new OAuth2Endpoint<>(accessTokenEndpoint, accessTokenEndpointPath, responseHandler); this.requestSupplier = requestSupplier; - this.refreshBefore = refreshBefore; this.fallbackTokenProvider = fallbackTokenProvider; this.newTokenConsumer = newTokenConsumer; + final String name = "oauth2-token-loader/" + concatPath(accessTokenEndpoint.uri().toString(), + accessTokenEndpointPath); final AsyncLoaderBuilder loaderBuilder = AsyncLoader.builder(this::loadToken) + .name(name) .expireIf(token -> !isValidToken(token)); - if (!refreshBefore.isZero()) { - loaderBuilder.refreshIf(this::shouldRefresh); + if (refreshIf != null) { + loaderBuilder.refreshIf(refreshIf); } tokenLoader = loaderBuilder.build(); } + private String concatPath(String uri, String path) { + requireNonNull(uri, "uri"); + requireNonNull(path, "path"); + + if (uri.charAt(uri.length() - 1) == '/') { + if (path.charAt(0) == '/') { + // Deduplicate double slash + return uri + path.substring(1); + } else { + return uri + path; + } + } else { + if (path.charAt(0) == '/') { + return uri + path; + } else { + // Add a slash between uri and path + return uri + '/' + path; + } + } + } + private CompletableFuture loadToken(@Nullable GrantedOAuth2AccessToken token) { final CompletableFuture newTokenFuture = new CompletableFuture<>(); @@ -137,10 +159,6 @@ private static boolean isValidToken(@Nullable GrantedOAuth2AccessToken token) { return token != null && token.isValid(Instant.now()); } - private boolean shouldRefresh(GrantedOAuth2AccessToken token) { - return !token.isValid(Instant.now().plus(refreshBefore)); - } - private void obtainAccessToken(CompletableFuture future) { executeRequest(accessTokenRequest(), future); } diff --git a/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/OAuth2AuthorizationGrantBuilder.java b/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/OAuth2AuthorizationGrantBuilder.java index dea48addb81..503d5a23cff 100644 --- a/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/OAuth2AuthorizationGrantBuilder.java +++ b/oauth2/src/main/java/com/linecorp/armeria/client/auth/oauth2/OAuth2AuthorizationGrantBuilder.java @@ -20,8 +20,10 @@ import static java.util.Objects.requireNonNull; import java.time.Duration; +import java.time.Instant; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.function.Supplier; import com.linecorp.armeria.client.WebClient; @@ -36,10 +38,17 @@ @UnstableApi public final class OAuth2AuthorizationGrantBuilder { + private static final Duration DEFAULT_REFRESH_BEFORE = Duration.ofMinutes(1L); // 1 minute + /** * A period when the token should be refreshed proactively prior to its expiry. */ - private static final Duration DEFAULT_REFRESH_BEFORE = Duration.ofMinutes(1L); // 1 minute + private static final Predicate DEFAULT_REFRESH_IF = + toRefreshIf(DEFAULT_REFRESH_BEFORE); + + private static Predicate toRefreshIf(Duration refreshBefore) { + return token -> !token.isValid(Instant.now().plus(refreshBefore)); + } private final WebClient accessTokenEndpoint; private final String accessTokenEndpointPath; @@ -48,7 +57,8 @@ public final class OAuth2AuthorizationGrantBuilder { private Supplier requestSupplier; private OAuth2ResponseHandler responseHandler = DefaultAccessTokenResponseHandler.INSTANCE; - private Duration refreshBefore = DEFAULT_REFRESH_BEFORE; + @Nullable + private Predicate refreshIf = DEFAULT_REFRESH_IF; @Nullable private Supplier> fallbackTokenProvider; @@ -101,7 +111,20 @@ public OAuth2AuthorizationGrantBuilder responseHandler( public OAuth2AuthorizationGrantBuilder refreshBefore(Duration refreshBefore) { requireNonNull(refreshBefore, "refreshBefore"); checkState(!refreshBefore.isNegative(), "refreshBefore: %s (expected: >= 0)", refreshBefore); - this.refreshBefore = refreshBefore; + if (refreshBefore.isZero()) { + refreshIf = null; + return this; + } else { + return refreshIf(toRefreshIf(refreshBefore)); + } + } + + /** + * Sets a period when the token should be refreshed proactively prior to its expiry. + */ + public OAuth2AuthorizationGrantBuilder refreshIf(Predicate refreshIf) { + requireNonNull(refreshIf, "refreshIf"); + this.refreshIf = refreshIf; return this; } @@ -143,7 +166,7 @@ public OAuth2AuthorizationGrantBuilder newTokenConsumer( public OAuth2AuthorizationGrant build() { checkState(requestSupplier != null, "accessTokenRequest() is not set."); return new DefaultOAuth2AuthorizationGrant( - accessTokenEndpoint, accessTokenEndpointPath, requestSupplier, responseHandler, refreshBefore, + accessTokenEndpoint, accessTokenEndpointPath, requestSupplier, responseHandler, refreshIf, fallbackTokenProvider, newTokenConsumer); } } diff --git a/settings.gradle b/settings.gradle index 23ce71870cc..dfe6de83cad 100644 --- a/settings.gradle +++ b/settings.gradle @@ -90,6 +90,7 @@ project(':version-catalog').with { // Published Java projects includeWithFlags ':annotation-processor', 'java', 'publish', 'relocate' +includeWithFlags ':athenz', 'java11', 'publish', 'relocate', 'native' includeWithFlags ':brave5', 'java', 'publish', 'relocate', 'no_aggregation' project(':brave5').projectDir = file('brave/brave5') includeWithFlags ':brave6', 'java', 'publish', 'relocate', 'native'