From 0e2b278dd55b6b7cbaf5c9acc8eb492bf2322a37 Mon Sep 17 00:00:00 2001 From: Bjørn Christian Seime Date: Thu, 17 Nov 2022 14:27:52 +0100 Subject: Introduce Cloud data plane security filter --- .../security/cloud/CloudDataPlaneFilter.java | 145 ++++++++++++++++++++ .../http/filter/security/cloud/package-info.java | 8 ++ ...ilter.security.misc.cloud-data-plane-filter.def | 7 + .../security/cloud/CloudDataPlaneFilterTest.java | 150 +++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java create mode 100644 jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java create mode 100644 jdisc-security-filters/src/main/resources/configdefinitions/jdisc.http.filter.security.misc.cloud-data-plane-filter.def create mode 100644 jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java (limited to 'jdisc-security-filters') diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java new file mode 100644 index 00000000000..146b2c0cd23 --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java @@ -0,0 +1,145 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.cloud; + +import com.yahoo.component.annotation.Inject; +import com.yahoo.container.jdisc.AclMapping; +import com.yahoo.container.jdisc.RequestHandlerSpec; +import com.yahoo.container.jdisc.RequestView; +import com.yahoo.jdisc.Response; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.filter.security.base.JsonSecurityRequestFilterBase; +import com.yahoo.security.X509CertificateUtils; + +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.READ; +import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.WRITE; + +/** + * @author bjorncs + */ +public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { + + private static final Logger log = Logger.getLogger(CloudDataPlaneFilter.class.getName()); + + private final boolean legacyMode; + private final List allowedClients; + + @Inject + public CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg) { + this.legacyMode = cfg.legacyMode(); + if (legacyMode) { + allowedClients = List.of(); + log.fine(() -> "Legacy mode enabled"); + } else { + allowedClients = parseClients(cfg); + } + } + + private static List parseClients(CloudDataPlaneFilterConfig cfg) { + Set ids = new HashSet<>(); + List clients = new ArrayList<>(cfg.clients().size()); + for (var c : cfg.clients()) { + if (ids.contains(c.id())) + throw new IllegalArgumentException("Clients definition has duplicate id '%s'".formatted(c.id())); + ids.add(c.id()); + List certs; + try { + certs = c.certificates().stream().map(X509CertificateUtils::fromPem).toList(); + } catch (Exception e) { + throw new IllegalArgumentException( + "Client '%s' contains invalid X.509 certificate PEM: %s".formatted(c.id(), e.toString()), e); + } + EnumSet permissions = c.permissions().stream().map(Permission::of) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(Permission.class))); + clients.add(new Client(c.id(), permissions, certs)); + } + if (clients.isEmpty()) throw new IllegalArgumentException("Empty clients configuration"); + log.fine(() -> "Configured clients with ids %s".formatted(ids)); + return clients; + } + + @Override + protected Optional filter(DiscFilterRequest req) { + var certs = req.getClientCertificateChain(); + log.fine(() -> "Certificate chain contains %d elements".formatted(certs.size())); + if (certs.isEmpty()) { + log.fine("Missing client certificate"); + return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, "Unauthorized")); + } + if (legacyMode) { + log.fine("Legacy mode validation complete"); + req.setUserPrincipal(new ClientPrincipal(Set.of(), Set.of(READ, WRITE))); + return Optional.empty(); + } + RequestView view = req.asRequestView(); + var permission = Optional.ofNullable((RequestHandlerSpec) req.getAttribute(RequestHandlerSpec.ATTRIBUTE_NAME)) + .or(() -> Optional.of(RequestHandlerSpec.DEFAULT_INSTANCE)) + .flatMap(spec -> { + var action = spec.aclMapping().get(view); + var maybePermission = Permission.of(action); + if (maybePermission.isEmpty()) log.fine(() -> "Unknown action '%s'".formatted(action)); + return maybePermission; + }).orElse(null); + if (permission == null) { + log.fine(() -> "No valid permission mapping defined for %s @ '%s'".formatted(view.method(), view.uri())); + return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Forbidden")); + } + var clientCert = certs.get(0); + var clientIds = new TreeSet(); + var permissions = new TreeSet(); + for (Client c : allowedClients) { + if (c.permissions().contains(permission) && c.certificates().contains(clientCert)) { + clientIds.add(c.id()); + permissions.addAll(c.permissions()); + } + } + log.fine(() -> "Client with ids=%s, permissions=%s" + .formatted(clientIds, permissions.stream().map(Permission::asString).toList())); + if (clientIds.isEmpty()) return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Forbidden")); + req.setUserPrincipal(new ClientPrincipal(clientIds, permissions)); + return Optional.empty(); + } + + public record ClientPrincipal(Set ids, Set permissions) implements Principal { + public ClientPrincipal { ids = Set.copyOf(ids); permissions = Set.copyOf(permissions); } + @Override public String getName() { + return "ids=%s,permissions=%s".formatted(ids, permissions.stream().map(Permission::asString).toList()); + } + } + + enum Permission { READ, WRITE; + String asString() { + return switch (this) { + case READ -> "read"; + case WRITE -> "write"; + }; + } + static Permission of(String v) { + return switch (v) { + case "read" -> READ; + case "write" -> WRITE; + default -> throw new IllegalArgumentException("Invalid permission '%s'".formatted(v)); + }; + } + static Optional of(AclMapping.Action a) { + if (a.equals(AclMapping.Action.READ)) return Optional.of(READ); + if (a.equals(AclMapping.Action.WRITE)) return Optional.of(WRITE); + return Optional.empty(); + } + } + + private record Client(String id, EnumSet permissions, List certificates) { + Client { permissions = EnumSet.copyOf(permissions); certificates = List.copyOf(certificates); } + } +} diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java new file mode 100644 index 00000000000..a4b2a23ea95 --- /dev/null +++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java @@ -0,0 +1,8 @@ +// Copyright Yahoo. 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.cloud; + +import com.yahoo.osgi.annotation.ExportPackage; \ No newline at end of file diff --git a/jdisc-security-filters/src/main/resources/configdefinitions/jdisc.http.filter.security.misc.cloud-data-plane-filter.def b/jdisc-security-filters/src/main/resources/configdefinitions/jdisc.http.filter.security.misc.cloud-data-plane-filter.def new file mode 100644 index 00000000000..570ab98aadf --- /dev/null +++ b/jdisc-security-filters/src/main/resources/configdefinitions/jdisc.http.filter.security.misc.cloud-data-plane-filter.def @@ -0,0 +1,7 @@ +# Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +namespace=jdisc.http.filter.security.cloud + +legacyMode bool default=false +clients[].id string +clients[].permissions[] string +clients[].certificates[] string diff --git a/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java new file mode 100644 index 00000000000..1ffdf3e5a77 --- /dev/null +++ b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java @@ -0,0 +1,150 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.jdisc.http.filter.security.cloud; + +import com.yahoo.container.jdisc.AclMapping.Action; +import com.yahoo.container.jdisc.HttpMethodAclMapping; +import com.yahoo.container.jdisc.RequestHandlerSpec; +import com.yahoo.container.jdisc.RequestHandlerTestDriver.MockResponseHandler; +import com.yahoo.jdisc.http.HttpRequest.Method; +import com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.ClientPrincipal; +import com.yahoo.jdisc.http.filter.util.FilterTestUtils; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.X509CertificateBuilder; +import com.yahoo.security.X509CertificateUtils; +import org.junit.jupiter.api.Test; + +import javax.security.auth.x500.X500Principal; +import java.math.BigInteger; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Set; + +import static com.yahoo.jdisc.Response.Status.FORBIDDEN; +import static com.yahoo.jdisc.Response.Status.UNAUTHORIZED; +import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.READ; +import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.WRITE; +import static com.yahoo.security.KeyAlgorithm.EC; +import static com.yahoo.security.SignatureAlgorithm.SHA256_WITH_ECDSA; +import static java.time.Instant.EPOCH; +import static java.time.temporal.ChronoUnit.DAYS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * @author bjorncs + */ +class CloudDataPlaneFilterTest { + + private static final X509Certificate FEED_CERT = certificate("my-feed-client"); + private static final X509Certificate SEARCH_CERT = certificate("my-search-client"); + private static final X509Certificate LEGACY_CLIENT = certificate("my-legacy-client"); + private static final String FEED_CLIENT_ID = "feed-client"; + private static final String SEARCH_CLIENT_ID = "search-client"; + + @Test + void accepts_any_trusted_client_certificate_in_legacy_mode() { + var req = FilterTestUtils.newRequestBuilder().withClientCertificate(LEGACY_CLIENT).build(); + var responseHandler = new MockResponseHandler(); + newFilterWithLegacyMode().filter(req, responseHandler); + assertNull(responseHandler.getResponse()); + assertEquals(new ClientPrincipal(Set.of(), Set.of(READ, WRITE)), req.getUserPrincipal()); + } + + @Test + void fails_on_missing_certificate_in_legacy_mode() { + var req = FilterTestUtils.newRequestBuilder().build(); + var responseHandler = new MockResponseHandler(); + newFilterWithLegacyMode().filter(req, responseHandler); + assertNotNull(responseHandler.getResponse()); + assertEquals(UNAUTHORIZED, responseHandler.getResponse().getStatus()); + } + + @Test + void accepts_client_with_valid_certificate() { + var req = FilterTestUtils.newRequestBuilder() + .withMethod(Method.POST) + .withClientCertificate(FEED_CERT) + .build(); + var responseHandler = new MockResponseHandler(); + newFilterWithClientsConfig().filter(req, responseHandler); + assertNull(responseHandler.getResponse()); + assertEquals(new ClientPrincipal(Set.of(FEED_CLIENT_ID), Set.of(WRITE)), req.getUserPrincipal()); + } + + @Test + void fails_on_client_with_invalid_permissions() { + var req = FilterTestUtils.newRequestBuilder() + .withMethod(Method.POST) + .withClientCertificate(SEARCH_CERT) + .build(); + var responseHandler = new MockResponseHandler(); + newFilterWithClientsConfig().filter(req, responseHandler); + assertNotNull(responseHandler.getResponse()); + assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus()); + } + + @Test + void supports_handler_with_custom_request_spec() { + // Spec that maps POST as action 'read' + var spec = RequestHandlerSpec.builder() + .withAclMapping(HttpMethodAclMapping.standard() + .override(Method.POST, Action.READ).build()) + .build(); + var req = FilterTestUtils.newRequestBuilder() + .withMethod(Method.POST) + .withClientCertificate(SEARCH_CERT) + .withAttribute(RequestHandlerSpec.ATTRIBUTE_NAME, spec) + .build(); + var responseHandler = new MockResponseHandler(); + newFilterWithClientsConfig().filter(req, responseHandler); + assertNull(responseHandler.getResponse()); + assertEquals(new ClientPrincipal(Set.of(SEARCH_CLIENT_ID), Set.of(READ)), req.getUserPrincipal()); + } + + @Test + void fails_on_handler_with_custom_request_spec_with_invalid_action() { + // Spec that maps POST as action 'read' + var spec = RequestHandlerSpec.builder() + .withAclMapping(HttpMethodAclMapping.standard() + .override(Method.GET, Action.custom("custom")).build()) + .build(); + var req = FilterTestUtils.newRequestBuilder() + .withMethod(Method.GET) + .withClientCertificate(SEARCH_CERT) + .withAttribute(RequestHandlerSpec.ATTRIBUTE_NAME, spec) + .build(); + var responseHandler = new MockResponseHandler(); + newFilterWithClientsConfig().filter(req, responseHandler); + assertNotNull(responseHandler.getResponse()); + assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus()); + } + + private static CloudDataPlaneFilter newFilterWithLegacyMode() { + return new CloudDataPlaneFilter(new CloudDataPlaneFilterConfig.Builder().legacyMode(true).build()); + } + + private static CloudDataPlaneFilter newFilterWithClientsConfig() { + return new CloudDataPlaneFilter( + new CloudDataPlaneFilterConfig.Builder() + .clients(List.of( + new CloudDataPlaneFilterConfig.Clients.Builder() + .certificates(X509CertificateUtils.toPem(FEED_CERT)) + .permissions(WRITE.asString()) + .id(FEED_CLIENT_ID), + new CloudDataPlaneFilterConfig.Clients.Builder() + .certificates(X509CertificateUtils.toPem(SEARCH_CERT)) + .permissions(READ.asString()) + .id(SEARCH_CLIENT_ID))) + .build()); + } + + private static X509Certificate certificate(String name) { + var key = KeyUtils.generateKeypair(EC); + var subject = new X500Principal("CN=%s".formatted(name)); + return X509CertificateBuilder + .fromKeypair(key, subject, EPOCH, EPOCH.plus(1, DAYS), SHA256_WITH_ECDSA, BigInteger.ONE).build(); + } + + +} \ No newline at end of file -- cgit v1.2.3