diff options
author | Bjørn Christian Seime <bjorncs@oath.com> | 2018-06-20 13:09:55 +0200 |
---|---|---|
committer | Bjørn Christian Seime <bjorncs@oath.com> | 2018-06-20 13:15:11 +0200 |
commit | b38471d94959eb172e82ee102404bc669a14d96b (patch) | |
tree | d1a9e399c6c4f4bb1ee76a1b80454125cfbbd0fe /jdisc-security-filters | |
parent | bf74c1a064739c123921a2e85e9427bae7019290 (diff) |
Add new Athenz security filter based on ZPE
- Allow flexible configuration of filter using a resource mapper
- Add helper class to extract role and identity from role certificates
Diffstat (limited to 'jdisc-security-filters')
8 files changed, 349 insertions, 0 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..5bbf111e5b2 --- /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.AccessCheckResult; +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.getResourceNameAndMapping(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) { + AccessCheckResult accessCheckResult = accessCheck.checkAccess(credentials, resAndAction.resourceName(), resAndAction.action()); + if (accessCheckResult == AccessCheckResult.ALLOW) { + request.setUserPrincipal(principalFactory.apply(credentials)); + return Optional.empty(); + } + return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Access forbidden: " + accessCheckResult.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> { + AccessCheckResult 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..00c3c27d57b --- /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> getResourceNameAndMapping(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..1f9090876f7 --- /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> getResourceNameAndMapping(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/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..ff7f9af11c6 --- /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.AccessCheckResult; +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(AccessCheckResult.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 AccessCheckResult checkAccessAllowed(ZToken roleToken, AthenzResourceName resourceName, String action) { + return AccessCheckResult.ALLOW; + } + + @Override + public AccessCheckResult checkAccessAllowed(X509Certificate roleCertificate, AthenzResourceName resourceName, String action) { + return AccessCheckResult.ALLOW; + } + } + + static class DenyingZpe implements Zpe { + @Override + public AccessCheckResult checkAccessAllowed(ZToken roleToken, AthenzResourceName resourceName, String action) { + return AccessCheckResult.DENY; + } + + @Override + public AccessCheckResult checkAccessAllowed(X509Certificate roleCertificate, AthenzResourceName resourceName, String action) { + return AccessCheckResult.ALLOW; + } + } + +}
\ No newline at end of file |