diff options
author | Morten Tokle <morten.tokle@gmail.com> | 2018-06-21 13:04:58 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-06-21 13:04:58 +0200 |
commit | 50ec63c3a06da7bef8cc8706decb1872a10f4389 (patch) | |
tree | 14f5b40fb0c7e4cbd358df0366f3dff0e4843262 | |
parent | 44820b516a16cda3e82ba7beab8e2fe5bd3f4831 (diff) | |
parent | 12afa2bee25c6c22448f9d79acc3a38e68a4cf45 (diff) |
Merge pull request #6245 from vespa-engine/bjorncs/athenz-filter
Bjorncs/athenz filter
21 files changed, 679 insertions, 36 deletions
diff --git a/jdisc-security-filters/pom.xml b/jdisc-security-filters/pom.xml index bcee244ef69..5e8356e94f1 100644 --- a/jdisc-security-filters/pom.xml +++ b/jdisc-security-filters/pom.xml @@ -23,6 +23,12 @@ <version>${project.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>vespa-athenz</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> <!-- test --> <dependency> @@ -41,6 +47,7 @@ <artifactId>mockito-core</artifactId> <scope>test</scope> </dependency> + </dependencies> <build> diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzAuthorizationFilter.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzAuthorizationFilter.java new file mode 100644 index 00000000000..74e0ee36959 --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzAuthorizationFilter.java @@ -0,0 +1,145 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.google.inject.Inject; +import com.yahoo.jdisc.Response; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.filter.security.athenz.AthenzAuthorizationFilterConfig.CredentialsToVerify; +import com.yahoo.jdisc.http.filter.security.athenz.RequestResourceMapper.ResourceNameAndAction; +import com.yahoo.jdisc.http.filter.security.base.JsonSecurityRequestFilterBase; +import com.yahoo.vespa.athenz.api.AthenzIdentity; +import com.yahoo.vespa.athenz.api.AthenzPrincipal; +import com.yahoo.vespa.athenz.api.AthenzResourceName; +import com.yahoo.vespa.athenz.api.AthenzRole; +import com.yahoo.vespa.athenz.api.ZToken; +import com.yahoo.vespa.athenz.tls.AthenzX509CertificateUtils; +import com.yahoo.vespa.athenz.zpe.AuthorizationResult; +import com.yahoo.vespa.athenz.zpe.DefaultZpe; +import com.yahoo.vespa.athenz.zpe.Zpe; + +import java.security.cert.X509Certificate; +import java.util.Optional; +import java.util.function.Function; + +import static java.util.Collections.singletonList; + +/** + * An Athenz security filter that uses a configured action and resource name to control access. + * + * @author bjorncs + */ +public class AthenzAuthorizationFilter extends JsonSecurityRequestFilterBase { + + private final String headerName; + private final Zpe zpe; + private final RequestResourceMapper requestResourceMapper; + private final CredentialsToVerify.Enum credentialsToVerify; + + @Inject + public AthenzAuthorizationFilter(AthenzAuthorizationFilterConfig config, RequestResourceMapper resourceMapper) { + this(config, resourceMapper, new DefaultZpe()); + } + + AthenzAuthorizationFilter(AthenzAuthorizationFilterConfig config, + RequestResourceMapper resourceMapper, + Zpe zpe) { + this.headerName = config.roleTokenHeaderName(); + this.credentialsToVerify = config.credentialsToVerify(); + this.requestResourceMapper = resourceMapper; + this.zpe = zpe; + } + + @Override + protected Optional<ErrorResponse> filter(DiscFilterRequest request) { + Optional<ResourceNameAndAction> resourceMapping = + requestResourceMapper.getResourceNameAndAction(request.getMethod(), request.getRequestURI(), request.getQueryString()); + if (!resourceMapping.isPresent()) { + return Optional.empty(); + } + Optional<X509Certificate> roleCertificate = getRoleCertificate(request); + Optional<ZToken> roleToken = getRoleToken(request, headerName); + switch (credentialsToVerify) { + case CERTIFICATE_ONLY: { + if (!roleCertificate.isPresent()) { + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, "Missing client certificate")); + } + return checkAccessAllowed(roleCertificate.get(), resourceMapping.get(), request); + } + case TOKEN_ONLY: { + if (!roleToken.isPresent()) { + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, + String.format("Role token header '%s' is missing or does not have a value.", headerName))); + } + return checkAccessAllowed(roleToken.get(), resourceMapping.get(), request); + } + case ANY: { + if (!roleCertificate.isPresent() && !roleToken.isPresent()) { + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, "Both role token and role certificate is missing")); + } + if (roleCertificate.isPresent()) { + return checkAccessAllowed(roleCertificate.get(), resourceMapping.get(), request); + } else { + return checkAccessAllowed(roleToken.get(), resourceMapping.get(), request); + } + } + default: { + throw new IllegalStateException("Unexpected mode: " + credentialsToVerify); + } + } + } + + private static Optional<X509Certificate> getRoleCertificate(DiscFilterRequest request) { + return Optional.of(request.getClientCertificateChain()) + .filter(chain -> !chain.isEmpty()) + .map(chain -> chain.get(0)) + .filter(AthenzX509CertificateUtils::isAthenzRoleCertificate); + } + + private static Optional<ZToken> getRoleToken(DiscFilterRequest request, String headerName) { + return Optional.ofNullable(request.getHeader(headerName)) + .filter(token -> !token.isEmpty()) + .map(ZToken::new); + } + + private Optional<ErrorResponse> checkAccessAllowed(X509Certificate certificate, + ResourceNameAndAction resourceNameAndAction, + DiscFilterRequest request) { + return checkAccessAllowed( + certificate, resourceNameAndAction, request, zpe::checkAccessAllowed, AthenzAuthorizationFilter::createPrincipal); + } + + private Optional<ErrorResponse> checkAccessAllowed(ZToken roleToken, + ResourceNameAndAction resourceNameAndAction, + DiscFilterRequest request) { + return checkAccessAllowed( + roleToken, resourceNameAndAction, request, zpe::checkAccessAllowed, AthenzAuthorizationFilter::createPrincipal); + } + + private static <C> Optional<ErrorResponse> checkAccessAllowed(C credentials, + ResourceNameAndAction resAndAction, + DiscFilterRequest request, + ZpeCheck<C> accessCheck, + Function<C, AthenzPrincipal> principalFactory) { + AuthorizationResult authorizationResult = accessCheck.checkAccess(credentials, resAndAction.resourceName(), resAndAction.action()); + if (authorizationResult == AuthorizationResult.ALLOW) { + request.setUserPrincipal(principalFactory.apply(credentials)); + return Optional.empty(); + } + return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Access forbidden: " + authorizationResult.getDescription())); + } + + private static AthenzPrincipal createPrincipal(X509Certificate certificate) { + AthenzIdentity identity = AthenzX509CertificateUtils.getIdentityFromRoleCertificate(certificate); + AthenzRole role = AthenzX509CertificateUtils.getRolesFromRoleCertificate(certificate); + return new AthenzPrincipal(identity, singletonList(role)); + } + + private static AthenzPrincipal createPrincipal(ZToken roleToken) { + return new AthenzPrincipal(roleToken.getIdentity(), roleToken.getRoles()); + } + + @FunctionalInterface private interface ZpeCheck<C> { + AuthorizationResult checkAccess(C credentials, AthenzResourceName resourceName, String action); + } + +} diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/RequestResourceMapper.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/RequestResourceMapper.java new file mode 100644 index 00000000000..77709975cba --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/RequestResourceMapper.java @@ -0,0 +1,37 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.yahoo.vespa.athenz.api.AthenzResourceName; + +import java.util.Optional; + +/** + * Maps a request to an {@link AthenzResourceName} and an action. + * + * @author bjorncs + */ +public interface RequestResourceMapper { + + /** + * @return A resource name + action to use for access control, empty if no access control should be performed. + */ + Optional<ResourceNameAndAction> getResourceNameAndAction(String method, String uriPath, String uriQuery); + + class ResourceNameAndAction { + private final AthenzResourceName resourceName; + private final String action; + + public ResourceNameAndAction(AthenzResourceName resourceName, String action) { + this.resourceName = resourceName; + this.action = action; + } + + public AthenzResourceName resourceName() { + return resourceName; + } + + public String action() { + return action; + } + } +} diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/StaticRequestResourceMapper.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/StaticRequestResourceMapper.java new file mode 100644 index 00000000000..ded13a9def4 --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/StaticRequestResourceMapper.java @@ -0,0 +1,33 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.google.inject.Inject; +import com.yahoo.vespa.athenz.api.AthenzResourceName; + +import java.util.Optional; + +/** + * A simple {@link RequestResourceMapper} that uses a fixed resource name and action + * + * @author bjorncs + */ +public class StaticRequestResourceMapper implements RequestResourceMapper { + + private final AthenzResourceName resourceName; + private final String action; + + @Inject + public StaticRequestResourceMapper(StaticRequestResourceMapperConfig config) { + this(AthenzResourceName.fromString(config.resourceName()), config.action()); + } + + StaticRequestResourceMapper(AthenzResourceName resourceName, String action) { + this.resourceName = resourceName; + this.action = action; + } + + @Override + public Optional<ResourceNameAndAction> getResourceNameAndAction(String method, String uriPath, String uriQuery) { + return Optional.of(new ResourceNameAndAction(resourceName, action)); + } +} diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/package-info.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/package-info.java new file mode 100644 index 00000000000..6ec5bd4322e --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/athenz/package-info.java @@ -0,0 +1,8 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author bjorncs + */ +@ExportPackage +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.yahoo.osgi.annotation.ExportPackage;
\ No newline at end of file diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/base/JsonSecurityRequestFilterBase.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/base/JsonSecurityRequestFilterBase.java index e2440bc4c5f..ec8a93019b0 100644 --- a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/base/JsonSecurityRequestFilterBase.java +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/base/JsonSecurityRequestFilterBase.java @@ -36,6 +36,7 @@ public abstract class JsonSecurityRequestFilterBase implements SecurityRequestFi errorMessage.put("code", error.errorCode); errorMessage.put("message", error.message); error.response.headers().put("Content-Type", "application/json"); // Note: Overwrites header if already exists + error.response.headers().put("Cache-Control", "must-revalidate,no-cache,no-store"); try (FastContentWriter writer = ResponseDispatch.newInstance(error.response).connectFastWriter(responseHandler)) { writer.write(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(errorMessage)); } catch (JsonProcessingException e) { diff --git a/jdisc-security-filters/src/main/resources/configdefinitions/athenz-authorization-filter.def b/jdisc-security-filters/src/main/resources/configdefinitions/athenz-authorization-filter.def new file mode 100644 index 00000000000..c60b7a125f8 --- /dev/null +++ b/jdisc-security-filters/src/main/resources/configdefinitions/athenz-authorization-filter.def @@ -0,0 +1,8 @@ +# Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +namespace=jdisc.http.filter.security.athenz + +# Which credentials to verify. Note: ANY will prioritize token over certificate if both are present. +credentialsToVerify enum { CERTIFICATE_ONLY, TOKEN_ONLY, ANY } default=ANY + +# Name of header which includes role token. Must be set if 'credentialsTypeRequired' is set to TOKEN_ONLY or ANY. +roleTokenHeaderName string default="" diff --git a/jdisc-security-filters/src/main/resources/configdefinitions/static-request-resource-mapper.def b/jdisc-security-filters/src/main/resources/configdefinitions/static-request-resource-mapper.def new file mode 100644 index 00000000000..de89c1f9198 --- /dev/null +++ b/jdisc-security-filters/src/main/resources/configdefinitions/static-request-resource-mapper.def @@ -0,0 +1,8 @@ +# Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +namespace=jdisc.http.filter.security.athenz + +# Athenz resource name on format '<domain-name>:<entity-name>' +resourceName string + +# Action name +action string
\ No newline at end of file diff --git a/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzAuthorizationFilterTest.java b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzAuthorizationFilterTest.java new file mode 100644 index 00000000000..a5242571130 --- /dev/null +++ b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/athenz/AthenzAuthorizationFilterTest.java @@ -0,0 +1,103 @@ +package com.yahoo.jdisc.http.filter.security.athenz; + +import com.yahoo.container.jdisc.RequestHandlerTestDriver; +import com.yahoo.jdisc.Response; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.vespa.athenz.api.AthenzResourceName; +import com.yahoo.vespa.athenz.api.ZToken; +import com.yahoo.vespa.athenz.zpe.AuthorizationResult; +import com.yahoo.vespa.athenz.zpe.Zpe; +import org.junit.Test; +import org.mockito.Mockito; + +import java.security.cert.X509Certificate; + +import static com.yahoo.jdisc.http.filter.security.athenz.AthenzAuthorizationFilterConfig.CredentialsToVerify.Enum.ANY; +import static java.util.Collections.emptyList; +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.when; + +/** + * @author bjorncs + */ +public class AthenzAuthorizationFilterTest { + + private static final AthenzResourceName RESOURCE_NAME = new AthenzResourceName("domain", "my-resource-name"); + private static final String ACTION = "update"; + private static final String HEADER_NAME = "Athenz-Role-Token"; + private static final AthenzAuthorizationFilterConfig CONFIG = createConfig(); + + private static AthenzAuthorizationFilterConfig createConfig() { + return new AthenzAuthorizationFilterConfig( + new AthenzAuthorizationFilterConfig.Builder() + .roleTokenHeaderName(HEADER_NAME) + .credentialsToVerify(ANY)); + } + + @Test + public void accepts_valid_requests() { + AthenzAuthorizationFilter filter = + new AthenzAuthorizationFilter( + CONFIG, new StaticRequestResourceMapper(RESOURCE_NAME, ACTION), new AllowingZpe()); + + RequestHandlerTestDriver.MockResponseHandler responseHandler = new RequestHandlerTestDriver.MockResponseHandler(); + filter.filter(createRequest(), responseHandler); + + assertNull(responseHandler.getResponse()); + } + + @Test + public void returns_error_on_forbidden_requests() { + AthenzAuthorizationFilter filter = + new AthenzAuthorizationFilter( + CONFIG, new StaticRequestResourceMapper(RESOURCE_NAME, ACTION), new DenyingZpe()); + + RequestHandlerTestDriver.MockResponseHandler responseHandler = new RequestHandlerTestDriver.MockResponseHandler(); + filter.filter(createRequest(), responseHandler); + + Response response = responseHandler.getResponse(); + assertNotNull(response); + assertEquals(403, response.getStatus()); + String content = responseHandler.readAll(); + assertThat(content, containsString(AuthorizationResult.DENY.getDescription())); + } + + private static DiscFilterRequest createRequest() { + DiscFilterRequest request = Mockito.mock(DiscFilterRequest.class); + when(request.getHeader(HEADER_NAME)).thenReturn("v=Z1;d=domain;r=my-role;p=my-domain.my-service"); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/my/path"); + when(request.getQueryString()).thenReturn(null); + when(request.getClientCertificateChain()).thenReturn(emptyList()); + return request; + } + + static class AllowingZpe implements Zpe { + @Override + public AuthorizationResult checkAccessAllowed(ZToken roleToken, AthenzResourceName resourceName, String action) { + return AuthorizationResult.ALLOW; + } + + @Override + public AuthorizationResult checkAccessAllowed(X509Certificate roleCertificate, AthenzResourceName resourceName, String action) { + return AuthorizationResult.ALLOW; + } + } + + static class DenyingZpe implements Zpe { + @Override + public AuthorizationResult checkAccessAllowed(ZToken roleToken, AthenzResourceName resourceName, String action) { + return AuthorizationResult.DENY; + } + + @Override + public AuthorizationResult checkAccessAllowed(X509Certificate roleCertificate, AthenzResourceName resourceName, String action) { + return AuthorizationResult.DENY; + } + } + +}
\ No newline at end of file diff --git a/parent/pom.xml b/parent/pom.xml index 10e93d4ffbf..aa75c124579 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -671,6 +671,11 @@ <version>${athenz.version}</version> </dependency> <dependency> + <groupId>com.yahoo.athenz</groupId> + <artifactId>athenz-zpe-java-client</artifactId> + <version>${athenz.version}</version> + </dependency> + <dependency> <groupId>com.github.tomakehurst</groupId> <artifactId>wiremock-standalone</artifactId> <version>2.6.0</version> diff --git a/vespa-athenz/pom.xml b/vespa-athenz/pom.xml index 7721d1829e5..75116812915 100644 --- a/vespa-athenz/pom.xml +++ b/vespa-athenz/pom.xml @@ -111,6 +111,34 @@ </exclusions> </dependency> <dependency> + <groupId>com.yahoo.athenz</groupId> + <artifactId>athenz-zpe-java-client</artifactId> + <scope>compile</scope> + <exclusions> + <exclusion> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </exclusion> + <exclusion> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk15on</artifactId> + </exclusion> + <!--Exclude all Jackson bundles provided by JDisc --> + <exclusion> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-core</artifactId> + </exclusion> + <exclusion> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + </exclusion> + <exclusion> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-annotations</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpcore</artifactId> <version>4.4.1</version> diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzIdentityCertificate.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzIdentityCertificate.java deleted file mode 100644 index 0e9e9432790..00000000000 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzIdentityCertificate.java +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.athenz.api; - -import java.security.PrivateKey; -import java.security.cert.X509Certificate; - -/** - * @author bjorncs - */ -public class AthenzIdentityCertificate { - - private final X509Certificate certificate; - private final PrivateKey privateKey; - - public AthenzIdentityCertificate(X509Certificate certificate, PrivateKey privateKey) { - this.certificate = certificate; - this.privateKey = privateKey; - } - - public X509Certificate getCertificate() { - return certificate; - } - - public PrivateKey getPrivateKey() { - return privateKey; - } -} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzPrincipal.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzPrincipal.java index e96f5bd72d4..2330b1e439f 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzPrincipal.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzPrincipal.java @@ -2,9 +2,12 @@ package com.yahoo.vespa.athenz.api; import java.security.Principal; +import java.util.List; import java.util.Objects; import java.util.Optional; +import static java.util.Collections.emptyList; + /** * @author bjorncs */ @@ -12,15 +15,24 @@ public class AthenzPrincipal implements Principal { private final AthenzIdentity athenzIdentity; private final NToken nToken; + private final List<AthenzRole> roles; public AthenzPrincipal(AthenzIdentity athenzIdentity) { - this(athenzIdentity, null); + this(athenzIdentity, null, emptyList()); + } + + public AthenzPrincipal(AthenzIdentity athenzIdentity, NToken nToken) { + this(athenzIdentity, nToken, emptyList()); } - public AthenzPrincipal(AthenzIdentity athenzIdentity, - NToken nToken) { + public AthenzPrincipal(AthenzIdentity identity, List<AthenzRole> roles) { + this(identity, null, roles); + } + + private AthenzPrincipal(AthenzIdentity athenzIdentity, NToken nToken, List<AthenzRole> roles) { this.athenzIdentity = athenzIdentity; this.nToken = nToken; + this.roles = roles; } public AthenzIdentity getIdentity() { @@ -40,6 +52,10 @@ public class AthenzPrincipal implements Principal { return Optional.ofNullable(nToken); } + public List<AthenzRole> getRoles() { + return roles; + } + @Override public String toString() { return "AthenzPrincipal{" + diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzResourceName.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzResourceName.java new file mode 100644 index 00000000000..f7aa2affc86 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzResourceName.java @@ -0,0 +1,74 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.api; + +import java.util.Objects; + +/** + * Athenz resource name + * + * @author bjorncs + */ +public class AthenzResourceName { + + private final AthenzDomain domain; + private final String entityName; + + public AthenzResourceName(AthenzDomain domain, String entityName) { + this.domain = domain; + this.entityName = entityName; + } + + public AthenzResourceName(String domain, String entityName) { + this(new AthenzDomain(domain), entityName); + } + + /** + * @param resourceName A resource name string on format 'domain:entity' + * @return the parsed resource name + */ + public static AthenzResourceName fromString(String resourceName) { + String[] split = resourceName.split(":"); + if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) { + throw new IllegalArgumentException("Invalid resource name: " + resourceName); + } + return new AthenzResourceName(split[0], split[1]); + } + + public AthenzDomain getDomain() { + return domain; + } + + public String getDomainName() { + return domain.getName(); + } + + public String getEntityName() { + return entityName; + } + + public String toResourceNameString() { + return String.format("%s:%s", domain.getName(), entityName); + } + + @Override + public String toString() { + return "AthenzResourceName{" + + "domain=" + domain + + ", entityName='" + entityName + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AthenzResourceName that = (AthenzResourceName) o; + return Objects.equals(domain, that.domain) && + Objects.equals(entityName, that.entityName); + } + + @Override + public int hashCode() { + return Objects.hash(domain, entityName); + } +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/ZToken.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/ZToken.java index ae520e66429..36c06132532 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/ZToken.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/ZToken.java @@ -1,7 +1,14 @@ -// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.athenz.api; +import com.yahoo.athenz.auth.token.RoleToken; +import com.yahoo.vespa.athenz.utils.AthenzIdentities; + +import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toList; /** * Represents an Athenz ZToken (role token) @@ -10,27 +17,38 @@ import java.util.Objects; */ public class ZToken { - private final String rawToken; + private final RoleToken token; public ZToken(String rawToken) { - this.rawToken = rawToken; + this.token = new RoleToken(rawToken); } public String getRawToken() { - return rawToken; + return token.getSignedToken(); + } + + public AthenzIdentity getIdentity() { + return AthenzIdentities.from(token.getPrincipal()); } + public List<AthenzRole> getRoles() { + String domain = token.getDomain(); + return token.getRoles().stream() + .map(roleName -> new AthenzRole(domain, roleName)) + .collect(toList());} + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ZToken zToken = (ZToken) o; - return Objects.equals(rawToken, zToken.rawToken); + return Objects.equals(getRawToken(), zToken.getRawToken()); } @Override public int hashCode() { - return Objects.hash(rawToken); + return Objects.hash(getRawToken()); } + } diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/tls/AthenzX509CertificateUtils.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/tls/AthenzX509CertificateUtils.java new file mode 100644 index 00000000000..46aca707be1 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/tls/AthenzX509CertificateUtils.java @@ -0,0 +1,58 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.tls; + +import com.yahoo.vespa.athenz.api.AthenzIdentity; +import com.yahoo.vespa.athenz.api.AthenzRole; +import com.yahoo.vespa.athenz.utils.AthenzIdentities; + +import java.security.cert.X509Certificate; +import java.util.List; + +import static com.yahoo.vespa.athenz.tls.SubjectAlternativeName.Type.RFC822_NAME; + +/** + * Utility methods for Athenz issued x509 certificates + * + * @author bjorncs + */ +public class AthenzX509CertificateUtils { + + private static final String COMMON_NAME_ROLE_DELIMITER = ":role."; + + private AthenzX509CertificateUtils() {} + + public static boolean isAthenzRoleCertificate(X509Certificate certificate) { + return isAthenzIssuedCertificate(certificate) && + X509CertificateUtils.getSubjectCommonNames(certificate).get(0).contains(COMMON_NAME_ROLE_DELIMITER); + } + + public static boolean isAthenzIssuedCertificate(X509Certificate certificate) { + return X509CertificateUtils.getIssuerCommonNames(certificate).stream() + .anyMatch(cn -> cn.equalsIgnoreCase("Yahoo Athenz CA") || cn.equalsIgnoreCase("Athenz AWS CA")); + } + + public static AthenzIdentity getIdentityFromRoleCertificate(X509Certificate certificate) { + List<SubjectAlternativeName> sans = X509CertificateUtils.getSubjectAlternativeNames(certificate); + return sans.stream() + .filter(san -> san.getType() == RFC822_NAME) + .map(SubjectAlternativeName::getValue) + .map(AthenzX509CertificateUtils::getIdentityFromSanEmail) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Could not find identity in SAN: " + sans)); + } + + public static AthenzRole getRolesFromRoleCertificate(X509Certificate certificate) { + String commonName = X509CertificateUtils.getSubjectCommonNames(certificate).get(0); + int delimiterIndex = commonName.indexOf(COMMON_NAME_ROLE_DELIMITER); + String domain = commonName.substring(0, delimiterIndex); + String roleName = commonName.substring(delimiterIndex + COMMON_NAME_ROLE_DELIMITER.length()); + return new AthenzRole(domain, roleName); + } + + private static AthenzIdentity getIdentityFromSanEmail(String email) { + int separator = email.indexOf('@'); + if (separator == -1) throw new IllegalArgumentException("Invalid SAN email: " + email); + return AthenzIdentities.from(email.substring(0, separator)); + } + +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/AuthorizationResult.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/AuthorizationResult.java new file mode 100644 index 00000000000..faf05011af9 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/AuthorizationResult.java @@ -0,0 +1,46 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.zpe; + +import com.yahoo.athenz.zpe.AuthZpeClient.AccessCheckStatus; + +import java.util.Arrays; + +/** + * The various types of access control results. + * + * @author bjorncs + */ +public enum AuthorizationResult { + ALLOW(AccessCheckStatus.ALLOW), + DENY(AccessCheckStatus.DENY), + DENY_NO_MATCH(AccessCheckStatus.DENY_NO_MATCH), + DENY_ROLETOKEN_EXPIRED(AccessCheckStatus.DENY_ROLETOKEN_EXPIRED), + DENY_ROLETOKEN_INVALID(AccessCheckStatus.DENY_ROLETOKEN_INVALID), + DENY_DOMAIN_MISMATCH(AccessCheckStatus.DENY_DOMAIN_MISMATCH), + DENY_DOMAIN_NOT_FOUND(AccessCheckStatus.DENY_DOMAIN_NOT_FOUND), + DENY_DOMAIN_EXPIRED(AccessCheckStatus.DENY_DOMAIN_EXPIRED), + DENY_DOMAIN_EMPTY(AccessCheckStatus.DENY_DOMAIN_EMPTY), + DENY_INVALID_PARAMETERS(AccessCheckStatus.DENY_INVALID_PARAMETERS), + DENY_CERT_MISMATCH_ISSUER(AccessCheckStatus.DENY_CERT_MISMATCH_ISSUER), + DENY_CERT_MISSING_SUBJECT(AccessCheckStatus.DENY_CERT_MISSING_SUBJECT), + DENY_CERT_MISSING_DOMAIN(AccessCheckStatus.DENY_CERT_MISSING_DOMAIN), + DENY_CERT_MISSING_ROLE_NAME(AccessCheckStatus.DENY_CERT_MISSING_ROLE_NAME); + + private final AccessCheckStatus wrappedElement; + + AuthorizationResult(AccessCheckStatus wrappedElement) { + this.wrappedElement = wrappedElement; + } + + public String getDescription() { + return wrappedElement.toString(); + } + + static AuthorizationResult fromAccessCheckStatus(AccessCheckStatus accessCheckStatus) { + return Arrays.stream(values()) + .filter(value -> value.wrappedElement == accessCheckStatus) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown status: " + accessCheckStatus)); + } + +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/DefaultZpe.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/DefaultZpe.java new file mode 100644 index 00000000000..a02d9c7a97a --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/DefaultZpe.java @@ -0,0 +1,29 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.zpe; + +import com.yahoo.athenz.zpe.AuthZpeClient; +import com.yahoo.vespa.athenz.api.AthenzResourceName; +import com.yahoo.vespa.athenz.api.ZToken; + +import java.security.cert.X509Certificate; + +/** + * The default implementation of {@link Zpe}. + * This implementation is currently based on the official Athenz ZPE library. + * + * @author bjorncs + */ +public class DefaultZpe implements Zpe { + @Override + public AuthorizationResult checkAccessAllowed(ZToken roleToken, AthenzResourceName resourceName, String action) { + return AuthorizationResult.fromAccessCheckStatus( + AuthZpeClient.allowAccess(roleToken.getRawToken(), resourceName.toResourceNameString(), action)); + } + + @Override + public AuthorizationResult checkAccessAllowed(X509Certificate roleCertificate, AthenzResourceName resourceName, String action) { + return AuthorizationResult.fromAccessCheckStatus( + AuthZpeClient.allowAccess(roleCertificate, resourceName.toResourceNameString(), action)); + } + +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/Zpe.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/Zpe.java new file mode 100644 index 00000000000..e22e27f1508 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/Zpe.java @@ -0,0 +1,17 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.zpe; + +import com.yahoo.vespa.athenz.api.AthenzResourceName; +import com.yahoo.vespa.athenz.api.ZToken; + +import java.security.cert.X509Certificate; + +/** + * Interface for interacting with ZPE (Authorization Policy Engine) + * + * @author bjorncs + */ +public interface Zpe { + AuthorizationResult checkAccessAllowed(ZToken roleToken, AthenzResourceName resourceName, String action); + AuthorizationResult checkAccessAllowed(X509Certificate roleCertificate, AthenzResourceName resourceName, String action); +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/package-info.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/package-info.java new file mode 100644 index 00000000000..341eb887021 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/zpe/package-info.java @@ -0,0 +1,8 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author bjorncs + */ +@ExportPackage +package com.yahoo.vespa.athenz.zpe; + +import com.yahoo.osgi.annotation.ExportPackage;
\ No newline at end of file diff --git a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/api/AthenzResourceNameTest.java b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/api/AthenzResourceNameTest.java new file mode 100644 index 00000000000..b9f4bd5369e --- /dev/null +++ b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/api/AthenzResourceNameTest.java @@ -0,0 +1,21 @@ +package com.yahoo.vespa.athenz.api; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * @author bjorncs + */ +public class AthenzResourceNameTest { + + @Test + public void can_serialize_and_deserialize_to_string() { + AthenzResourceName resourceName = new AthenzResourceName(new AthenzDomain("domain"), "entity"); + String resourceNameString = resourceName.toResourceNameString(); + assertEquals("domain:entity", resourceNameString); + AthenzResourceName deserializedResourceName = AthenzResourceName.fromString(resourceNameString); + assertEquals(deserializedResourceName, resourceName); + } + +}
\ No newline at end of file |