summaryrefslogtreecommitdiffstats
path: root/jdisc-security-filters
diff options
context:
space:
mode:
authorBjørn Christian Seime <bjorncs@yahooinc.com>2023-06-14 14:52:14 +0200
committerBjørn Christian Seime <bjorncs@yahooinc.com>2023-06-14 15:32:41 +0200
commit0503ee770a7f64d0be8d28983cce9d1df3652e77 (patch)
tree33ec45b3c7d58aa2a601446e96cf27117255bae2 /jdisc-security-filters
parentc184aacc4c84e2e649bd2e92c18d2e951f240465 (diff)
Support tokens in Cloud data plane filter
Diffstat (limited to 'jdisc-security-filters')
-rw-r--r--jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java105
-rw-r--r--jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java143
2 files changed, 225 insertions, 23 deletions
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
index 04446ddd4de..8a179a4e609 100644
--- 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
@@ -2,15 +2,23 @@
package com.yahoo.jdisc.http.filter.security.cloud;
import com.yahoo.component.annotation.Inject;
+import com.yahoo.component.provider.ComponentRegistry;
import com.yahoo.container.jdisc.AclMapping;
import com.yahoo.container.jdisc.RequestHandlerSpec;
import com.yahoo.container.jdisc.RequestView;
+import com.yahoo.container.logging.AccessLogEntry;
import com.yahoo.jdisc.Response;
import com.yahoo.jdisc.http.filter.DiscFilterRequest;
import com.yahoo.jdisc.http.filter.security.base.JsonSecurityRequestFilterBase;
import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig;
+import com.yahoo.jdisc.http.server.jetty.DataplaneProxyCredentials;
import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.security.token.Token;
+import com.yahoo.security.token.TokenCheckHash;
+import com.yahoo.security.token.TokenDomain;
+import com.yahoo.security.token.TokenFingerprint;
+import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
@@ -25,6 +33,7 @@ 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;
+import static com.yahoo.jdisc.http.server.jetty.AccessLoggingRequestHandler.CONTEXT_KEY_ACCESS_LOG_ENTRY;
/**
* @author bjorncs
@@ -35,35 +44,64 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase {
private final boolean legacyMode;
private final List<Client> allowedClients;
+ private final TokenDomain tokenDomain;
@Inject
- public CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg) {
+ public CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg,
+ ComponentRegistry<DataplaneProxyCredentials> optionalReverseProxy) {
+ this(cfg, reverseProxyCert(optionalReverseProxy).orElse(null));
+ }
+
+ CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg, X509Certificate reverseProxyCert) {
this.legacyMode = cfg.legacyMode();
+ this.tokenDomain = new TokenDomain(new byte[0], cfg.tokenContext().getBytes(StandardCharsets.UTF_8));
if (legacyMode) {
allowedClients = List.of();
log.fine(() -> "Legacy mode enabled");
} else {
- allowedClients = parseClients(cfg);
+ allowedClients = parseClients(cfg, reverseProxyCert);
}
}
- private static List<Client> parseClients(CloudDataPlaneFilterConfig cfg) {
+ private static Optional<X509Certificate> reverseProxyCert(
+ ComponentRegistry<DataplaneProxyCredentials> optionalReverseProxy) {
+ return optionalReverseProxy.allComponents().stream().findAny().map(DataplaneProxyCredentials::certificate);
+ }
+
+ private static List<Client> parseClients(CloudDataPlaneFilterConfig cfg, X509Certificate reverseProxyCert) {
Set<String> ids = new HashSet<>();
List<Client> 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<X509Certificate> certs;
- try {
- certs = c.certificates().stream().map(X509CertificateUtils::fromPem).toList();
- } catch (Exception e) {
+ if (!c.certificates().isEmpty() && !c.tokens().isEmpty())
+ throw new IllegalArgumentException("Client '%s' has both certificate and token configured".formatted(c.id()));
+ if (!c.tokens().isEmpty() && reverseProxyCert == null)
throw new IllegalArgumentException(
- "Client '%s' contains invalid X.509 certificate PEM: %s".formatted(c.id(), e.toString()), e);
- }
+ "Client '%s' has token configured but reverse proxy certificate is missing".formatted(c.id()));
+ ids.add(c.id());
EnumSet<Permission> permissions = c.permissions().stream().map(Permission::of)
.collect(Collectors.toCollection(() -> EnumSet.noneOf(Permission.class)));
- clients.add(new Client(c.id(), permissions, certs));
+ if (!c.certificates().isEmpty()) {
+ List<X509Certificate> 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);
+ }
+ clients.add(new Client(c.id(), permissions, certs, List.of()));
+ } else {
+ var tokens = new ArrayList<TokenVersion>();
+ for (var token : c.tokens()) {
+ for (int version = 0; version < token.checkAccessHashes().size(); version++) {
+ tokens.add(TokenVersion.of(
+ token.id(), token.fingerprints().get(version), token.checkAccessHashes().get(version)));
+ }
+ }
+ // Add reverse proxy certificate as required certificate for client definition
+ clients.add(new Client(c.id(), permissions, List.of(reverseProxyCert), tokens));
+ }
}
if (clients.isEmpty()) throw new IllegalArgumentException("Empty clients configuration");
log.fine(() -> "Configured clients with ids %s".formatted(ids));
@@ -97,13 +135,31 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase {
return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Forbidden"));
}
var clientCert = certs.get(0);
+ var requestTokenHash = requestToken(req).orElse(null);
var clientIds = new TreeSet<String>();
var permissions = new TreeSet<Permission>();
+ var matchedTokens = new HashSet<TokenVersion>();
for (Client c : allowedClients) {
- if (c.permissions().contains(permission) && c.certificates().contains(clientCert)) {
- clientIds.add(c.id());
- permissions.addAll(c.permissions());
+ if (!c.permissions().contains(permission)) continue;
+ if (!c.certificates().contains(clientCert)) continue;
+ if (!c.tokens().isEmpty()) {
+ var matchedToken = c.tokens().stream()
+ .filter(t -> t.accessHash().equals(requestTokenHash)).findAny().orElse(null);
+ if (matchedToken == null) continue;
+ matchedTokens.add(matchedToken);
}
+ clientIds.add(c.id());
+ permissions.addAll(c.permissions());
+ }
+ if (matchedTokens.size() > 1) {
+ log.warning("Multiple tokens matched for request %s"
+ .formatted(matchedTokens.stream().map(TokenVersion::id).toList()));
+ return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Forbidden"));
+ }
+ var matchedToken = matchedTokens.stream().findAny().orElse(null);
+ if (matchedToken != null) {
+ addAccessLogEntry(req, "token.id", matchedToken.id());
+ addAccessLogEntry(req, "token.hash", matchedToken.fingerprint().toDelimitedHexString());
}
log.fine(() -> "Client with ids=%s, permissions=%s"
.formatted(clientIds, permissions.stream().map(Permission::asString).toList()));
@@ -112,6 +168,17 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase {
return Optional.empty();
}
+ private Optional<TokenCheckHash> requestToken(DiscFilterRequest req) {
+ return Optional.ofNullable(req.getHeader("Authorization"))
+ .filter(h -> h.startsWith("Bearer "))
+ .map(t -> t.substring("Bearer ".length()).trim())
+ .map(t -> TokenCheckHash.of(Token.of(tokenDomain, t), 32));
+ }
+
+ private static void addAccessLogEntry(DiscFilterRequest req, String key, String value) {
+ ((AccessLogEntry) req.getAttribute(CONTEXT_KEY_ACCESS_LOG_ENTRY)).addKeyValue(key, value);
+ }
+
public record ClientPrincipal(Set<String> ids, Set<Permission> permissions) implements Principal {
public ClientPrincipal { ids = Set.copyOf(ids); permissions = Set.copyOf(permissions); }
@Override public String getName() {
@@ -140,7 +207,13 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase {
}
}
- private record Client(String id, EnumSet<Permission> permissions, List<X509Certificate> certificates) {
- Client { permissions = EnumSet.copyOf(permissions); certificates = List.copyOf(certificates); }
+ private record TokenVersion(String id, TokenFingerprint fingerprint, TokenCheckHash accessHash) {
+ static TokenVersion of(String id, String fingerprint, String accessHash) {
+ return new TokenVersion(id, TokenFingerprint.ofHex(fingerprint), TokenCheckHash.ofHex(accessHash));
+ }
+ }
+
+ private record Client(String id, EnumSet<Permission> permissions, List<X509Certificate> certificates, List<TokenVersion> tokens) {
+ Client { permissions = EnumSet.copyOf(permissions); certificates = List.copyOf(certificates); tokens = List.copyOf(tokens); }
}
}
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
index d8b6312e90e..2dd577c18d6 100644
--- 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
@@ -5,6 +5,7 @@ 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.container.logging.AccessLogEntry;
import com.yahoo.jdisc.http.HttpRequest.Method;
import com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.ClientPrincipal;
import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig;
@@ -12,6 +13,10 @@ import com.yahoo.jdisc.http.filter.util.FilterTestUtils;
import com.yahoo.security.KeyUtils;
import com.yahoo.security.X509CertificateBuilder;
import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.security.token.Token;
+import com.yahoo.security.token.TokenCheckHash;
+import com.yahoo.security.token.TokenDomain;
+import com.yahoo.security.token.TokenGenerator;
import org.junit.jupiter.api.Test;
import javax.security.auth.x500.X500Principal;
@@ -39,13 +44,21 @@ 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 X509Certificate LEGACY_CLIENT_CERT = certificate("my-legacy-client");
+ private static final X509Certificate REVERSE_PROXY_CERT = certificate("nginx");
private static final String FEED_CLIENT_ID = "feed-client";
- private static final String SEARCH_CLIENT_ID = "search-client";
+ private static final String MTLS_SEARCH_CLIENT_ID = "mtls-search-client";
+ private static final String TOKEN_SEARCH_CLIENT = "token-search-client";
+ private static final String TOKEN_CONTEXT = "my-token-context";
+ private static final String TOKEN_ID = "my-token-id";
+ private static final Token VALID_TOKEN =
+ TokenGenerator.generateToken(TokenDomain.of("fp-ctx", TOKEN_CONTEXT), "vespa_token_", 32);
+ private static final Token UNKNOWN_TOKEN =
+ TokenGenerator.generateToken(TokenDomain.of("fp-ctx", TOKEN_CONTEXT), "vespa_token_", 32);
@Test
void accepts_any_trusted_client_certificate_in_legacy_mode() {
- var req = FilterTestUtils.newRequestBuilder().withClientCertificate(LEGACY_CLIENT).build();
+ var req = FilterTestUtils.newRequestBuilder().withClientCertificate(LEGACY_CLIENT_CERT).build();
var responseHandler = new MockResponseHandler();
newFilterWithLegacyMode().filter(req, responseHandler);
assertNull(responseHandler.getResponse());
@@ -100,7 +113,7 @@ class CloudDataPlaneFilterTest {
var responseHandler = new MockResponseHandler();
newFilterWithClientsConfig().filter(req, responseHandler);
assertNull(responseHandler.getResponse());
- assertEquals(new ClientPrincipal(Set.of(SEARCH_CLIENT_ID), Set.of(READ)), req.getUserPrincipal());
+ assertEquals(new ClientPrincipal(Set.of(MTLS_SEARCH_CLIENT_ID), Set.of(READ)), req.getUserPrincipal());
}
@Test
@@ -121,13 +134,121 @@ class CloudDataPlaneFilterTest {
assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
}
+ @Test
+ void accepts_reverse_proxy_with_token() {
+ var entry = new AccessLogEntry();
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withAccessLogEntry(entry)
+ .withClientCertificate(REVERSE_PROXY_CERT)
+ .withHeader("Authorization", "Bearer " + VALID_TOKEN.secretTokenString())
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNull(responseHandler.getResponse());
+ assertEquals(new ClientPrincipal(Set.of(TOKEN_SEARCH_CLIENT), Set.of(READ)), req.getUserPrincipal());
+ assertEquals(TOKEN_ID, entry.getKeyValues().get("token.id").get(0));
+ assertEquals(VALID_TOKEN.fingerprint().toDelimitedHexString(), entry.getKeyValues().get("token.hash").get(0));
+ }
+
+ @Test
+ void fails_for_reverse_proxy_with_token_wrong_permission() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.POST)
+ .withClientCertificate(REVERSE_PROXY_CERT)
+ .withHeader("Authorization", "Bearer " + VALID_TOKEN.secretTokenString())
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void fails_for_reverse_proxy_without_token() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withClientCertificate(REVERSE_PROXY_CERT)
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void fails_for_reverse_proxy_with_unknown_token() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withClientCertificate(REVERSE_PROXY_CERT)
+ .withHeader("Authorization", "Bearer " + UNKNOWN_TOKEN.secretTokenString())
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void fails_for_reverse_proxy_without_configured_token() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withClientCertificate(REVERSE_PROXY_CERT)
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void fails_for_missing_certificate_with_token() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withHeader("Authorization", "Bearer " + VALID_TOKEN.secretTokenString())
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(UNAUTHORIZED, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void fails_for_unknown_certificate_with_token() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withClientCertificate(LEGACY_CLIENT_CERT)
+ .withHeader("Authorization", "Bearer " + VALID_TOKEN.secretTokenString())
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void certificate_has_precedence_over_token() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.POST)
+ .withClientCertificate(FEED_CERT)
+ .withHeader("Authorization", "Bearer " + VALID_TOKEN.secretTokenString())
+ .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());
+ }
+
private static CloudDataPlaneFilter newFilterWithLegacyMode() {
- return new CloudDataPlaneFilter(new CloudDataPlaneFilterConfig.Builder().legacyMode(true).build());
+ return new CloudDataPlaneFilter(
+ new CloudDataPlaneFilterConfig.Builder()
+ .legacyMode(true).build(), (X509Certificate) null);
}
private static CloudDataPlaneFilter newFilterWithClientsConfig() {
return new CloudDataPlaneFilter(
new CloudDataPlaneFilterConfig.Builder()
+ .tokenContext(TOKEN_CONTEXT)
.clients(List.of(
new CloudDataPlaneFilterConfig.Clients.Builder()
.certificates(X509CertificateUtils.toPem(FEED_CERT))
@@ -136,8 +257,16 @@ class CloudDataPlaneFilterTest {
new CloudDataPlaneFilterConfig.Clients.Builder()
.certificates(X509CertificateUtils.toPem(SEARCH_CERT))
.permissions(READ.asString())
- .id(SEARCH_CLIENT_ID)))
- .build());
+ .id(MTLS_SEARCH_CLIENT_ID),
+ new CloudDataPlaneFilterConfig.Clients.Builder()
+ .tokens(new CloudDataPlaneFilterConfig.Clients.Tokens.Builder()
+ .id(TOKEN_ID)
+ .checkAccessHashes(TokenCheckHash.of(VALID_TOKEN, 32).toHexString())
+ .fingerprints(VALID_TOKEN.fingerprint().toDelimitedHexString()))
+ .permissions(READ.asString())
+ .id(TOKEN_SEARCH_CLIENT)))
+ .build(),
+ REVERSE_PROXY_CERT);
}
private static X509Certificate certificate(String name) {