From be48131fc5fd4e3a55f0f66f29b344811b4db4d6 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Sat, 20 Sep 2025 17:53:11 +0000 Subject: [PATCH 01/40] OpaPolarisAuthorizer --- polaris-core/build.gradle.kts | 2 + .../core/auth/OpaPolarisAuthorizer.java | 283 ++++++++++++++++++ .../core/auth/OpaPolarisAuthorizerTest.java | 118 ++++++++ 3 files changed, 403 insertions(+) create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java create mode 100644 polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index ba5335701e..17ba938657 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -24,6 +24,7 @@ plugins { dependencies { implementation(project(":polaris-api-management-model")) + implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation(platform(libs.iceberg.bom)) implementation("org.apache.iceberg:iceberg-api") @@ -99,6 +100,7 @@ dependencies { implementation(platform(libs.google.cloud.storage.bom)) implementation("com.google.cloud:google-cloud-storage") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testCompileOnly(project(":polaris-immutables")) testAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java new file mode 100644 index 0000000000..70b17af357 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -0,0 +1,283 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +// Removed Quarkus/MicroProfile annotations for portability +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.util.List; +import java.util.Set; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; + +/** + * OPA-based implementation of {@link PolarisAuthorizer}. + * + *

This authorizer delegates authorization decisions to an Open Policy Agent (OPA) server using a + * configurable REST API endpoint and policy path. The input to OPA is constructed from the + * principal, entities, operation, and resource context. + */ +public class OpaPolarisAuthorizer implements PolarisAuthorizer { + private final String opaServerUrl; + private final String opaPolicyPath; + + /** + * Constructs an OpaPolarisAuthorizer using system properties or environment variables for + * configuration. + * + *

The OPA server URL and policy path are read from either system properties or environment + * variables. Defaults are provided if not set. + */ + public OpaPolarisAuthorizer() { + this.opaServerUrl = + System.getProperty( + "opa.server.url", + System.getenv().getOrDefault("OPA_SERVER_URL", "http://localhost:8181")); + this.opaPolicyPath = + System.getProperty( + "opa.policy.path", + System.getenv().getOrDefault("OPA_POLICY_PATH", "/v1/data/polaris/authz/allow")); + } + + private final OkHttpClient httpClient = new OkHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Authorizes a single target and secondary entity for the given principal and operation. + * + *

Delegates to the multi-target version for consistency. + * + * @param polarisPrincipal the principal requesting authorization + * @param activatedEntities the set of activated entities (roles, etc.) + * @param authzOp the operation to authorize + * @param target the main target entity + * @param secondary the secondary entity (if any) + * @throws RuntimeException if authorization is denied by OPA + */ + @Override + public void authorizeOrThrow( + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull Set activatedEntities, + @Nonnull PolarisAuthorizableOperation authzOp, + @Nullable PolarisResolvedPathWrapper target, + @Nullable PolarisResolvedPathWrapper secondary) { + authorizeOrThrow( + polarisPrincipal, + activatedEntities, + authzOp, + target == null ? null : List.of(target), + secondary == null ? null : List.of(secondary)); + } + + /** + * Authorizes one or more target and secondary entities for the given principal and operation. + * + *

Sends the authorization context to OPA and throws if not allowed. + * + * @param polarisPrincipal the principal requesting authorization + * @param activatedEntities the set of activated entities (roles, etc.) + * @param authzOp the operation to authorize + * @param targets the list of main target entities + * @param secondaries the list of secondary entities (if any) + * @throws RuntimeException if authorization is denied by OPA + */ + @Override + public void authorizeOrThrow( + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull Set activatedEntities, + @Nonnull PolarisAuthorizableOperation authzOp, + @Nullable List targets, + @Nullable List secondaries) { + boolean allowed = queryOpa(polarisPrincipal, activatedEntities, authzOp, targets, secondaries); + if (!allowed) { + throw new RuntimeException("OPA denied authorization"); + } + } + + /** + * Sends an authorization query to the OPA server and parses the response. + * + *

Builds the OPA input JSON, sends it via HTTP POST, and checks the 'allow' field in the + * response. + * + * @param principal the principal requesting authorization + * @param entities the set of activated entities + * @param op the operation to authorize + * @param targets the list of main target entities + * @param secondaries the list of secondary entities (if any) + * @return true if OPA allows the operation, false otherwise + * @throws RuntimeException if the OPA query fails + */ + private boolean queryOpa( + PolarisPrincipal principal, + Set entities, + PolarisAuthorizableOperation op, + List targets, + List secondaries) { + try { + String inputJson = buildOpaInputJson(principal, entities, op, targets, secondaries); + RequestBody body = RequestBody.create(inputJson, MediaType.parse("application/json")); + Request request = new Request.Builder().url(opaServerUrl + opaPolicyPath).post(body).build(); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) return false; + // Parse response JSON for 'result.allow' + ObjectNode respNode = (ObjectNode) objectMapper.readTree(response.body().string()); + return respNode.path("result").path("allow").asBoolean(false); + } + } catch (IOException e) { + throw new RuntimeException("OPA query failed", e); + } + } + + /** + * Builds the OPA input JSON for the authorization query. + * + *

Assembles the actor, action, resource, and context sections into the expected OPA input + * format. + * + * @param principal the principal requesting authorization + * @param entities the set of activated entities + * @param op the operation to authorize + * @param targets the list of main target entities + * @param secondaries the list of secondary entities (if any) + * @return the OPA input JSON string + * @throws IOException if JSON serialization fails + */ + private String buildOpaInputJson( + PolarisPrincipal principal, + Set entities, + PolarisAuthorizableOperation op, + List targets, + List secondaries) + throws IOException { + ObjectNode input = objectMapper.createObjectNode(); + input.set("actor", buildActorNode(principal)); + input.put("action", op.name()); + input.set("resource", buildResourceNode(targets, secondaries)); + input.set("context", buildContextNode()); + ObjectNode root = objectMapper.createObjectNode(); + root.set("input", input); + return objectMapper.writeValueAsString(root); + } + + /** + * Builds the actor section of the OPA input JSON. + * + *

Includes principal name, roles, and all properties as a generic field. + * + * @param principal the principal requesting authorization + * @return the actor node for OPA input + */ + private ObjectNode buildActorNode(PolarisPrincipal principal) { + ObjectNode actor = objectMapper.createObjectNode(); + actor.put("principal", principal.getName()); + ArrayNode roles = objectMapper.createArrayNode(); + for (String role : principal.getRoles()) roles.add(role); + actor.set("roles", roles); + ObjectNode propertiesNode = objectMapper.createObjectNode(); + for (var entry : principal.getProperties().entrySet()) { + propertiesNode.put(entry.getKey(), entry.getValue()); + } + actor.set("properties", propertiesNode); + return actor; + } + + /** + * Builds the resource section of the OPA input JSON. + * + *

Includes the main target entity under 'primary' and secondary entities under 'secondaries'. + * + * @param targets the list of main target entities + * @param secondaries the list of secondary entities + * @return the resource node for OPA input + */ + private ObjectNode buildResourceNode( + List targets, List secondaries) { + ObjectNode resource = objectMapper.createObjectNode(); + // Main targets as 'targets' array + ArrayNode targetsArray = objectMapper.createArrayNode(); + if (targets != null && !targets.isEmpty()) { + for (PolarisResolvedPathWrapper targetWrapper : targets) { + targetsArray.add(buildSingleResourceNode(targetWrapper)); + } + } + resource.set("targets", targetsArray); + // Secondaries as array + ArrayNode secondariesArray = objectMapper.createArrayNode(); + if (secondaries != null && !secondaries.isEmpty()) { + for (PolarisResolvedPathWrapper secondaryWrapper : secondaries) { + secondariesArray.add(buildSingleResourceNode(secondaryWrapper)); + } + } + resource.set("secondaries", secondariesArray); + return resource; + } + + /** Helper to build a resource node for a single PolarisResolvedPathWrapper. */ + private ObjectNode buildSingleResourceNode(PolarisResolvedPathWrapper wrapper) { + ObjectNode node = objectMapper.createObjectNode(); + if (wrapper == null) return node; + var resolvedEntity = wrapper.getResolvedLeafEntity(); + if (resolvedEntity != null) { + var entity = resolvedEntity.getEntity(); + node.put("type", entity.getType().name()); + node.put("name", entity.getName()); + var parentPath = wrapper.getResolvedParentPath(); + if (parentPath != null && !parentPath.isEmpty()) { + ArrayNode parentsArray = objectMapper.createArrayNode(); + for (var parent : parentPath) { + ObjectNode parentNode = objectMapper.createObjectNode(); + parentNode.put("type", parent.getEntity().getType().name()); + parentNode.put("name", parent.getEntity().getName()); + parentsArray.add(parentNode); + } + node.set("parents", parentsArray); + } + ObjectNode props = objectMapper.createObjectNode(); + for (var entry : entity.getPropertiesAsMap().entrySet()) { + props.put(entry.getKey(), entry.getValue()); + } + node.set("properties", props); + } + return node; + } + + /** + * Builds the context section of the OPA input JSON. + * + *

Includes only timestamp and request ID. + * + * @return the context node for OPA input + */ + private ObjectNode buildContextNode() { + ObjectNode context = objectMapper.createObjectNode(); + context.put("time", java.time.ZonedDateTime.now().toString()); + context.put("request_id", java.util.UUID.randomUUID().toString()); + return context; + } +} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java new file mode 100644 index 0000000000..984d4af14b --- /dev/null +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -0,0 +1,118 @@ +package org.apache.polaris.core.auth; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class OpaPolarisAuthorizerTest { + @Test + void testOpaInputJsonFormat() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); + server.start(); + String url = server.url("/v1/data/polaris/authz/allow").toString(); + System.setProperty("opa.server.url", url.replace("/v1/data/polaris/authz/allow", "")); + System.setProperty("opa.policy.path", "/v1/data/polaris/authz/allow"); + + OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer(); + PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); + Mockito.when(principal.getName()).thenReturn("eve"); + Mockito.when(principal.getRoles()).thenReturn(Set.of("auditor")); + Mockito.when(principal.getProperties()).thenReturn(Map.of("department", "finance")); + + Set entities = Set.of(); + PolarisResolvedPathWrapper target = Mockito.mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper secondary = Mockito.mock(PolarisResolvedPathWrapper.class); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, target, secondary)); + + // Get the request sent to the mock server + var recordedRequest = server.takeRequest(); + String requestBody = recordedRequest.getBody().readUtf8(); + + // Parse and verify JSON structure + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(requestBody); + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + + server.shutdown(); + } + + @Test + void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); + server.start(); + String url = server.url("/v1/data/polaris/authz/allow").toString(); + System.setProperty("opa.server.url", url.replace("/v1/data/polaris/authz/allow", "")); + System.setProperty("opa.policy.path", "/v1/data/polaris/authz/allow"); + + OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer(); + PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); + Mockito.when(principal.getName()).thenReturn("alice"); + Mockito.when(principal.getRoles()).thenReturn(Set.of("admin")); + Mockito.when(principal.getProperties()).thenReturn(Map.of()); + + Set entities = Set.of(); + PolarisResolvedPathWrapper target = Mockito.mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper secondary = Mockito.mock(PolarisResolvedPathWrapper.class); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.CREATE_CATALOG, + target, + secondary)); + + server.shutdown(); + } + + @Test + void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); + server.start(); + String url = server.url("/v1/data/polaris/authz/allow").toString(); + System.setProperty("opa.server.url", url.replace("/v1/data/polaris/authz/allow", "")); + System.setProperty("opa.policy.path", "/v1/data/polaris/authz/allow"); + + OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer(); + PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); + Mockito.when(principal.getName()).thenReturn("bob"); + Mockito.when(principal.getRoles()).thenReturn(Set.of("user")); + Mockito.when(principal.getProperties()).thenReturn(Map.of()); + + Set entities = Set.of(); + PolarisResolvedPathWrapper target1 = Mockito.mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper target2 = Mockito.mock(PolarisResolvedPathWrapper.class); + List targets = List.of(target1, target2); + List secondaries = List.of(); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, targets, secondaries)); + + server.shutdown(); + } +} From 317cdc46d72fb2d3c6c68078c1274e349373cbf8 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Sat, 20 Sep 2025 23:58:31 +0000 Subject: [PATCH 02/40] add CDI AuthorizerProducer --- .../core/auth/OpaPolarisAuthorizer.java | 51 +++++++++++++------ .../core/auth/OpaPolarisAuthorizerTest.java | 30 +++++++---- .../src/main/resources/application.properties | 9 ++++ .../service/it/AuthorizerProducerIT.java | 25 +++++++++ .../service/auth/AuthorizerProducer.java | 33 ++++++++++++ 5 files changed, 123 insertions(+), 25 deletions(-) create mode 100644 runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java create mode 100644 runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index 70b17af357..c51a8701fe 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -45,28 +45,47 @@ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private final String opaServerUrl; private final String opaPolicyPath; + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + + /** Private constructor for factory method and advanced wiring. */ + private OpaPolarisAuthorizer( + String opaServerUrl, + String opaPolicyPath, + OkHttpClient httpClient, + ObjectMapper objectMapper) { + this.opaServerUrl = opaServerUrl; + this.opaPolicyPath = opaPolicyPath; + this.httpClient = httpClient; + this.objectMapper = objectMapper; + } /** - * Constructs an OpaPolarisAuthorizer using system properties or environment variables for - * configuration. + * Static factory for runtime configuration and CDI producer compatibility. * - *

The OPA server URL and policy path are read from either system properties or environment - * variables. Defaults are provided if not set. + * @param opaServerUrl OPA server URL + * @param opaPolicyPath OPA policy path + * @param timeoutMs HTTP call timeout in milliseconds + * @param client OkHttpClient (optional, can be null) + * @param mapper ObjectMapper (optional, can be null) + * @return OpaPolarisAuthorizer instance */ - public OpaPolarisAuthorizer() { - this.opaServerUrl = - System.getProperty( - "opa.server.url", - System.getenv().getOrDefault("OPA_SERVER_URL", "http://localhost:8181")); - this.opaPolicyPath = - System.getProperty( - "opa.policy.path", - System.getenv().getOrDefault("OPA_POLICY_PATH", "/v1/data/polaris/authz/allow")); + public static OpaPolarisAuthorizer create( + String opaServerUrl, + String opaPolicyPath, + int timeoutMs, + OkHttpClient client, + ObjectMapper mapper) { + OkHttpClient clientWithTimeout = + (client != null) + ? client.newBuilder().callTimeout(java.time.Duration.ofMillis(timeoutMs)).build() + : new OkHttpClient.Builder() + .callTimeout(java.time.Duration.ofMillis(timeoutMs)) + .build(); + ObjectMapper objectMapper = (mapper != null) ? mapper : new ObjectMapper(); + return new OpaPolarisAuthorizer(opaServerUrl, opaPolicyPath, clientWithTimeout, objectMapper); } - private final OkHttpClient httpClient = new OkHttpClient(); - private final ObjectMapper objectMapper = new ObjectMapper(); - /** * Authorizes a single target and secondary entity for the given principal and operation. * diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index 984d4af14b..96bec38269 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -20,10 +20,14 @@ void testOpaInputJsonFormat() throws Exception { server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); server.start(); String url = server.url("/v1/data/polaris/authz/allow").toString(); - System.setProperty("opa.server.url", url.replace("/v1/data/polaris/authz/allow", "")); - System.setProperty("opa.policy.path", "/v1/data/polaris/authz/allow"); - OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer(); + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + url.replace("/v1/data/polaris/authz/allow", ""), + "/v1/data/polaris/authz/allow", + 2000, + null, + null); PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); Mockito.when(principal.getName()).thenReturn("eve"); Mockito.when(principal.getRoles()).thenReturn(Set.of("auditor")); @@ -62,10 +66,14 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); server.start(); String url = server.url("/v1/data/polaris/authz/allow").toString(); - System.setProperty("opa.server.url", url.replace("/v1/data/polaris/authz/allow", "")); - System.setProperty("opa.policy.path", "/v1/data/polaris/authz/allow"); - OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer(); + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + url.replace("/v1/data/polaris/authz/allow", ""), + "/v1/data/polaris/authz/allow", + 2000, + null, + null); PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); Mockito.when(principal.getName()).thenReturn("alice"); Mockito.when(principal.getRoles()).thenReturn(Set.of("admin")); @@ -93,10 +101,14 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); server.start(); String url = server.url("/v1/data/polaris/authz/allow").toString(); - System.setProperty("opa.server.url", url.replace("/v1/data/polaris/authz/allow", "")); - System.setProperty("opa.policy.path", "/v1/data/polaris/authz/allow"); - OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer(); + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + url.replace("/v1/data/polaris/authz/allow", ""), + "/v1/data/polaris/authz/allow", + 2000, + null, + null); PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); Mockito.when(principal.getName()).thenReturn("bob"); Mockito.when(principal.getRoles()).thenReturn(Set.of("user")); diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 2eb7e8592d..b79df4a558 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -193,6 +193,15 @@ polaris.oidc.principal-roles-mapper.type=default # polaris.storage.gcp.token=token # polaris.storage.gcp.lifespan=PT1H +# Polaris authorization implementation settings +# Which authorizer to use: "default" (PolarisAuthorizerImpl) or "opa" (OpaPolarisAuthorizer) +polaris.authorization.implementation=default + +# OPA Authorizer Configuration: effective only if polaris.authorization.implementation=opa +# polaris.authorization.opa.url=http://localhost:8181 +# polaris.authorization.opa.policyPath=/v1/data/polaris/authz/allow +# polaris.authorization.opa.timeout-ms=2000 + quarkus.arc.ignored-split-packages=\ org.apache.polaris.service.catalog.api,\ org.apache.polaris.service.catalog.api.impl,\ diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java new file mode 100644 index 0000000000..d99fa4ecc4 --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java @@ -0,0 +1,25 @@ +package org.apache.polaris.service.it; + +import jakarta.inject.Inject; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerImpl; +import org.apache.polaris.core.auth.OpaPolarisAuthorizer; +import org.junit.jupiter.api.Test; +import io.quarkus.test.junit.QuarkusTest; +import static org.junit.jupiter.api.Assertions.*; + +@QuarkusTest +public class AuthorizerProducerIT { + + @Inject + PolarisAuthorizer polarisAuthorizer; + + @Test + void testDefaultAuthorizerIsImpl() { + // Should be PolarisAuthorizerImpl if polaris.authorization.implementation=default + assertTrue(polarisAuthorizer instanceof PolarisAuthorizerImpl, + "Default authorizer should be PolarisAuthorizerImpl"); + } + + // You can add more tests to check OPA wiring by overriding config in test resources +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java new file mode 100644 index 0000000000..09170e6c9d --- /dev/null +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java @@ -0,0 +1,33 @@ +package org.apache.polaris.service.auth; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerImpl; +import org.apache.polaris.core.auth.OpaPolarisAuthorizer; + +@ApplicationScoped +public class AuthorizerProducer { + + @ConfigProperty(name = "polaris.authorization.implementation", defaultValue = "default") + String impl; + + @ConfigProperty(name = "polaris.authorization.opa.url", defaultValue = "http://localhost:8181") + String opaUrl; + + @ConfigProperty(name = "polaris.authorization.opa.policyPath", defaultValue = "/v1/data/polaris/allow") + String policyPath; + + @ConfigProperty(name = "polaris.authorization.opa.timeout-ms", defaultValue = "2000") + int timeoutMs; + + @Produces + @ApplicationScoped + PolarisAuthorizer polarisAuthorizer() { + if ("opa".equalsIgnoreCase(impl)) { + return OpaPolarisAuthorizer.create(opaUrl, policyPath, timeoutMs, null, null); + } + return new PolarisAuthorizerImpl(); + } +} From 07398558e8d4b304cc5a8426955e2045c8d366f0 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:04:50 +0000 Subject: [PATCH 03/40] inject polarisAuthorizer in ServiceProducers CDI --- runtime/service/build.gradle.kts | 1 + .../service/it/AuthorizerProducerIT.java | 25 ------------ .../service/it/ServiceProducersIT.java | 38 +++++++++++++++++++ .../service/auth/AuthorizerProducer.java | 33 ---------------- .../config/AuthorizationConfiguration.java | 18 +++++++++ .../service/config/ServiceProducers.java | 16 ++++++-- 6 files changed, 69 insertions(+), 62 deletions(-) delete mode 100644 runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java create mode 100644 runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java delete mode 100644 runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java create mode 100644 runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index 095a68468c..634669f131 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -25,6 +25,7 @@ plugins { } dependencies { + implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation(project(":polaris-core")) implementation(project(":polaris-api-management-model")) implementation(project(":polaris-api-management-service")) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java deleted file mode 100644 index d99fa4ecc4..0000000000 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/AuthorizerProducerIT.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.apache.polaris.service.it; - -import jakarta.inject.Inject; -import org.apache.polaris.core.auth.PolarisAuthorizer; -import org.apache.polaris.core.auth.PolarisAuthorizerImpl; -import org.apache.polaris.core.auth.OpaPolarisAuthorizer; -import org.junit.jupiter.api.Test; -import io.quarkus.test.junit.QuarkusTest; -import static org.junit.jupiter.api.Assertions.*; - -@QuarkusTest -public class AuthorizerProducerIT { - - @Inject - PolarisAuthorizer polarisAuthorizer; - - @Test - void testDefaultAuthorizerIsImpl() { - // Should be PolarisAuthorizerImpl if polaris.authorization.implementation=default - assertTrue(polarisAuthorizer instanceof PolarisAuthorizerImpl, - "Default authorizer should be PolarisAuthorizerImpl"); - } - - // You can add more tests to check OPA wiring by overriding config in test resources -} diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java new file mode 100644 index 0000000000..68588f18d8 --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java @@ -0,0 +1,38 @@ +package org.apache.polaris.service.it; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import jakarta.inject.Inject; +import java.util.HashMap; +import java.util.Map; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.service.config.ServiceProducers; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@io.quarkus.test.junit.TestProfile(ServiceProducersIT.InlineConfig.class) +public class ServiceProducersIT { + + public static class InlineConfig implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.implementation", "default"); + config.put("polaris.authorization.opa.url", "http://localhost:8181"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/allow"); + config.put("polaris.authorization.opa.timeout-ms", "2000"); + return config; + } + } + + @Inject ServiceProducers serviceProducers; + + @Test + void testPolarisAuthorizerProduced() { + PolarisAuthorizer authorizer = serviceProducers.polarisAuthorizer(); + assertNotNull(authorizer, "PolarisAuthorizer should be produced"); + // Optionally, add more assertions based on expected type/config + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java deleted file mode 100644 index 09170e6c9d..0000000000 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthorizerProducer.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.apache.polaris.service.auth; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.inject.Produces; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.apache.polaris.core.auth.PolarisAuthorizer; -import org.apache.polaris.core.auth.PolarisAuthorizerImpl; -import org.apache.polaris.core.auth.OpaPolarisAuthorizer; - -@ApplicationScoped -public class AuthorizerProducer { - - @ConfigProperty(name = "polaris.authorization.implementation", defaultValue = "default") - String impl; - - @ConfigProperty(name = "polaris.authorization.opa.url", defaultValue = "http://localhost:8181") - String opaUrl; - - @ConfigProperty(name = "polaris.authorization.opa.policyPath", defaultValue = "/v1/data/polaris/allow") - String policyPath; - - @ConfigProperty(name = "polaris.authorization.opa.timeout-ms", defaultValue = "2000") - int timeoutMs; - - @Produces - @ApplicationScoped - PolarisAuthorizer polarisAuthorizer() { - if ("opa".equalsIgnoreCase(impl)) { - return OpaPolarisAuthorizer.create(opaUrl, policyPath, timeoutMs, null, null); - } - return new PolarisAuthorizerImpl(); - } -} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java new file mode 100644 index 0000000000..9d93a461bc --- /dev/null +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -0,0 +1,18 @@ +package org.apache.polaris.service.config; + +import io.smallrye.config.ConfigMapping; + +@ConfigMapping(prefix = "polaris.authorization") +public interface AuthorizationConfiguration { + String implementation(); + + OpaConfig opa(); + + interface OpaConfig { + String url(); + + String policyPath(); + + int timeoutMs(); + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index c833686b49..4abe1777b4 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -38,7 +38,6 @@ import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; import org.apache.polaris.core.PolarisDiagnostics; import org.apache.polaris.core.auth.PolarisAuthorizer; -import org.apache.polaris.core.auth.PolarisAuthorizerImpl; import org.apache.polaris.core.config.PolarisConfigurationStore; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.core.context.CallContext; @@ -163,10 +162,19 @@ public RealmConfig realmConfig(CallContext callContext) { return callContext.getRealmConfig(); } + @jakarta.inject.Inject AuthorizationConfiguration authorizationConfig; + + @jakarta.inject.Inject RealmConfig realmConfig; + @Produces - @RequestScoped - public PolarisAuthorizer polarisAuthorizer(RealmConfig realmConfig) { - return new PolarisAuthorizerImpl(realmConfig); + @ApplicationScoped + public PolarisAuthorizer polarisAuthorizer() { + if ("opa".equalsIgnoreCase(authorizationConfig.implementation())) { + AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); + return org.apache.polaris.core.auth.OpaPolarisAuthorizer.create( + opa.url(), opa.policyPath(), opa.timeoutMs(), null, null); + } + return new org.apache.polaris.core.auth.PolarisAuthorizerImpl(realmConfig); } // Polaris service beans - selected from @Identifier-annotated beans From 7be04821da12ac47d9a400fee815dac78a4b0028 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 23 Sep 2025 02:44:17 +0000 Subject: [PATCH 04/40] add integration test --- .../auth/PolarisOpaIntegrationTest.java | 32 +++++++++++ .../test/commons/OpaIntegrationProfile.java | 18 +++++++ .../polaris/test/commons/OpaTestResource.java | 53 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java create mode 100644 runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java create mode 100644 runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java new file mode 100644 index 0000000000..7116f5926f --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java @@ -0,0 +1,32 @@ +package org.apache.polaris.service.auth; + +import static io.restassured.RestAssured.given; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.apache.polaris.test.commons.OpaIntegrationProfile; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(OpaIntegrationProfile.class) +public class PolarisOpaIntegrationTest { + @Test + void testOpaAllowsAdmin() { + given() + .header("X-Test-Principal", "admin") + .when() + .get("/api/catalog/namespaces") + .then() + .statusCode(200); + } + + @Test + void testOpaDeniesNonAdmin() { + given() + .header("X-Test-Principal", "bob") + .when() + .get("/api/catalog/namespaces") + .then() + .statusCode(403); + } +} diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java new file mode 100644 index 0000000000..bc43535689 --- /dev/null +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java @@ -0,0 +1,18 @@ +package org.apache.polaris.test.commons; + +import io.quarkus.test.junit.QuarkusTestProfile; +import java.util.List; +import java.util.Map; + +public class OpaIntegrationProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + // Additional config overrides can be added here + return Map.of(); + } + + @Override + public List testResources() { + return List.of(new TestResourceEntry(OpaTestResource.class, Map.of())); + } +} diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java new file mode 100644 index 0000000000..7eb613a442 --- /dev/null +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -0,0 +1,53 @@ +package org.apache.polaris.test.commons; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class OpaTestResource implements QuarkusTestResourceLifecycleManager { + private GenericContainer opa; + private int mappedPort; + + @Override + public Map start() { + opa = + new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:latest")) + .withExposedPorts(8181); + opa.start(); + mappedPort = opa.getMappedPort(8181); + String baseUrl = "http://localhost:" + mappedPort; + // Load a simple Rego policy + try { + URL url = new URL(baseUrl + "/v1/policies/polaris-authz"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("PUT"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "text/plain"); + String rego = "package polaris.authz\n\nallow { input.principal == \"admin\" }"; + try (OutputStream os = conn.getOutputStream()) { + os.write(rego.getBytes(StandardCharsets.UTF_8)); + } + conn.getResponseCode(); + } catch (Exception e) { + throw new RuntimeException("Failed to load OPA policy", e); + } + Map config = new HashMap<>(); + config.put("polaris.authz.implementation", "opa"); + config.put("polaris.authz.opa.base-url", baseUrl); + config.put("polaris.authz.opa.package", "polaris/authz"); + return config; + } + + @Override + public void stop() { + if (opa != null) { + opa.stop(); + } + } +} From c7701cbda7258994383e163df2bd7c53a1465c1d Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:31:26 +0000 Subject: [PATCH 05/40] license --- .../core/auth/OpaPolarisAuthorizerTest.java | 18 ++++++++++++++++++ .../polaris/service/it/ServiceProducersIT.java | 18 ++++++++++++++++++ .../config/AuthorizationConfiguration.java | 18 ++++++++++++++++++ .../auth/PolarisOpaIntegrationTest.java | 18 ++++++++++++++++++ .../test/commons/OpaIntegrationProfile.java | 18 ++++++++++++++++++ .../polaris/test/commons/OpaTestResource.java | 18 ++++++++++++++++++ 6 files changed, 108 insertions(+) diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index 96bec38269..a252a40e97 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -1,3 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java index 68588f18d8..0e90216c2f 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java @@ -1,3 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.it; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 9d93a461bc..0f4b7cdf46 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -1,3 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.config; import io.smallrye.config.ConfigMapping; diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java index 7116f5926f..6fd47034f2 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java @@ -1,3 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth; import static io.restassured.RestAssured.given; diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java index bc43535689..8ddce58c7f 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java @@ -1,3 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.test.commons; import io.quarkus.test.junit.QuarkusTestProfile; diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java index 7eb613a442..addf1c9fe3 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -1,3 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.test.commons; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; From ec3c1426a8c469b593956de6a5f9a7a44dfbeb9a Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:44:43 +0000 Subject: [PATCH 06/40] add integration tests --- .../core/auth/OpaPolarisAuthorizer.java | 7 +- .../config/AuthorizationConfiguration.java | 12 +- .../service/config/ServiceProducers.java | 6 +- .../service/auth/OpaIntegrationTest.java | 246 ++++++++++++++++++ .../auth/PolarisOpaIntegrationTest.java | 50 ---- .../test/commons/OpaIntegrationProfile.java | 36 --- .../polaris/test/commons/OpaTestResource.java | 79 ++++-- 7 files changed, 324 insertions(+), 112 deletions(-) create mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java delete mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java delete mode 100644 runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index c51a8701fe..f99448fec4 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -32,6 +32,7 @@ import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; @@ -96,7 +97,7 @@ public static OpaPolarisAuthorizer create( * @param authzOp the operation to authorize * @param target the main target entity * @param secondary the secondary entity (if any) - * @throws RuntimeException if authorization is denied by OPA + * @throws ForbiddenException if authorization is denied by OPA */ @Override public void authorizeOrThrow( @@ -123,7 +124,7 @@ public void authorizeOrThrow( * @param authzOp the operation to authorize * @param targets the list of main target entities * @param secondaries the list of secondary entities (if any) - * @throws RuntimeException if authorization is denied by OPA + * @throws ForbiddenException if authorization is denied by OPA */ @Override public void authorizeOrThrow( @@ -134,7 +135,7 @@ public void authorizeOrThrow( @Nullable List secondaries) { boolean allowed = queryOpa(polarisPrincipal, activatedEntities, authzOp, targets, secondaries); if (!allowed) { - throw new RuntimeException("OPA denied authorization"); + throw new ForbiddenException("OPA denied authorization"); } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 0f4b7cdf46..14ba48355e 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -19,18 +19,24 @@ package org.apache.polaris.service.config; import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; +import java.util.Optional; @ConfigMapping(prefix = "polaris.authorization") public interface AuthorizationConfiguration { + @WithDefault("default") String implementation(); OpaConfig opa(); interface OpaConfig { - String url(); + Optional url(); - String policyPath(); + @WithName("policy-path") + Optional policyPath(); - int timeoutMs(); + @WithName("timeout-ms") + Optional timeoutMs(); } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index 4abe1777b4..7cd1a5e5c4 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -172,7 +172,11 @@ public PolarisAuthorizer polarisAuthorizer() { if ("opa".equalsIgnoreCase(authorizationConfig.implementation())) { AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); return org.apache.polaris.core.auth.OpaPolarisAuthorizer.create( - opa.url(), opa.policyPath(), opa.timeoutMs(), null, null); + opa.url().orElse(null), + opa.policyPath().orElse(null), + opa.timeoutMs().orElse(2000), // Default to 2000ms if not specified + null, + null); } return new org.apache.polaris.core.auth.PolarisAuthorizerImpl(realmConfig); } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java new file mode 100644 index 0000000000..e44683924b --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -0,0 +1,246 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.fail; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.polaris.service.admin.PolarisAdminService; +import org.apache.polaris.test.commons.OpaTestResource; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(OpaIntegrationTest.InternalOpaProfile.class) +public class OpaIntegrationTest { + + @Inject PolarisAdminService adminService; + + public static class InternalOpaProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.implementation", "opa"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.timeout-ms", "2000"); + + // TODO: Add tests for OIDC and federated principal + config.put("polaris.authentication.type", "internal"); + + return config; + } + + @Override + public List testResources() { + String customRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + + # Deny stranger user explicitly (though default is false) + allow { + input.actor.principal == "stranger" + false + } + """; + + return List.of( + new TestResourceEntry( + OpaTestResource.class, + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); + } + } + + @Test + void testOpaAllowsRootUser() { + // Test demonstrates the complete integration flow: + // 1. OAuth token acquisition with internal authentication + // 2. OPA policy allowing root users + + // Get a token using the catalog service OAuth endpoint + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + // Parse JSON response to get access_token + String accessToken = null; + if (response.contains("\"access_token\"")) { + accessToken = response.substring(response.indexOf("\"access_token\"") + 15); + accessToken = accessToken.substring(accessToken.indexOf("\"") + 1); + accessToken = accessToken.substring(0, accessToken.indexOf("\"")); + } + + if (accessToken == null) { + fail("Failed to parse access_token from OAuth response: " + response); + } + + // Use the Bearer token to test OPA authorization + // The JWT token has principal "root" which our policy allows + given() + .header("Authorization", "Bearer " + accessToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - "root" user is allowed by policy + } + + @Test + void testOpaPolicyDeniesStrangerUser() { + // Create a "stranger" principal and get its access token + String strangerToken = createPrincipalAndGetToken("stranger"); + + // Use the stranger token to test OPA authorization - should be denied + given() + .header("Authorization", "Bearer " + strangerToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(403); // Should be forbidden by OPA policy - stranger is denied + } + + @Test + void testOpaAllowsAdminUser() { + // Create an "admin" principal and get its access token + String adminToken = createPrincipalAndGetToken("admin"); + + // Use the admin token to test OPA authorization - should be allowed + given() + .header("Authorization", "Bearer " + adminToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - admin user is allowed by policy + } + + /** Helper method to create a principal and get an OAuth access token for that principal */ + private String createPrincipalAndGetToken(String principalName) { + // First get admin token to create the principal + String adminToken = getAdminToken(); + + // Create the principal using the admin token + String createResponse = + given() + .contentType("application/json") + .header("Authorization", "Bearer " + adminToken) + .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") + .when() + .post("/api/management/v1/principals") + .then() + .statusCode(201) + .extract() + .body() + .asString(); + + // Parse the principal's credentials from the response + String clientId = extractJsonValue(createResponse, "clientId"); + String clientSecret = extractJsonValue(createResponse, "clientSecret"); + + if (clientId == null || clientSecret == null) { + fail("Could not parse principal credentials from response: " + createResponse); + } + + // Get access token for the newly created principal + String tokenResponse = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", clientId) + .formParam("client_secret", clientSecret) + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(tokenResponse, "access_token"); + if (accessToken == null) { + fail("Could not get access token for principal " + principalName); + } + + return accessToken; + } + + /** Helper method to get admin access token */ + private String getAdminToken() { + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(response, "access_token"); + if (accessToken == null) { + fail("Failed to parse access_token from admin OAuth response: " + response); + } + return accessToken; + } + + /** Simple JSON value extractor */ + private String extractJsonValue(String json, String key) { + String searchKey = "\"" + key + "\""; + if (json.contains(searchKey)) { + String value = json.substring(json.indexOf(searchKey) + searchKey.length()); + value = value.substring(value.indexOf("\"") + 1); + value = value.substring(0, value.indexOf("\"")); + return value; + } + return null; + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java deleted file mode 100644 index 6fd47034f2..0000000000 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/PolarisOpaIntegrationTest.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.service.auth; - -import static io.restassured.RestAssured.given; - -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.TestProfile; -import org.apache.polaris.test.commons.OpaIntegrationProfile; -import org.junit.jupiter.api.Test; - -@QuarkusTest -@TestProfile(OpaIntegrationProfile.class) -public class PolarisOpaIntegrationTest { - @Test - void testOpaAllowsAdmin() { - given() - .header("X-Test-Principal", "admin") - .when() - .get("/api/catalog/namespaces") - .then() - .statusCode(200); - } - - @Test - void testOpaDeniesNonAdmin() { - given() - .header("X-Test-Principal", "bob") - .when() - .get("/api/catalog/namespaces") - .then() - .statusCode(403); - } -} diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java deleted file mode 100644 index 8ddce58c7f..0000000000 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaIntegrationProfile.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.test.commons; - -import io.quarkus.test.junit.QuarkusTestProfile; -import java.util.List; -import java.util.Map; - -public class OpaIntegrationProfile implements QuarkusTestProfile { - @Override - public Map getConfigOverrides() { - // Additional config overrides can be added here - return Map.of(); - } - - @Override - public List testResources() { - return List.of(new TestResourceEntry(OpaTestResource.class, Map.of())); - } -} diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java index addf1c9fe3..467764c9a8 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -23,49 +23,90 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; public class OpaTestResource implements QuarkusTestResourceLifecycleManager { - private GenericContainer opa; + private static GenericContainer opa; private int mappedPort; + private Map resourceConfig; + + @Override + public void init(Map initArgs) { + this.resourceConfig = initArgs; + } @Override public Map start() { - opa = - new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:latest")) - .withExposedPorts(8181); - opa.start(); + // Reuse container across tests to speed up execution + if (opa == null || !opa.isRunning()) { + opa = + new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:0.63.0")) + .withExposedPorts(8181) + .withCommand("run", "--server", "--addr=0.0.0.0:8181") + .withReuse(true) + .waitingFor( + Wait.forHttp("/health") + .forPort(8181) + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(30))); + opa.start(); + } + mappedPort = opa.getMappedPort(8181); String baseUrl = "http://localhost:" + mappedPort; - // Load a simple Rego policy + + loadRegoPolicy(baseUrl); + + Map config = new HashMap<>(); + config.put("polaris.authorization.opa.url", baseUrl); + return config; + } + + private void loadRegoPolicy(String baseUrl) { + String policyName = resourceConfig.get("policy-name"); + String regoPolicy = resourceConfig.get("rego-policy"); + + if (policyName == null) { + throw new IllegalArgumentException("policy-name parameter is required for OpaTestResource"); + } + if (regoPolicy == null) { + throw new IllegalArgumentException("rego-policy parameter is required for OpaTestResource"); + } + try { - URL url = new URL(baseUrl + "/v1/policies/polaris-authz"); + URL url = new URL(baseUrl + "/v1/policies/" + policyName); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("PUT"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "text/plain"); - String rego = "package polaris.authz\n\nallow { input.principal == \"admin\" }"; + try (OutputStream os = conn.getOutputStream()) { - os.write(rego.getBytes(StandardCharsets.UTF_8)); + os.write(regoPolicy.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + throw new RuntimeException("OPA policy upload failed, HTTP " + code); } - conn.getResponseCode(); } catch (Exception e) { - throw new RuntimeException("Failed to load OPA policy", e); + // Surface container logs to help debug on CI + String logs = ""; + try { + logs = opa.getLogs(); + } catch (Throwable ignored) { + } + throw new RuntimeException("Failed to load OPA policy. Container logs:\n" + logs, e); } - Map config = new HashMap<>(); - config.put("polaris.authz.implementation", "opa"); - config.put("polaris.authz.opa.base-url", baseUrl); - config.put("polaris.authz.opa.package", "polaris/authz"); - return config; } @Override public void stop() { - if (opa != null) { - opa.stop(); - } + // Don't stop the container to allow reuse across tests + // Container will be cleaned up when the JVM exits } } From ed6f265c26ff93beb593589880d1a3e0722d81d4 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 25 Sep 2025 02:58:16 +0000 Subject: [PATCH 07/40] minor fixes --- .../src/main/resources/application.properties | 2 +- .../polaris/service/config/ServiceProducers.java | 2 +- .../polaris/service/auth/OpaIntegrationTest.java | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index b79df4a558..11c2c00ad3 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -199,7 +199,7 @@ polaris.authorization.implementation=default # OPA Authorizer Configuration: effective only if polaris.authorization.implementation=opa # polaris.authorization.opa.url=http://localhost:8181 -# polaris.authorization.opa.policyPath=/v1/data/polaris/authz/allow +# polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow # polaris.authorization.opa.timeout-ms=2000 quarkus.arc.ignored-split-packages=\ diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index 7cd1a5e5c4..a2fa5d732e 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -167,7 +167,7 @@ public RealmConfig realmConfig(CallContext callContext) { @jakarta.inject.Inject RealmConfig realmConfig; @Produces - @ApplicationScoped + @RequestScoped public PolarisAuthorizer polarisAuthorizer() { if ("opa".equalsIgnoreCase(authorizationConfig.implementation())) { AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index e44683924b..0d9786a77c 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -159,14 +159,14 @@ void testOpaAllowsAdminUser() { /** Helper method to create a principal and get an OAuth access token for that principal */ private String createPrincipalAndGetToken(String principalName) { - // First get admin token to create the principal - String adminToken = getAdminToken(); + // First get root token to create the principal + String rootToken = getRootToken(); - // Create the principal using the admin token + // Create the principal using the root token String createResponse = given() .contentType("application/json") - .header("Authorization", "Bearer " + adminToken) + .header("Authorization", "Bearer " + rootToken) .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") .when() .post("/api/management/v1/principals") @@ -208,8 +208,8 @@ private String createPrincipalAndGetToken(String principalName) { return accessToken; } - /** Helper method to get admin access token */ - private String getAdminToken() { + /** Helper method to get root access token */ + private String getRootToken() { String response = given() .contentType("application/x-www-form-urlencoded") From 5caf1f41131d36137d96dd27c787053466b68e46 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:01:51 +0000 Subject: [PATCH 08/40] adopt review feedback --- gradle/libs.versions.toml | 3 + polaris-core/build.gradle.kts | 2 +- .../core/auth/OpaPolarisAuthorizer.java | 68 ++++++++++++------- .../core/auth/PolarisAuthorizerFactory.java | 38 +++++++++++ .../core/auth/PolarisAuthorizerImpl.java | 2 - .../src/main/resources/application.properties | 6 +- runtime/service/build.gradle.kts | 2 +- .../service/it/ServiceProducersIT.java | 11 ++- .../auth/DefaultPolarisAuthorizerFactory.java | 37 ++++++++++ .../auth/OpaPolarisAuthorizerFactory.java | 52 ++++++++++++++ .../config/AuthorizationConfiguration.java | 5 +- .../service/config/ServiceProducers.java | 23 +++---- .../service/auth/OpaIntegrationTest.java | 6 +- 13 files changed, 195 insertions(+), 60 deletions(-) create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java create mode 100644 runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java create mode 100644 runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae67ac2e13..ad0d709509 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ # [versions] +apache-httpclient = "4.5.14" checkstyle = "10.25.0" hadoop = "3.4.2" hive = "3.1.3" @@ -40,9 +41,11 @@ swagger = "1.6.16" # (aka mention of the dependency removed). # antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests +apache-httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "apache-httpclient" } assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" } auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" } awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.34.0" } +awssdk-apache-client = { module = "software.amazon.awssdk:apache-client", version = "2.34.0" } awaitility = { module = "org.awaitility:awaitility", version = "4.3.0" } azuresdk-bom = { module = "com.azure:azure-sdk-bom", version = "1.2.38" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version = "3.2.2" } diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index 17ba938657..d2c72a9ecb 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -24,7 +24,7 @@ plugins { dependencies { implementation(project(":polaris-api-management-model")) - implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation(libs.apache.httpclient) implementation(platform(libs.iceberg.bom)) implementation("org.apache.iceberg:iceberg-api") diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index f99448fec4..6e66b932c4 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -25,13 +25,16 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; @@ -46,14 +49,14 @@ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private final String opaServerUrl; private final String opaPolicyPath; - private final OkHttpClient httpClient; + private final CloseableHttpClient httpClient; private final ObjectMapper objectMapper; /** Private constructor for factory method and advanced wiring. */ private OpaPolarisAuthorizer( String opaServerUrl, String opaPolicyPath, - OkHttpClient httpClient, + CloseableHttpClient httpClient, ObjectMapper objectMapper) { this.opaServerUrl = opaServerUrl; this.opaPolicyPath = opaPolicyPath; @@ -67,7 +70,7 @@ private OpaPolarisAuthorizer( * @param opaServerUrl OPA server URL * @param opaPolicyPath OPA policy path * @param timeoutMs HTTP call timeout in milliseconds - * @param client OkHttpClient (optional, can be null) + * @param client Apache HttpClient (optional, can be null) * @param mapper ObjectMapper (optional, can be null) * @return OpaPolarisAuthorizer instance */ @@ -75,16 +78,25 @@ public static OpaPolarisAuthorizer create( String opaServerUrl, String opaPolicyPath, int timeoutMs, - OkHttpClient client, + Object client, // Accept any client type for compatibility ObjectMapper mapper) { - OkHttpClient clientWithTimeout = - (client != null) - ? client.newBuilder().callTimeout(java.time.Duration.ofMillis(timeoutMs)).build() - : new OkHttpClient.Builder() - .callTimeout(java.time.Duration.ofMillis(timeoutMs)) - .build(); - ObjectMapper objectMapper = (mapper != null) ? mapper : new ObjectMapper(); - return new OpaPolarisAuthorizer(opaServerUrl, opaPolicyPath, clientWithTimeout, objectMapper); + + // Create Apache HttpClient with timeout configuration + RequestConfig requestConfig = + RequestConfig.custom() + .setConnectTimeout(timeoutMs) + .setSocketTimeout(timeoutMs) + .setConnectionRequestTimeout(timeoutMs) + .build(); + + CloseableHttpClient httpClient = + (client instanceof CloseableHttpClient) + ? (CloseableHttpClient) client + : HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); + + ObjectMapper objectMapperWithDefaults = mapper != null ? mapper : new ObjectMapper(); + return new OpaPolarisAuthorizer( + opaServerUrl, opaPolicyPath, httpClient, objectMapperWithDefaults); } /** @@ -161,12 +173,22 @@ private boolean queryOpa( List secondaries) { try { String inputJson = buildOpaInputJson(principal, entities, op, targets, secondaries); - RequestBody body = RequestBody.create(inputJson, MediaType.parse("application/json")); - Request request = new Request.Builder().url(opaServerUrl + opaPolicyPath).post(body).build(); - try (Response response = httpClient.newCall(request).execute()) { - if (!response.isSuccessful()) return false; - // Parse response JSON for 'result.allow' - ObjectNode respNode = (ObjectNode) objectMapper.readTree(response.body().string()); + + // Create HTTP POST request using Apache HttpComponents + HttpPost httpPost = new HttpPost(opaServerUrl + opaPolicyPath); + httpPost.setHeader("Content-Type", "application/json"); + httpPost.setEntity(new StringEntity(inputJson, StandardCharsets.UTF_8)); + + // Execute request + try (CloseableHttpResponse response = httpClient.execute(httpPost)) { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != 200) { + return false; + } + + // Read and parse response + String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + ObjectNode respNode = (ObjectNode) objectMapper.readTree(responseBody); return respNode.path("result").path("allow").asBoolean(false); } } catch (IOException e) { diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java new file mode 100644 index 0000000000..7d01934ec7 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import org.apache.polaris.core.config.RealmConfig; + +/** + * Factory interface for creating PolarisAuthorizer instances. + * + *

This follows the standard Polaris pattern of using CDI with @Identifier annotations to select + * different implementations at runtime. + */ +public interface PolarisAuthorizerFactory { + + /** + * Creates a PolarisAuthorizer instance with the given realm configuration. + * + * @param realmConfig the realm configuration + * @return a configured PolarisAuthorizer instance + */ + PolarisAuthorizer create(RealmConfig realmConfig); +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java index 5091f4b82d..9cc18fbdc3 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java @@ -109,7 +109,6 @@ import com.google.common.collect.SetMultimap; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import jakarta.inject.Inject; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -533,7 +532,6 @@ public class PolarisAuthorizerImpl implements PolarisAuthorizer { private final RealmConfig realmConfig; - @Inject public PolarisAuthorizerImpl(RealmConfig realmConfig) { this.realmConfig = realmConfig; } diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 11c2c00ad3..a2e26e9a4d 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -193,11 +193,11 @@ polaris.oidc.principal-roles-mapper.type=default # polaris.storage.gcp.token=token # polaris.storage.gcp.lifespan=PT1H -# Polaris authorization implementation settings +# Polaris authorization type settings # Which authorizer to use: "default" (PolarisAuthorizerImpl) or "opa" (OpaPolarisAuthorizer) -polaris.authorization.implementation=default +polaris.authorization.type=default -# OPA Authorizer Configuration: effective only if polaris.authorization.implementation=opa +# OPA Authorizer Configuration: effective only if polaris.authorization.type=opa # polaris.authorization.opa.url=http://localhost:8181 # polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow # polaris.authorization.opa.timeout-ms=2000 diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index 634669f131..b57708ffe3 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -25,7 +25,7 @@ plugins { } dependencies { - implementation("com.squareup.okhttp3:okhttp:4.12.0") + // HTTP client already included via AWS SDK implementation(project(":polaris-core")) implementation(project(":polaris-api-management-model")) implementation(project(":polaris-api-management-service")) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java index 0e90216c2f..3b02ac1163 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java @@ -26,7 +26,6 @@ import java.util.HashMap; import java.util.Map; import org.apache.polaris.core.auth.PolarisAuthorizer; -import org.apache.polaris.service.config.ServiceProducers; import org.junit.jupiter.api.Test; @QuarkusTest @@ -37,7 +36,7 @@ public static class InlineConfig implements QuarkusTestProfile { @Override public Map getConfigOverrides() { Map config = new HashMap<>(); - config.put("polaris.authorization.implementation", "default"); + config.put("polaris.authorization.type", "default"); config.put("polaris.authorization.opa.url", "http://localhost:8181"); config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/allow"); config.put("polaris.authorization.opa.timeout-ms", "2000"); @@ -45,12 +44,12 @@ public Map getConfigOverrides() { } } - @Inject ServiceProducers serviceProducers; + @Inject PolarisAuthorizer polarisAuthorizer; @Test void testPolarisAuthorizerProduced() { - PolarisAuthorizer authorizer = serviceProducers.polarisAuthorizer(); - assertNotNull(authorizer, "PolarisAuthorizer should be produced"); - // Optionally, add more assertions based on expected type/config + assertNotNull(polarisAuthorizer, "PolarisAuthorizer should be produced"); + // Verify it's the correct implementation for default config + assertNotNull(polarisAuthorizer, "PolarisAuthorizer should not be null"); } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java new file mode 100644 index 0000000000..0019ba91b9 --- /dev/null +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth; + +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.RequestScoped; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.auth.PolarisAuthorizerImpl; +import org.apache.polaris.core.config.RealmConfig; + +/** Factory for creating the default Polaris authorizer implementation. */ +@RequestScoped +@Identifier("default") +public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { + + @Override + public PolarisAuthorizer create(RealmConfig realmConfig) { + return new PolarisAuthorizerImpl(realmConfig); + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java new file mode 100644 index 0000000000..1cc866225c --- /dev/null +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth; + +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import org.apache.polaris.core.auth.OpaPolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.config.RealmConfig; +import org.apache.polaris.service.config.AuthorizationConfiguration; + +/** Factory for creating OPA-based Polaris authorizer implementations. */ +@RequestScoped +@Identifier("opa") +public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { + + private final AuthorizationConfiguration authorizationConfig; + + @Inject + public OpaPolarisAuthorizerFactory(AuthorizationConfiguration authorizationConfig) { + this.authorizationConfig = authorizationConfig; + } + + @Override + public PolarisAuthorizer create(RealmConfig realmConfig) { + AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); + return OpaPolarisAuthorizer.create( + opa.url().orElse(null), + opa.policyPath().orElse(null), + opa.timeoutMs().orElse(2000), // Default to 2000ms if not specified + null, + null); + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 14ba48355e..f19fae64ad 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -20,23 +20,20 @@ import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; -import io.smallrye.config.WithName; import java.util.Optional; @ConfigMapping(prefix = "polaris.authorization") public interface AuthorizationConfiguration { @WithDefault("default") - String implementation(); + String type(); OpaConfig opa(); interface OpaConfig { Optional url(); - @WithName("policy-path") Optional policyPath(); - @WithName("timeout-ms") Optional timeoutMs(); } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index a2fa5d732e..90c729d1eb 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -38,6 +38,7 @@ import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; import org.apache.polaris.core.PolarisDiagnostics; import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerFactory; import org.apache.polaris.core.config.PolarisConfigurationStore; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.core.context.CallContext; @@ -162,23 +163,15 @@ public RealmConfig realmConfig(CallContext callContext) { return callContext.getRealmConfig(); } - @jakarta.inject.Inject AuthorizationConfiguration authorizationConfig; - - @jakarta.inject.Inject RealmConfig realmConfig; - @Produces @RequestScoped - public PolarisAuthorizer polarisAuthorizer() { - if ("opa".equalsIgnoreCase(authorizationConfig.implementation())) { - AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); - return org.apache.polaris.core.auth.OpaPolarisAuthorizer.create( - opa.url().orElse(null), - opa.policyPath().orElse(null), - opa.timeoutMs().orElse(2000), // Default to 2000ms if not specified - null, - null); - } - return new org.apache.polaris.core.auth.PolarisAuthorizerImpl(realmConfig); + public PolarisAuthorizer polarisAuthorizer( + AuthorizationConfiguration authorizationConfig, + RealmConfig realmConfig, + @Any Instance authorizerFactories) { + PolarisAuthorizerFactory factory = + authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get(); + return factory.create(realmConfig); } // Polaris service beans - selected from @Identifier-annotated beans diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index 0d9786a77c..02d89b26f4 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -25,11 +25,9 @@ import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; import io.quarkus.test.junit.TestProfile; -import jakarta.inject.Inject; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.apache.polaris.service.admin.PolarisAdminService; import org.apache.polaris.test.commons.OpaTestResource; import org.junit.jupiter.api.Test; @@ -37,13 +35,11 @@ @TestProfile(OpaIntegrationTest.InternalOpaProfile.class) public class OpaIntegrationTest { - @Inject PolarisAdminService adminService; - public static class InternalOpaProfile implements QuarkusTestProfile { @Override public Map getConfigOverrides() { Map config = new HashMap<>(); - config.put("polaris.authorization.implementation", "opa"); + config.put("polaris.authorization.type", "opa"); config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); config.put("polaris.authorization.opa.timeout-ms", "2000"); From 3935c6a73b020a64398e61496272cb3e2e57d2ab Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:04:05 +0000 Subject: [PATCH 09/40] remove comment --- runtime/service/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index b57708ffe3..095a68468c 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -25,7 +25,6 @@ plugins { } dependencies { - // HTTP client already included via AWS SDK implementation(project(":polaris-core")) implementation(project(":polaris-api-management-model")) implementation(project(":polaris-api-management-service")) From 5ad1030599554253dd11478475fe34af70c6ad19 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 30 Sep 2025 02:34:25 +0000 Subject: [PATCH 10/40] support https and bearer token authz --- .../polaris/core/auth/FileTokenProvider.java | 162 +++++++++ .../core/auth/OpaPolarisAuthorizer.java | 142 +++++++- .../core/auth/StaticTokenProvider.java | 37 ++ .../polaris/core/auth/TokenProvider.java | 51 +++ .../core/auth/FileTokenProviderTest.java | 142 ++++++++ .../core/auth/OpaPolarisAuthorizerTest.java | 315 +++++++++++++++++ .../core/auth/StaticTokenProviderTest.java | 52 +++ .../src/main/resources/application.properties | 13 + .../auth/OpaPolarisAuthorizerFactory.java | 41 +++ .../config/AuthorizationConfiguration.java | 21 ++ .../auth/OpaFileTokenIntegrationTest.java | 231 +++++++++++++ .../service/auth/OpaIntegrationTest.java | 114 ++++++- .../auth/OpaPolarisAuthorizerFactoryTest.java | 193 +++++++++++ .../polaris/test/commons/OpaTestResource.java | 316 ++++++++++++++++-- 14 files changed, 1792 insertions(+), 38 deletions(-) create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java create mode 100644 polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java create mode 100644 polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java create mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java create mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java new file mode 100644 index 0000000000..92c505e43d --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A token provider that reads tokens from a file and automatically reloads them based on a + * configurable refresh interval. + * + *

This is particularly useful in Kubernetes environments where tokens are mounted as files and + * refreshed by external systems (e.g., service account tokens, projected volumes, etc.). + * + *

The token file is expected to contain the bearer token as plain text. Leading and trailing + * whitespace will be trimmed. + */ +public class FileTokenProvider implements TokenProvider { + + private static final Logger logger = LoggerFactory.getLogger(FileTokenProvider.class); + + private final Path tokenFilePath; + private final Duration refreshInterval; + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + private volatile String cachedToken; + private volatile Instant lastRefresh; + private volatile boolean closed = false; + + /** + * Create a new file-based token provider. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes + */ + public FileTokenProvider(String tokenFilePath, Duration refreshInterval) { + this.tokenFilePath = Paths.get(tokenFilePath); + this.refreshInterval = refreshInterval; + this.lastRefresh = Instant.MIN; // Force initial load + + logger.info( + "Created file token provider for path: {} with refresh interval: {}", + tokenFilePath, + refreshInterval); + } + + @Override + @Nullable + public String getToken() { + if (closed) { + logger.warn("Token provider is closed, returning null"); + return null; + } + + // Check if we need to refresh + if (shouldRefresh()) { + refreshToken(); + } + + lock.readLock().lock(); + try { + return cachedToken; + } finally { + lock.readLock().unlock(); + } + } + + @Override + public void close() { + closed = true; + lock.writeLock().lock(); + try { + cachedToken = null; + logger.info("File token provider closed"); + } finally { + lock.writeLock().unlock(); + } + } + + private boolean shouldRefresh() { + return lastRefresh.plus(refreshInterval).isBefore(Instant.now()); + } + + private void refreshToken() { + lock.writeLock().lock(); + try { + // Double-check pattern - another thread might have refreshed while we waited for the lock + if (!shouldRefresh()) { + return; + } + + String newToken = loadTokenFromFile(); + cachedToken = newToken; + lastRefresh = Instant.now(); + + if (logger.isDebugEnabled()) { + logger.debug( + "Token refreshed from file: {} (token present: {})", + tokenFilePath, + newToken != null && !newToken.isEmpty()); + } + + } finally { + lock.writeLock().unlock(); + } + } + + @Nullable + private String loadTokenFromFile() { + try { + if (!Files.exists(tokenFilePath)) { + logger.warn("Token file does not exist: {}", tokenFilePath); + return null; + } + + if (!Files.isReadable(tokenFilePath)) { + logger.warn("Token file is not readable: {}", tokenFilePath); + return null; + } + + String content = Files.readString(tokenFilePath, StandardCharsets.UTF_8); + String token = content.trim(); + + if (token.isEmpty()) { + logger.warn("Token file is empty: {}", tokenFilePath); + return null; + } + + return token; + + } catch (IOException e) { + logger.error("Failed to read token from file: {}", tokenFilePath, e); + return null; + } + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index 6e66b932c4..88c813e840 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -24,16 +24,25 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.util.List; +import java.util.Locale; import java.util.Set; +import javax.net.ssl.SSLContext; +import org.apache.http.HttpHeaders; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.conn.ssl.TrustAllStrategy; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; import org.apache.http.util.EntityUtils; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; @@ -49,6 +58,7 @@ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private final String opaServerUrl; private final String opaPolicyPath; + private final TokenProvider tokenProvider; private final CloseableHttpClient httpClient; private final ObjectMapper objectMapper; @@ -56,20 +66,26 @@ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private OpaPolarisAuthorizer( String opaServerUrl, String opaPolicyPath, + TokenProvider tokenProvider, CloseableHttpClient httpClient, ObjectMapper objectMapper) { this.opaServerUrl = opaServerUrl; this.opaPolicyPath = opaPolicyPath; + this.tokenProvider = tokenProvider; this.httpClient = httpClient; this.objectMapper = objectMapper; } /** - * Static factory for runtime configuration and CDI producer compatibility. + * Static factory that accepts a TokenProvider for advanced token management. * * @param opaServerUrl OPA server URL * @param opaPolicyPath OPA policy path + * @param tokenProvider Token provider for authentication (optional) * @param timeoutMs HTTP call timeout in milliseconds + * @param verifySsl Whether to verify SSL certificates for HTTPS connections + * @param trustStorePath Custom SSL trust store path (optional) + * @param trustStorePassword Custom SSL trust store password (optional) * @param client Apache HttpClient (optional, can be null) * @param mapper ObjectMapper (optional, can be null) * @return OpaPolarisAuthorizer instance @@ -77,26 +93,113 @@ private OpaPolarisAuthorizer( public static OpaPolarisAuthorizer create( String opaServerUrl, String opaPolicyPath, + TokenProvider tokenProvider, int timeoutMs, + boolean verifySsl, + String trustStorePath, + String trustStorePassword, Object client, // Accept any client type for compatibility ObjectMapper mapper) { - // Create Apache HttpClient with timeout configuration - RequestConfig requestConfig = - RequestConfig.custom() - .setConnectTimeout(timeoutMs) - .setSocketTimeout(timeoutMs) - .setConnectionRequestTimeout(timeoutMs) - .build(); + try { + // Create request configuration with timeouts + RequestConfig requestConfig = + RequestConfig.custom() + .setConnectTimeout(timeoutMs) + .setSocketTimeout(timeoutMs) + .setConnectionRequestTimeout(timeoutMs) + .build(); + + // Configure SSL context for HTTPS connections + SSLContext sslContext = null; + SSLConnectionSocketFactory sslSocketFactory = null; + + if (opaServerUrl != null && opaServerUrl.toLowerCase(Locale.ROOT).startsWith("https")) { + SSLContextBuilder sslContextBuilder = SSLContextBuilder.create(); + + if (!verifySsl) { + // Disable SSL verification (for development/testing) + sslContextBuilder.loadTrustMaterial(TrustAllStrategy.INSTANCE); + sslContext = sslContextBuilder.build(); + sslSocketFactory = + new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); + } else if (trustStorePath != null && !trustStorePath.isEmpty()) { + // Load custom trust store for SSL verification + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { + trustStore.load( + trustStoreStream, + trustStorePassword != null ? trustStorePassword.toCharArray() : null); + } + sslContextBuilder.loadTrustMaterial(trustStore, null); + sslContext = sslContextBuilder.build(); + sslSocketFactory = new SSLConnectionSocketFactory(sslContext); + } else { + // Use default system trust store for SSL verification + sslContext = SSLContextBuilder.create().build(); + sslSocketFactory = new SSLConnectionSocketFactory(sslContext); + } + } - CloseableHttpClient httpClient = - (client instanceof CloseableHttpClient) - ? (CloseableHttpClient) client - : HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); + // Create HTTP client with SSL configuration + CloseableHttpClient httpClient; + if (client instanceof CloseableHttpClient) { + httpClient = (CloseableHttpClient) client; + } else { + if (sslSocketFactory != null) { + httpClient = + HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .setSSLSocketFactory(sslSocketFactory) + .build(); + } else { + httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); + } + } - ObjectMapper objectMapperWithDefaults = mapper != null ? mapper : new ObjectMapper(); - return new OpaPolarisAuthorizer( - opaServerUrl, opaPolicyPath, httpClient, objectMapperWithDefaults); + ObjectMapper objectMapperWithDefaults = mapper != null ? mapper : new ObjectMapper(); + return new OpaPolarisAuthorizer( + opaServerUrl, opaPolicyPath, tokenProvider, httpClient, objectMapperWithDefaults); + } catch (Exception e) { + throw new RuntimeException("Failed to create OpaPolarisAuthorizer with SSL configuration", e); + } + } + + /** + * Convenience factory method for backward compatibility with String bearer tokens. + * + * @param opaServerUrl OPA server URL + * @param opaPolicyPath OPA policy path + * @param bearerToken Bearer token for authentication (optional) + * @param timeoutMs HTTP call timeout in milliseconds + * @param verifySsl Whether to verify SSL certificates for HTTPS connections + * @param trustStorePath Custom SSL trust store path (optional) + * @param trustStorePassword Custom SSL trust store password (optional) + * @param client Apache HttpClient (optional, can be null) + * @param mapper ObjectMapper (optional, can be null) + * @return OpaPolarisAuthorizer instance + */ + public static OpaPolarisAuthorizer create( + String opaServerUrl, + String opaPolicyPath, + String bearerToken, + int timeoutMs, + boolean verifySsl, + String trustStorePath, + String trustStorePassword, + Object client, + ObjectMapper mapper) { + TokenProvider tokenProvider = new StaticTokenProvider(bearerToken); + return create( + opaServerUrl, + opaPolicyPath, + tokenProvider, + timeoutMs, + verifySsl, + trustStorePath, + trustStorePassword, + client, + mapper); } /** @@ -177,6 +280,15 @@ private boolean queryOpa( // Create HTTP POST request using Apache HttpComponents HttpPost httpPost = new HttpPost(opaServerUrl + opaPolicyPath); httpPost.setHeader("Content-Type", "application/json"); + + // Add bearer token authentication if provided + if (tokenProvider != null) { + String token = tokenProvider.getToken(); + if (token != null && !token.isEmpty()) { + httpPost.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + } + httpPost.setEntity(new StringEntity(inputJson, StandardCharsets.UTF_8)); // Execute request diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java new file mode 100644 index 0000000000..89bafb8844 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import jakarta.annotation.Nullable; + +/** A simple token provider that returns a static string value. */ +public class StaticTokenProvider implements TokenProvider { + + private final String token; + + public StaticTokenProvider(@Nullable String token) { + this.token = token; + } + + @Override + @Nullable + public String getToken() { + return token; + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java new file mode 100644 index 0000000000..c1885048c9 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import jakarta.annotation.Nullable; + +/** + * Interface for providing bearer tokens for authentication. + * + *

Implementations can provide tokens from various sources such as: + * + *

+ */ +public interface TokenProvider { + + /** + * Get the current bearer token. + * + * @return the bearer token, or null if no token is available + */ + @Nullable + String getToken(); + + /** + * Clean up any resources used by this token provider. Should be called when the provider is no + * longer needed. + */ + default void close() { + // Default implementation does nothing + } +} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java new file mode 100644 index 0000000000..774b57cae7 --- /dev/null +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class FileTokenProviderTest { + + @TempDir Path tempDir; + + @Test + public void testLoadTokenFromFile() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String expectedToken = "test-bearer-token-123"; + Files.writeString(tokenFile, expectedToken); + + // Create file token provider + FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); + } + + @Test + public void testLoadTokenFromFileWithWhitespace() throws IOException { + // Create a temporary token file with whitespace + Path tokenFile = tempDir.resolve("token.txt"); + String tokenWithWhitespace = " test-bearer-token-456 \n\t"; + String expectedToken = "test-bearer-token-456"; + Files.writeString(tokenFile, tokenWithWhitespace); + + // Create file token provider + FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval (should trim whitespace) + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); + } + + @Test + public void testTokenRefresh() throws IOException, InterruptedException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String initialToken = "initial-token"; + Files.writeString(tokenFile, initialToken); + + // Create file token provider with short refresh interval + FileTokenProvider provider = + new FileTokenProvider(tokenFile.toString(), Duration.ofMillis(100)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(initialToken, token1); + + // Wait for refresh interval to pass + Thread.sleep(200); + + // Update the file + String updatedToken = "updated-token"; + Files.writeString(tokenFile, updatedToken); + + // Test that token is refreshed + String token2 = provider.getToken(); + assertEquals(updatedToken, token2); + + provider.close(); + } + + @Test + public void testNonExistentFile() { + // Create file token provider for non-existent file + FileTokenProvider provider = + new FileTokenProvider("/non/existent/file.txt", Duration.ofMinutes(5)); + + // Test token retrieval (should return null) + String token = provider.getToken(); + assertNull(token); + + provider.close(); + } + + @Test + public void testEmptyFile() throws IOException { + // Create an empty token file + Path tokenFile = tempDir.resolve("empty.txt"); + Files.writeString(tokenFile, ""); + + // Create file token provider + FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval (should return null for empty file) + String token = provider.getToken(); + assertNull(token); + + provider.close(); + } + + @Test + public void testClosedProvider() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + Files.writeString(tokenFile, "test-token"); + + // Create and close file token provider + FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + provider.close(); + + // Test token retrieval after closing (should return null) + String token = provider.getToken(); + assertNull(token); + } +} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index a252a40e97..5fe4713cca 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -19,19 +19,40 @@ package org.apache.polaris.core.auth; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import org.apache.http.HttpEntity; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +/** + * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication + */ public class OpaPolarisAuthorizerTest { + @Test void testOpaInputJsonFormat() throws Exception { MockWebServer server = new MockWebServer(); @@ -43,9 +64,14 @@ void testOpaInputJsonFormat() throws Exception { OpaPolarisAuthorizer.create( url.replace("/v1/data/polaris/authz/allow", ""), "/v1/data/polaris/authz/allow", + (String) null, 2000, + true, + null, + null, null, null); + PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); Mockito.when(principal.getName()).thenReturn("eve"); Mockito.when(principal.getRoles()).thenReturn(Set.of("auditor")); @@ -89,9 +115,14 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { OpaPolarisAuthorizer.create( url.replace("/v1/data/polaris/authz/allow", ""), "/v1/data/polaris/authz/allow", + (String) null, 2000, + true, + null, + null, null, null); + PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); Mockito.when(principal.getName()).thenReturn("alice"); Mockito.when(principal.getRoles()).thenReturn(Set.of("admin")); @@ -124,9 +155,14 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { OpaPolarisAuthorizer.create( url.replace("/v1/data/polaris/authz/allow", ""), "/v1/data/polaris/authz/allow", + (String) null, 2000, + true, + null, + null, null, null); + PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); Mockito.when(principal.getName()).thenReturn("bob"); Mockito.when(principal.getRoles()).thenReturn(Set.of("user")); @@ -145,4 +181,283 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { server.shutdown(); } + + // ===== Bearer Token and HTTPS Tests ===== + + @Test + public void testCreateWithBearerTokenAndHttps() { + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "https://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + 2000, + true, + null, + null, + null, + null); + + assertTrue(authorizer != null); + } + + @Test + public void testCreateWithBearerTokenAndHttpsNoSslVerification() { + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "https://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + 2000, + false, + null, + null, + null, + null); + + assertTrue(authorizer != null); + } + + @Test + public void testCreateWithHttpsAndSslVerificationDisabled() { + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "https://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + 2000, + false, + null, + null, + null, + null); + assertTrue(authorizer != null); + } + + @Test + public void testBearerTokenIsAddedToHttpRequest() throws IOException { + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); + StatusLine mockStatusLine = mock(StatusLine.class); + HttpEntity mockEntity = mock(HttpEntity.class); + + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); + when(mockStatusLine.getStatusCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()) + .thenReturn( + new ByteArrayInputStream( + "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); + + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "http://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + 2000, + true, + null, + null, + mockHttpClient, + new ObjectMapper()); + + PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); + when(mockPrincipal.getName()).thenReturn("test-user"); + when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); + when(mockPrincipal.getProperties()).thenReturn(Map.of()); + + PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); + when(mockOperation.name()).thenReturn("READ"); + assertDoesNotThrow( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); + + ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(httpPostCaptor.capture()); + + HttpPost capturedRequest = httpPostCaptor.getValue(); + assertTrue(capturedRequest.containsHeader("Authorization")); + String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); + assertTrue( + authHeader.equals("Bearer test-bearer-token"), + "Expected 'Bearer test-bearer-token' but got '" + authHeader + "'"); + } + + @Test + public void testAuthorizationFailsWithoutBearerToken() throws IOException { + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); + StatusLine mockStatusLine = mock(StatusLine.class); + + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); + when(mockStatusLine.getStatusCode()).thenReturn(401); + + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "http://opa.example.com:8181", + "/v1/data/polaris/authz", + (String) null, + 2000, + true, + null, + null, + mockHttpClient, + new ObjectMapper()); + + PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); + when(mockPrincipal.getName()).thenReturn("test-user"); + when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); + when(mockPrincipal.getProperties()).thenReturn(Map.of()); + + PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); + when(mockOperation.name()).thenReturn("READ"); + assertThrows( + ForbiddenException.class, + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); + } + + @Test + public void testBearerTokenFromTokenProvider() throws IOException { + // Mock HTTP client and response + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); + StatusLine mockStatusLine = mock(StatusLine.class); + HttpEntity mockEntity = mock(HttpEntity.class); + + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); + when(mockStatusLine.getStatusCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()) + .thenReturn( + new ByteArrayInputStream( + "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); + + // Create a custom token provider + TokenProvider tokenProvider = new StaticTokenProvider("custom-token-from-provider"); + + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "http://opa.example.com:8181", + "/v1/data/polaris/authz", + tokenProvider, + 2000, + true, + null, + null, + mockHttpClient, + new ObjectMapper()); + + // Create mock principal and entities + PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); + when(mockPrincipal.getName()).thenReturn("test-user"); + when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); + when(mockPrincipal.getProperties()).thenReturn(Map.of()); + + PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); + when(mockOperation.name()).thenReturn("READ"); + + // Execute authorization (should not throw since we mocked allow=true) + assertDoesNotThrow( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); + + // Capture the HTTP request to verify bearer token header + ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(httpPostCaptor.capture()); + + HttpPost capturedRequest = httpPostCaptor.getValue(); + + // Verify the Authorization header with bearer token from provider + assertTrue(capturedRequest.containsHeader("Authorization")); + String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); + assertTrue( + authHeader.equals("Bearer custom-token-from-provider"), + "Expected 'Bearer custom-token-from-provider' but got '" + authHeader + "'"); + } + + @Test + public void testNullTokenFromTokenProvider() throws IOException { + // Mock HTTP client and response + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); + StatusLine mockStatusLine = mock(StatusLine.class); + HttpEntity mockEntity = mock(HttpEntity.class); + + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); + when(mockStatusLine.getStatusCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()) + .thenReturn( + new ByteArrayInputStream( + "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); + + // Create a token provider that returns null + TokenProvider tokenProvider = new StaticTokenProvider(null); + + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + "http://opa.example.com:8181", + "/v1/data/polaris/authz", + tokenProvider, + 2000, + true, + null, + null, + mockHttpClient, + new ObjectMapper()); + + // Create mock principal and entities + PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); + when(mockPrincipal.getName()).thenReturn("test-user"); + when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); + when(mockPrincipal.getProperties()).thenReturn(Map.of()); + + PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); + when(mockOperation.name()).thenReturn("READ"); + + // Execute authorization (should not throw since we mocked allow=true) + assertDoesNotThrow( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); + + // Capture the HTTP request to verify no Authorization header is set + ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(httpPostCaptor.capture()); + + HttpPost capturedRequest = httpPostCaptor.getValue(); + + // Verify no Authorization header is present when token provider returns null + assertTrue( + !capturedRequest.containsHeader("Authorization") + || capturedRequest.getFirstHeader("Authorization") == null); + } } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java new file mode 100644 index 0000000000..6e49f874f6 --- /dev/null +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +public class StaticTokenProviderTest { + + @Test + public void testStaticTokenProvider() { + String expectedToken = "static-bearer-token"; + StaticTokenProvider provider = new StaticTokenProvider(expectedToken); + + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + } + + @Test + public void testStaticTokenProviderWithNull() { + StaticTokenProvider provider = new StaticTokenProvider(null); + + String token = provider.getToken(); + assertNull(token); + } + + @Test + public void testStaticTokenProviderWithEmptyString() { + StaticTokenProvider provider = new StaticTokenProvider(""); + + String token = provider.getToken(); + assertEquals("", token); + } +} diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index a2e26e9a4d..f22d570f4a 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -202,6 +202,19 @@ polaris.authorization.type=default # polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow # polaris.authorization.opa.timeout-ms=2000 +# Bearer token authentication (choose one of the following approaches): +# Option 1: Static bearer token value (takes precedence if both are set) +# polaris.authorization.opa.bearer-token.static-value=your-bearer-token-here + +# Option 2: File-based bearer token (useful for Kubernetes token rotation) +# polaris.authorization.opa.bearer-token.file-path=/var/run/secrets/tokens/opa-token +# polaris.authorization.opa.bearer-token.refresh-interval=300 + +# SSL/TLS Configuration +# polaris.authorization.opa.verify-ssl=true +# polaris.authorization.opa.trust-store-path=/path/to/truststore.jks +# polaris.authorization.opa.trust-store-password=truststore-password + quarkus.arc.ignored-split-packages=\ org.apache.polaris.service.catalog.api,\ org.apache.polaris.service.catalog.api.impl,\ diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java index 1cc866225c..3f700cd1c3 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java @@ -21,9 +21,13 @@ import io.smallrye.common.annotation.Identifier; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; +import java.time.Duration; +import org.apache.polaris.core.auth.FileTokenProvider; import org.apache.polaris.core.auth.OpaPolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.auth.StaticTokenProvider; +import org.apache.polaris.core.auth.TokenProvider; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.service.config.AuthorizationConfiguration; @@ -42,11 +46,48 @@ public OpaPolarisAuthorizerFactory(AuthorizationConfiguration authorizationConfi @Override public PolarisAuthorizer create(RealmConfig realmConfig) { AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); + + // Create appropriate token provider based on configuration + TokenProvider tokenProvider = createTokenProvider(opa); + return OpaPolarisAuthorizer.create( opa.url().orElse(null), opa.policyPath().orElse(null), + tokenProvider, opa.timeoutMs().orElse(2000), // Default to 2000ms if not specified + opa.verifySsl(), // Default is true from @WithDefault annotation + opa.trustStorePath().orElse(null), + opa.trustStorePassword().orElse(null), null, null); } + + /** + * Creates a token provider based on the OPA configuration. + * + *

Prioritizes static token over file-based token: + * + *

    + *
  1. If bearerToken.staticValue is set, uses StaticTokenProvider + *
  2. If bearerToken.filePath is set, uses FileTokenProvider + *
  3. Otherwise, returns StaticTokenProvider with null token + *
+ */ + private TokenProvider createTokenProvider(AuthorizationConfiguration.OpaConfig opa) { + AuthorizationConfiguration.BearerTokenConfig bearerToken = opa.bearerToken(); + + // Static token takes precedence + if (bearerToken.staticValue().isPresent()) { + return new StaticTokenProvider(bearerToken.staticValue().get()); + } + + // File-based token as fallback + if (bearerToken.filePath().isPresent()) { + Duration refreshInterval = Duration.ofSeconds(bearerToken.refreshInterval()); + return new FileTokenProvider(bearerToken.filePath().get(), refreshInterval); + } + + // No token configured + return new StaticTokenProvider(null); + } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index f19fae64ad..71aae2b9f0 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -35,5 +35,26 @@ interface OpaConfig { Optional policyPath(); Optional timeoutMs(); + + BearerTokenConfig bearerToken(); + + @WithDefault("true") + boolean verifySsl(); + + Optional trustStorePath(); + + Optional trustStorePassword(); + } + + interface BearerTokenConfig { + /** Static bearer token value (takes precedence over file-based token) */ + Optional staticValue(); + + /** Path to file containing bearer token (used if staticValue is not set) */ + Optional filePath(); + + /** How often to refresh file-based bearer tokens (in seconds) */ + @WithDefault("300") + int refreshInterval(); } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java new file mode 100644 index 0000000000..83183619d1 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.fail; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import java.io.IOException; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(OpaIntegrationTest.FileTokenOpaProfile.class) +public class OpaFileTokenIntegrationTest { + + /** + * Test demonstrates OPA integration with file-based bearer token authentication. This test + * verifies that the FileTokenProvider correctly reads tokens from a file and that the full + * integration works with file-based configuration. + */ + @Test + void testOpaAllowsRootUserWithFileToken() { + // Test demonstrates the complete integration flow with file-based tokens: + // 1. OAuth token acquisition with internal authentication + // 2. OPA policy allowing root users + // 3. Bearer token read from file by FileTokenProvider + + // Get a token using the catalog service OAuth endpoint + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + // Parse JSON response to get access_token + String accessToken = extractJsonValue(response, "access_token"); + + if (accessToken == null) { + fail("Failed to parse access_token from OAuth response: " + response); + } + + // Use the Bearer token to test OPA authorization + // The JWT token has principal "root" which our policy allows + given() + .header("Authorization", "Bearer " + accessToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - "root" user is allowed by policy + } + + @Test + void testFileTokenRefresh() throws IOException, InterruptedException { + // This test verifies that the FileTokenProvider refreshes tokens from the file + + // First verify the system works with the initial token + String rootToken = getRootToken(); + + given() + .header("Authorization", "Bearer " + rootToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); + + // Update the token file with a new value + // Note: In a real test, we'd need to coordinate with the OPA server to accept the new token + // For this demo, we'll just verify the file can be updated + var tokenFile = OpaIntegrationTest.FileTokenOpaProfile.getTokenFile(); + if (tokenFile != null && Files.exists(tokenFile)) { + String originalContent = Files.readString(tokenFile); + + // Update the file content + Files.writeString(tokenFile, "test-opa-bearer-token-updated-12345"); + + // Wait for refresh interval (1 second as configured) + Thread.sleep(1500); + + // Verify the file was updated + String updatedContent = Files.readString(tokenFile); + if (updatedContent.equals(originalContent)) { + fail("Token file was not updated as expected"); + } + + // Note: We can't test that OPA actually receives the new token without + // coordinating with the OPA test container, but we've verified the file mechanism works + } + } + + @Test + void testOpaPolicyDeniesStrangerUserWithFileToken() { + // Create a "stranger" principal and get its access token + String strangerToken = createPrincipalAndGetToken("stranger"); + + // Use the stranger token to test OPA authorization - should be denied + given() + .header("Authorization", "Bearer " + strangerToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(403); // Should be forbidden by OPA policy - stranger is denied + } + + @Test + void testOpaAllowsAdminUserWithFileToken() { + // Create an "admin" principal and get its access token + String adminToken = createPrincipalAndGetToken("admin"); + + // Use the admin token to test OPA authorization - should be allowed + given() + .header("Authorization", "Bearer " + adminToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - admin user is allowed by policy + } + + /** Helper method to create a principal and get an OAuth access token for that principal */ + private String createPrincipalAndGetToken(String principalName) { + // First get root token to create the principal + String rootToken = getRootToken(); + + // Create the principal using the root token + String createResponse = + given() + .contentType("application/json") + .header("Authorization", "Bearer " + rootToken) + .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") + .when() + .post("/api/management/v1/principals") + .then() + .statusCode(201) + .extract() + .body() + .asString(); + + // Parse the principal's credentials from the response + String clientId = extractJsonValue(createResponse, "clientId"); + String clientSecret = extractJsonValue(createResponse, "clientSecret"); + + if (clientId == null || clientSecret == null) { + fail("Could not parse principal credentials from response: " + createResponse); + } + + // Get access token for the newly created principal + String tokenResponse = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", clientId) + .formParam("client_secret", clientSecret) + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(tokenResponse, "access_token"); + if (accessToken == null) { + fail("Could not get access token for principal " + principalName); + } + + return accessToken; + } + + /** Helper method to get root access token */ + private String getRootToken() { + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(response, "access_token"); + if (accessToken == null) { + fail("Failed to parse access_token from admin OAuth response: " + response); + } + return accessToken; + } + + /** Simple JSON value extractor */ + private String extractJsonValue(String json, String key) { + String searchKey = "\"" + key + "\""; + if (json.contains(searchKey)) { + String value = json.substring(json.indexOf(searchKey) + searchKey.length()); + value = value.substring(value.indexOf("\"") + 1); + value = value.substring(0, value.indexOf("\"")); + return value; + } + return null; + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index 02d89b26f4..137e85fc22 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -25,6 +25,9 @@ import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; import io.quarkus.test.junit.TestProfile; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,10 +35,16 @@ import org.junit.jupiter.api.Test; @QuarkusTest -@TestProfile(OpaIntegrationTest.InternalOpaProfile.class) +@TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class) public class OpaIntegrationTest { - public static class InternalOpaProfile implements QuarkusTestProfile { + /** + * Test demonstrates OPA integration with bearer token authentication and HTTPS support. The OPA + * container runs with HTTPS and self-signed certificates. The OpaPolarisAuthorizer is configured + * to verify SSL certificates using a custom trust store containing the self-signed certificate + * generated by the test infrastructure. + */ + public static class StaticTokenOpaProfile implements QuarkusTestProfile { @Override public Map getConfigOverrides() { Map config = new HashMap<>(); @@ -43,6 +52,13 @@ public Map getConfigOverrides() { config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); config.put("polaris.authorization.opa.timeout-ms", "2000"); + // Configure OPA server authentication with static bearer token and HTTPS + config.put( + "polaris.authorization.opa.bearer-token.static-value", "test-opa-bearer-token-12345"); + config.put( + "polaris.authorization.opa.verify-ssl", + "true"); // Enable SSL verification with trust store + // TODO: Add tests for OIDC and federated principal config.put("polaris.authentication.type", "internal"); @@ -77,7 +93,85 @@ public List testResources() { return List.of( new TestResourceEntry( OpaTestResource.class, - Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); + Map.of( + "policy-name", "polaris-authz", + "rego-policy", customRegoPolicy, + "use-https", "true", + "bearer-token", "test-opa-bearer-token-12345"))); + } + } + + public static class FileTokenOpaProfile implements QuarkusTestProfile { + private static volatile Path tokenFile; + + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.timeout-ms", "2000"); + + // Create temporary token file for testing + try { + tokenFile = Files.createTempFile("opa-test-token", ".txt"); + Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); + tokenFile.toFile().deleteOnExit(); + } catch (IOException e) { + throw new RuntimeException("Failed to create test token file", e); + } + + // Configure OPA server authentication with file-based bearer token and HTTPS + config.put("polaris.authorization.opa.bearer-token.file-path", tokenFile.toString()); + config.put( + "polaris.authorization.opa.bearer-token.refresh-interval", + "1"); // 1 second for fast testing + config.put( + "polaris.authorization.opa.verify-ssl", + "true"); // Enable SSL verification with trust store + + // TODO: Add tests for OIDC and federated principal + config.put("polaris.authentication.type", "internal"); + + return config; + } + + @Override + public List testResources() { + String customRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + + # Deny stranger user explicitly (though default is false) + allow { + input.actor.principal == "stranger" + false + } + """; + + return List.of( + new TestResourceEntry( + OpaTestResource.class, + Map.of( + "policy-name", "polaris-authz", + "rego-policy", customRegoPolicy, + "use-https", "true", + "bearer-token", "test-opa-bearer-token-from-file-67890"))); + } + + public static Path getTokenFile() { + return tokenFile; } } @@ -153,6 +247,20 @@ void testOpaAllowsAdminUser() { .statusCode(200); // Should succeed - admin user is allowed by policy } + @Test + void testOpaBearerTokenAuthentication() { + // Test that OpaPolarisAuthorizer is configured to send bearer tokens + // and can handle HTTPS connections with proper SSL verification + String rootToken = getRootToken(); + + given() + .header("Authorization", "Bearer " + rootToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); + } + /** Helper method to create a principal and get an OAuth access token for that principal */ private String createPrincipalAndGetToken(String principalName) { // First get root token to create the principal diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java new file mode 100644 index 0000000000..c9da20b28f --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Optional; +import org.apache.polaris.core.auth.FileTokenProvider; +import org.apache.polaris.core.auth.OpaPolarisAuthorizer; +import org.apache.polaris.core.config.RealmConfig; +import org.apache.polaris.service.config.AuthorizationConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class OpaPolarisAuthorizerFactoryTest { + + @TempDir Path tempDir; + + @Test + public void testFactoryCreatesStaticTokenProvider() { + // Mock configuration for static token + AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = + mock(AuthorizationConfiguration.BearerTokenConfig.class); + when(bearerTokenConfig.staticValue()).thenReturn(Optional.of("static-token-value")); + when(bearerTokenConfig.filePath()).thenReturn(Optional.empty()); + + AuthorizationConfiguration.OpaConfig opaConfig = + mock(AuthorizationConfiguration.OpaConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); + when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.verifySsl()).thenReturn(true); + when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); + when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + + AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); + when(authConfig.opa()).thenReturn(opaConfig); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } + + @Test + public void testFactoryCreatesFileTokenProvider() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("bearer-token.txt"); + String tokenValue = "file-based-token-value"; + Files.writeString(tokenFile, tokenValue); + + // Mock configuration for file-based token + AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = + mock(AuthorizationConfiguration.BearerTokenConfig.class); + when(bearerTokenConfig.staticValue()).thenReturn(Optional.empty()); // No static token + when(bearerTokenConfig.filePath()).thenReturn(Optional.of(tokenFile.toString())); + when(bearerTokenConfig.refreshInterval()).thenReturn(300); + + AuthorizationConfiguration.OpaConfig opaConfig = + mock(AuthorizationConfiguration.OpaConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); + when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.verifySsl()).thenReturn(true); + when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); + when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + + AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); + when(authConfig.opa()).thenReturn(opaConfig); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } + + @Test + public void testFileTokenProviderActuallyReadsFromFile() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("bearer-token.txt"); + String tokenValue = "file-based-token-from-disk"; + Files.writeString(tokenFile, tokenValue); + + // Create FileTokenProvider directly to test it reads the file + FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Verify the token is read from the file + String actualToken = provider.getToken(); + assertEquals(tokenValue, actualToken); + + provider.close(); + } + + @Test + public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("bearer-token.txt"); + Files.writeString(tokenFile, "file-token-value"); + + // Mock configuration with BOTH static and file tokens + AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = + mock(AuthorizationConfiguration.BearerTokenConfig.class); + when(bearerTokenConfig.staticValue()) + .thenReturn(Optional.of("static-token-value")); // Static token present + when(bearerTokenConfig.filePath()) + .thenReturn(Optional.of(tokenFile.toString())); // File token also present + when(bearerTokenConfig.refreshInterval()).thenReturn(300); + + AuthorizationConfiguration.OpaConfig opaConfig = + mock(AuthorizationConfiguration.OpaConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); + when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.verifySsl()).thenReturn(true); + when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); + when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + + AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); + when(authConfig.opa()).thenReturn(opaConfig); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + // Note: We can't easily test which token provider is used without exposing internals, + // but we can verify that the authorizer was created successfully. + } + + @Test + public void testFactoryWithNoTokenConfiguration() { + // Mock configuration with no tokens + AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = + mock(AuthorizationConfiguration.BearerTokenConfig.class); + when(bearerTokenConfig.staticValue()).thenReturn(Optional.empty()); + when(bearerTokenConfig.filePath()).thenReturn(Optional.empty()); + + AuthorizationConfiguration.OpaConfig opaConfig = + mock(AuthorizationConfiguration.OpaConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); + when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.verifySsl()).thenReturn(true); + when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); + when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + + AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); + when(authConfig.opa()).thenReturn(opaConfig); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } +} diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java index 467764c9a8..5e333cf752 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -19,13 +19,24 @@ package org.apache.polaris.test.commons; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.IOException; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.time.Duration; import java.util.HashMap; import java.util.Map; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; @@ -34,6 +45,8 @@ public class OpaTestResource implements QuarkusTestResourceLifecycleManager { private static GenericContainer opa; private int mappedPort; private Map resourceConfig; + private Path tempCertDir; + private boolean useHttps; @Override public void init(Map initArgs) { @@ -42,49 +55,265 @@ public void init(Map initArgs) { @Override public Map start() { - // Reuse container across tests to speed up execution - if (opa == null || !opa.isRunning()) { - opa = - new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:0.63.0")) - .withExposedPorts(8181) - .withCommand("run", "--server", "--addr=0.0.0.0:8181") - .withReuse(true) + // Check if HTTPS is requested + useHttps = "true".equals(resourceConfig.get("use-https")); + String bearerToken = resourceConfig.get("bearer-token"); + + try { + // Setup HTTPS certificates if requested + if (useHttps) { + setupSelfSignedCertificates(); + } + + // Reuse container across tests to speed up execution + if (opa == null || !opa.isRunning()) { + opa = + new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:0.63.0")) + .withExposedPorts(8181) + .withReuse(true); + + if (useHttps) { + // Configure OPA to use HTTPS with self-signed certificates + opa.withFileSystemBind(tempCertDir.toString(), "/certs", BindMode.READ_ONLY) + .withCommand( + "run", + "--server", + "--addr=0.0.0.0:8181", + "--tls-cert-file=/certs/server.crt", + "--tls-private-key-file=/certs/server.key") + .waitingFor( + Wait.forHttp("/health") + .forPort(8181) + .usingTls() // Use HTTPS for health check + .allowInsecure() // Allow self-signed certificates + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(60))); + } else { + // Configure OPA for HTTP + opa.withCommand("run", "--server", "--addr=0.0.0.0:8181") .waitingFor( Wait.forHttp("/health") .forPort(8181) .forStatusCode(200) .withStartupTimeout(Duration.ofSeconds(30))); - opa.start(); + } + + opa.start(); + } + + mappedPort = opa.getMappedPort(8181); + + String protocol = useHttps ? "https" : "http"; + String baseUrl = protocol + "://localhost:" + mappedPort; + + // Load Rego policy into OPA + loadRegoPolicy(baseUrl, bearerToken, "policy-name", "rego-policy"); + + // Load server authentication policy only for HTTPS mode + if (useHttps && bearerToken != null && !bearerToken.isEmpty()) { + loadServerAuthPolicy(baseUrl, bearerToken); + } + + Map config = new HashMap<>(); + config.put("polaris.authorization.opa.url", baseUrl); + + // For HTTPS mode, provide the trust store path for SSL verification + if (useHttps && tempCertDir != null) { + config.put( + "polaris.authorization.opa.trust-store-path", + tempCertDir.resolve("truststore.jks").toString()); + config.put("polaris.authorization.opa.trust-store-password", "test-password"); + } + + return config; + + } catch (Exception e) { + throw new RuntimeException("Failed to start OPA test resource", e); } + } + + private void setupSelfSignedCertificates() throws IOException, InterruptedException { + // Create temporary directory for certificates + tempCertDir = Files.createTempDirectory("opa-certs"); + tempCertDir.toFile().deleteOnExit(); + + Path keyFile = tempCertDir.resolve("server.key"); + Path certFile = tempCertDir.resolve("server.crt"); + Path trustStoreFile = tempCertDir.resolve("truststore.jks"); + Path configFile = tempCertDir.resolve("openssl.conf"); + + // Create OpenSSL config for self-signed certificate with SAN + String opensslConfig = + """ + [req] + default_bits = 2048 + prompt = no + default_md = sha256 + distinguished_name = dn + req_extensions = v3_req + + [dn] + C=US + ST=Test + L=Test + O=Test + CN=localhost - mappedPort = opa.getMappedPort(8181); - String baseUrl = "http://localhost:" + mappedPort; + [v3_req] + subjectAltName = @alt_names - loadRegoPolicy(baseUrl); + [alt_names] + DNS.1 = localhost + DNS.2 = opa + IP.1 = 127.0.0.1 + """; - Map config = new HashMap<>(); - config.put("polaris.authorization.opa.url", baseUrl); - return config; + try (FileWriter writer = new FileWriter(configFile.toFile(), StandardCharsets.UTF_8)) { + writer.write(opensslConfig); + } + + // Generate private key + ProcessBuilder keyGenProcess = + new ProcessBuilder("openssl", "genrsa", "-out", keyFile.toString(), "2048"); + Process keyGen = keyGenProcess.start(); + if (keyGen.waitFor() != 0) { + throw new RuntimeException("Failed to generate private key for OPA HTTPS"); + } + + // Generate self-signed certificate + ProcessBuilder certGenProcess = + new ProcessBuilder( + "openssl", + "req", + "-new", + "-x509", + "-key", + keyFile.toString(), + "-out", + certFile.toString(), + "-days", + "365", + "-config", + configFile.toString(), + "-extensions", + "v3_req"); + Process certGen = certGenProcess.start(); + if (certGen.waitFor() != 0) { + throw new RuntimeException("Failed to generate certificate for OPA HTTPS"); + } + + // Create a trust store containing the self-signed certificate + createTrustStore(certFile, trustStoreFile); + + // Make sure files will be cleaned up + keyFile.toFile().deleteOnExit(); + certFile.toFile().deleteOnExit(); + trustStoreFile.toFile().deleteOnExit(); + configFile.toFile().deleteOnExit(); } - private void loadRegoPolicy(String baseUrl) { - String policyName = resourceConfig.get("policy-name"); - String regoPolicy = resourceConfig.get("rego-policy"); + private void createTrustStore(Path certFile, Path trustStoreFile) throws IOException { + try { + // Load the certificate + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate; + try (FileInputStream certInputStream = new FileInputStream(certFile.toFile())) { + certificate = (X509Certificate) certificateFactory.generateCertificate(certInputStream); + } + + // Create a new trust store + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); // Initialize empty trust store + + // Add the certificate to the trust store + trustStore.setCertificateEntry("opa-test-cert", certificate); + + // Save the trust store to file + try (var trustStoreOutputStream = Files.newOutputStream(trustStoreFile)) { + trustStore.store(trustStoreOutputStream, "test-password".toCharArray()); + } + } catch (Exception e) { + throw new IOException("Failed to create trust store", e); + } + } + + private void loadServerAuthPolicy(String baseUrl, String bearerToken) { + // Create a server authentication policy that only allows the specific bearer token + String serverAuthPolicy = + String.format( + """ + package system.authz + + default allow := false + + # Allow requests with the correct bearer token + allow { + input.identity.type == "bearer" + input.identity.token == "%s" + } + + # Allow health check endpoint without authentication + allow { + input.path[0] == "health" + } + """, + bearerToken); + + try { + URL url = new URL(baseUrl + "/v1/policies/server_auth"); + HttpURLConnection conn = createConnection(url); + conn.setRequestMethod("PUT"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "text/plain"); + + // Use the bearer token to authenticate this policy upload + conn.setRequestProperty("Authorization", "Bearer " + bearerToken); + + try (OutputStream os = conn.getOutputStream()) { + os.write(serverAuthPolicy.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + throw new RuntimeException("OPA server auth policy upload failed, HTTP " + code); + } + } catch (Exception e) { + String logs = ""; + try { + logs = opa.getLogs(); + } catch (Throwable ignored) { + } + throw new RuntimeException( + "Failed to load OPA server auth policy. Container logs:\n" + logs, e); + } + } + + private void loadRegoPolicy( + String baseUrl, String bearerToken, String policyNameKey, String regoPolicyKey) { + String policyName = resourceConfig.get(policyNameKey); + String regoPolicy = resourceConfig.get(regoPolicyKey); if (policyName == null) { - throw new IllegalArgumentException("policy-name parameter is required for OpaTestResource"); + throw new IllegalArgumentException( + policyNameKey + " parameter is required for OpaTestResource"); } if (regoPolicy == null) { - throw new IllegalArgumentException("rego-policy parameter is required for OpaTestResource"); + throw new IllegalArgumentException( + regoPolicyKey + " parameter is required for OpaTestResource"); } try { URL url = new URL(baseUrl + "/v1/policies/" + policyName); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + HttpURLConnection conn = createConnection(url); conn.setRequestMethod("PUT"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "text/plain"); + // Add bearer token for server authentication if provided + if (bearerToken != null && !bearerToken.isEmpty()) { + conn.setRequestProperty("Authorization", "Bearer " + bearerToken); + } + try (OutputStream os = conn.getOutputStream()) { os.write(regoPolicy.getBytes(StandardCharsets.UTF_8)); } @@ -104,9 +333,56 @@ private void loadRegoPolicy(String baseUrl) { } } + private HttpURLConnection createConnection(URL url) throws Exception { + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + + // Configure SSL context to use the same trust store as OpaPolarisAuthorizer for HTTPS + if (useHttps && conn instanceof HttpsURLConnection httpsConn) { + // Load the trust store we created for consistent SSL verification + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + Path trustStoreFile = tempCertDir.resolve("truststore.jks"); + try (FileInputStream trustStoreStream = new FileInputStream(trustStoreFile.toFile())) { + trustStore.load(trustStoreStream, "test-password".toCharArray()); + } + + // Create SSL context with the trust store + SSLContext sslContext = SSLContext.getInstance("TLS"); + javax.net.ssl.TrustManagerFactory tmf = + javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + sslContext.init(null, tmf.getTrustManagers(), new java.security.SecureRandom()); + + httpsConn.setSSLSocketFactory(sslContext.getSocketFactory()); + // We can verify hostname since our certificate includes localhost + httpsConn.setHostnameVerifier( + (hostname, session) -> "localhost".equals(hostname) || "127.0.0.1".equals(hostname)); + } + + return conn; + } + @Override public void stop() { // Don't stop the container to allow reuse across tests // Container will be cleaned up when the JVM exits + + // Clean up temporary certificate directory + if (tempCertDir != null) { + try { + Files.walk(tempCertDir) + .sorted((a, b) -> b.compareTo(a)) // Delete files before directories + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // Ignore cleanup errors + } + }); + } catch (Exception e) { + // Ignore cleanup errors + } + } } } From 0785bdb16b0fbc35bfa1e85bdeb34ca95cc05c07 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:19:17 +0000 Subject: [PATCH 11/40] file token provider and token refresh --- .../polaris/core/auth/FileTokenProvider.java | 81 +++++- .../apache/polaris/core/auth/JwtDecoder.java | 94 +++++++ .../core/auth/FileTokenProviderTest.java | 209 +++++++++++++++ .../polaris/core/auth/JwtDecoderTest.java | 238 ++++++++++++++++++ .../src/main/resources/application.properties | 4 + .../auth/OpaPolarisAuthorizerFactory.java | 6 +- .../config/AuthorizationConfiguration.java | 15 ++ .../auth/OpaPolarisAuthorizerFactoryTest.java | 10 + 8 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java create mode 100644 polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java index 92c505e43d..61e26e4049 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java @@ -26,6 +26,7 @@ import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; +import java.util.Optional; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import org.slf4j.Logger; @@ -33,13 +34,17 @@ /** * A token provider that reads tokens from a file and automatically reloads them based on a - * configurable refresh interval. + * configurable refresh interval or JWT expiration timing. * *

This is particularly useful in Kubernetes environments where tokens are mounted as files and * refreshed by external systems (e.g., service account tokens, projected volumes, etc.). * *

The token file is expected to contain the bearer token as plain text. Leading and trailing * whitespace will be trimmed. + * + *

If JWT expiration refresh is enabled and the token is a valid JWT with an 'exp' claim, the + * provider will automatically refresh the token based on the expiration time minus a configurable + * buffer, rather than using the fixed refresh interval. */ public class FileTokenProvider implements TokenProvider { @@ -47,27 +52,51 @@ public class FileTokenProvider implements TokenProvider { private final Path tokenFilePath; private final Duration refreshInterval; + private final boolean jwtExpirationRefresh; + private final Duration jwtExpirationBuffer; private final ReadWriteLock lock = new ReentrantReadWriteLock(); private volatile String cachedToken; private volatile Instant lastRefresh; + private volatile Instant nextRefresh; private volatile boolean closed = false; /** - * Create a new file-based token provider. + * Create a new file-based token provider with basic refresh interval. * * @param tokenFilePath path to the file containing the bearer token * @param refreshInterval how often to check for token file changes */ public FileTokenProvider(String tokenFilePath, Duration refreshInterval) { + this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); + } + + /** + * Create a new file-based token provider with JWT expiration support. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) + * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing + * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + */ + public FileTokenProvider( + String tokenFilePath, + Duration refreshInterval, + boolean jwtExpirationRefresh, + Duration jwtExpirationBuffer) { this.tokenFilePath = Paths.get(tokenFilePath); this.refreshInterval = refreshInterval; + this.jwtExpirationRefresh = jwtExpirationRefresh; + this.jwtExpirationBuffer = jwtExpirationBuffer; this.lastRefresh = Instant.MIN; // Force initial load + this.nextRefresh = Instant.MIN; // Force initial calculation logger.info( - "Created file token provider for path: {} with refresh interval: {}", + "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", tokenFilePath, - refreshInterval); + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer); } @Override @@ -104,7 +133,7 @@ public void close() { } private boolean shouldRefresh() { - return lastRefresh.plus(refreshInterval).isBefore(Instant.now()); + return Instant.now().isAfter(nextRefresh); } private void refreshToken() { @@ -119,11 +148,15 @@ private void refreshToken() { cachedToken = newToken; lastRefresh = Instant.now(); + // Calculate next refresh time based on JWT expiration or fixed interval + nextRefresh = calculateNextRefresh(newToken); + if (logger.isDebugEnabled()) { logger.debug( - "Token refreshed from file: {} (token present: {})", + "Token refreshed from file: {} (token present: {}), next refresh: {}", tokenFilePath, - newToken != null && !newToken.isEmpty()); + newToken != null && !newToken.isEmpty(), + nextRefresh); } } finally { @@ -131,6 +164,40 @@ private void refreshToken() { } } + /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */ + private Instant calculateNextRefresh(@Nullable String token) { + if (token == null || !jwtExpirationRefresh) { + // Use fixed interval + return lastRefresh.plus(refreshInterval); + } + + // Attempt to parse as JWT and extract expiration + Optional expiration = JwtDecoder.getExpirationTime(token); + + if (expiration.isPresent()) { + // Refresh before expiration minus buffer + Instant refreshTime = expiration.get().minus(jwtExpirationBuffer); + + // Ensure refresh time is in the future and not too soon (at least 1 second) + Instant minRefreshTime = Instant.now().plus(Duration.ofSeconds(1)); + if (refreshTime.isBefore(minRefreshTime)) { + logger.warn( + "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get()); + return lastRefresh.plus(refreshInterval); + } + + logger.debug( + "Using JWT expiration-based refresh: token expires at {}, refreshing at {}", + expiration.get(), + refreshTime); + return refreshTime; + } + + // Fall back to fixed interval (token is not a valid JWT or has no expiration) + logger.debug("Token is not a valid JWT or has no expiration, using fixed refresh interval"); + return lastRefresh.plus(refreshInterval); + } + @Nullable private String loadTokenFromFile() { try { diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java new file mode 100644 index 0000000000..f1ee4d012d --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Simple JWT decoder that extracts claims without signature verification. This is used solely for + * reading the expiration time from JWT tokens to determine refresh timing. + */ +public class JwtDecoder { + private static final Logger LOG = LoggerFactory.getLogger(JwtDecoder.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Decode a JWT token and extract the expiration time if present. + * + * @param token the JWT token string + * @return the expiration time as an Instant, or empty if not present or invalid + */ + public static Optional getExpirationTime(String token) { + return decodePayload(token).flatMap(JwtDecoder::getExpirationTime); + } + + /** + * Decode the payload of a JWT token without signature verification. + * + * @param token the JWT token string + * @return the decoded payload as a JsonNode, or empty if invalid + */ + public static Optional decodePayload(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length != 3) { + LOG.debug("Invalid JWT format: expected 3 parts separated by dots"); + return Optional.empty(); + } + + // Decode the payload (second part) + String payload = parts[1]; + byte[] decodedBytes = Base64.getUrlDecoder().decode(payload); + String payloadJson = new String(decodedBytes, StandardCharsets.UTF_8); + + // Parse JSON + JsonNode payloadNode = OBJECT_MAPPER.readTree(payloadJson); + return Optional.of(payloadNode); + + } catch (Exception e) { + LOG.debug("Failed to decode JWT token: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Extract the expiration time from a decoded JWT payload. + * + * @param payloadNode the decoded JWT payload + * @return the expiration time as an Instant, or empty if not present or invalid + */ + public static Optional getExpirationTime(JsonNode payloadNode) { + JsonNode expNode = payloadNode.get("exp"); + + if (expNode == null || !expNode.isNumber()) { + LOG.debug("JWT does not contain a valid 'exp' claim"); + return Optional.empty(); + } + + long expSeconds = expNode.asLong(); + return Optional.of(Instant.ofEpochSecond(expSeconds)); + } +} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java index 774b57cae7..028a5fc2d8 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java @@ -21,10 +21,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -139,4 +145,207 @@ public void testClosedProvider() throws IOException { String token = provider.getToken(); assertNull(token); } + + @Test + public void testJwtExpirationRefresh() throws IOException, InterruptedException { + // Create a temporary token file with a JWT that expires in 10 seconds + Path tokenFile = tempDir.resolve("jwt-token.txt"); + String jwtToken = createJwtWithExpiration(Instant.now().plusSeconds(10)); + Files.writeString(tokenFile, jwtToken); + + // Create file token provider with JWT expiration refresh enabled + // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds) + FileTokenProvider provider = + new FileTokenProvider( + tokenFile.toString(), Duration.ofMinutes(10), true, Duration.ofSeconds(3)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(jwtToken, token1); + + // Wait for 7.1 seconds (should trigger refresh due to 3 second buffer) + Thread.sleep(7100); + + // Update the file with a new JWT + String newJwtToken = createJwtWithExpiration(Instant.now().plusSeconds(20)); + Files.writeString(tokenFile, newJwtToken); + + // Test that token is refreshed + String token2 = provider.getToken(); + assertEquals(newJwtToken, token2); + + provider.close(); + } + + @Test + public void testJwtExpirationRefreshDisabled() throws IOException, InterruptedException { + // Create a temporary token file with a JWT that expires in 1 second + Path tokenFile = tempDir.resolve("jwt-token.txt"); + String jwtToken = createJwtWithExpiration(Instant.now().plusSeconds(1)); + Files.writeString(tokenFile, jwtToken); + + // Create file token provider with JWT expiration refresh disabled + FileTokenProvider provider = + new FileTokenProvider( + tokenFile.toString(), Duration.ofMillis(100), false, Duration.ofSeconds(1)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(jwtToken, token1); + + // Wait for fixed refresh interval (100ms) + Thread.sleep(150); + + // Update the file + String newToken = "updated-non-jwt-token"; + Files.writeString(tokenFile, newToken); + + // Test that token is refreshed based on fixed interval, not JWT expiration + String token2 = provider.getToken(); + assertEquals(newToken, token2); + + provider.close(); + } + + @Test + public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException, InterruptedException { + // Create a temporary token file with a non-JWT token + Path tokenFile = tempDir.resolve("token.txt"); + String nonJwtToken = "not-a-jwt-token"; + Files.writeString(tokenFile, nonJwtToken); + + // Create file token provider with JWT expiration refresh enabled + FileTokenProvider provider = + new FileTokenProvider( + tokenFile.toString(), Duration.ofMillis(100), true, Duration.ofSeconds(1)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(nonJwtToken, token1); + + // Wait for fallback refresh interval + Thread.sleep(150); + + // Update the file + String updatedToken = "updated-non-jwt-token"; + Files.writeString(tokenFile, updatedToken); + + // Test that token is refreshed using fallback interval + String token2 = provider.getToken(); + assertEquals(updatedToken, token2); + + provider.close(); + } + + @Test + public void testJwtExpirationTooSoon() throws IOException { + // Create a temporary token file with a JWT that expires very soon (in the past) + Path tokenFile = tempDir.resolve("jwt-token.txt"); + String expiredJwtToken = createJwtWithExpiration(Instant.now().minusSeconds(1)); + Files.writeString(tokenFile, expiredJwtToken); + + // Create file token provider with JWT expiration refresh enabled + FileTokenProvider provider = + new FileTokenProvider( + tokenFile.toString(), Duration.ofMinutes(5), true, Duration.ofSeconds(60)); + + // Should fall back to fixed interval when JWT expires too soon + String token = provider.getToken(); + assertEquals(expiredJwtToken, token); + + provider.close(); + } + + @Test + public void testJwtWithoutExpirationClaim() throws IOException { + // Create a temporary token file with a JWT without expiration + Path tokenFile = tempDir.resolve("jwt-token.txt"); + String jwtWithoutExp = createJwtWithoutExpiration(); + Files.writeString(tokenFile, jwtWithoutExp); + + // Create file token provider with JWT expiration refresh enabled + FileTokenProvider provider = + new FileTokenProvider( + tokenFile.toString(), Duration.ofMillis(100), true, Duration.ofSeconds(1)); + + // Should fall back to fixed interval when JWT has no expiration + String token = provider.getToken(); + assertEquals(jwtWithoutExp, token); + + provider.close(); + } + + /** Helper method to create a JWT with a specific expiration time. */ + private String createJwtWithExpiration(Instant expiration) { + try { + ObjectMapper mapper = new ObjectMapper(); + + // Create header + Map header = new HashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + String headerJson = mapper.writeValueAsString(header); + String encodedHeader = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + + // Create payload with expiration + Map payload = new HashMap<>(); + payload.put("iss", "test"); + payload.put("exp", expiration.getEpochSecond()); + String payloadJson = mapper.writeValueAsString(payload); + String encodedPayload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + + // Create fake signature (we don't verify signatures) + String signature = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); + + return encodedHeader + "." + encodedPayload + "." + signature; + } catch (Exception e) { + throw new RuntimeException("Failed to create test JWT", e); + } + } + + /** Helper method to create a JWT without an expiration claim. */ + private String createJwtWithoutExpiration() { + try { + ObjectMapper mapper = new ObjectMapper(); + + // Create header + Map header = new HashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + String headerJson = mapper.writeValueAsString(header); + String encodedHeader = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + + // Create payload without expiration + Map payload = new HashMap<>(); + payload.put("iss", "test"); + payload.put("custom", "value"); + String payloadJson = mapper.writeValueAsString(payload); + String encodedPayload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + + // Create fake signature (we don't verify signatures) + String signature = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); + + return encodedHeader + "." + encodedPayload + "." + signature; + } catch (Exception e) { + throw new RuntimeException("Failed to create test JWT", e); + } + } } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java new file mode 100644 index 0000000000..513fa74338 --- /dev/null +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java @@ -0,0 +1,238 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.core.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +public class JwtDecoderTest { + + @Test + public void testValidJwtWithExpiration() throws Exception { + Instant expiration = Instant.now().plusSeconds(3600); + String jwt = createJwtWithExpiration(expiration); + + Optional result = JwtDecoder.getExpirationTime(jwt); + + assertTrue(result.isPresent()); + assertEquals(expiration.getEpochSecond(), result.get().getEpochSecond()); + } + + @Test + public void testJwtWithoutExpiration() throws Exception { + String jwt = createJwtWithoutExpiration(); + + Optional result = JwtDecoder.getExpirationTime(jwt); + + assertTrue(result.isEmpty()); + } + + @Test + public void testInvalidJwtFormat() { + Optional result = JwtDecoder.getExpirationTime("not-a-jwt"); + assertTrue(result.isEmpty()); + } + + @Test + public void testJwtWithTwoParts() { + Optional result = JwtDecoder.getExpirationTime("header.payload"); + assertTrue(result.isEmpty()); + } + + @Test + public void testJwtWithFourParts() { + Optional result = JwtDecoder.getExpirationTime("header.payload.signature.extra"); + assertTrue(result.isEmpty()); + } + + @Test + public void testJwtWithInvalidBase64() { + Optional result = JwtDecoder.getExpirationTime("invalid!.base64@.content#"); + assertTrue(result.isEmpty()); + } + + @Test + public void testJwtWithInvalidJson() { + String invalidPayload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{invalid json}".getBytes(StandardCharsets.UTF_8)); + String jwt = "header." + invalidPayload + ".signature"; + + Optional result = JwtDecoder.getExpirationTime(jwt); + assertTrue(result.isEmpty()); + } + + @Test + public void testJwtWithNonNumericExpiration() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + + // Create header + Map header = new HashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + String headerJson = mapper.writeValueAsString(header); + String encodedHeader = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + + // Create payload with string expiration + Map payload = new HashMap<>(); + payload.put("iss", "test"); + payload.put("exp", "not-a-number"); + String payloadJson = mapper.writeValueAsString(payload); + String encodedPayload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + + String signature = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); + + String jwt = encodedHeader + "." + encodedPayload + "." + signature; + + Optional result = JwtDecoder.getExpirationTime(jwt); + assertTrue(result.isEmpty()); + } + + /** Helper method to create a JWT with a specific expiration time. */ + private String createJwtWithExpiration(Instant expiration) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + + // Create header + Map header = new HashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + String headerJson = mapper.writeValueAsString(header); + String encodedHeader = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + + // Create payload with expiration + Map payload = new HashMap<>(); + payload.put("iss", "test"); + payload.put("exp", expiration.getEpochSecond()); + String payloadJson = mapper.writeValueAsString(payload); + String encodedPayload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + + // Create fake signature + String signature = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); + + return encodedHeader + "." + encodedPayload + "." + signature; + } + + /** Helper method to create a JWT without an expiration claim. */ + private String createJwtWithoutExpiration() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + + // Create header + Map header = new HashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + String headerJson = mapper.writeValueAsString(header); + String encodedHeader = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + + // Create payload without expiration + Map payload = new HashMap<>(); + payload.put("iss", "test"); + payload.put("custom", "value"); + String payloadJson = mapper.writeValueAsString(payload); + String encodedPayload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); + + // Create fake signature + String signature = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); + + return encodedHeader + "." + encodedPayload + "." + signature; + } + + @Test + public void testDecodePayload() throws Exception { + Instant expiration = Instant.now().plusSeconds(3600); + String jwt = createJwtWithExpiration(expiration); + + Optional result = JwtDecoder.decodePayload(jwt); + + assertTrue(result.isPresent()); + JsonNode payload = result.get(); + assertTrue(payload.has("exp")); + assertTrue(payload.has("iss")); + assertEquals("test", payload.get("iss").asText()); + assertEquals(expiration.getEpochSecond(), payload.get("exp").asLong()); + } + + @Test + public void testDecodePayloadInvalidToken() { + Optional result = JwtDecoder.decodePayload("not-a-jwt"); + assertTrue(result.isEmpty()); + } + + @Test + public void testGetExpirationTimeFromPayload() throws Exception { + Instant expiration = Instant.now().plusSeconds(7200); + String jwt = createJwtWithExpiration(expiration); + + Optional payload = JwtDecoder.decodePayload(jwt); + assertTrue(payload.isPresent()); + + Optional result = JwtDecoder.getExpirationTime(payload.get()); + + assertTrue(result.isPresent()); + assertEquals(expiration.getEpochSecond(), result.get().getEpochSecond()); + } + + @Test + public void testGetExpirationTimeFromPayloadWithoutExp() throws Exception { + String jwt = createJwtWithoutExpiration(); + + Optional payload = JwtDecoder.decodePayload(jwt); + assertTrue(payload.isPresent()); + + Optional result = JwtDecoder.getExpirationTime(payload.get()); + assertTrue(result.isEmpty()); + } +} diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index f22d570f4a..a0b207be86 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -210,6 +210,10 @@ polaris.authorization.type=default # polaris.authorization.opa.bearer-token.file-path=/var/run/secrets/tokens/opa-token # polaris.authorization.opa.bearer-token.refresh-interval=300 +# JWT Expiration Support (for file-based tokens): +# polaris.authorization.opa.bearer-token.jwt-expiration-refresh=true +# polaris.authorization.opa.bearer-token.jwt-expiration-buffer=60 + # SSL/TLS Configuration # polaris.authorization.opa.verify-ssl=true # polaris.authorization.opa.trust-store-path=/path/to/truststore.jks diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java index 3f700cd1c3..bf947a2528 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java @@ -84,7 +84,11 @@ private TokenProvider createTokenProvider(AuthorizationConfiguration.OpaConfig o // File-based token as fallback if (bearerToken.filePath().isPresent()) { Duration refreshInterval = Duration.ofSeconds(bearerToken.refreshInterval()); - return new FileTokenProvider(bearerToken.filePath().get(), refreshInterval); + boolean jwtExpirationRefresh = bearerToken.jwtExpirationRefresh(); + Duration jwtExpirationBuffer = Duration.ofSeconds(bearerToken.jwtExpirationBuffer()); + + return new FileTokenProvider( + bearerToken.filePath().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); } // No token configured diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 71aae2b9f0..20416998ba 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -56,5 +56,20 @@ interface BearerTokenConfig { /** How often to refresh file-based bearer tokens (in seconds) */ @WithDefault("300") int refreshInterval(); + + /** + * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If + * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on + * the expiration time minus the buffer, rather than the fixed refresh interval. + */ + @WithDefault("true") + boolean jwtExpirationRefresh(); + + /** + * Buffer time in seconds before JWT expiration to refresh the token. Only used when + * jwtExpirationRefresh is true and the token is a valid JWT. Default is 60 seconds. + */ + @WithDefault("60") + int jwtExpirationBuffer(); } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java index c9da20b28f..5a5fe34562 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java @@ -46,6 +46,9 @@ public void testFactoryCreatesStaticTokenProvider() { mock(AuthorizationConfiguration.BearerTokenConfig.class); when(bearerTokenConfig.staticValue()).thenReturn(Optional.of("static-token-value")); when(bearerTokenConfig.filePath()).thenReturn(Optional.empty()); + when(bearerTokenConfig.refreshInterval()).thenReturn(300); + when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); + when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); AuthorizationConfiguration.OpaConfig opaConfig = mock(AuthorizationConfiguration.OpaConfig.class); @@ -82,6 +85,8 @@ public void testFactoryCreatesFileTokenProvider() throws IOException { when(bearerTokenConfig.staticValue()).thenReturn(Optional.empty()); // No static token when(bearerTokenConfig.filePath()).thenReturn(Optional.of(tokenFile.toString())); when(bearerTokenConfig.refreshInterval()).thenReturn(300); + when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); + when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); AuthorizationConfiguration.OpaConfig opaConfig = mock(AuthorizationConfiguration.OpaConfig.class); @@ -136,6 +141,8 @@ public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { when(bearerTokenConfig.filePath()) .thenReturn(Optional.of(tokenFile.toString())); // File token also present when(bearerTokenConfig.refreshInterval()).thenReturn(300); + when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); + when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); AuthorizationConfiguration.OpaConfig opaConfig = mock(AuthorizationConfiguration.OpaConfig.class); @@ -168,6 +175,9 @@ public void testFactoryWithNoTokenConfiguration() { mock(AuthorizationConfiguration.BearerTokenConfig.class); when(bearerTokenConfig.staticValue()).thenReturn(Optional.empty()); when(bearerTokenConfig.filePath()).thenReturn(Optional.empty()); + when(bearerTokenConfig.refreshInterval()).thenReturn(300); + when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); + when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); AuthorizationConfiguration.OpaConfig opaConfig = mock(AuthorizationConfiguration.OpaConfig.class); From 85baedc9f9510a272606b6ab47f9ff79747201de Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:59:40 +0000 Subject: [PATCH 12/40] fix --- gradle/libs.versions.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9180ea4e4a..4963fb1c07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-ser antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests apache-httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "apache-httpclient" } assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" } +auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" } awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.34.5" } awaitility = { module = "org.awaitility:awaitility", version = "4.3.0" } azuresdk-bom = { module = "com.azure:azure-sdk-bom", version = "1.2.38" } From 642127507c041b1caec8db440f3b553d34d8aed9 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 1 Oct 2025 01:54:55 +0000 Subject: [PATCH 13/40] refactoring --- polaris-core/build.gradle.kts | 1 - .../core/auth/OpaPolarisAuthorizerTest.java | 570 ++++++++++++++---- .../polaris/test/commons/OpaTestResource.java | 4 +- 3 files changed, 451 insertions(+), 124 deletions(-) diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index d2c72a9ecb..d1777d5b09 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -100,7 +100,6 @@ dependencies { implementation(platform(libs.google.cloud.storage.bom)) implementation("com.google.cloud:google-cloud-storage") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testCompileOnly(project(":polaris-immutables")) testAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index 5fe4713cca..5e8a81ad13 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -19,6 +19,7 @@ package org.apache.polaris.core.auth; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -26,16 +27,20 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; import org.apache.http.HttpEntity; import org.apache.http.StatusLine; import org.apache.http.client.methods.CloseableHttpResponse; @@ -43,10 +48,12 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityType; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.apache.polaris.core.persistence.ResolvedPolarisEntity; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; /** * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication @@ -55,45 +62,274 @@ public class OpaPolarisAuthorizerTest { @Test void testOpaInputJsonFormat() throws Exception { - MockWebServer server = new MockWebServer(); - server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); - server.start(); - String url = server.url("/v1/data/polaris/authz/allow").toString(); + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + + String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( - url.replace("/v1/data/polaris/authz/allow", ""), - "/v1/data/polaris/authz/allow", - (String) null, - 2000, - true, - null, - null, - null, - null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); - PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); - Mockito.when(principal.getName()).thenReturn("eve"); - Mockito.when(principal.getRoles()).thenReturn(Set.of("auditor")); - Mockito.when(principal.getProperties()).thenReturn(Map.of("department", "finance")); + PolarisPrincipal principal = + PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); Set entities = Set.of(); - PolarisResolvedPathWrapper target = Mockito.mock(PolarisResolvedPathWrapper.class); - PolarisResolvedPathWrapper secondary = Mockito.mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); assertDoesNotThrow( () -> authorizer.authorizeOrThrow( principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, target, secondary)); - // Get the request sent to the mock server - var recordedRequest = server.takeRequest(); - String requestBody = recordedRequest.getBody().readUtf8(); + // Parse and verify JSON structure from captured request + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + + server.stop(0); + } + + @Test + void testOpaRequestJsonWithHierarchicalResource() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + + String url = "http://localhost:" + server.getAddress().getPort(); + + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + + // Set up a realistic principal + PolarisPrincipal principal = + PolarisPrincipal.of( + "alice", + Map.of("department", "analytics", "level", "senior"), + Set.of("data_engineer", "analyst")); + + // Create a hierarchical resource structure: catalog.namespace.table + // Create catalog entity using builder pattern + PolarisEntity catalogEntity = + new PolarisEntity.Builder() + .setName("prod_catalog") + .setType(PolarisEntityType.CATALOG) + .setId(100L) + .setCatalogId(100L) + .setParentId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create namespace entity using builder pattern + PolarisEntity namespaceEntity = + new PolarisEntity.Builder() + .setName("sales_data") + .setType(PolarisEntityType.NAMESPACE) + .setId(200L) + .setCatalogId(100L) + .setParentId(100L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create table entity using builder pattern + PolarisEntity tableEntity = + new PolarisEntity.Builder() + .setName("customer_orders") + .setType(PolarisEntityType.TABLE_LIKE) + .setId(300L) + .setCatalogId(100L) + .setParentId(200L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create hierarchical path: catalog -> namespace -> table + // Build a realistic resolved path using ResolvedPolarisEntity objects + List resolvedPath = + List.of( + createResolvedEntity(catalogEntity), + createResolvedEntity(namespaceEntity), + createResolvedEntity(tableEntity)); + PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath); + + Set entities = Set.of(catalogEntity, namespaceEntity, tableEntity); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); + + // Parse and verify the complete JSON structure + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + + // Verify top-level structure + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + + // Verify actor details + var actor = input.get("actor"); + assertTrue(actor.has("principal"), "Actor should have 'principal' field"); + assertEquals("alice", actor.get("principal").asText()); + assertTrue(actor.has("roles"), "Actor should have 'roles' field"); + assertTrue(actor.get("roles").isArray(), "Roles should be an array"); + assertEquals(2, actor.get("roles").size()); + assertTrue(actor.has("properties"), "Actor should have 'properties' field"); + assertEquals("analytics", actor.get("properties").get("department").asText()); + assertEquals("senior", actor.get("properties").get("level").asText()); + + // Verify action + var action = input.get("action"); + assertEquals("LOAD_TABLE", action.asText()); + + // Verify resource structure - this is the key part for hierarchical resources + var resource = input.get("resource"); + assertTrue(resource.has("targets"), "Resource should have 'targets' field"); + assertTrue(resource.has("secondaries"), "Resource should have 'secondaries' field"); + + var targets = resource.get("targets"); + assertTrue(targets.isArray(), "Targets should be an array"); + assertEquals(1, targets.size(), "Should have exactly one target"); + + var target = targets.get(0); + // Verify the target entity (table) details + assertTrue(target.isObject(), "Target should be an object"); + assertTrue(target.has("type"), "Target should have 'type' field"); + assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); + assertTrue(target.has("name"), "Target should have 'name' field"); + assertEquals( + "customer_orders", target.get("name").asText(), "Target name should be customer_orders"); + + // Verify the hierarchical parents array + assertTrue(target.has("parents"), "Target should have 'parents' field"); + var parents = target.get("parents"); + assertTrue(parents.isArray(), "Parents should be an array"); + assertEquals(2, parents.size(), "Should have 2 parents (catalog and namespace)"); + + // Verify catalog parent (first in the hierarchy) + var catalogParent = parents.get(0); + assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); + assertEquals( + "prod_catalog", catalogParent.get("name").asText(), "Catalog name should be prod_catalog"); + + // Verify namespace parent (second in the hierarchy) + var namespaceParent = parents.get(1); + assertEquals( + "NAMESPACE", namespaceParent.get("type").asText(), "Second parent should be namespace"); + assertEquals( + "sales_data", namespaceParent.get("name").asText(), "Namespace name should be sales_data"); + + // Verify properties field exists + assertTrue(target.has("properties"), "Target should have 'properties' field"); + assertTrue(target.get("properties").isObject(), "Properties should be an object"); + + var secondaries = resource.get("secondaries"); + assertTrue(secondaries.isArray(), "Secondaries should be an array"); + assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); + + server.stop(0); + } + + @Test + void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + + String url = "http://localhost:" + server.getAddress().getPort(); + + OpaPolarisAuthorizer authorizer = + OpaPolarisAuthorizer.create( + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + + // Set up a realistic principal + PolarisPrincipal principal = + PolarisPrincipal.of( + "bob", + Map.of("team", "ml", "project", "forecasting"), + Set.of("data_scientist", "analyst")); + + // Create a multi-level namespace structure: catalog.department.team.table + // Create catalog entity + PolarisEntity catalogEntity = + new PolarisEntity.Builder() + .setName("analytics_catalog") + .setType(PolarisEntityType.CATALOG) + .setId(100L) + .setCatalogId(100L) + .setParentId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create first-level namespace entity (department) + PolarisEntity departmentEntity = + new PolarisEntity.Builder() + .setName("engineering") + .setType(PolarisEntityType.NAMESPACE) + .setId(200L) + .setCatalogId(100L) + .setParentId(100L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create second-level namespace entity (team) + PolarisEntity teamEntity = + new PolarisEntity.Builder() + .setName("machine_learning") + .setType(PolarisEntityType.NAMESPACE) + .setId(300L) + .setCatalogId(100L) + .setParentId(200L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create table entity + PolarisEntity tableEntity = + new PolarisEntity.Builder() + .setName("feature_store") + .setType(PolarisEntityType.TABLE_LIKE) + .setId(400L) + .setCatalogId(100L) + .setParentId(300L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create hierarchical path: catalog -> department -> team -> table + List resolvedPath = + List.of( + createResolvedEntity(catalogEntity), + createResolvedEntity(departmentEntity), + createResolvedEntity(teamEntity), + createResolvedEntity(tableEntity)); + PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath); + + Set entities = + Set.of(catalogEntity, departmentEntity, teamEntity, tableEntity); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); - // Parse and verify JSON structure - com.fasterxml.jackson.databind.ObjectMapper mapper = - new com.fasterxml.jackson.databind.ObjectMapper(); - com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(requestBody); + // Parse and verify the complete JSON structure + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + + // Verify top-level structure assertTrue(root.has("input"), "Root should have 'input' field"); var input = root.get("input"); assertTrue(input.has("actor"), "Input should have 'actor' field"); @@ -101,36 +337,85 @@ void testOpaInputJsonFormat() throws Exception { assertTrue(input.has("resource"), "Input should have 'resource' field"); assertTrue(input.has("context"), "Input should have 'context' field"); - server.shutdown(); + // Verify actor details + var actor = input.get("actor"); + assertEquals("bob", actor.get("principal").asText()); + assertEquals(2, actor.get("roles").size()); + assertEquals("ml", actor.get("properties").get("team").asText()); + assertEquals("forecasting", actor.get("properties").get("project").asText()); + + // Verify action + var action = input.get("action"); + assertEquals("LOAD_TABLE", action.asText()); + + // Verify resource structure with multi-level namespace hierarchy + var resource = input.get("resource"); + var targets = resource.get("targets"); + assertEquals(1, targets.size(), "Should have exactly one target"); + + var target = targets.get(0); + // Verify the target entity (table) details + assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); + assertEquals( + "feature_store", target.get("name").asText(), "Target name should be feature_store"); + + // Verify the multi-level hierarchical parents array + assertTrue(target.has("parents"), "Target should have 'parents' field"); + var parents = target.get("parents"); + assertTrue(parents.isArray(), "Parents should be an array"); + assertEquals(3, parents.size(), "Should have 3 parents (catalog, department, team)"); + + // Verify catalog parent (first in the hierarchy) + var catalogParent = parents.get(0); + assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); + assertEquals( + "analytics_catalog", + catalogParent.get("name").asText(), + "Catalog name should be analytics_catalog"); + + // Verify department namespace parent (second in the hierarchy) + var departmentParent = parents.get(1); + assertEquals( + "NAMESPACE", departmentParent.get("type").asText(), "Second parent should be namespace"); + assertEquals( + "engineering", + departmentParent.get("name").asText(), + "Department name should be engineering"); + + // Verify team namespace parent (third in the hierarchy) + var teamParent = parents.get(2); + assertEquals("NAMESPACE", teamParent.get("type").asText(), "Third parent should be namespace"); + assertEquals( + "machine_learning", + teamParent.get("name").asText(), + "Team name should be machine_learning"); + + // Verify properties field exists + assertTrue(target.has("properties"), "Target should have 'properties' field"); + assertTrue(target.get("properties").isObject(), "Properties should be an object"); + + var secondaries = resource.get("secondaries"); + assertTrue(secondaries.isArray(), "Secondaries should be an array"); + assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); + + server.stop(0); } @Test void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { - MockWebServer server = new MockWebServer(); - server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); - server.start(); - String url = server.url("/v1/data/polaris/authz/allow").toString(); + HttpServer server = createServerWithAllowResponse(); + + String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( - url.replace("/v1/data/polaris/authz/allow", ""), - "/v1/data/polaris/authz/allow", - (String) null, - 2000, - true, - null, - null, - null, - null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); - PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); - Mockito.when(principal.getName()).thenReturn("alice"); - Mockito.when(principal.getRoles()).thenReturn(Set.of("admin")); - Mockito.when(principal.getProperties()).thenReturn(Map.of()); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); Set entities = Set.of(); - PolarisResolvedPathWrapper target = Mockito.mock(PolarisResolvedPathWrapper.class); - PolarisResolvedPathWrapper secondary = Mockito.mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); assertDoesNotThrow( () -> @@ -141,36 +426,24 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { target, secondary)); - server.shutdown(); + server.stop(0); } @Test void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { - MockWebServer server = new MockWebServer(); - server.enqueue(new MockResponse().setBody("{\"result\":{\"allow\":true}}")); - server.start(); - String url = server.url("/v1/data/polaris/authz/allow").toString(); + HttpServer server = createServerWithAllowResponse(); + + String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( - url.replace("/v1/data/polaris/authz/allow", ""), - "/v1/data/polaris/authz/allow", - (String) null, - 2000, - true, - null, - null, - null, - null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); - PolarisPrincipal principal = Mockito.mock(PolarisPrincipal.class); - Mockito.when(principal.getName()).thenReturn("bob"); - Mockito.when(principal.getRoles()).thenReturn(Set.of("user")); - Mockito.when(principal.getProperties()).thenReturn(Map.of()); + PolarisPrincipal principal = PolarisPrincipal.of("bob", Map.of(), Set.of("user")); Set entities = Set.of(); - PolarisResolvedPathWrapper target1 = Mockito.mock(PolarisResolvedPathWrapper.class); - PolarisResolvedPathWrapper target2 = Mockito.mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper target1 = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper target2 = new PolarisResolvedPathWrapper(List.of()); List targets = List.of(target1, target2); List secondaries = List.of(); @@ -179,7 +452,7 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { authorizer.authorizeOrThrow( principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, targets, secondaries)); - server.shutdown(); + server.stop(0); } // ===== Bearer Token and HTTPS Tests ===== @@ -262,13 +535,10 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { mockHttpClient, new ObjectMapper()); - PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); - when(mockPrincipal.getName()).thenReturn("test-user"); - when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); - when(mockPrincipal.getProperties()).thenReturn(Map.of()); + PolarisPrincipal mockPrincipal = + PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); - when(mockOperation.name()).thenReturn("READ"); + PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; assertDoesNotThrow( () -> { authorizer.authorizeOrThrow( @@ -279,15 +549,8 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { (PolarisResolvedPathWrapper) null); }); - ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); - verify(mockHttpClient).execute(httpPostCaptor.capture()); - - HttpPost capturedRequest = httpPostCaptor.getValue(); - assertTrue(capturedRequest.containsHeader("Authorization")); - String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); - assertTrue( - authHeader.equals("Bearer test-bearer-token"), - "Expected 'Bearer test-bearer-token' but got '" + authHeader + "'"); + // Verify the Authorization header with static bearer token + verifyAuthorizationHeader(mockHttpClient, "test-bearer-token"); } @Test @@ -312,13 +575,10 @@ public void testAuthorizationFailsWithoutBearerToken() throws IOException { mockHttpClient, new ObjectMapper()); - PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); - when(mockPrincipal.getName()).thenReturn("test-user"); - when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); - when(mockPrincipal.getProperties()).thenReturn(Map.of()); + PolarisPrincipal mockPrincipal = + PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); - when(mockOperation.name()).thenReturn("READ"); + PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; assertThrows( ForbiddenException.class, () -> { @@ -348,9 +608,10 @@ public void testBearerTokenFromTokenProvider() throws IOException { new ByteArrayInputStream( "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); - // Create a custom token provider - TokenProvider tokenProvider = new StaticTokenProvider("custom-token-from-provider"); + // Create token provider that returns a dynamic token + TokenProvider tokenProvider = () -> "dynamic-token-12345"; + // Create authorizer with the token provider instead of static token OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( "http://opa.example.com:8181", @@ -364,13 +625,10 @@ public void testBearerTokenFromTokenProvider() throws IOException { new ObjectMapper()); // Create mock principal and entities - PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); - when(mockPrincipal.getName()).thenReturn("test-user"); - when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); - when(mockPrincipal.getProperties()).thenReturn(Map.of()); + PolarisPrincipal mockPrincipal = + PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); - when(mockOperation.name()).thenReturn("READ"); + PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; // Execute authorization (should not throw since we mocked allow=true) assertDoesNotThrow( @@ -383,18 +641,8 @@ public void testBearerTokenFromTokenProvider() throws IOException { (PolarisResolvedPathWrapper) null); }); - // Capture the HTTP request to verify bearer token header - ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); - verify(mockHttpClient).execute(httpPostCaptor.capture()); - - HttpPost capturedRequest = httpPostCaptor.getValue(); - // Verify the Authorization header with bearer token from provider - assertTrue(capturedRequest.containsHeader("Authorization")); - String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); - assertTrue( - authHeader.equals("Bearer custom-token-from-provider"), - "Expected 'Bearer custom-token-from-provider' but got '" + authHeader + "'"); + verifyAuthorizationHeader(mockHttpClient, "dynamic-token-12345"); } @Test @@ -430,13 +678,10 @@ public void testNullTokenFromTokenProvider() throws IOException { new ObjectMapper()); // Create mock principal and entities - PolarisPrincipal mockPrincipal = mock(PolarisPrincipal.class); - when(mockPrincipal.getName()).thenReturn("test-user"); - when(mockPrincipal.getRoles()).thenReturn(Collections.emptySet()); - when(mockPrincipal.getProperties()).thenReturn(Map.of()); + PolarisPrincipal mockPrincipal = + PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - PolarisAuthorizableOperation mockOperation = mock(PolarisAuthorizableOperation.class); - when(mockOperation.name()).thenReturn("READ"); + PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; // Execute authorization (should not throw since we mocked allow=true) assertDoesNotThrow( @@ -449,15 +694,98 @@ public void testNullTokenFromTokenProvider() throws IOException { (PolarisResolvedPathWrapper) null); }); - // Capture the HTTP request to verify no Authorization header is set + // Verify no Authorization header is present when token provider returns null + verifyAuthorizationHeader(mockHttpClient, null); + } + + private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) { + return new ResolvedPolarisEntity(entity, null, null); + } + + /** + * Helper method to create and start an HTTP server that captures request bodies. + * + * @param capturedRequestBody Array to store the captured request body + * @return Started HttpServer instance + */ + private HttpServer createServerWithRequestCapture(String[] capturedRequestBody) + throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/v1/data/polaris/authz/allow", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + // Capture request body + byte[] requestBytes = exchange.getRequestBody().readAllBytes(); + capturedRequestBody[0] = new String(requestBytes, StandardCharsets.UTF_8); + + String response = "{\"result\":{\"allow\":true}}"; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes(StandardCharsets.UTF_8)); + } + } + }); + server.start(); + return server; + } + + /** + * Helper method to create and start an HTTP server that returns a simple allow response. + * + * @return Started HttpServer instance + */ + private HttpServer createServerWithAllowResponse() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/v1/data/polaris/authz/allow", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + String response = "{\"result\":{\"allow\":true}}"; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes(StandardCharsets.UTF_8)); + } + } + }); + server.start(); + return server; + } + + /** + * Helper method to capture and verify HTTP request Authorization header. + * + * @param mockHttpClient The mocked HTTP client to verify against + * @param expectedToken The expected bearer token value, or null if no Authorization header + * expected + */ + private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, String expectedToken) + throws IOException { + // Capture the HTTP request to verify bearer token header ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); verify(mockHttpClient).execute(httpPostCaptor.capture()); HttpPost capturedRequest = httpPostCaptor.getValue(); - // Verify no Authorization header is present when token provider returns null - assertTrue( - !capturedRequest.containsHeader("Authorization") - || capturedRequest.getFirstHeader("Authorization") == null); + if (expectedToken != null) { + // Verify the Authorization header is present and contains the expected token + assertTrue( + capturedRequest.containsHeader("Authorization"), + "Authorization header should be present when bearer token is provided"); + String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); + assertEquals( + "Bearer " + expectedToken, + authHeader, + "Authorization header should contain the correct bearer token"); + } else { + // Verify no Authorization header is present when token is null + assertTrue( + !capturedRequest.containsHeader("Authorization"), + "Authorization header should not be present when token provider returns null"); + } } } diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java index 5e333cf752..024ed90f8b 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -87,7 +87,7 @@ public Map start() { .usingTls() // Use HTTPS for health check .allowInsecure() // Allow self-signed certificates .forStatusCode(200) - .withStartupTimeout(Duration.ofSeconds(60))); + .withStartupTimeout(Duration.ofSeconds(120))); } else { // Configure OPA for HTTP opa.withCommand("run", "--server", "--addr=0.0.0.0:8181") @@ -95,7 +95,7 @@ public Map start() { Wait.forHttp("/health") .forPort(8181) .forStatusCode(200) - .withStartupTimeout(Duration.ofSeconds(30))); + .withStartupTimeout(Duration.ofSeconds(120))); } opa.start(); From 36f687c7ab04058c47b8db03b081aa639a98264f Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:56:35 +0000 Subject: [PATCH 14/40] refactor tests, disable ssl verification in integration tests --- gradle/libs.versions.toml | 5 + .../core/auth/OpaPolarisAuthorizer.java | 114 +++++------- .../core/auth/OpaPolarisAuthorizerTest.java | 47 +++-- .../auth/OpaFileTokenIntegrationTest.java | 84 ++++++++- .../service/auth/OpaIntegrationTest.java | 80 +-------- runtime/test-common/build.gradle.kts | 5 + .../polaris/test/commons/OpaTestResource.java | 169 +++++------------- 7 files changed, 223 insertions(+), 281 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4963fb1c07..1d5f567650 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ [versions] apache-httpclient = "4.5.14" +bouncycastle = "1.78.1" checkstyle = "10.25.0" hadoop = "3.4.2" hive = "3.1.3" @@ -26,6 +27,7 @@ iceberg = "1.10.0" # Ensure to update the iceberg version in regtests to keep re quarkus = "3.27.0" immutables = "2.11.4" jmh = "1.37" +netty = "4.1.104.Final" picocli = "4.7.7" scala212 = "2.12.19" spark35 = "3.5.7" @@ -48,6 +50,8 @@ auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" } awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.34.5" } awaitility = { module = "org.awaitility:awaitility", version = "4.3.0" } azuresdk-bom = { module = "com.azure:azure-sdk-bom", version = "1.2.38" } +bouncycastle-provider = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +bouncycastle-pkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version = "3.2.2" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.19.0" } commons-text = { module = "org.apache.commons:commons-text", version = "1.14.0" } @@ -83,6 +87,7 @@ keycloak-admin-client = { module = "org.keycloak:keycloak-admin-client", version jcstress-core = { module = "org.openjdk.jcstress:jcstress-core", version = "0.16" } jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } jmh-generator-annprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } +netty-handler = { module = "io.netty:netty-handler", version.ref = "netty" } logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.19" } micrometer-bom = { module = "io.micrometer:micrometer-bom", version = "1.15.4" } microprofile-fault-tolerance-api = { module = "org.eclipse.microprofile.fault-tolerance:microprofile-fault-tolerance-api", version = "4.1.2" } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index 88c813e840..9b39c15280 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -110,36 +110,9 @@ public static OpaPolarisAuthorizer create( .setConnectionRequestTimeout(timeoutMs) .build(); - // Configure SSL context for HTTPS connections - SSLContext sslContext = null; - SSLConnectionSocketFactory sslSocketFactory = null; - - if (opaServerUrl != null && opaServerUrl.toLowerCase(Locale.ROOT).startsWith("https")) { - SSLContextBuilder sslContextBuilder = SSLContextBuilder.create(); - - if (!verifySsl) { - // Disable SSL verification (for development/testing) - sslContextBuilder.loadTrustMaterial(TrustAllStrategy.INSTANCE); - sslContext = sslContextBuilder.build(); - sslSocketFactory = - new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); - } else if (trustStorePath != null && !trustStorePath.isEmpty()) { - // Load custom trust store for SSL verification - KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { - trustStore.load( - trustStoreStream, - trustStorePassword != null ? trustStorePassword.toCharArray() : null); - } - sslContextBuilder.loadTrustMaterial(trustStore, null); - sslContext = sslContextBuilder.build(); - sslSocketFactory = new SSLConnectionSocketFactory(sslContext); - } else { - // Use default system trust store for SSL verification - sslContext = SSLContextBuilder.create().build(); - sslSocketFactory = new SSLConnectionSocketFactory(sslContext); - } - } + // Configure SSL for HTTPS connections + SSLConnectionSocketFactory sslSocketFactory = + createSslSocketFactory(opaServerUrl, verifySsl, trustStorePath, trustStorePassword); // Create HTTP client with SSL configuration CloseableHttpClient httpClient; @@ -165,43 +138,6 @@ public static OpaPolarisAuthorizer create( } } - /** - * Convenience factory method for backward compatibility with String bearer tokens. - * - * @param opaServerUrl OPA server URL - * @param opaPolicyPath OPA policy path - * @param bearerToken Bearer token for authentication (optional) - * @param timeoutMs HTTP call timeout in milliseconds - * @param verifySsl Whether to verify SSL certificates for HTTPS connections - * @param trustStorePath Custom SSL trust store path (optional) - * @param trustStorePassword Custom SSL trust store password (optional) - * @param client Apache HttpClient (optional, can be null) - * @param mapper ObjectMapper (optional, can be null) - * @return OpaPolarisAuthorizer instance - */ - public static OpaPolarisAuthorizer create( - String opaServerUrl, - String opaPolicyPath, - String bearerToken, - int timeoutMs, - boolean verifySsl, - String trustStorePath, - String trustStorePassword, - Object client, - ObjectMapper mapper) { - TokenProvider tokenProvider = new StaticTokenProvider(bearerToken); - return create( - opaServerUrl, - opaPolicyPath, - tokenProvider, - timeoutMs, - verifySsl, - trustStorePath, - trustStorePassword, - client, - mapper); - } - /** * Authorizes a single target and secondary entity for the given principal and operation. * @@ -434,4 +370,48 @@ private ObjectNode buildContextNode() { context.put("request_id", java.util.UUID.randomUUID().toString()); return context; } + + /** + * Creates an SSL socket factory for HTTPS connections based on the configuration. + * + * @param opaServerUrl the OPA server URL + * @param verifySsl whether to verify SSL certificates + * @param trustStorePath custom trust store path (optional) + * @param trustStorePassword custom trust store password (optional) + * @return SSLConnectionSocketFactory for HTTPS connections, or null for HTTP + * @throws Exception if SSL configuration fails + */ + private static SSLConnectionSocketFactory createSslSocketFactory( + String opaServerUrl, boolean verifySsl, String trustStorePath, String trustStorePassword) + throws Exception { + + // Only configure SSL for HTTPS URLs + if (opaServerUrl == null || !opaServerUrl.toLowerCase(Locale.ROOT).startsWith("https")) { + return null; + } + + SSLContextBuilder sslContextBuilder = SSLContextBuilder.create(); + SSLContext sslContext; + + if (!verifySsl) { + // Disable SSL verification (for development/testing) + sslContextBuilder.loadTrustMaterial(TrustAllStrategy.INSTANCE); + sslContext = sslContextBuilder.build(); + return new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); + } else if (trustStorePath != null && !trustStorePath.isEmpty()) { + // Load custom trust store for SSL verification + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { + trustStore.load( + trustStoreStream, trustStorePassword != null ? trustStorePassword.toCharArray() : null); + } + sslContextBuilder.loadTrustMaterial(trustStore, null); + sslContext = sslContextBuilder.build(); + return new SSLConnectionSocketFactory(sslContext); + } else { + // Use default system trust store for SSL verification + sslContext = SSLContextBuilder.create().build(); + return new SSLConnectionSocketFactory(sslContext); + } + } } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index 5e8a81ad13..ea868bae58 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -70,7 +70,7 @@ void testOpaInputJsonFormat() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); PolarisPrincipal principal = @@ -108,7 +108,7 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); // Set up a realistic principal @@ -253,7 +253,7 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); // Set up a realistic principal @@ -408,7 +408,7 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); @@ -436,7 +436,7 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); PolarisPrincipal principal = PolarisPrincipal.of("bob", Map.of(), Set.of("user")); @@ -460,7 +460,7 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { @Test public void testCreateWithBearerTokenAndHttps() { OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( "https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", @@ -477,7 +477,7 @@ public void testCreateWithBearerTokenAndHttps() { @Test public void testCreateWithBearerTokenAndHttpsNoSslVerification() { OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( "https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", @@ -494,7 +494,7 @@ public void testCreateWithBearerTokenAndHttpsNoSslVerification() { @Test public void testCreateWithHttpsAndSslVerificationDisabled() { OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( "https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", @@ -524,7 +524,7 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( "http://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", @@ -564,7 +564,7 @@ public void testAuthorizationFailsWithoutBearerToken() throws IOException { when(mockStatusLine.getStatusCode()).thenReturn(401); OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( + createWithStringToken( "http://opa.example.com:8181", "/v1/data/polaris/authz", (String) null, @@ -788,4 +788,31 @@ private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, Strin "Authorization header should not be present when token provider returns null"); } } + + /** + * Convenience helper method for creating OpaPolarisAuthorizer with String bearer token. This + * provides the same API as the removed String-based create method for test convenience. + */ + private static OpaPolarisAuthorizer createWithStringToken( + String opaServerUrl, + String opaPolicyPath, + String bearerToken, + int timeoutMs, + boolean verifySsl, + String trustStorePath, + String trustStorePassword, + Object client, + ObjectMapper mapper) { + TokenProvider tokenProvider = new StaticTokenProvider(bearerToken); + return OpaPolarisAuthorizer.create( + opaServerUrl, + opaPolicyPath, + tokenProvider, + timeoutMs, + verifySsl, + trustStorePath, + trustStorePassword, + client, + mapper); + } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java index 83183619d1..572863fb20 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java @@ -22,15 +22,95 @@ import static org.junit.jupiter.api.Assertions.fail; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; import io.quarkus.test.junit.TestProfile; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.polaris.test.commons.OpaTestResource; import org.junit.jupiter.api.Test; @QuarkusTest -@TestProfile(OpaIntegrationTest.FileTokenOpaProfile.class) +@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) public class OpaFileTokenIntegrationTest { + public static class FileTokenOpaProfile implements QuarkusTestProfile { + private static volatile Path tokenFile; + + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.timeout-ms", "2000"); + + // Create temporary token file for testing + try { + tokenFile = Files.createTempFile("opa-test-token", ".txt"); + Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); + tokenFile.toFile().deleteOnExit(); + } catch (IOException e) { + throw new RuntimeException("Failed to create test token file", e); + } + + // Configure OPA server authentication with file-based bearer token and HTTPS + config.put("polaris.authorization.opa.bearer-token.file-path", tokenFile.toString()); + config.put( + "polaris.authorization.opa.bearer-token.refresh-interval", + "1"); // 1 second for fast testing + config.put( + "polaris.authorization.opa.verify-ssl", "false"); // Disable SSL verification for tests + + // TODO: Add tests for OIDC and federated principal + config.put("polaris.authentication.type", "internal"); + + return config; + } + + @Override + public List testResources() { + String customRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + + # Deny stranger user explicitly (though default is false) + allow { + input.actor.principal == "stranger" + false + } + """; + + return List.of( + new TestResourceEntry( + OpaTestResource.class, + Map.of( + "policy-name", "polaris-authz", + "rego-policy", customRegoPolicy, + "use-https", "true", + "bearer-token", "test-opa-bearer-token-from-file-67890"))); + } + + public static Path getTokenFile() { + return tokenFile; + } + } + /** * Test demonstrates OPA integration with file-based bearer token authentication. This test * verifies that the FileTokenProvider correctly reads tokens from a file and that the full @@ -93,7 +173,7 @@ void testFileTokenRefresh() throws IOException, InterruptedException { // Update the token file with a new value // Note: In a real test, we'd need to coordinate with the OPA server to accept the new token // For this demo, we'll just verify the file can be updated - var tokenFile = OpaIntegrationTest.FileTokenOpaProfile.getTokenFile(); + var tokenFile = FileTokenOpaProfile.getTokenFile(); if (tokenFile != null && Files.exists(tokenFile)) { String originalContent = Files.readString(tokenFile); diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index 137e85fc22..9c4de724e5 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -25,9 +25,6 @@ import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; import io.quarkus.test.junit.TestProfile; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -56,8 +53,7 @@ public Map getConfigOverrides() { config.put( "polaris.authorization.opa.bearer-token.static-value", "test-opa-bearer-token-12345"); config.put( - "polaris.authorization.opa.verify-ssl", - "true"); // Enable SSL verification with trust store + "polaris.authorization.opa.verify-ssl", "false"); // Disable SSL verification for tests // TODO: Add tests for OIDC and federated principal config.put("polaris.authentication.type", "internal"); @@ -101,80 +97,6 @@ public List testResources() { } } - public static class FileTokenOpaProfile implements QuarkusTestProfile { - private static volatile Path tokenFile; - - @Override - public Map getConfigOverrides() { - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.timeout-ms", "2000"); - - // Create temporary token file for testing - try { - tokenFile = Files.createTempFile("opa-test-token", ".txt"); - Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); - tokenFile.toFile().deleteOnExit(); - } catch (IOException e) { - throw new RuntimeException("Failed to create test token file", e); - } - - // Configure OPA server authentication with file-based bearer token and HTTPS - config.put("polaris.authorization.opa.bearer-token.file-path", tokenFile.toString()); - config.put( - "polaris.authorization.opa.bearer-token.refresh-interval", - "1"); // 1 second for fast testing - config.put( - "polaris.authorization.opa.verify-ssl", - "true"); // Enable SSL verification with trust store - - // TODO: Add tests for OIDC and federated principal - config.put("polaris.authentication.type", "internal"); - - return config; - } - - @Override - public List testResources() { - String customRegoPolicy = - """ - package polaris.authz - - default allow := false - - # Allow root user for all operations - allow { - input.actor.principal == "root" - } - - # Allow admin user for all operations - allow { - input.actor.principal == "admin" - } - - # Deny stranger user explicitly (though default is false) - allow { - input.actor.principal == "stranger" - false - } - """; - - return List.of( - new TestResourceEntry( - OpaTestResource.class, - Map.of( - "policy-name", "polaris-authz", - "rego-policy", customRegoPolicy, - "use-https", "true", - "bearer-token", "test-opa-bearer-token-from-file-67890"))); - } - - public static Path getTokenFile() { - return tokenFile; - } - } - @Test void testOpaAllowsRootUser() { // Test demonstrates the complete integration flow: diff --git a/runtime/test-common/build.gradle.kts b/runtime/test-common/build.gradle.kts index b728ea17ac..95828b1239 100644 --- a/runtime/test-common/build.gradle.kts +++ b/runtime/test-common/build.gradle.kts @@ -42,6 +42,11 @@ dependencies { implementation("org.testcontainers:testcontainers") implementation("org.testcontainers:postgresql") + // Netty for SSL certificate generation + implementation(libs.netty.handler) + implementation(libs.bouncycastle.provider) + implementation(libs.bouncycastle.pkix) + implementation(libs.testcontainers.keycloak) { exclude(group = "org.keycloak", module = "keycloak-admin-client") } diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java index 024ed90f8b..f4cc5c5a37 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -18,9 +18,8 @@ */ package org.apache.polaris.test.commons; +import io.netty.handler.ssl.util.SelfSignedCertificate; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; -import java.io.FileInputStream; -import java.io.FileWriter; import java.io.IOException; import java.io.OutputStream; import java.net.HttpURLConnection; @@ -28,15 +27,11 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.security.KeyStore; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; import java.time.Duration; import java.util.HashMap; import java.util.Map; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; -import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; @@ -74,7 +69,14 @@ public Map start() { if (useHttps) { // Configure OPA to use HTTPS with self-signed certificates - opa.withFileSystemBind(tempCertDir.toString(), "/certs", BindMode.READ_ONLY) + opa.withCopyFileToContainer( + org.testcontainers.utility.MountableFile.forHostPath( + tempCertDir.resolve("server.crt")), + "/certs/server.crt") + .withCopyFileToContainer( + org.testcontainers.utility.MountableFile.forHostPath( + tempCertDir.resolve("server.key")), + "/certs/server.key") .withCommand( "run", "--server", @@ -102,9 +104,10 @@ public Map start() { } mappedPort = opa.getMappedPort(8181); + String containerHost = opa.getHost(); // This will be the actual Docker host String protocol = useHttps ? "https" : "http"; - String baseUrl = protocol + "://localhost:" + mappedPort; + String baseUrl = protocol + "://" + containerHost + ":" + mappedPort; // Load Rego policy into OPA loadRegoPolicy(baseUrl, bearerToken, "policy-name", "rego-policy"); @@ -117,14 +120,6 @@ public Map start() { Map config = new HashMap<>(); config.put("polaris.authorization.opa.url", baseUrl); - // For HTTPS mode, provide the trust store path for SSL verification - if (useHttps && tempCertDir != null) { - config.put( - "polaris.authorization.opa.trust-store-path", - tempCertDir.resolve("truststore.jks").toString()); - config.put("polaris.authorization.opa.trust-store-password", "test-password"); - } - return config; } catch (Exception e) { @@ -132,108 +127,30 @@ public Map start() { } } - private void setupSelfSignedCertificates() throws IOException, InterruptedException { + private void setupSelfSignedCertificates() throws IOException { // Create temporary directory for certificates tempCertDir = Files.createTempDirectory("opa-certs"); tempCertDir.toFile().deleteOnExit(); Path keyFile = tempCertDir.resolve("server.key"); Path certFile = tempCertDir.resolve("server.crt"); - Path trustStoreFile = tempCertDir.resolve("truststore.jks"); - Path configFile = tempCertDir.resolve("openssl.conf"); - - // Create OpenSSL config for self-signed certificate with SAN - String opensslConfig = - """ - [req] - default_bits = 2048 - prompt = no - default_md = sha256 - distinguished_name = dn - req_extensions = v3_req - - [dn] - C=US - ST=Test - L=Test - O=Test - CN=localhost - - [v3_req] - subjectAltName = @alt_names - - [alt_names] - DNS.1 = localhost - DNS.2 = opa - IP.1 = 127.0.0.1 - """; - - try (FileWriter writer = new FileWriter(configFile.toFile(), StandardCharsets.UTF_8)) { - writer.write(opensslConfig); - } - - // Generate private key - ProcessBuilder keyGenProcess = - new ProcessBuilder("openssl", "genrsa", "-out", keyFile.toString(), "2048"); - Process keyGen = keyGenProcess.start(); - if (keyGen.waitFor() != 0) { - throw new RuntimeException("Failed to generate private key for OPA HTTPS"); - } - - // Generate self-signed certificate - ProcessBuilder certGenProcess = - new ProcessBuilder( - "openssl", - "req", - "-new", - "-x509", - "-key", - keyFile.toString(), - "-out", - certFile.toString(), - "-days", - "365", - "-config", - configFile.toString(), - "-extensions", - "v3_req"); - Process certGen = certGenProcess.start(); - if (certGen.waitFor() != 0) { - throw new RuntimeException("Failed to generate certificate for OPA HTTPS"); - } - // Create a trust store containing the self-signed certificate - createTrustStore(certFile, trustStoreFile); - - // Make sure files will be cleaned up - keyFile.toFile().deleteOnExit(); - certFile.toFile().deleteOnExit(); - trustStoreFile.toFile().deleteOnExit(); - configFile.toFile().deleteOnExit(); - } - - private void createTrustStore(Path certFile, Path trustStoreFile) throws IOException { try { - // Load the certificate - CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); - X509Certificate certificate; - try (FileInputStream certInputStream = new FileInputStream(certFile.toFile())) { - certificate = (X509Certificate) certificateFactory.generateCertificate(certInputStream); - } + // Generate self-signed certificate using Netty with BouncyCastle support + SelfSignedCertificate certificate = new SelfSignedCertificate("localhost"); - // Create a new trust store - KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - trustStore.load(null, null); // Initialize empty trust store + // Copy the generated certificate and key to our temp directory + Files.copy(certificate.certificate().toPath(), certFile); + Files.copy(certificate.privateKey().toPath(), keyFile); - // Add the certificate to the trust store - trustStore.setCertificateEntry("opa-test-cert", certificate); + // Make sure files will be cleaned up + keyFile.toFile().deleteOnExit(); + certFile.toFile().deleteOnExit(); - // Save the trust store to file - try (var trustStoreOutputStream = Files.newOutputStream(trustStoreFile)) { - trustStore.store(trustStoreOutputStream, "test-password".toCharArray()); - } + // Clean up the temporary Netty certificate files + certificate.delete(); } catch (Exception e) { - throw new IOException("Failed to create trust store", e); + throw new IOException("Failed to generate self-signed certificate using Netty", e); } } @@ -336,27 +253,33 @@ private void loadRegoPolicy( private HttpURLConnection createConnection(URL url) throws Exception { HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - // Configure SSL context to use the same trust store as OpaPolarisAuthorizer for HTTPS + // For HTTPS connections in test environment, disable SSL verification entirely if (useHttps && conn instanceof HttpsURLConnection httpsConn) { - // Load the trust store we created for consistent SSL verification - KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - Path trustStoreFile = tempCertDir.resolve("truststore.jks"); - try (FileInputStream trustStoreStream = new FileInputStream(trustStoreFile.toFile())) { - trustStore.load(trustStoreStream, "test-password".toCharArray()); - } - - // Create SSL context with the trust store + // Create a trust-all SSL context for test purposes SSLContext sslContext = SSLContext.getInstance("TLS"); - javax.net.ssl.TrustManagerFactory tmf = - javax.net.ssl.TrustManagerFactory.getInstance( - javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(trustStore); - sslContext.init(null, tmf.getTrustManagers(), new java.security.SecureRandom()); + sslContext.init( + null, + new javax.net.ssl.TrustManager[] { + new javax.net.ssl.X509TrustManager() { + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return null; + } + + @Override + public void checkClientTrusted( + java.security.cert.X509Certificate[] certs, String authType) {} + + @Override + public void checkServerTrusted( + java.security.cert.X509Certificate[] certs, String authType) {} + } + }, + new java.security.SecureRandom()); httpsConn.setSSLSocketFactory(sslContext.getSocketFactory()); - // We can verify hostname since our certificate includes localhost - httpsConn.setHostnameVerifier( - (hostname, session) -> "localhost".equals(hostname) || "127.0.0.1".equals(hostname)); + // Disable hostname verification for test environments + httpsConn.setHostnameVerifier((hostname, session) -> true); } return conn; From c1ae608700bc4f8c7e003f632321230a2ede1e56 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:03:00 +0000 Subject: [PATCH 15/40] use http in integration tests --- .../auth/OpaFileTokenIntegrationTest.java | 8 +- .../service/auth/OpaIntegrationTest.java | 17 +- runtime/test-common/build.gradle.kts | 5 - .../polaris/test/commons/OpaTestResource.java | 212 +----------------- 4 files changed, 20 insertions(+), 222 deletions(-) diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java index 572863fb20..2de89a27d1 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java @@ -57,7 +57,7 @@ public Map getConfigOverrides() { throw new RuntimeException("Failed to create test token file", e); } - // Configure OPA server authentication with file-based bearer token and HTTPS + // Configure OPA server authentication with file-based bearer token config.put("polaris.authorization.opa.bearer-token.file-path", tokenFile.toString()); config.put( "polaris.authorization.opa.bearer-token.refresh-interval", @@ -99,11 +99,7 @@ public List testResources() { return List.of( new TestResourceEntry( OpaTestResource.class, - Map.of( - "policy-name", "polaris-authz", - "rego-policy", customRegoPolicy, - "use-https", "true", - "bearer-token", "test-opa-bearer-token-from-file-67890"))); + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); } public static Path getTokenFile() { diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index 9c4de724e5..03184bf8ab 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -36,10 +36,9 @@ public class OpaIntegrationTest { /** - * Test demonstrates OPA integration with bearer token authentication and HTTPS support. The OPA - * container runs with HTTPS and self-signed certificates. The OpaPolarisAuthorizer is configured - * to verify SSL certificates using a custom trust store containing the self-signed certificate - * generated by the test infrastructure. + * Test demonstrates OPA integration with bearer token authentication. The OPA container runs with + * HTTP for simplicity in CI environments. The OpaPolarisAuthorizer is configured to disable SSL + * verification for test purposes. */ public static class StaticTokenOpaProfile implements QuarkusTestProfile { @Override @@ -49,7 +48,7 @@ public Map getConfigOverrides() { config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); config.put("polaris.authorization.opa.timeout-ms", "2000"); - // Configure OPA server authentication with static bearer token and HTTPS + // Configure OPA server authentication with static bearer token config.put( "polaris.authorization.opa.bearer-token.static-value", "test-opa-bearer-token-12345"); config.put( @@ -89,11 +88,7 @@ public List testResources() { return List.of( new TestResourceEntry( OpaTestResource.class, - Map.of( - "policy-name", "polaris-authz", - "rego-policy", customRegoPolicy, - "use-https", "true", - "bearer-token", "test-opa-bearer-token-12345"))); + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); } } @@ -172,7 +167,7 @@ void testOpaAllowsAdminUser() { @Test void testOpaBearerTokenAuthentication() { // Test that OpaPolarisAuthorizer is configured to send bearer tokens - // and can handle HTTPS connections with proper SSL verification + // and can handle HTTP connections for testing String rootToken = getRootToken(); given() diff --git a/runtime/test-common/build.gradle.kts b/runtime/test-common/build.gradle.kts index 95828b1239..b728ea17ac 100644 --- a/runtime/test-common/build.gradle.kts +++ b/runtime/test-common/build.gradle.kts @@ -42,11 +42,6 @@ dependencies { implementation("org.testcontainers:testcontainers") implementation("org.testcontainers:postgresql") - // Netty for SSL certificate generation - implementation(libs.netty.handler) - implementation(libs.bouncycastle.provider) - implementation(libs.bouncycastle.pkix) - implementation(libs.testcontainers.keycloak) { exclude(group = "org.keycloak", module = "keycloak-admin-client") } diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java index f4cc5c5a37..6ceafc125a 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java @@ -18,20 +18,14 @@ */ package org.apache.polaris.test.commons; -import io.netty.handler.ssl.util.SelfSignedCertificate; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; -import java.io.IOException; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; import java.util.Map; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; @@ -40,8 +34,6 @@ public class OpaTestResource implements QuarkusTestResourceLifecycleManager { private static GenericContainer opa; private int mappedPort; private Map resourceConfig; - private Path tempCertDir; - private boolean useHttps; @Override public void init(Map initArgs) { @@ -50,72 +42,29 @@ public void init(Map initArgs) { @Override public Map start() { - // Check if HTTPS is requested - useHttps = "true".equals(resourceConfig.get("use-https")); - String bearerToken = resourceConfig.get("bearer-token"); - try { - // Setup HTTPS certificates if requested - if (useHttps) { - setupSelfSignedCertificates(); - } - // Reuse container across tests to speed up execution if (opa == null || !opa.isRunning()) { opa = new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:0.63.0")) .withExposedPorts(8181) - .withReuse(true); - - if (useHttps) { - // Configure OPA to use HTTPS with self-signed certificates - opa.withCopyFileToContainer( - org.testcontainers.utility.MountableFile.forHostPath( - tempCertDir.resolve("server.crt")), - "/certs/server.crt") - .withCopyFileToContainer( - org.testcontainers.utility.MountableFile.forHostPath( - tempCertDir.resolve("server.key")), - "/certs/server.key") - .withCommand( - "run", - "--server", - "--addr=0.0.0.0:8181", - "--tls-cert-file=/certs/server.crt", - "--tls-private-key-file=/certs/server.key") - .waitingFor( - Wait.forHttp("/health") - .forPort(8181) - .usingTls() // Use HTTPS for health check - .allowInsecure() // Allow self-signed certificates - .forStatusCode(200) - .withStartupTimeout(Duration.ofSeconds(120))); - } else { - // Configure OPA for HTTP - opa.withCommand("run", "--server", "--addr=0.0.0.0:8181") - .waitingFor( - Wait.forHttp("/health") - .forPort(8181) - .forStatusCode(200) - .withStartupTimeout(Duration.ofSeconds(120))); - } + .withReuse(true) + .withCommand("run", "--server", "--addr=0.0.0.0:8181") + .waitingFor( + Wait.forHttp("/health") + .forPort(8181) + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(120))); opa.start(); } mappedPort = opa.getMappedPort(8181); - String containerHost = opa.getHost(); // This will be the actual Docker host - - String protocol = useHttps ? "https" : "http"; - String baseUrl = protocol + "://" + containerHost + ":" + mappedPort; + String containerHost = opa.getHost(); + String baseUrl = "http://" + containerHost + ":" + mappedPort; // Load Rego policy into OPA - loadRegoPolicy(baseUrl, bearerToken, "policy-name", "rego-policy"); - - // Load server authentication policy only for HTTPS mode - if (useHttps && bearerToken != null && !bearerToken.isEmpty()) { - loadServerAuthPolicy(baseUrl, bearerToken); - } + loadRegoPolicy(baseUrl, "policy-name", "rego-policy"); Map config = new HashMap<>(); config.put("polaris.authorization.opa.url", baseUrl); @@ -127,86 +76,7 @@ public Map start() { } } - private void setupSelfSignedCertificates() throws IOException { - // Create temporary directory for certificates - tempCertDir = Files.createTempDirectory("opa-certs"); - tempCertDir.toFile().deleteOnExit(); - - Path keyFile = tempCertDir.resolve("server.key"); - Path certFile = tempCertDir.resolve("server.crt"); - - try { - // Generate self-signed certificate using Netty with BouncyCastle support - SelfSignedCertificate certificate = new SelfSignedCertificate("localhost"); - - // Copy the generated certificate and key to our temp directory - Files.copy(certificate.certificate().toPath(), certFile); - Files.copy(certificate.privateKey().toPath(), keyFile); - - // Make sure files will be cleaned up - keyFile.toFile().deleteOnExit(); - certFile.toFile().deleteOnExit(); - - // Clean up the temporary Netty certificate files - certificate.delete(); - } catch (Exception e) { - throw new IOException("Failed to generate self-signed certificate using Netty", e); - } - } - - private void loadServerAuthPolicy(String baseUrl, String bearerToken) { - // Create a server authentication policy that only allows the specific bearer token - String serverAuthPolicy = - String.format( - """ - package system.authz - - default allow := false - - # Allow requests with the correct bearer token - allow { - input.identity.type == "bearer" - input.identity.token == "%s" - } - - # Allow health check endpoint without authentication - allow { - input.path[0] == "health" - } - """, - bearerToken); - - try { - URL url = new URL(baseUrl + "/v1/policies/server_auth"); - HttpURLConnection conn = createConnection(url); - conn.setRequestMethod("PUT"); - conn.setDoOutput(true); - conn.setRequestProperty("Content-Type", "text/plain"); - - // Use the bearer token to authenticate this policy upload - conn.setRequestProperty("Authorization", "Bearer " + bearerToken); - - try (OutputStream os = conn.getOutputStream()) { - os.write(serverAuthPolicy.getBytes(StandardCharsets.UTF_8)); - } - - int code = conn.getResponseCode(); - if (code < 200 || code >= 300) { - throw new RuntimeException("OPA server auth policy upload failed, HTTP " + code); - } - } catch (Exception e) { - String logs = ""; - try { - logs = opa.getLogs(); - } catch (Throwable ignored) { - } - throw new RuntimeException( - "Failed to load OPA server auth policy. Container logs:\n" + logs, e); - } - } - - private void loadRegoPolicy( - String baseUrl, String bearerToken, String policyNameKey, String regoPolicyKey) { + private void loadRegoPolicy(String baseUrl, String policyNameKey, String regoPolicyKey) { String policyName = resourceConfig.get(policyNameKey); String regoPolicy = resourceConfig.get(regoPolicyKey); @@ -221,16 +91,11 @@ private void loadRegoPolicy( try { URL url = new URL(baseUrl + "/v1/policies/" + policyName); - HttpURLConnection conn = createConnection(url); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("PUT"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "text/plain"); - // Add bearer token for server authentication if provided - if (bearerToken != null && !bearerToken.isEmpty()) { - conn.setRequestProperty("Authorization", "Bearer " + bearerToken); - } - try (OutputStream os = conn.getOutputStream()) { os.write(regoPolicy.getBytes(StandardCharsets.UTF_8)); } @@ -250,62 +115,9 @@ private void loadRegoPolicy( } } - private HttpURLConnection createConnection(URL url) throws Exception { - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - - // For HTTPS connections in test environment, disable SSL verification entirely - if (useHttps && conn instanceof HttpsURLConnection httpsConn) { - // Create a trust-all SSL context for test purposes - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init( - null, - new javax.net.ssl.TrustManager[] { - new javax.net.ssl.X509TrustManager() { - @Override - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return null; - } - - @Override - public void checkClientTrusted( - java.security.cert.X509Certificate[] certs, String authType) {} - - @Override - public void checkServerTrusted( - java.security.cert.X509Certificate[] certs, String authType) {} - } - }, - new java.security.SecureRandom()); - - httpsConn.setSSLSocketFactory(sslContext.getSocketFactory()); - // Disable hostname verification for test environments - httpsConn.setHostnameVerifier((hostname, session) -> true); - } - - return conn; - } - @Override public void stop() { // Don't stop the container to allow reuse across tests // Container will be cleaned up when the JVM exits - - // Clean up temporary certificate directory - if (tempCertDir != null) { - try { - Files.walk(tempCertDir) - .sorted((a, b) -> b.compareTo(a)) // Delete files before directories - .forEach( - path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - // Ignore cleanup errors - } - }); - } catch (Exception e) { - // Ignore cleanup errors - } - } } } From 65167263842d15a3281a1795dacf5d4b77742010 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:55:31 +0000 Subject: [PATCH 16/40] remove properties from initial implementation --- .../polaris/core/auth/OpaPolarisAuthorizer.java | 12 +----------- .../polaris/core/auth/OpaPolarisAuthorizerTest.java | 13 ------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index 9b39c15280..be5cf3d8d6 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -278,7 +278,7 @@ private String buildOpaInputJson( /** * Builds the actor section of the OPA input JSON. * - *

Includes principal name, roles, and all properties as a generic field. + *

Includes principal name, and roles as a generic field. * * @param principal the principal requesting authorization * @return the actor node for OPA input @@ -289,11 +289,6 @@ private ObjectNode buildActorNode(PolarisPrincipal principal) { ArrayNode roles = objectMapper.createArrayNode(); for (String role : principal.getRoles()) roles.add(role); actor.set("roles", roles); - ObjectNode propertiesNode = objectMapper.createObjectNode(); - for (var entry : principal.getProperties().entrySet()) { - propertiesNode.put(entry.getKey(), entry.getValue()); - } - actor.set("properties", propertiesNode); return actor; } @@ -348,11 +343,6 @@ private ObjectNode buildSingleResourceNode(PolarisResolvedPathWrapper wrapper) { } node.set("parents", parentsArray); } - ObjectNode props = objectMapper.createObjectNode(); - for (var entry : entity.getPropertiesAsMap().entrySet()) { - props.put(entry.getKey(), entry.getValue()); - } - node.set("properties", props); } return node; } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index ea868bae58..30357901a3 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -187,9 +187,6 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { assertTrue(actor.has("roles"), "Actor should have 'roles' field"); assertTrue(actor.get("roles").isArray(), "Roles should be an array"); assertEquals(2, actor.get("roles").size()); - assertTrue(actor.has("properties"), "Actor should have 'properties' field"); - assertEquals("analytics", actor.get("properties").get("department").asText()); - assertEquals("senior", actor.get("properties").get("level").asText()); // Verify action var action = input.get("action"); @@ -232,10 +229,6 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { assertEquals( "sales_data", namespaceParent.get("name").asText(), "Namespace name should be sales_data"); - // Verify properties field exists - assertTrue(target.has("properties"), "Target should have 'properties' field"); - assertTrue(target.get("properties").isObject(), "Properties should be an object"); - var secondaries = resource.get("secondaries"); assertTrue(secondaries.isArray(), "Secondaries should be an array"); assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); @@ -341,8 +334,6 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { var actor = input.get("actor"); assertEquals("bob", actor.get("principal").asText()); assertEquals(2, actor.get("roles").size()); - assertEquals("ml", actor.get("properties").get("team").asText()); - assertEquals("forecasting", actor.get("properties").get("project").asText()); // Verify action var action = input.get("action"); @@ -390,10 +381,6 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { teamParent.get("name").asText(), "Team name should be machine_learning"); - // Verify properties field exists - assertTrue(target.has("properties"), "Target should have 'properties' field"); - assertTrue(target.get("properties").isObject(), "Properties should be an object"); - var secondaries = resource.get("secondaries"); assertTrue(secondaries.isArray(), "Secondaries should be an array"); assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); From edfe61a64c6fb1b8cf9818b5ea2f8916c49ee16b Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 8 Oct 2025 02:05:04 +0000 Subject: [PATCH 17/40] remove unused ssl dependencies --- gradle/libs.versions.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d5f567650..4963fb1c07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,6 @@ [versions] apache-httpclient = "4.5.14" -bouncycastle = "1.78.1" checkstyle = "10.25.0" hadoop = "3.4.2" hive = "3.1.3" @@ -27,7 +26,6 @@ iceberg = "1.10.0" # Ensure to update the iceberg version in regtests to keep re quarkus = "3.27.0" immutables = "2.11.4" jmh = "1.37" -netty = "4.1.104.Final" picocli = "4.7.7" scala212 = "2.12.19" spark35 = "3.5.7" @@ -50,8 +48,6 @@ auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" } awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.34.5" } awaitility = { module = "org.awaitility:awaitility", version = "4.3.0" } azuresdk-bom = { module = "com.azure:azure-sdk-bom", version = "1.2.38" } -bouncycastle-provider = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } -bouncycastle-pkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version = "3.2.2" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.19.0" } commons-text = { module = "org.apache.commons:commons-text", version = "1.14.0" } @@ -87,7 +83,6 @@ keycloak-admin-client = { module = "org.keycloak:keycloak-admin-client", version jcstress-core = { module = "org.openjdk.jcstress:jcstress-core", version = "0.16" } jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } jmh-generator-annprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } -netty-handler = { module = "io.netty:netty-handler", version.ref = "netty" } logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.19" } micrometer-bom = { module = "io.micrometer:micrometer-bom", version = "1.15.4" } microprofile-fault-tolerance-api = { module = "org.eclipse.microprofile.fault-tolerance:microprofile-fault-tolerance-api", version = "4.1.2" } From 723dec1c7c246387b7ba5d5de00ec75aca1b8234 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:57:35 +0000 Subject: [PATCH 18/40] adopt review feedback --- ...Provider.java => BearerTokenProvider.java} | 2 +- ...ider.java => FileBearerTokenProvider.java} | 20 ++-- .../core/auth/OpaPolarisAuthorizer.java | 34 ++++--- ...er.java => StaticBearerTokenProvider.java} | 7 +- ....java => FileBearerTokenProviderTest.java} | 42 +++++---- .../core/auth/OpaPolarisAuthorizerTest.java | 43 ++++----- ...ava => StaticBearerTokenProviderTest.java} | 19 +--- .../auth/OpaPolarisAuthorizerFactory.java | 62 +++---------- .../config/AuthorizationConfiguration.java | 40 +++++++- .../service/config/ServiceProducers.java | 58 ++++++++++++ .../auth/OpaFileTokenIntegrationTest.java | 6 +- .../auth/OpaPolarisAuthorizerFactoryTest.java | 93 ++++++++++++++++--- 12 files changed, 275 insertions(+), 151 deletions(-) rename polaris-core/src/main/java/org/apache/polaris/core/auth/{TokenProvider.java => BearerTokenProvider.java} (97%) rename polaris-core/src/main/java/org/apache/polaris/core/auth/{FileTokenProvider.java => FileBearerTokenProvider.java} (94%) rename polaris-core/src/main/java/org/apache/polaris/core/auth/{StaticTokenProvider.java => StaticBearerTokenProvider.java} (86%) rename polaris-core/src/test/java/org/apache/polaris/core/auth/{FileTokenProviderTest.java => FileBearerTokenProviderTest.java} (90%) rename polaris-core/src/test/java/org/apache/polaris/core/auth/{StaticTokenProviderTest.java => StaticBearerTokenProviderTest.java} (69%) diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/BearerTokenProvider.java similarity index 97% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java rename to polaris-core/src/main/java/org/apache/polaris/core/auth/BearerTokenProvider.java index c1885048c9..15a6b17fc0 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/TokenProvider.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/BearerTokenProvider.java @@ -31,7 +31,7 @@ *

  • External token services * */ -public interface TokenProvider { +public interface BearerTokenProvider { /** * Get the current bearer token. diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java similarity index 94% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java rename to polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java index 61e26e4049..7433b67111 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileTokenProvider.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java @@ -46,9 +46,9 @@ * provider will automatically refresh the token based on the expiration time minus a configurable * buffer, rather than using the fixed refresh interval. */ -public class FileTokenProvider implements TokenProvider { +public class FileBearerTokenProvider implements BearerTokenProvider { - private static final Logger logger = LoggerFactory.getLogger(FileTokenProvider.class); + private static final Logger logger = LoggerFactory.getLogger(FileBearerTokenProvider.class); private final Path tokenFilePath; private final Duration refreshInterval; @@ -67,7 +67,7 @@ public class FileTokenProvider implements TokenProvider { * @param tokenFilePath path to the file containing the bearer token * @param refreshInterval how often to check for token file changes */ - public FileTokenProvider(String tokenFilePath, Duration refreshInterval) { + public FileBearerTokenProvider(String tokenFilePath, Duration refreshInterval) { this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); } @@ -79,7 +79,7 @@ public FileTokenProvider(String tokenFilePath, Duration refreshInterval) { * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token */ - public FileTokenProvider( + public FileBearerTokenProvider( String tokenFilePath, Duration refreshInterval, boolean jwtExpirationRefresh, @@ -151,13 +151,11 @@ private void refreshToken() { // Calculate next refresh time based on JWT expiration or fixed interval nextRefresh = calculateNextRefresh(newToken); - if (logger.isDebugEnabled()) { - logger.debug( - "Token refreshed from file: {} (token present: {}), next refresh: {}", - tokenFilePath, - newToken != null && !newToken.isEmpty(), - nextRefresh); - } + logger.debug( + "Token refreshed from file: {} (token present: {}), next refresh: {}", + tokenFilePath, + newToken != null && !newToken.isEmpty(), + nextRefresh); } finally { lock.writeLock().unlock(); diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index be5cf3d8d6..5e80d383db 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -18,10 +18,10 @@ */ package org.apache.polaris.core.auth; -// Removed Quarkus/MicroProfile annotations for portability import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Strings; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.FileInputStream; @@ -58,7 +58,7 @@ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private final String opaServerUrl; private final String opaPolicyPath; - private final TokenProvider tokenProvider; + private final BearerTokenProvider tokenProvider; private final CloseableHttpClient httpClient; private final ObjectMapper objectMapper; @@ -66,7 +66,7 @@ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private OpaPolarisAuthorizer( String opaServerUrl, String opaPolicyPath, - TokenProvider tokenProvider, + BearerTokenProvider tokenProvider, CloseableHttpClient httpClient, ObjectMapper objectMapper) { this.opaServerUrl = opaServerUrl; @@ -77,7 +77,7 @@ private OpaPolarisAuthorizer( } /** - * Static factory that accepts a TokenProvider for advanced token management. + * Static factory that accepts a BearerTokenProvider for advanced token management. * * @param opaServerUrl OPA server URL * @param opaPolicyPath OPA policy path @@ -87,19 +87,24 @@ private OpaPolarisAuthorizer( * @param trustStorePath Custom SSL trust store path (optional) * @param trustStorePassword Custom SSL trust store password (optional) * @param client Apache HttpClient (optional, can be null) - * @param mapper ObjectMapper (optional, can be null) * @return OpaPolarisAuthorizer instance */ public static OpaPolarisAuthorizer create( String opaServerUrl, String opaPolicyPath, - TokenProvider tokenProvider, + BearerTokenProvider tokenProvider, int timeoutMs, boolean verifySsl, String trustStorePath, String trustStorePassword, - Object client, // Accept any client type for compatibility - ObjectMapper mapper) { + Object client) { + + if (Strings.isNullOrEmpty(opaServerUrl)) { + throw new IllegalArgumentException("opaServerUrl cannot be null or empty"); + } + if (Strings.isNullOrEmpty(opaPolicyPath)) { + throw new IllegalArgumentException("opaPolicyPath cannot be null or empty"); + } try { // Create request configuration with timeouts @@ -130,7 +135,7 @@ public static OpaPolarisAuthorizer create( } } - ObjectMapper objectMapperWithDefaults = mapper != null ? mapper : new ObjectMapper(); + ObjectMapper objectMapperWithDefaults = new ObjectMapper(); return new OpaPolarisAuthorizer( opaServerUrl, opaPolicyPath, tokenProvider, httpClient, objectMapperWithDefaults); } catch (Exception e) { @@ -194,7 +199,7 @@ public void authorizeOrThrow( * Sends an authorization query to the OPA server and parses the response. * *

    Builds the OPA input JSON, sends it via HTTP POST, and checks the 'allow' field in the - * response. + * response. The request format follows the OPA REST API specification for data queries. * * @param principal the principal requesting authorization * @param entities the set of activated entities @@ -203,6 +208,7 @@ public void authorizeOrThrow( * @param secondaries the list of secondary entities (if any) * @return true if OPA allows the operation, false otherwise * @throws RuntimeException if the OPA query fails + * @see OPA REST API Documentation */ private boolean queryOpa( PolarisPrincipal principal, @@ -250,6 +256,12 @@ private boolean queryOpa( *

    Assembles the actor, action, resource, and context sections into the expected OPA input * format. * + *

    Note: OpaPolarisAuthorizer bypasses Polaris's built-in role-based + * authorization system. This includes both principal roles and catalog roles that would normally + * be processed by Polaris. Instead, authorization decisions are delegated entirely to the + * configured OPA policies, which receive the raw principal information and must implement their + * own role/permission logic. + * * @param principal the principal requesting authorization * @param entities the set of activated entities * @param op the operation to authorize @@ -388,7 +400,7 @@ private static SSLConnectionSocketFactory createSslSocketFactory( sslContextBuilder.loadTrustMaterial(TrustAllStrategy.INSTANCE); sslContext = sslContextBuilder.build(); return new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); - } else if (trustStorePath != null && !trustStorePath.isEmpty()) { + } else if (!Strings.isNullOrEmpty(trustStorePath)) { // Load custom trust store for SSL verification KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticBearerTokenProvider.java similarity index 86% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java rename to polaris-core/src/main/java/org/apache/polaris/core/auth/StaticBearerTokenProvider.java index 89bafb8844..4f6c44ad14 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticTokenProvider.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticBearerTokenProvider.java @@ -18,19 +18,16 @@ */ package org.apache.polaris.core.auth; -import jakarta.annotation.Nullable; - /** A simple token provider that returns a static string value. */ -public class StaticTokenProvider implements TokenProvider { +public class StaticBearerTokenProvider implements BearerTokenProvider { private final String token; - public StaticTokenProvider(@Nullable String token) { + public StaticBearerTokenProvider(String token) { this.token = token; } @Override - @Nullable public String getToken() { return token; } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/FileBearerTokenProviderTest.java similarity index 90% rename from polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java rename to polaris-core/src/test/java/org/apache/polaris/core/auth/FileBearerTokenProviderTest.java index 028a5fc2d8..af8592c4a4 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileTokenProviderTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/FileBearerTokenProviderTest.java @@ -34,7 +34,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -public class FileTokenProviderTest { +public class FileBearerTokenProviderTest { @TempDir Path tempDir; @@ -46,7 +46,8 @@ public void testLoadTokenFromFile() throws IOException { Files.writeString(tokenFile, expectedToken); // Create file token provider - FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); // Test token retrieval String actualToken = provider.getToken(); @@ -64,7 +65,8 @@ public void testLoadTokenFromFileWithWhitespace() throws IOException { Files.writeString(tokenFile, tokenWithWhitespace); // Create file token provider - FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); // Test token retrieval (should trim whitespace) String actualToken = provider.getToken(); @@ -81,8 +83,8 @@ public void testTokenRefresh() throws IOException, InterruptedException { Files.writeString(tokenFile, initialToken); // Create file token provider with short refresh interval - FileTokenProvider provider = - new FileTokenProvider(tokenFile.toString(), Duration.ofMillis(100)); + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMillis(100)); // Test initial token String token1 = provider.getToken(); @@ -105,8 +107,8 @@ public void testTokenRefresh() throws IOException, InterruptedException { @Test public void testNonExistentFile() { // Create file token provider for non-existent file - FileTokenProvider provider = - new FileTokenProvider("/non/existent/file.txt", Duration.ofMinutes(5)); + FileBearerTokenProvider provider = + new FileBearerTokenProvider("/non/existent/file.txt", Duration.ofMinutes(5)); // Test token retrieval (should return null) String token = provider.getToken(); @@ -122,7 +124,8 @@ public void testEmptyFile() throws IOException { Files.writeString(tokenFile, ""); // Create file token provider - FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); // Test token retrieval (should return null for empty file) String token = provider.getToken(); @@ -138,7 +141,8 @@ public void testClosedProvider() throws IOException { Files.writeString(tokenFile, "test-token"); // Create and close file token provider - FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); provider.close(); // Test token retrieval after closing (should return null) @@ -155,8 +159,8 @@ public void testJwtExpirationRefresh() throws IOException, InterruptedException // Create file token provider with JWT expiration refresh enabled // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds) - FileTokenProvider provider = - new FileTokenProvider( + FileBearerTokenProvider provider = + new FileBearerTokenProvider( tokenFile.toString(), Duration.ofMinutes(10), true, Duration.ofSeconds(3)); // Test initial token @@ -185,8 +189,8 @@ public void testJwtExpirationRefreshDisabled() throws IOException, InterruptedEx Files.writeString(tokenFile, jwtToken); // Create file token provider with JWT expiration refresh disabled - FileTokenProvider provider = - new FileTokenProvider( + FileBearerTokenProvider provider = + new FileBearerTokenProvider( tokenFile.toString(), Duration.ofMillis(100), false, Duration.ofSeconds(1)); // Test initial token @@ -215,8 +219,8 @@ public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException, Interrupt Files.writeString(tokenFile, nonJwtToken); // Create file token provider with JWT expiration refresh enabled - FileTokenProvider provider = - new FileTokenProvider( + FileBearerTokenProvider provider = + new FileBearerTokenProvider( tokenFile.toString(), Duration.ofMillis(100), true, Duration.ofSeconds(1)); // Test initial token @@ -245,8 +249,8 @@ public void testJwtExpirationTooSoon() throws IOException { Files.writeString(tokenFile, expiredJwtToken); // Create file token provider with JWT expiration refresh enabled - FileTokenProvider provider = - new FileTokenProvider( + FileBearerTokenProvider provider = + new FileBearerTokenProvider( tokenFile.toString(), Duration.ofMinutes(5), true, Duration.ofSeconds(60)); // Should fall back to fixed interval when JWT expires too soon @@ -264,8 +268,8 @@ public void testJwtWithoutExpirationClaim() throws IOException { Files.writeString(tokenFile, jwtWithoutExp); // Create file token provider with JWT expiration refresh enabled - FileTokenProvider provider = - new FileTokenProvider( + FileBearerTokenProvider provider = + new FileBearerTokenProvider( tokenFile.toString(), Duration.ofMillis(100), true, Duration.ofSeconds(1)); // Should fall back to fixed interval when JWT has no expiration diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index 30357901a3..90456d200e 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -71,7 +71,7 @@ void testOpaInputJsonFormat() throws Exception { OpaPolarisAuthorizer authorizer = createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); PolarisPrincipal principal = PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); @@ -109,7 +109,7 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { OpaPolarisAuthorizer authorizer = createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); // Set up a realistic principal PolarisPrincipal principal = @@ -247,7 +247,7 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { OpaPolarisAuthorizer authorizer = createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); // Set up a realistic principal PolarisPrincipal principal = @@ -396,7 +396,7 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { OpaPolarisAuthorizer authorizer = createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); @@ -424,7 +424,7 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { OpaPolarisAuthorizer authorizer = createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null, null); + url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); PolarisPrincipal principal = PolarisPrincipal.of("bob", Map.of(), Set.of("user")); @@ -455,7 +455,6 @@ public void testCreateWithBearerTokenAndHttps() { true, null, null, - null, null); assertTrue(authorizer != null); @@ -472,7 +471,6 @@ public void testCreateWithBearerTokenAndHttpsNoSslVerification() { false, null, null, - null, null); assertTrue(authorizer != null); @@ -489,7 +487,6 @@ public void testCreateWithHttpsAndSslVerificationDisabled() { false, null, null, - null, null); assertTrue(authorizer != null); } @@ -519,8 +516,7 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { true, null, null, - mockHttpClient, - new ObjectMapper()); + mockHttpClient); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -559,8 +555,7 @@ public void testAuthorizationFailsWithoutBearerToken() throws IOException { true, null, null, - mockHttpClient, - new ObjectMapper()); + mockHttpClient); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -579,7 +574,7 @@ public void testAuthorizationFailsWithoutBearerToken() throws IOException { } @Test - public void testBearerTokenFromTokenProvider() throws IOException { + public void testBearerTokenFromBearerTokenProvider() throws IOException { // Mock HTTP client and response CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); @@ -596,7 +591,7 @@ public void testBearerTokenFromTokenProvider() throws IOException { "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); // Create token provider that returns a dynamic token - TokenProvider tokenProvider = () -> "dynamic-token-12345"; + BearerTokenProvider tokenProvider = () -> "dynamic-token-12345"; // Create authorizer with the token provider instead of static token OpaPolarisAuthorizer authorizer = @@ -608,8 +603,7 @@ public void testBearerTokenFromTokenProvider() throws IOException { true, null, null, - mockHttpClient, - new ObjectMapper()); + mockHttpClient); // Create mock principal and entities PolarisPrincipal mockPrincipal = @@ -633,7 +627,7 @@ public void testBearerTokenFromTokenProvider() throws IOException { } @Test - public void testNullTokenFromTokenProvider() throws IOException { + public void testNullTokenFromBearerTokenProvider() throws IOException { // Mock HTTP client and response CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); @@ -650,7 +644,7 @@ public void testNullTokenFromTokenProvider() throws IOException { "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); // Create a token provider that returns null - TokenProvider tokenProvider = new StaticTokenProvider(null); + BearerTokenProvider tokenProvider = new StaticBearerTokenProvider(null); OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( @@ -661,8 +655,7 @@ public void testNullTokenFromTokenProvider() throws IOException { true, null, null, - mockHttpClient, - new ObjectMapper()); + mockHttpClient); // Create mock principal and entities PolarisPrincipal mockPrincipal = @@ -686,7 +679,7 @@ public void testNullTokenFromTokenProvider() throws IOException { } private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) { - return new ResolvedPolarisEntity(entity, null, null); + return new ResolvedPolarisEntity(entity, List.of(), List.of()); } /** @@ -788,9 +781,8 @@ private static OpaPolarisAuthorizer createWithStringToken( boolean verifySsl, String trustStorePath, String trustStorePassword, - Object client, - ObjectMapper mapper) { - TokenProvider tokenProvider = new StaticTokenProvider(bearerToken); + Object client) { + BearerTokenProvider tokenProvider = new StaticBearerTokenProvider(bearerToken); return OpaPolarisAuthorizer.create( opaServerUrl, opaPolicyPath, @@ -799,7 +791,6 @@ private static OpaPolarisAuthorizer createWithStringToken( verifySsl, trustStorePath, trustStorePassword, - client, - mapper); + client); } } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticBearerTokenProviderTest.java similarity index 69% rename from polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java rename to polaris-core/src/test/java/org/apache/polaris/core/auth/StaticBearerTokenProviderTest.java index 6e49f874f6..559add872e 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticTokenProviderTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticBearerTokenProviderTest.java @@ -19,32 +19,23 @@ package org.apache.polaris.core.auth; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; import org.junit.jupiter.api.Test; -public class StaticTokenProviderTest { +public class StaticBearerTokenProviderTest { @Test - public void testStaticTokenProvider() { + public void testStaticBearerTokenProvider() { String expectedToken = "static-bearer-token"; - StaticTokenProvider provider = new StaticTokenProvider(expectedToken); + StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken); String actualToken = provider.getToken(); assertEquals(expectedToken, actualToken); } @Test - public void testStaticTokenProviderWithNull() { - StaticTokenProvider provider = new StaticTokenProvider(null); - - String token = provider.getToken(); - assertNull(token); - } - - @Test - public void testStaticTokenProviderWithEmptyString() { - StaticTokenProvider provider = new StaticTokenProvider(""); + public void testStaticBearerTokenProviderWithEmptyString() { + StaticBearerTokenProvider provider = new StaticBearerTokenProvider(""); String token = provider.getToken(); assertEquals("", token); diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java index bf947a2528..1c123647ce 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java @@ -19,79 +19,47 @@ package org.apache.polaris.service.auth; import io.smallrye.common.annotation.Identifier; -import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import java.time.Duration; -import org.apache.polaris.core.auth.FileTokenProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.polaris.core.auth.BearerTokenProvider; import org.apache.polaris.core.auth.OpaPolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; -import org.apache.polaris.core.auth.StaticTokenProvider; -import org.apache.polaris.core.auth.TokenProvider; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.service.config.AuthorizationConfiguration; /** Factory for creating OPA-based Polaris authorizer implementations. */ -@RequestScoped +@ApplicationScoped @Identifier("opa") public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { private final AuthorizationConfiguration authorizationConfig; + private final CloseableHttpClient httpClient; + private final BearerTokenProvider tokenProvider; @Inject - public OpaPolarisAuthorizerFactory(AuthorizationConfiguration authorizationConfig) { + public OpaPolarisAuthorizerFactory( + AuthorizationConfiguration authorizationConfig, + @Identifier("opa-http-client") CloseableHttpClient httpClient, + @Identifier("opa-bearer-token-provider") BearerTokenProvider tokenProvider) { this.authorizationConfig = authorizationConfig; + this.httpClient = httpClient; + this.tokenProvider = tokenProvider; } @Override public PolarisAuthorizer create(RealmConfig realmConfig) { AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); - // Create appropriate token provider based on configuration - TokenProvider tokenProvider = createTokenProvider(opa); - return OpaPolarisAuthorizer.create( opa.url().orElse(null), opa.policyPath().orElse(null), tokenProvider, - opa.timeoutMs().orElse(2000), // Default to 2000ms if not specified - opa.verifySsl(), // Default is true from @WithDefault annotation + opa.timeoutMs(), + opa.verifySsl(), opa.trustStorePath().orElse(null), opa.trustStorePassword().orElse(null), - null, - null); - } - - /** - * Creates a token provider based on the OPA configuration. - * - *

    Prioritizes static token over file-based token: - * - *

      - *
    1. If bearerToken.staticValue is set, uses StaticTokenProvider - *
    2. If bearerToken.filePath is set, uses FileTokenProvider - *
    3. Otherwise, returns StaticTokenProvider with null token - *
    - */ - private TokenProvider createTokenProvider(AuthorizationConfiguration.OpaConfig opa) { - AuthorizationConfiguration.BearerTokenConfig bearerToken = opa.bearerToken(); - - // Static token takes precedence - if (bearerToken.staticValue().isPresent()) { - return new StaticTokenProvider(bearerToken.staticValue().get()); - } - - // File-based token as fallback - if (bearerToken.filePath().isPresent()) { - Duration refreshInterval = Duration.ofSeconds(bearerToken.refreshInterval()); - boolean jwtExpirationRefresh = bearerToken.jwtExpirationRefresh(); - Duration jwtExpirationBuffer = Duration.ofSeconds(bearerToken.jwtExpirationBuffer()); - - return new FileTokenProvider( - bearerToken.filePath().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); - } - - // No token configured - return new StaticTokenProvider(null); + httpClient); } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 20416998ba..54a2456c78 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -18,6 +18,7 @@ */ package org.apache.polaris.service.config; +import com.google.common.base.Strings; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; import java.util.Optional; @@ -34,7 +35,8 @@ interface OpaConfig { Optional policyPath(); - Optional timeoutMs(); + @WithDefault("2000") + int timeoutMs(); BearerTokenConfig bearerToken(); @@ -47,6 +49,10 @@ interface OpaConfig { } interface BearerTokenConfig { + /** Whether bearer token authentication is enabled */ + @WithDefault("false") + boolean enabled(); + /** Static bearer token value (takes precedence over file-based token) */ Optional staticValue(); @@ -71,5 +77,37 @@ interface BearerTokenConfig { */ @WithDefault("60") int jwtExpirationBuffer(); + + default void validate() { + if (!enabled()) { + // Skip validation if bearer token authentication is disabled + return; + } + + // If enabled, ensure at least one token source is configured + if (staticValue().isEmpty() && filePath().isEmpty()) { + throw new IllegalArgumentException( + "Bearer token authentication is enabled but neither staticValue nor filePath is configured"); + } + + // If staticValue is provided, ensure it's not null or empty + if (staticValue().isPresent() && Strings.isNullOrEmpty(staticValue().get())) { + throw new IllegalArgumentException( + "staticValue cannot be null or empty when bearer token authentication is enabled"); + } + + // If filePath is provided, ensure it's not null or empty + if (filePath().isPresent() && Strings.isNullOrEmpty(filePath().get())) { + throw new IllegalArgumentException( + "filePath cannot be null or empty when bearer token authentication is enabled"); + } + + if (refreshInterval() <= 0) { + throw new IllegalArgumentException("refreshInterval must be greater than 0"); + } + if (jwtExpirationBuffer() <= 0) { + throw new IllegalArgumentException("jwtExpirationBuffer must be greater than 0"); + } + } } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index f50c6f5a55..c7e2a0d9c0 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -34,11 +34,16 @@ import jakarta.ws.rs.core.Context; import java.time.Clock; import java.util.stream.Collectors; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; import org.apache.polaris.core.PolarisCallContext; import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; import org.apache.polaris.core.PolarisDiagnostics; +import org.apache.polaris.core.auth.BearerTokenProvider; +import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.auth.StaticBearerTokenProvider; import org.apache.polaris.core.config.PolarisConfigurationStore; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.core.context.CallContext; @@ -245,6 +250,59 @@ public StsClientsPool stsClientsPool( return new StsClientsPool(config.effectiveClientsCacheMaxSize(), httpClient, meterRegistry); } + @Produces + @Singleton + @Identifier("opa-http-client") + public CloseableHttpClient opaHttpClient() { + return HttpClients.custom().build(); + } + + public void closeOpaHttpClient( + @Disposes @Identifier("opa-http-client") CloseableHttpClient client) { + try { + client.close(); + } catch (Exception e) { + LOGGER.warn("Error closing OPA HTTP client", e); + } + } + + @Produces + @Singleton + @Identifier("opa-bearer-token-provider") + public BearerTokenProvider opaBearerTokenProvider( + AuthorizationConfiguration authorizationConfig) { + AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); + AuthorizationConfiguration.BearerTokenConfig bearerToken = opa.bearerToken(); + + // Validate configuration before using it + bearerToken.validate(); + + // Check if bearer token authentication is enabled + if (!bearerToken.enabled()) { + return new StaticBearerTokenProvider(""); + } + + // Static token takes precedence + if (bearerToken.staticValue().isPresent()) { + return new StaticBearerTokenProvider(bearerToken.staticValue().get()); + } + + // File-based token as fallback + if (bearerToken.filePath().isPresent()) { + java.time.Duration refreshInterval = + java.time.Duration.ofSeconds(bearerToken.refreshInterval()); + boolean jwtExpirationRefresh = bearerToken.jwtExpirationRefresh(); + java.time.Duration jwtExpirationBuffer = + java.time.Duration.ofSeconds(bearerToken.jwtExpirationBuffer()); + + return new FileBearerTokenProvider( + bearerToken.filePath().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); + } + + // No token configured (this shouldn't happen due to validation, but it's here as fallback) + return new StaticBearerTokenProvider(""); + } + /** * Eagerly initialize the in-memory default realm on startup, so that users can check the * credentials printed to stdout immediately. diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java index 2de89a27d1..33bed96054 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java @@ -109,7 +109,7 @@ public static Path getTokenFile() { /** * Test demonstrates OPA integration with file-based bearer token authentication. This test - * verifies that the FileTokenProvider correctly reads tokens from a file and that the full + * verifies that the FileBearerTokenProvider correctly reads tokens from a file and that the full * integration works with file-based configuration. */ @Test @@ -117,7 +117,7 @@ void testOpaAllowsRootUserWithFileToken() { // Test demonstrates the complete integration flow with file-based tokens: // 1. OAuth token acquisition with internal authentication // 2. OPA policy allowing root users - // 3. Bearer token read from file by FileTokenProvider + // 3. Bearer token read from file by FileBearerTokenProvider // Get a token using the catalog service OAuth endpoint String response = @@ -154,7 +154,7 @@ void testOpaAllowsRootUserWithFileToken() { @Test void testFileTokenRefresh() throws IOException, InterruptedException { - // This test verifies that the FileTokenProvider refreshes tokens from the file + // This test verifies that the FileBearerTokenProvider refreshes tokens from the file // First verify the system works with the initial token String rootToken = getRootToken(); diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java index 5a5fe34562..e02c619cb1 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -28,7 +29,8 @@ import java.nio.file.Path; import java.time.Duration; import java.util.Optional; -import org.apache.polaris.core.auth.FileTokenProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.auth.OpaPolarisAuthorizer; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.service.config.AuthorizationConfiguration; @@ -55,7 +57,7 @@ public void testFactoryCreatesStaticTokenProvider() { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.timeoutMs()).thenReturn(2000); when(opaConfig.verifySsl()).thenReturn(true); when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); @@ -63,7 +65,8 @@ public void testFactoryCreatesStaticTokenProvider() { AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -73,7 +76,7 @@ public void testFactoryCreatesStaticTokenProvider() { } @Test - public void testFactoryCreatesFileTokenProvider() throws IOException { + public void testFactoryCreatesFileBearerTokenProvider() throws IOException { // Create a temporary token file Path tokenFile = tempDir.resolve("bearer-token.txt"); String tokenValue = "file-based-token-value"; @@ -93,7 +96,7 @@ public void testFactoryCreatesFileTokenProvider() throws IOException { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.timeoutMs()).thenReturn(2000); when(opaConfig.verifySsl()).thenReturn(true); when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); @@ -101,7 +104,8 @@ public void testFactoryCreatesFileTokenProvider() throws IOException { AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -111,14 +115,15 @@ public void testFactoryCreatesFileTokenProvider() throws IOException { } @Test - public void testFileTokenProviderActuallyReadsFromFile() throws IOException { + public void testFileBearerTokenProviderActuallyReadsFromFile() throws IOException { // Create a temporary token file Path tokenFile = tempDir.resolve("bearer-token.txt"); String tokenValue = "file-based-token-from-disk"; Files.writeString(tokenFile, tokenValue); - // Create FileTokenProvider directly to test it reads the file - FileTokenProvider provider = new FileTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + // Create FileBearerTokenProvider directly to test it reads the file + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); // Verify the token is read from the file String actualToken = provider.getToken(); @@ -149,7 +154,7 @@ public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.timeoutMs()).thenReturn(2000); when(opaConfig.verifySsl()).thenReturn(true); when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); @@ -157,7 +162,8 @@ public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -184,7 +190,7 @@ public void testFactoryWithNoTokenConfiguration() { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(Optional.of(2000)); + when(opaConfig.timeoutMs()).thenReturn(2000); when(opaConfig.verifySsl()).thenReturn(true); when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); @@ -192,7 +198,8 @@ public void testFactoryWithNoTokenConfiguration() { AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(authConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -200,4 +207,64 @@ public void testFactoryWithNoTokenConfiguration() { assertNotNull(authorizer); } + + @Test + public void testFactoryValidatesConfiguration() { + // Create a real implementation of BearerTokenConfig that will have invalid values + AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = + new AuthorizationConfiguration.BearerTokenConfig() { + @Override + public Optional staticValue() { + return Optional.empty(); + } + + @Override + public Optional filePath() { + return Optional.empty(); + } + + @Override + public int refreshInterval() { + return -1; + } // Invalid: negative value + + @Override + public boolean jwtExpirationRefresh() { + return true; + } + + @Override + public int jwtExpirationBuffer() { + return 60; + } + }; + + AuthorizationConfiguration.OpaConfig opaConfig = + mock(AuthorizationConfiguration.OpaConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); + when(opaConfig.timeoutMs()).thenReturn(2000); + when(opaConfig.verifySsl()).thenReturn(true); + when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); + when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + + AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); + when(authConfig.opa()).thenReturn(opaConfig); + + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(authConfig, mockHttpClient); + + // Should throw IllegalArgumentException due to invalid refresh interval + RealmConfig realmConfig = mock(RealmConfig.class); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> factory.create(realmConfig), + "Expected IllegalArgumentException for invalid refresh interval"); + + assertEquals("refreshInterval must be greater than 0", exception.getMessage()); + } } From 479ac60f5150411470d5c9e5abae9ee49ee7e52d Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 9 Oct 2025 03:02:09 +0000 Subject: [PATCH 19/40] Notes about Beta --- .../org/apache/polaris/core/auth/OpaPolarisAuthorizer.java | 4 ++++ runtime/defaults/src/main/resources/application.properties | 4 ++++ .../polaris/service/config/AuthorizationConfiguration.java | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index 5e80d383db..cecadf85d4 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -54,6 +54,10 @@ *

    This authorizer delegates authorization decisions to an Open Policy Agent (OPA) server using a * configurable REST API endpoint and policy path. The input to OPA is constructed from the * principal, entities, operation, and resource context. + * + *

    Beta Feature: This implementation is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. */ public class OpaPolarisAuthorizer implements PolarisAuthorizer { private final String opaServerUrl; diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 1b60bb9ff1..17383d0a31 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -202,11 +202,15 @@ polaris.oidc.principal-roles-mapper.type=default polaris.authorization.type=default # OPA Authorizer Configuration: effective only if polaris.authorization.type=opa +# NOTE: The OPA Authorizer is currently in Beta and is not a stable release. +# It may undergo breaking changes in future versions. # polaris.authorization.opa.url=http://localhost:8181 # polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow # polaris.authorization.opa.timeout-ms=2000 # Bearer token authentication (choose one of the following approaches): +# polaris.authorization.opa.bearer-token.enabled=false + # Option 1: Static bearer token value (takes precedence if both are set) # polaris.authorization.opa.bearer-token.static-value=your-bearer-token-here diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 54a2456c78..76da33eb9a 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -30,6 +30,13 @@ public interface AuthorizationConfiguration { OpaConfig opa(); + /** + * Configuration for OPA (Open Policy Agent) authorization. + * + *

    Beta Feature: OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ interface OpaConfig { Optional url(); From f46f97b4809017d466fc3b8aea90aa6b5e0b8546 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:15:40 +0000 Subject: [PATCH 20/40] adopt more feedback --- polaris-core/build.gradle.kts | 1 + .../core/auth/FileBearerTokenProvider.java | 23 ++++++- .../apache/polaris/core/auth/JwtDecoder.java | 63 ++++++++----------- .../polaris/core/auth/JwtDecoderTest.java | 18 +++--- .../auth/DefaultPolarisAuthorizerFactory.java | 4 +- .../auth/OpaPolarisAuthorizerFactoryTest.java | 40 ++++++++---- 6 files changed, 86 insertions(+), 63 deletions(-) diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index d1777d5b09..46405de803 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(libs.caffeine) implementation(libs.guava) implementation(libs.slf4j.api) + implementation(libs.auth0.jwt) compileOnly(project(":polaris-immutables")) annotationProcessor(project(":polaris-immutables", configuration = "processor")) diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java index 7433b67111..529fa6b47e 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java @@ -18,6 +18,9 @@ */ package org.apache.polaris.core.auth; +import com.auth0.jwt.JWT; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.DecodedJWT; import jakarta.annotation.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -26,6 +29,7 @@ import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; +import java.util.Date; import java.util.Optional; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -170,7 +174,7 @@ private Instant calculateNextRefresh(@Nullable String token) { } // Attempt to parse as JWT and extract expiration - Optional expiration = JwtDecoder.getExpirationTime(token); + Optional expiration = getJwtExpirationTime(token); if (expiration.isPresent()) { // Refresh before expiration minus buffer @@ -224,4 +228,21 @@ private String loadTokenFromFile() { return null; } } + + /** + * Extract the expiration time from a JWT token without signature verification. + * + * @param token the JWT token string + * @return the expiration time as an Instant, or empty if not present or invalid + */ + private Optional getJwtExpirationTime(String token) { + try { + DecodedJWT decodedJWT = JWT.decode(token); + Date expiresAt = decodedJWT.getExpiresAt(); + return expiresAt != null ? Optional.of(expiresAt.toInstant()) : Optional.empty(); + } catch (JWTDecodeException e) { + logger.debug("Failed to decode JWT token: {}", e.getMessage()); + return Optional.empty(); + } + } } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java index f1ee4d012d..88bedeffd2 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java @@ -18,11 +18,11 @@ */ package org.apache.polaris.core.auth; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.nio.charset.StandardCharsets; +import com.auth0.jwt.JWT; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.DecodedJWT; import java.time.Instant; -import java.util.Base64; +import java.util.Date; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,10 +30,12 @@ /** * Simple JWT decoder that extracts claims without signature verification. This is used solely for * reading the expiration time from JWT tokens to determine refresh timing. + * + *

    Uses the java-jwt library for reliable JWT parsing while maintaining the same functionality + * as the previous manual implementation. */ public class JwtDecoder { private static final Logger LOG = LoggerFactory.getLogger(JwtDecoder.class); - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** * Decode a JWT token and extract the expiration time if present. @@ -42,53 +44,40 @@ public class JwtDecoder { * @return the expiration time as an Instant, or empty if not present or invalid */ public static Optional getExpirationTime(String token) { - return decodePayload(token).flatMap(JwtDecoder::getExpirationTime); + try { + DecodedJWT decodedJWT = JWT.decode(token); + Date expiresAt = decodedJWT.getExpiresAt(); + return expiresAt != null ? Optional.of(expiresAt.toInstant()) : Optional.empty(); + } catch (JWTDecodeException e) { + LOG.debug("Failed to decode JWT token: {}", e.getMessage()); + return Optional.empty(); + } } /** * Decode the payload of a JWT token without signature verification. * * @param token the JWT token string - * @return the decoded payload as a JsonNode, or empty if invalid + * @return the decoded JWT, or empty if invalid */ - public static Optional decodePayload(String token) { + public static Optional decodePayload(String token) { try { - String[] parts = token.split("\\."); - if (parts.length != 3) { - LOG.debug("Invalid JWT format: expected 3 parts separated by dots"); - return Optional.empty(); - } - - // Decode the payload (second part) - String payload = parts[1]; - byte[] decodedBytes = Base64.getUrlDecoder().decode(payload); - String payloadJson = new String(decodedBytes, StandardCharsets.UTF_8); - - // Parse JSON - JsonNode payloadNode = OBJECT_MAPPER.readTree(payloadJson); - return Optional.of(payloadNode); - - } catch (Exception e) { + DecodedJWT decodedJWT = JWT.decode(token); + return Optional.of(decodedJWT); + } catch (JWTDecodeException e) { LOG.debug("Failed to decode JWT token: {}", e.getMessage()); return Optional.empty(); } } /** - * Extract the expiration time from a decoded JWT payload. + * Extract the expiration time from a decoded JWT. * - * @param payloadNode the decoded JWT payload - * @return the expiration time as an Instant, or empty if not present or invalid + * @param decodedJWT the decoded JWT + * @return the expiration time as an Instant, or empty if not present */ - public static Optional getExpirationTime(JsonNode payloadNode) { - JsonNode expNode = payloadNode.get("exp"); - - if (expNode == null || !expNode.isNumber()) { - LOG.debug("JWT does not contain a valid 'exp' claim"); - return Optional.empty(); - } - - long expSeconds = expNode.asLong(); - return Optional.of(Instant.ofEpochSecond(expSeconds)); + public static Optional getExpirationTime(DecodedJWT decodedJWT) { + Date expiresAt = decodedJWT.getExpiresAt(); + return expiresAt != null ? Optional.of(expiresAt.toInstant()) : Optional.empty(); } } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java index 513fa74338..7efec408e1 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java @@ -21,7 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.databind.JsonNode; +import com.auth0.jwt.interfaces.DecodedJWT; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -195,19 +195,17 @@ public void testDecodePayload() throws Exception { Instant expiration = Instant.now().plusSeconds(3600); String jwt = createJwtWithExpiration(expiration); - Optional result = JwtDecoder.decodePayload(jwt); + Optional result = JwtDecoder.decodePayload(jwt); assertTrue(result.isPresent()); - JsonNode payload = result.get(); - assertTrue(payload.has("exp")); - assertTrue(payload.has("iss")); - assertEquals("test", payload.get("iss").asText()); - assertEquals(expiration.getEpochSecond(), payload.get("exp").asLong()); + DecodedJWT decodedJWT = result.get(); + assertEquals(expiration.toEpochMilli() / 1000, decodedJWT.getExpiresAt().getTime() / 1000); + assertEquals("test", decodedJWT.getIssuer()); } @Test public void testDecodePayloadInvalidToken() { - Optional result = JwtDecoder.decodePayload("not-a-jwt"); + Optional result = JwtDecoder.decodePayload("not-a-jwt"); assertTrue(result.isEmpty()); } @@ -216,7 +214,7 @@ public void testGetExpirationTimeFromPayload() throws Exception { Instant expiration = Instant.now().plusSeconds(7200); String jwt = createJwtWithExpiration(expiration); - Optional payload = JwtDecoder.decodePayload(jwt); + Optional payload = JwtDecoder.decodePayload(jwt); assertTrue(payload.isPresent()); Optional result = JwtDecoder.getExpirationTime(payload.get()); @@ -229,7 +227,7 @@ public void testGetExpirationTimeFromPayload() throws Exception { public void testGetExpirationTimeFromPayloadWithoutExp() throws Exception { String jwt = createJwtWithoutExpiration(); - Optional payload = JwtDecoder.decodePayload(jwt); + Optional payload = JwtDecoder.decodePayload(jwt); assertTrue(payload.isPresent()); Optional result = JwtDecoder.getExpirationTime(payload.get()); diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java index 0019ba91b9..e99263bb1c 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java @@ -19,14 +19,14 @@ package org.apache.polaris.service.auth; import io.smallrye.common.annotation.Identifier; -import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.context.ApplicationScoped; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; import org.apache.polaris.core.auth.PolarisAuthorizerImpl; import org.apache.polaris.core.config.RealmConfig; /** Factory for creating the default Polaris authorizer implementation. */ -@RequestScoped +@ApplicationScoped @Identifier("default") public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java index e02c619cb1..445614f5fd 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java @@ -20,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -30,6 +29,7 @@ import java.time.Duration; import java.util.Optional; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.polaris.core.auth.BearerTokenProvider; import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.auth.OpaPolarisAuthorizer; import org.apache.polaris.core.config.RealmConfig; @@ -66,7 +66,8 @@ public void testFactoryCreatesStaticTokenProvider() { when(authConfig.opa()).thenReturn(opaConfig); OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); + new OpaPolarisAuthorizerFactory( + authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -105,7 +106,8 @@ public void testFactoryCreatesFileBearerTokenProvider() throws IOException { when(authConfig.opa()).thenReturn(opaConfig); OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); + new OpaPolarisAuthorizerFactory( + authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -163,7 +165,8 @@ public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { when(authConfig.opa()).thenReturn(opaConfig); OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); + new OpaPolarisAuthorizerFactory( + authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -199,7 +202,8 @@ public void testFactoryWithNoTokenConfiguration() { when(authConfig.opa()).thenReturn(opaConfig); OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(authConfig, mock(CloseableHttpClient.class)); + new OpaPolarisAuthorizerFactory( + authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -210,9 +214,19 @@ public void testFactoryWithNoTokenConfiguration() { @Test public void testFactoryValidatesConfiguration() { + // Create a mock BearerTokenProvider that throws an exception + BearerTokenProvider mockTokenProvider = mock(BearerTokenProvider.class); + when(mockTokenProvider.getToken()) + .thenThrow(new IllegalArgumentException("refreshInterval must be greater than 0")); + // Create a real implementation of BearerTokenConfig that will have invalid values AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = new AuthorizationConfiguration.BearerTokenConfig() { + @Override + public boolean enabled() { + return true; + } + @Override public Optional staticValue() { return Optional.empty(); @@ -254,17 +268,17 @@ public int jwtExpirationBuffer() { CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(authConfig, mockHttpClient); + new OpaPolarisAuthorizerFactory(authConfig, mockHttpClient, mockTokenProvider); - // Should throw IllegalArgumentException due to invalid refresh interval + // Create authorizer instance - this should succeed since validation happens at token provider + // level RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> factory.create(realmConfig), - "Expected IllegalArgumentException for invalid refresh interval"); + // The authorizer should be created successfully + assertNotNull(authorizer); - assertEquals("refreshInterval must be greater than 0", exception.getMessage()); + // Note: Validation of bearer token configuration now happens when the BearerTokenProvider + // is created by the CDI system, not when the factory creates the authorizer. } } From 81de61e7a93371304e02aec7803082c30b1f61f4 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:23:10 +0000 Subject: [PATCH 21/40] remove JwtDecoder in favor of auth0 java-jwt --- .../apache/polaris/core/auth/JwtDecoder.java | 83 ------ .../polaris/core/auth/JwtDecoderTest.java | 236 ------------------ 2 files changed, 319 deletions(-) delete mode 100644 polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java delete mode 100644 polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java deleted file mode 100644 index 88bedeffd2..0000000000 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/JwtDecoder.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.core.auth; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.exceptions.JWTDecodeException; -import com.auth0.jwt.interfaces.DecodedJWT; -import java.time.Instant; -import java.util.Date; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Simple JWT decoder that extracts claims without signature verification. This is used solely for - * reading the expiration time from JWT tokens to determine refresh timing. - * - *

    Uses the java-jwt library for reliable JWT parsing while maintaining the same functionality - * as the previous manual implementation. - */ -public class JwtDecoder { - private static final Logger LOG = LoggerFactory.getLogger(JwtDecoder.class); - - /** - * Decode a JWT token and extract the expiration time if present. - * - * @param token the JWT token string - * @return the expiration time as an Instant, or empty if not present or invalid - */ - public static Optional getExpirationTime(String token) { - try { - DecodedJWT decodedJWT = JWT.decode(token); - Date expiresAt = decodedJWT.getExpiresAt(); - return expiresAt != null ? Optional.of(expiresAt.toInstant()) : Optional.empty(); - } catch (JWTDecodeException e) { - LOG.debug("Failed to decode JWT token: {}", e.getMessage()); - return Optional.empty(); - } - } - - /** - * Decode the payload of a JWT token without signature verification. - * - * @param token the JWT token string - * @return the decoded JWT, or empty if invalid - */ - public static Optional decodePayload(String token) { - try { - DecodedJWT decodedJWT = JWT.decode(token); - return Optional.of(decodedJWT); - } catch (JWTDecodeException e) { - LOG.debug("Failed to decode JWT token: {}", e.getMessage()); - return Optional.empty(); - } - } - - /** - * Extract the expiration time from a decoded JWT. - * - * @param decodedJWT the decoded JWT - * @return the expiration time as an Instant, or empty if not present - */ - public static Optional getExpirationTime(DecodedJWT decodedJWT) { - Date expiresAt = decodedJWT.getExpiresAt(); - return expiresAt != null ? Optional.of(expiresAt.toInstant()) : Optional.empty(); - } -} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java deleted file mode 100644 index 7efec408e1..0000000000 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/JwtDecoderTest.java +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.core.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.auth0.jwt.interfaces.DecodedJWT; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.Test; - -public class JwtDecoderTest { - - @Test - public void testValidJwtWithExpiration() throws Exception { - Instant expiration = Instant.now().plusSeconds(3600); - String jwt = createJwtWithExpiration(expiration); - - Optional result = JwtDecoder.getExpirationTime(jwt); - - assertTrue(result.isPresent()); - assertEquals(expiration.getEpochSecond(), result.get().getEpochSecond()); - } - - @Test - public void testJwtWithoutExpiration() throws Exception { - String jwt = createJwtWithoutExpiration(); - - Optional result = JwtDecoder.getExpirationTime(jwt); - - assertTrue(result.isEmpty()); - } - - @Test - public void testInvalidJwtFormat() { - Optional result = JwtDecoder.getExpirationTime("not-a-jwt"); - assertTrue(result.isEmpty()); - } - - @Test - public void testJwtWithTwoParts() { - Optional result = JwtDecoder.getExpirationTime("header.payload"); - assertTrue(result.isEmpty()); - } - - @Test - public void testJwtWithFourParts() { - Optional result = JwtDecoder.getExpirationTime("header.payload.signature.extra"); - assertTrue(result.isEmpty()); - } - - @Test - public void testJwtWithInvalidBase64() { - Optional result = JwtDecoder.getExpirationTime("invalid!.base64@.content#"); - assertTrue(result.isEmpty()); - } - - @Test - public void testJwtWithInvalidJson() { - String invalidPayload = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString("{invalid json}".getBytes(StandardCharsets.UTF_8)); - String jwt = "header." + invalidPayload + ".signature"; - - Optional result = JwtDecoder.getExpirationTime(jwt); - assertTrue(result.isEmpty()); - } - - @Test - public void testJwtWithNonNumericExpiration() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - - // Create header - Map header = new HashMap<>(); - header.put("alg", "HS256"); - header.put("typ", "JWT"); - String headerJson = mapper.writeValueAsString(header); - String encodedHeader = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); - - // Create payload with string expiration - Map payload = new HashMap<>(); - payload.put("iss", "test"); - payload.put("exp", "not-a-number"); - String payloadJson = mapper.writeValueAsString(payload); - String encodedPayload = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); - - String signature = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); - - String jwt = encodedHeader + "." + encodedPayload + "." + signature; - - Optional result = JwtDecoder.getExpirationTime(jwt); - assertTrue(result.isEmpty()); - } - - /** Helper method to create a JWT with a specific expiration time. */ - private String createJwtWithExpiration(Instant expiration) throws Exception { - ObjectMapper mapper = new ObjectMapper(); - - // Create header - Map header = new HashMap<>(); - header.put("alg", "HS256"); - header.put("typ", "JWT"); - String headerJson = mapper.writeValueAsString(header); - String encodedHeader = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); - - // Create payload with expiration - Map payload = new HashMap<>(); - payload.put("iss", "test"); - payload.put("exp", expiration.getEpochSecond()); - String payloadJson = mapper.writeValueAsString(payload); - String encodedPayload = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); - - // Create fake signature - String signature = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); - - return encodedHeader + "." + encodedPayload + "." + signature; - } - - /** Helper method to create a JWT without an expiration claim. */ - private String createJwtWithoutExpiration() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - - // Create header - Map header = new HashMap<>(); - header.put("alg", "HS256"); - header.put("typ", "JWT"); - String headerJson = mapper.writeValueAsString(header); - String encodedHeader = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); - - // Create payload without expiration - Map payload = new HashMap<>(); - payload.put("iss", "test"); - payload.put("custom", "value"); - String payloadJson = mapper.writeValueAsString(payload); - String encodedPayload = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); - - // Create fake signature - String signature = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8)); - - return encodedHeader + "." + encodedPayload + "." + signature; - } - - @Test - public void testDecodePayload() throws Exception { - Instant expiration = Instant.now().plusSeconds(3600); - String jwt = createJwtWithExpiration(expiration); - - Optional result = JwtDecoder.decodePayload(jwt); - - assertTrue(result.isPresent()); - DecodedJWT decodedJWT = result.get(); - assertEquals(expiration.toEpochMilli() / 1000, decodedJWT.getExpiresAt().getTime() / 1000); - assertEquals("test", decodedJWT.getIssuer()); - } - - @Test - public void testDecodePayloadInvalidToken() { - Optional result = JwtDecoder.decodePayload("not-a-jwt"); - assertTrue(result.isEmpty()); - } - - @Test - public void testGetExpirationTimeFromPayload() throws Exception { - Instant expiration = Instant.now().plusSeconds(7200); - String jwt = createJwtWithExpiration(expiration); - - Optional payload = JwtDecoder.decodePayload(jwt); - assertTrue(payload.isPresent()); - - Optional result = JwtDecoder.getExpirationTime(payload.get()); - - assertTrue(result.isPresent()); - assertEquals(expiration.getEpochSecond(), result.get().getEpochSecond()); - } - - @Test - public void testGetExpirationTimeFromPayloadWithoutExp() throws Exception { - String jwt = createJwtWithoutExpiration(); - - Optional payload = JwtDecoder.decodePayload(jwt); - assertTrue(payload.isPresent()); - - Optional result = JwtDecoder.getExpirationTime(payload.get()); - assertTrue(result.isEmpty()); - } -} From d014a97eb777201cf2c54bc9b74f1918f9d888d7 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:15:45 +0000 Subject: [PATCH 22/40] use httpclient 5 --- gradle/libs.versions.toml | 4 +- polaris-core/build.gradle.kts | 2 +- .../core/auth/OpaPolarisAuthorizer.java | 51 +++++++++++-------- .../core/auth/OpaPolarisAuthorizerTest.java | 25 +++------ runtime/service/build.gradle.kts | 1 + .../auth/OpaPolarisAuthorizerFactory.java | 2 +- .../service/config/ServiceProducers.java | 4 +- .../auth/OpaPolarisAuthorizerFactoryTest.java | 2 +- 8 files changed, 47 insertions(+), 44 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0db3cd9b47..5acf440fe3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ # [versions] -apache-httpclient = "4.5.14" +apache-httpclient5 = "5.5" checkstyle = "10.25.0" hadoop = "3.4.2" hive = "3.1.3" @@ -42,7 +42,7 @@ swagger = "1.6.16" # quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-services-bom", version.ref="quarkus" } antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests -apache-httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "apache-httpclient" } +apache-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version.ref = "apache-httpclient5" } assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" } auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" } awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.35.0" } diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index 46405de803..29790494e5 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -24,7 +24,7 @@ plugins { dependencies { implementation(project(":polaris-api-management-model")) - implementation(libs.apache.httpclient) + implementation(libs.apache.httpclient5) implementation(platform(libs.iceberg.bom)) implementation("org.apache.iceberg:iceberg-api") diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index cecadf85d4..7b326920fb 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -26,24 +26,27 @@ import jakarta.annotation.Nullable; import java.io.FileInputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.util.List; import java.util.Locale; import java.util.Set; import javax.net.ssl.SSLContext; -import org.apache.http.HttpHeaders; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.conn.ssl.NoopHostnameVerifier; -import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.conn.ssl.TrustAllStrategy; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.ssl.SSLContextBuilder; -import org.apache.http.util.EntityUtils; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.TrustAllStrategy; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.apache.hc.core5.util.Timeout; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; @@ -114,9 +117,9 @@ public static OpaPolarisAuthorizer create( // Create request configuration with timeouts RequestConfig requestConfig = RequestConfig.custom() - .setConnectTimeout(timeoutMs) - .setSocketTimeout(timeoutMs) - .setConnectionRequestTimeout(timeoutMs) + .setConnectTimeout(Timeout.ofMilliseconds(timeoutMs)) + .setResponseTimeout(Timeout.ofMilliseconds(timeoutMs)) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(timeoutMs)) .build(); // Configure SSL for HTTPS connections @@ -132,7 +135,10 @@ public static OpaPolarisAuthorizer create( httpClient = HttpClients.custom() .setDefaultRequestConfig(requestConfig) - .setSSLSocketFactory(sslSocketFactory) + .setConnectionManager( + PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactory) + .build()) .build(); } else { httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); @@ -235,17 +241,22 @@ private boolean queryOpa( } } - httpPost.setEntity(new StringEntity(inputJson, StandardCharsets.UTF_8)); + httpPost.setEntity(new StringEntity(inputJson, ContentType.APPLICATION_JSON)); // Execute request try (CloseableHttpResponse response = httpClient.execute(httpPost)) { - int statusCode = response.getStatusLine().getStatusCode(); + int statusCode = response.getCode(); if (statusCode != 200) { return false; } // Read and parse response - String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + String responseBody; + try { + responseBody = EntityUtils.toString(response.getEntity()); + } catch (ParseException e) { + throw new RuntimeException("Failed to parse OPA response", e); + } ObjectNode respNode = (ObjectNode) objectMapper.readTree(responseBody); return respNode.path("result").path("allow").asBoolean(false); } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index 90456d200e..e2e6264afc 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -41,11 +41,10 @@ import java.util.List; import java.util.Map; import java.util.Set; -import org.apache.http.HttpEntity; -import org.apache.http.StatusLine; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.core5.http.HttpEntity; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.entity.PolarisEntity; @@ -495,12 +494,10 @@ public void testCreateWithHttpsAndSslVerificationDisabled() { public void testBearerTokenIsAddedToHttpRequest() throws IOException { CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - StatusLine mockStatusLine = mock(StatusLine.class); HttpEntity mockEntity = mock(HttpEntity.class); when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); - when(mockStatusLine.getStatusCode()).thenReturn(200); + when(mockResponse.getCode()).thenReturn(200); when(mockResponse.getEntity()).thenReturn(mockEntity); when(mockEntity.getContent()) .thenReturn( @@ -540,11 +537,9 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { public void testAuthorizationFailsWithoutBearerToken() throws IOException { CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - StatusLine mockStatusLine = mock(StatusLine.class); when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); - when(mockStatusLine.getStatusCode()).thenReturn(401); + when(mockResponse.getCode()).thenReturn(401); OpaPolarisAuthorizer authorizer = createWithStringToken( @@ -578,12 +573,10 @@ public void testBearerTokenFromBearerTokenProvider() throws IOException { // Mock HTTP client and response CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - StatusLine mockStatusLine = mock(StatusLine.class); HttpEntity mockEntity = mock(HttpEntity.class); when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); - when(mockStatusLine.getStatusCode()).thenReturn(200); + when(mockResponse.getCode()).thenReturn(200); when(mockResponse.getEntity()).thenReturn(mockEntity); when(mockEntity.getContent()) .thenReturn( @@ -631,12 +624,10 @@ public void testNullTokenFromBearerTokenProvider() throws IOException { // Mock HTTP client and response CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - StatusLine mockStatusLine = mock(StatusLine.class); HttpEntity mockEntity = mock(HttpEntity.class); when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getStatusLine()).thenReturn(mockStatusLine); - when(mockStatusLine.getStatusCode()).thenReturn(200); + when(mockResponse.getCode()).thenReturn(200); when(mockResponse.getEntity()).thenReturn(mockEntity); when(mockEntity.getContent()) .thenReturn( diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index 97a559f77a..f51aefb0d6 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation(libs.auth0.jwt) + implementation(libs.apache.httpclient5) implementation(libs.smallrye.common.annotation) implementation(libs.swagger.jaxrs) implementation(libs.microprofile.fault.tolerance.api) diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java index 1c123647ce..f6c04b7556 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java @@ -21,7 +21,7 @@ import io.smallrye.common.annotation.Identifier; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.polaris.core.auth.BearerTokenProvider; import org.apache.polaris.core.auth.OpaPolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizer; diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index cde46d5129..50c784ceb0 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -34,8 +34,8 @@ import jakarta.ws.rs.core.Context; import java.time.Clock; import java.util.stream.Collectors; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.polaris.core.PolarisCallContext; import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; import org.apache.polaris.core.PolarisDiagnostics; diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java index 445614f5fd..064baf6236 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java @@ -28,7 +28,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.Optional; -import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.polaris.core.auth.BearerTokenProvider; import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.auth.OpaPolarisAuthorizer; From 944d005caacd864e271cfa853d6dfd776a801576 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:09:28 +0000 Subject: [PATCH 23/40] opa http client factory refactoring --- .../core/auth/OpaPolarisAuthorizer.java | 114 ++--------------- .../core/auth/OpaPolarisAuthorizerTest.java | 84 ++---------- .../src/main/resources/application.properties | 12 +- .../auth/OpaPolarisAuthorizerFactory.java | 4 - .../config/AuthorizationConfiguration.java | 21 ++- .../service/config/OpaHttpClientFactory.java | 121 ++++++++++++++++++ .../service/config/ServiceProducers.java | 18 ++- .../auth/OpaPolarisAuthorizerFactoryTest.java | 35 +++-- .../config/OpaHttpClientFactoryTest.java | 73 +++++++++++ 9 files changed, 262 insertions(+), 220 deletions(-) create mode 100644 runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java create mode 100644 runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java index 7b326920fb..13cfa285f8 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java @@ -21,32 +21,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Preconditions; import com.google.common.base.Strings; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import java.io.FileInputStream; import java.io.IOException; -import java.security.KeyStore; import java.util.List; -import java.util.Locale; import java.util.Set; -import javax.net.ssl.SSLContext; import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; -import org.apache.hc.core5.ssl.SSLContextBuilder; -import org.apache.hc.core5.util.Timeout; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; @@ -89,67 +78,26 @@ private OpaPolarisAuthorizer( * @param opaServerUrl OPA server URL * @param opaPolicyPath OPA policy path * @param tokenProvider Token provider for authentication (optional) - * @param timeoutMs HTTP call timeout in milliseconds - * @param verifySsl Whether to verify SSL certificates for HTTPS connections - * @param trustStorePath Custom SSL trust store path (optional) - * @param trustStorePassword Custom SSL trust store password (optional) - * @param client Apache HttpClient (optional, can be null) + * @param client Apache HttpClient (required, injected by CDI). SSL configuration should be handled by the CDI producer. * @return OpaPolarisAuthorizer instance */ public static OpaPolarisAuthorizer create( String opaServerUrl, String opaPolicyPath, BearerTokenProvider tokenProvider, - int timeoutMs, - boolean verifySsl, - String trustStorePath, - String trustStorePassword, - Object client) { + @Nonnull CloseableHttpClient client) { - if (Strings.isNullOrEmpty(opaServerUrl)) { - throw new IllegalArgumentException("opaServerUrl cannot be null or empty"); - } - if (Strings.isNullOrEmpty(opaPolicyPath)) { - throw new IllegalArgumentException("opaPolicyPath cannot be null or empty"); - } + Preconditions.checkArgument( + !Strings.isNullOrEmpty(opaServerUrl), "opaServerUrl cannot be null or empty"); + Preconditions.checkArgument( + !Strings.isNullOrEmpty(opaPolicyPath), "opaPolicyPath cannot be null or empty"); try { - // Create request configuration with timeouts - RequestConfig requestConfig = - RequestConfig.custom() - .setConnectTimeout(Timeout.ofMilliseconds(timeoutMs)) - .setResponseTimeout(Timeout.ofMilliseconds(timeoutMs)) - .setConnectionRequestTimeout(Timeout.ofMilliseconds(timeoutMs)) - .build(); - - // Configure SSL for HTTPS connections - SSLConnectionSocketFactory sslSocketFactory = - createSslSocketFactory(opaServerUrl, verifySsl, trustStorePath, trustStorePassword); - - // Create HTTP client with SSL configuration - CloseableHttpClient httpClient; - if (client instanceof CloseableHttpClient) { - httpClient = (CloseableHttpClient) client; - } else { - if (sslSocketFactory != null) { - httpClient = - HttpClients.custom() - .setDefaultRequestConfig(requestConfig) - .setConnectionManager( - PoolingHttpClientConnectionManagerBuilder.create() - .setSSLSocketFactory(sslSocketFactory) - .build()) - .build(); - } else { - httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); - } - } - ObjectMapper objectMapperWithDefaults = new ObjectMapper(); return new OpaPolarisAuthorizer( - opaServerUrl, opaPolicyPath, tokenProvider, httpClient, objectMapperWithDefaults); + opaServerUrl, opaPolicyPath, tokenProvider, client, objectMapperWithDefaults); } catch (Exception e) { - throw new RuntimeException("Failed to create OpaPolarisAuthorizer with SSL configuration", e); + throw new RuntimeException("Failed to create OpaPolarisAuthorizer", e); } } @@ -387,48 +335,4 @@ private ObjectNode buildContextNode() { context.put("request_id", java.util.UUID.randomUUID().toString()); return context; } - - /** - * Creates an SSL socket factory for HTTPS connections based on the configuration. - * - * @param opaServerUrl the OPA server URL - * @param verifySsl whether to verify SSL certificates - * @param trustStorePath custom trust store path (optional) - * @param trustStorePassword custom trust store password (optional) - * @return SSLConnectionSocketFactory for HTTPS connections, or null for HTTP - * @throws Exception if SSL configuration fails - */ - private static SSLConnectionSocketFactory createSslSocketFactory( - String opaServerUrl, boolean verifySsl, String trustStorePath, String trustStorePassword) - throws Exception { - - // Only configure SSL for HTTPS URLs - if (opaServerUrl == null || !opaServerUrl.toLowerCase(Locale.ROOT).startsWith("https")) { - return null; - } - - SSLContextBuilder sslContextBuilder = SSLContextBuilder.create(); - SSLContext sslContext; - - if (!verifySsl) { - // Disable SSL verification (for development/testing) - sslContextBuilder.loadTrustMaterial(TrustAllStrategy.INSTANCE); - sslContext = sslContextBuilder.build(); - return new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); - } else if (!Strings.isNullOrEmpty(trustStorePath)) { - // Load custom trust store for SSL verification - KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { - trustStore.load( - trustStoreStream, trustStorePassword != null ? trustStorePassword.toCharArray() : null); - } - sslContextBuilder.loadTrustMaterial(trustStore, null); - sslContext = sslContextBuilder.build(); - return new SSLConnectionSocketFactory(sslContext); - } else { - // Use default system trust store for SSL verification - sslContext = SSLContextBuilder.create().build(); - return new SSLConnectionSocketFactory(sslContext); - } - } } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java index e2e6264afc..f064859111 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java @@ -43,6 +43,7 @@ import java.util.Set; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; import org.apache.hc.core5.http.HttpEntity; import org.apache.iceberg.exceptions.ForbiddenException; @@ -69,8 +70,7 @@ void testOpaInputJsonFormat() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); + createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); PolarisPrincipal principal = PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); @@ -107,8 +107,7 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); + createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); // Set up a realistic principal PolarisPrincipal principal = @@ -245,8 +244,7 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); + createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); // Set up a realistic principal PolarisPrincipal principal = @@ -394,8 +392,7 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); + createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); @@ -422,8 +419,7 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, 2000, true, null, null, null); + createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); PolarisPrincipal principal = PolarisPrincipal.of("bob", Map.of(), Set.of("user")); @@ -446,15 +442,7 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { @Test public void testCreateWithBearerTokenAndHttps() { OpaPolarisAuthorizer authorizer = - createWithStringToken( - "https://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - 2000, - true, - null, - null, - null); + createWithStringToken("https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", HttpClients.createDefault()); assertTrue(authorizer != null); } @@ -462,15 +450,7 @@ public void testCreateWithBearerTokenAndHttps() { @Test public void testCreateWithBearerTokenAndHttpsNoSslVerification() { OpaPolarisAuthorizer authorizer = - createWithStringToken( - "https://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - 2000, - false, - null, - null, - null); + createWithStringToken("https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", HttpClients.createDefault()); assertTrue(authorizer != null); } @@ -478,15 +458,7 @@ public void testCreateWithBearerTokenAndHttpsNoSslVerification() { @Test public void testCreateWithHttpsAndSslVerificationDisabled() { OpaPolarisAuthorizer authorizer = - createWithStringToken( - "https://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - 2000, - false, - null, - null, - null); + createWithStringToken("https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", HttpClients.createDefault()); assertTrue(authorizer != null); } @@ -505,15 +477,7 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); OpaPolarisAuthorizer authorizer = - createWithStringToken( - "http://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - 2000, - true, - null, - null, - mockHttpClient); + createWithStringToken("http://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", mockHttpClient); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -542,15 +506,7 @@ public void testAuthorizationFailsWithoutBearerToken() throws IOException { when(mockResponse.getCode()).thenReturn(401); OpaPolarisAuthorizer authorizer = - createWithStringToken( - "http://opa.example.com:8181", - "/v1/data/polaris/authz", - (String) null, - 2000, - true, - null, - null, - mockHttpClient); + createWithStringToken("http://opa.example.com:8181", "/v1/data/polaris/authz", (String) null, mockHttpClient); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -592,10 +548,6 @@ public void testBearerTokenFromBearerTokenProvider() throws IOException { "http://opa.example.com:8181", "/v1/data/polaris/authz", tokenProvider, - 2000, - true, - null, - null, mockHttpClient); // Create mock principal and entities @@ -642,10 +594,6 @@ public void testNullTokenFromBearerTokenProvider() throws IOException { "http://opa.example.com:8181", "/v1/data/polaris/authz", tokenProvider, - 2000, - true, - null, - null, mockHttpClient); // Create mock principal and entities @@ -768,20 +716,12 @@ private static OpaPolarisAuthorizer createWithStringToken( String opaServerUrl, String opaPolicyPath, String bearerToken, - int timeoutMs, - boolean verifySsl, - String trustStorePath, - String trustStorePassword, - Object client) { + CloseableHttpClient client) { BearerTokenProvider tokenProvider = new StaticBearerTokenProvider(bearerToken); return OpaPolarisAuthorizer.create( opaServerUrl, opaPolicyPath, tokenProvider, - timeoutMs, - verifySsl, - trustStorePath, - trustStorePassword, client); } } diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index d814daaaff..81b2463c02 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -206,7 +206,12 @@ polaris.authorization.type=default # It may undergo breaking changes in future versions. # polaris.authorization.opa.url=http://localhost:8181 # polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow -# polaris.authorization.opa.timeout-ms=2000 + +# HTTP Configuration +# polaris.authorization.opa.http.timeout-ms=2000 +# polaris.authorization.opa.http.verify-ssl=true +# polaris.authorization.opa.http.trust-store-path=/path/to/truststore.jks +# polaris.authorization.opa.http.trust-store-password=truststore-password # Bearer token authentication (choose one of the following approaches): # polaris.authorization.opa.bearer-token.enabled=false @@ -222,11 +227,6 @@ polaris.authorization.type=default # polaris.authorization.opa.bearer-token.jwt-expiration-refresh=true # polaris.authorization.opa.bearer-token.jwt-expiration-buffer=60 -# SSL/TLS Configuration -# polaris.authorization.opa.verify-ssl=true -# polaris.authorization.opa.trust-store-path=/path/to/truststore.jks -# polaris.authorization.opa.trust-store-password=truststore-password - # Polaris Credential Manager Config polaris.credential-manager.type=default diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java index f6c04b7556..f08f1e5959 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java @@ -56,10 +56,6 @@ public PolarisAuthorizer create(RealmConfig realmConfig) { opa.url().orElse(null), opa.policyPath().orElse(null), tokenProvider, - opa.timeoutMs(), - opa.verifySsl(), - opa.trustStorePath().orElse(null), - opa.trustStorePassword().orElse(null), httpClient); } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 76da33eb9a..8ddd051b0f 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -42,17 +42,24 @@ interface OpaConfig { Optional policyPath(); - @WithDefault("2000") - int timeoutMs(); - BearerTokenConfig bearerToken(); - @WithDefault("true") - boolean verifySsl(); + HttpConfig http(); - Optional trustStorePath(); + /** + * HTTP client configuration for OPA communication. + */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); - Optional trustStorePassword(); + @WithDefault("true") + boolean verifySsl(); + + Optional trustStorePath(); + + Optional trustStorePassword(); + } } interface BearerTokenConfig { diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java new file mode 100644 index 0000000000..78530b6967 --- /dev/null +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.config; + +import com.google.common.base.Strings; +import java.io.FileInputStream; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLContext; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory for creating HTTP clients configured for OPA communication with SSL support. + * + *

    This factory handles the creation of Apache HttpClient instances with proper SSL + * configuration, timeout settings, and connection pooling for communicating with + * Open Policy Agent (OPA) servers. + */ +public class OpaHttpClientFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(OpaHttpClientFactory.class); + + /** + * Creates a configured HTTP client for OPA communication. + * + * @param config HTTP configuration for timeouts and SSL settings + * @return configured CloseableHttpClient + */ + public static CloseableHttpClient createHttpClient(AuthorizationConfiguration.OpaConfig.HttpConfig config) { + RequestConfig requestConfig = RequestConfig.custom() + .setResponseTimeout(Timeout.ofMilliseconds(config.timeoutMs())) + .build(); + + if (!config.verifySsl()) { + // Create connection manager with custom TLS strategy (for development/testing) + try { + SSLContext sslContext = createSslContext(config); + DefaultClientTlsStrategy tlsStrategy = new DefaultClientTlsStrategy( + sslContext, NoopHostnameVerifier.INSTANCE); + + var connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(tlsStrategy) + .build(); + + return HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create SSL context for OPA client", e); + } + } + + // For SSL verification enabled, use default configuration + return HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build(); + } + + /** + * Creates an SSL context based on the configuration. + * + * @param config HTTP configuration containing SSL settings + * @return SSLContext for HTTPS connections + */ + private static SSLContext createSslContext(AuthorizationConfiguration.OpaConfig.HttpConfig config) + throws Exception { + if (!config.verifySsl()) { + // Disable SSL verification (for development/testing) + LOGGER.warn("SSL verification is disabled for OPA server. This should only be used in development/testing environments."); + return SSLContexts.custom() + .loadTrustMaterial(null, (X509Certificate[] chain, String authType) -> true) // trust all certificates + .build(); + } else if (config.trustStorePath().isPresent() && !Strings.isNullOrEmpty(config.trustStorePath().get())) { + // Load custom trust store for SSL verification + String trustStorePath = config.trustStorePath().get(); + LOGGER.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath); + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { + String trustStorePassword = config.trustStorePassword().orElse(null); + trustStore.load( + trustStoreStream, trustStorePassword != null ? trustStorePassword.toCharArray() : null); + } + return SSLContexts.custom() + .loadTrustMaterial(trustStore, null) + .build(); + } else { + // Use default system trust store for SSL verification + LOGGER.debug("Using default system trust store for OPA SSL verification"); + return SSLContexts.createDefault(); + } + } + +} \ No newline at end of file diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index 50c784ceb0..f79ae445b5 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -33,6 +33,7 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.Context; import java.time.Clock; +import java.time.Duration; import java.util.stream.Collectors; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -257,8 +258,15 @@ public StsClientsPool stsClientsPool( @Produces @Singleton @Identifier("opa-http-client") - public CloseableHttpClient opaHttpClient() { - return HttpClients.custom().build(); + public CloseableHttpClient opaHttpClient(AuthorizationConfiguration authorizationConfig) { + AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); + + try { + return OpaHttpClientFactory.createHttpClient(opa.http()); + } catch (Exception e) { + LOGGER.warn("Failed to create OPA HTTP client with SSL configuration, falling back to simple client", e); + return HttpClients.custom().build(); + } } public void closeOpaHttpClient( @@ -293,11 +301,9 @@ public BearerTokenProvider opaBearerTokenProvider( // File-based token as fallback if (bearerToken.filePath().isPresent()) { - java.time.Duration refreshInterval = - java.time.Duration.ofSeconds(bearerToken.refreshInterval()); + Duration refreshInterval = Duration.ofSeconds(bearerToken.refreshInterval()); boolean jwtExpirationRefresh = bearerToken.jwtExpirationRefresh(); - java.time.Duration jwtExpirationBuffer = - java.time.Duration.ofSeconds(bearerToken.jwtExpirationBuffer()); + Duration jwtExpirationBuffer = Duration.ofSeconds(bearerToken.jwtExpirationBuffer()); return new FileBearerTokenProvider( bearerToken.filePath().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java index 064baf6236..89eaae682e 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java @@ -57,10 +57,7 @@ public void testFactoryCreatesStaticTokenProvider() { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(2000); - when(opaConfig.verifySsl()).thenReturn(true); - when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); - when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + when(opaConfig.http()).thenReturn(createMockHttpConfig()); AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); @@ -97,10 +94,7 @@ public void testFactoryCreatesFileBearerTokenProvider() throws IOException { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(2000); - when(opaConfig.verifySsl()).thenReturn(true); - when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); - when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + when(opaConfig.http()).thenReturn(createMockHttpConfig()); AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); @@ -156,10 +150,7 @@ public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(2000); - when(opaConfig.verifySsl()).thenReturn(true); - when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); - when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + when(opaConfig.http()).thenReturn(createMockHttpConfig()); AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); @@ -193,10 +184,7 @@ public void testFactoryWithNoTokenConfiguration() { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(2000); - when(opaConfig.verifySsl()).thenReturn(true); - when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); - when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + when(opaConfig.http()).thenReturn(createMockHttpConfig()); AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); @@ -258,10 +246,7 @@ public int jwtExpirationBuffer() { when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.timeoutMs()).thenReturn(2000); - when(opaConfig.verifySsl()).thenReturn(true); - when(opaConfig.trustStorePath()).thenReturn(Optional.empty()); - when(opaConfig.trustStorePassword()).thenReturn(Optional.empty()); + when(opaConfig.http()).thenReturn(createMockHttpConfig()); AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); when(authConfig.opa()).thenReturn(opaConfig); @@ -281,4 +266,14 @@ public int jwtExpirationBuffer() { // Note: Validation of bearer token configuration now happens when the BearerTokenProvider // is created by the CDI system, not when the factory creates the authorizer. } + + private AuthorizationConfiguration.OpaConfig.HttpConfig createMockHttpConfig() { + AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = + mock(AuthorizationConfiguration.OpaConfig.HttpConfig.class); + when(httpConfig.timeoutMs()).thenReturn(2000); + when(httpConfig.verifySsl()).thenReturn(true); + when(httpConfig.trustStorePath()).thenReturn(Optional.empty()); + when(httpConfig.trustStorePassword()).thenReturn(Optional.empty()); + return httpConfig; + } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java new file mode 100644 index 0000000000..bd7c0836fb --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.config; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for OpaHttpClientFactory. + */ +public class OpaHttpClientFactoryTest { + + @Test + void testCreateHttpClientWithHttpUrl() throws Exception { + AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); + + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); + + assertNotNull(client); + client.close(); + } + + @Test + void testCreateHttpClientWithHttpsUrl() throws Exception { + AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = createMockHttpConfig(5000, false, null, null); + + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); + + assertNotNull(client); + client.close(); + } + + @Test + void testCreateHttpClientWithCustomTimeout() throws Exception { + AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = createMockHttpConfig(10000, true, null, null); + + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); + + assertNotNull(client); + client.close(); + } + + private AuthorizationConfiguration.OpaConfig.HttpConfig createMockHttpConfig( + int timeoutMs, boolean verifySsl, String trustStorePath, String trustStorePassword) { + AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = mock(AuthorizationConfiguration.OpaConfig.HttpConfig.class); + when(httpConfig.timeoutMs()).thenReturn(timeoutMs); + when(httpConfig.verifySsl()).thenReturn(verifySsl); + when(httpConfig.trustStorePath()).thenReturn(Optional.ofNullable(trustStorePath)); + when(httpConfig.trustStorePassword()).thenReturn(Optional.ofNullable(trustStorePassword)); + return httpConfig; + } +} \ No newline at end of file From 477839a6056e12b42a3f9cf2474cb06624c39580 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:46:31 +0000 Subject: [PATCH 24/40] extensions/auth/opa refactoring --- extensions/auth/opa/build.gradle.kts | 56 ++++ .../auth/opa/OpaAuthorizationConfig.java | 170 ++++++++++ .../auth/opa}/OpaHttpClientFactory.java | 61 ++-- .../auth/opa}/OpaPolarisAuthorizer.java | 9 +- .../auth/opa/OpaPolarisAuthorizerFactory.java | 127 +++++++ .../auth/opa}/OpaHttpClientFactoryTest.java | 30 +- .../opa/OpaPolarisAuthorizerFactoryTest.java | 236 +++++++++++++ .../auth/opa}/OpaPolarisAuthorizerTest.java | 71 ++-- gradle/projects.main.properties | 1 + .../main/resources/application-it.properties | 4 + .../src/main/resources/application.properties | 46 +-- runtime/service/build.gradle.kts | 3 + .../auth/opa/OpaFileTokenIntegrationTest.java | 310 ++++++++++++++++++ .../service/auth/opa/OpaIntegrationTest.java | 271 +++++++++++++++ .../service/it/ServiceProducersIT.java | 50 ++- .../auth/DefaultPolarisAuthorizerFactory.java | 2 +- .../auth/OpaPolarisAuthorizerFactory.java | 61 ---- .../config/AuthorizationConfiguration.java | 101 +----- .../service/config/ServiceProducers.java | 64 ---- .../auth/OpaPolarisAuthorizerFactoryTest.java | 279 ---------------- 20 files changed, 1335 insertions(+), 617 deletions(-) create mode 100644 extensions/auth/opa/build.gradle.kts create mode 100644 extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java rename {runtime/service/src/main/java/org/apache/polaris/service/config => extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa}/OpaHttpClientFactory.java (72%) rename {polaris-core/src/main/java/org/apache/polaris/core/auth => extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa}/OpaPolarisAuthorizer.java (97%) create mode 100644 extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java rename {runtime/service/src/test/java/org/apache/polaris/service/config => extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa}/OpaHttpClientFactoryTest.java (76%) create mode 100644 extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java rename {polaris-core/src/test/java/org/apache/polaris/core/auth => extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa}/OpaPolarisAuthorizerTest.java (93%) create mode 100644 runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java create mode 100644 runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java delete mode 100644 runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java delete mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java diff --git a/extensions/auth/opa/build.gradle.kts b/extensions/auth/opa/build.gradle.kts new file mode 100644 index 0000000000..b3334792d3 --- /dev/null +++ b/extensions/auth/opa/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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. + */ + +plugins { + id("polaris-server") + id("org.kordamp.gradle.jandex") +} + +dependencies { + implementation(project(":polaris-core")) + implementation(project(":polaris-runtime-service")) + implementation(libs.apache.httpclient5) + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation(libs.guava) + implementation(libs.slf4j.api) + + // Iceberg dependency for ForbiddenException + implementation(platform(libs.iceberg.bom)) + implementation("org.apache.iceberg:iceberg-api") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.smallrye.config.core) + + testImplementation(testFixtures(project(":polaris-core"))) + testImplementation(project(":polaris-runtime-test-common")) + testImplementation(platform(libs.junit.bom)) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.core) + testImplementation(platform(libs.quarkus.bom)) + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.rest-assured:rest-assured") + testImplementation("com.github.tomakehurst:wiremock:3.0.1") + testImplementation(platform(libs.testcontainers.bom)) + testImplementation("org.testcontainers:junit-jupiter") +} diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java new file mode 100644 index 0000000000..d99cb1317c --- /dev/null +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + *

    Beta Feature: OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional url(); + + Optional policyPath(); + + Optional auth(); + + Optional http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional trustStorePath(); + + Optional trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); + + /** Bearer token authentication configuration */ + Optional bearer(); + + default void validate() { + switch (type()) { + case "bearer": + checkArgument( + bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); + bearer().get().validate(); + break; + case "none": + // No authentication - nothing to validate + break; + default: + throw new IllegalArgumentException( + "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'"); + } + } + } + + interface BearerTokenConfig { + /** Type of bearer token configuration */ + @WithDefault("static-token") + String type(); + + /** Static bearer token configuration */ + Optional staticToken(); + + /** File-based bearer token configuration */ + Optional fileBased(); + + default void validate() { + switch (type()) { + case "static-token": + checkArgument( + staticToken().isPresent(), + "Static token configuration is required when type is 'static-token'"); + staticToken().get().validate(); + break; + case "file-based": + checkArgument( + fileBased().isPresent(), + "File-based configuration is required when type is 'file-based'"); + fileBased().get().validate(); + break; + default: + throw new IllegalArgumentException( + "Invalid bearer token type: " + type() + ". Must be 'static-token' or 'file-based'"); + } + } + + /** Configuration for static bearer tokens */ + interface StaticTokenConfig { + /** Static bearer token value */ + Optional value(); + + default void validate() { + checkArgument( + value().isPresent() && !value().get().isBlank(), + "Static bearer token value cannot be null or empty"); + } + } + + /** Configuration for file-based bearer tokens */ + interface FileBasedConfig { + /** Path to file containing bearer token */ + Optional path(); + + /** How often to refresh file-based bearer tokens (in seconds) */ + @WithDefault("300") + int refreshInterval(); + + /** + * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If + * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on + * the expiration time minus the buffer, rather than the fixed refresh interval. + */ + @WithDefault("true") + boolean jwtExpirationRefresh(); + + /** + * Buffer time in seconds before JWT expiration to refresh the token. Only used when + * jwtExpirationRefresh is true and the token is a valid JWT. Default is 60 seconds. + */ + @WithDefault("60") + int jwtExpirationBuffer(); + + default void validate() { + checkArgument( + path().isPresent() && !path().get().isBlank(), + "Bearer token file path cannot be null or empty"); + checkArgument(refreshInterval() > 0, "refreshInterval must be greater than 0"); + checkArgument(jwtExpirationBuffer() > 0, "jwtExpirationBuffer must be greater than 0"); + } + } + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java similarity index 72% rename from runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java rename to extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java index 78530b6967..5d611be906 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/OpaHttpClientFactory.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java @@ -16,17 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.config; +package org.apache.polaris.extension.auth.opa; import com.google.common.base.Strings; import java.io.FileInputStream; import java.security.KeyStore; import java.security.cert.X509Certificate; -import java.util.Locale; -import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLContext; import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.config.TlsConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; @@ -39,10 +36,10 @@ /** * Factory for creating HTTP clients configured for OPA communication with SSL support. - * + * *

    This factory handles the creation of Apache HttpClient instances with proper SSL - * configuration, timeout settings, and connection pooling for communicating with - * Open Policy Agent (OPA) servers. + * configuration, timeout settings, and connection pooling for communicating with Open Policy Agent + * (OPA) servers. */ public class OpaHttpClientFactory { private static final Logger LOGGER = LoggerFactory.getLogger(OpaHttpClientFactory.class); @@ -53,22 +50,24 @@ public class OpaHttpClientFactory { * @param config HTTP configuration for timeouts and SSL settings * @return configured CloseableHttpClient */ - public static CloseableHttpClient createHttpClient(AuthorizationConfiguration.OpaConfig.HttpConfig config) { - RequestConfig requestConfig = RequestConfig.custom() - .setResponseTimeout(Timeout.ofMilliseconds(config.timeoutMs())) - .build(); - + public static CloseableHttpClient createHttpClient(OpaAuthorizationConfig.HttpConfig config) { + RequestConfig requestConfig = + RequestConfig.custom() + .setResponseTimeout(Timeout.ofMilliseconds(config.timeoutMs())) + .build(); + if (!config.verifySsl()) { // Create connection manager with custom TLS strategy (for development/testing) try { SSLContext sslContext = createSslContext(config); - DefaultClientTlsStrategy tlsStrategy = new DefaultClientTlsStrategy( - sslContext, NoopHostnameVerifier.INSTANCE); - - var connectionManager = PoolingHttpClientConnectionManagerBuilder.create() - .setTlsSocketStrategy(tlsStrategy) - .build(); - + DefaultClientTlsStrategy tlsStrategy = + new DefaultClientTlsStrategy(sslContext, NoopHostnameVerifier.INSTANCE); + + var connectionManager = + PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(tlsStrategy) + .build(); + return HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestConfig) @@ -77,11 +76,9 @@ public static CloseableHttpClient createHttpClient(AuthorizationConfiguration.Op throw new RuntimeException("Failed to create SSL context for OPA client", e); } } - + // For SSL verification enabled, use default configuration - return HttpClients.custom() - .setDefaultRequestConfig(requestConfig) - .build(); + return HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); } /** @@ -90,15 +87,18 @@ public static CloseableHttpClient createHttpClient(AuthorizationConfiguration.Op * @param config HTTP configuration containing SSL settings * @return SSLContext for HTTPS connections */ - private static SSLContext createSslContext(AuthorizationConfiguration.OpaConfig.HttpConfig config) + private static SSLContext createSslContext(OpaAuthorizationConfig.HttpConfig config) throws Exception { if (!config.verifySsl()) { // Disable SSL verification (for development/testing) - LOGGER.warn("SSL verification is disabled for OPA server. This should only be used in development/testing environments."); + LOGGER.warn( + "SSL verification is disabled for OPA server. This should only be used in development/testing environments."); return SSLContexts.custom() - .loadTrustMaterial(null, (X509Certificate[] chain, String authType) -> true) // trust all certificates + .loadTrustMaterial( + null, (X509Certificate[] chain, String authType) -> true) // trust all certificates .build(); - } else if (config.trustStorePath().isPresent() && !Strings.isNullOrEmpty(config.trustStorePath().get())) { + } else if (config.trustStorePath().isPresent() + && !Strings.isNullOrEmpty(config.trustStorePath().get())) { // Load custom trust store for SSL verification String trustStorePath = config.trustStorePath().get(); LOGGER.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath); @@ -108,14 +108,11 @@ private static SSLContext createSslContext(AuthorizationConfiguration.OpaConfig. trustStore.load( trustStoreStream, trustStorePassword != null ? trustStorePassword.toCharArray() : null); } - return SSLContexts.custom() - .loadTrustMaterial(trustStore, null) - .build(); + return SSLContexts.custom().loadTrustMaterial(trustStore, null).build(); } else { // Use default system trust store for SSL verification LOGGER.debug("Using default system trust store for OPA SSL verification"); return SSLContexts.createDefault(); } } - -} \ No newline at end of file +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java similarity index 97% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java rename to extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index 13cfa285f8..b808a16fa6 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -37,6 +37,10 @@ import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.iceberg.exceptions.ForbiddenException; +import org.apache.polaris.core.auth.BearerTokenProvider; +import org.apache.polaris.core.auth.PolarisAuthorizableOperation; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisPrincipal; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; @@ -78,7 +82,8 @@ private OpaPolarisAuthorizer( * @param opaServerUrl OPA server URL * @param opaPolicyPath OPA policy path * @param tokenProvider Token provider for authentication (optional) - * @param client Apache HttpClient (required, injected by CDI). SSL configuration should be handled by the CDI producer. + * @param client Apache HttpClient (required, injected by CDI). SSL configuration should be + * handled by the CDI producer. * @return OpaPolarisAuthorizer instance */ public static OpaPolarisAuthorizer create( diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java new file mode 100644 index 0000000000..21c0b26484 --- /dev/null +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.extension.auth.opa; + +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Duration; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.polaris.core.auth.BearerTokenProvider; +import org.apache.polaris.core.auth.FileBearerTokenProvider; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.auth.StaticBearerTokenProvider; +import org.apache.polaris.core.config.RealmConfig; + +/** Factory for creating OPA-based Polaris authorizer implementations. */ +@ApplicationScoped +@Identifier("opa") +public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { + + private final OpaAuthorizationConfig opaConfig; + + @Inject + public OpaPolarisAuthorizerFactory(OpaAuthorizationConfig opaConfig) { + this.opaConfig = opaConfig; + } + + @Override + public PolarisAuthorizer create(RealmConfig realmConfig) { + // Validate configuration before creating authorizer + opaConfig.validate(); + + // Create HTTP client directly + CloseableHttpClient httpClient; + try { + if (opaConfig.http().isEmpty()) { + throw new IllegalStateException("HTTP configuration is required"); + } + httpClient = OpaHttpClientFactory.createHttpClient(opaConfig.http().get()); + } catch (Exception e) { + // Fallback to simple client + httpClient = HttpClients.custom().build(); + } + + // Create bearer token provider directly + if (opaConfig.auth().isEmpty()) { + throw new IllegalStateException("Authentication configuration is required"); + } + BearerTokenProvider tokenProvider = createBearerTokenProvider(opaConfig.auth().get()); + + if (opaConfig.url().isEmpty() || opaConfig.policyPath().isEmpty()) { + throw new IllegalStateException("URL and policy path are required"); + } + + return OpaPolarisAuthorizer.create( + opaConfig.url().get(), opaConfig.policyPath().get(), tokenProvider, httpClient); + } + + private BearerTokenProvider createBearerTokenProvider( + OpaAuthorizationConfig.AuthenticationConfig authConfig) { + switch (authConfig.type()) { + case "bearer": + if (authConfig.bearer().isEmpty()) { + throw new IllegalStateException("Bearer configuration is required when type is 'bearer'"); + } + return createBearerTokenProvider(authConfig.bearer().get()); + case "none": + return new StaticBearerTokenProvider(""); + default: + throw new IllegalStateException("Unsupported authentication type: " + authConfig.type()); + } + } + + private BearerTokenProvider createBearerTokenProvider( + OpaAuthorizationConfig.BearerTokenConfig bearerToken) { + switch (bearerToken.type()) { + case "static-token": + if (bearerToken.staticToken().isEmpty()) { + throw new IllegalStateException( + "Static token configuration is required when type is 'static-token'"); + } + OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticConfig = + bearerToken.staticToken().get(); + if (staticConfig.value().isEmpty()) { + throw new IllegalStateException("Static token value is required"); + } + return new StaticBearerTokenProvider(staticConfig.value().get()); + + case "file-based": + if (bearerToken.fileBased().isEmpty()) { + throw new IllegalStateException( + "File-based configuration is required when type is 'file-based'"); + } + OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig = + bearerToken.fileBased().get(); + if (fileConfig.path().isEmpty()) { + throw new IllegalStateException("File-based token path is required"); + } + Duration refreshInterval = Duration.ofSeconds(fileConfig.refreshInterval()); + boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh(); + Duration jwtExpirationBuffer = Duration.ofSeconds(fileConfig.jwtExpirationBuffer()); + return new FileBearerTokenProvider( + fileConfig.path().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); + + default: + throw new IllegalStateException("Unsupported bearer token type: " + bearerToken.type()); + } + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java similarity index 76% rename from runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java rename to extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java index bd7c0836fb..364882261a 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/config/OpaHttpClientFactoryTest.java +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.config; +package org.apache.polaris.extension.auth.opa; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; @@ -26,48 +26,46 @@ import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.junit.jupiter.api.Test; -/** - * Unit tests for OpaHttpClientFactory. - */ +/** Unit tests for OpaHttpClientFactory. */ public class OpaHttpClientFactoryTest { @Test void testCreateHttpClientWithHttpUrl() throws Exception { - AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); - + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); - + assertNotNull(client); client.close(); } @Test void testCreateHttpClientWithHttpsUrl() throws Exception { - AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = createMockHttpConfig(5000, false, null, null); - + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, false, null, null); + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); - + assertNotNull(client); client.close(); } @Test void testCreateHttpClientWithCustomTimeout() throws Exception { - AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = createMockHttpConfig(10000, true, null, null); - + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(10000, true, null, null); + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); - + assertNotNull(client); client.close(); } - private AuthorizationConfiguration.OpaConfig.HttpConfig createMockHttpConfig( + private OpaAuthorizationConfig.HttpConfig createMockHttpConfig( int timeoutMs, boolean verifySsl, String trustStorePath, String trustStorePassword) { - AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = mock(AuthorizationConfiguration.OpaConfig.HttpConfig.class); + OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); when(httpConfig.timeoutMs()).thenReturn(timeoutMs); when(httpConfig.verifySsl()).thenReturn(verifySsl); when(httpConfig.trustStorePath()).thenReturn(Optional.ofNullable(trustStorePath)); when(httpConfig.trustStorePassword()).thenReturn(Optional.ofNullable(trustStorePassword)); return httpConfig; } -} \ No newline at end of file +} diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java new file mode 100644 index 0000000000..ad81d02125 --- /dev/null +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.extension.auth.opa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Optional; +import org.apache.polaris.core.auth.FileBearerTokenProvider; +import org.apache.polaris.core.config.RealmConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class OpaPolarisAuthorizerFactoryTest { + + @TempDir Path tempDir; + + @Test + public void testFactoryCreatesStaticTokenProvider() { + // Mock configuration for static token + OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig.class); + when(staticTokenConfig.value()).thenReturn(Optional.of("static-token-value")); + + OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.class); + when(bearerTokenConfig.type()).thenReturn("static-token"); + when(bearerTokenConfig.staticToken()).thenReturn(Optional.of(staticTokenConfig)); + when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); + + OpaAuthorizationConfig.AuthenticationConfig authConfig = + mock(OpaAuthorizationConfig.AuthenticationConfig.class); + when(authConfig.type()).thenReturn("bearer"); + when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); + + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); + + OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); + when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } + + @Test + public void testFactoryCreatesFileBearerTokenProvider() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("bearer-token.txt"); + String tokenValue = "file-based-token-value"; + Files.writeString(tokenFile, tokenValue); + + // Mock configuration for file-based token + OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig.class); + when(fileTokenConfig.path()).thenReturn(Optional.of(tokenFile.toString())); + when(fileTokenConfig.refreshInterval()).thenReturn(300); + when(fileTokenConfig.jwtExpirationRefresh()).thenReturn(true); + when(fileTokenConfig.jwtExpirationBuffer()).thenReturn(60); + + OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.class); + when(bearerTokenConfig.type()).thenReturn("file-based"); + when(bearerTokenConfig.staticToken()).thenReturn(Optional.empty()); + when(bearerTokenConfig.fileBased()).thenReturn(Optional.of(fileTokenConfig)); + + OpaAuthorizationConfig.AuthenticationConfig authConfig = + mock(OpaAuthorizationConfig.AuthenticationConfig.class); + when(authConfig.type()).thenReturn("bearer"); + when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); + + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); + + OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); + when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } + + @Test + public void testFileBearerTokenProviderActuallyReadsFromFile() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("bearer-token.txt"); + String tokenValue = "file-based-token-from-disk"; + Files.writeString(tokenFile, tokenValue); + + // Create FileBearerTokenProvider directly to test it reads the file + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Verify the token is read from the file + String actualToken = provider.getToken(); + assertEquals(tokenValue, actualToken); + + provider.close(); + } + + @Test + public void testFactoryWithStaticTokenConfiguration() throws IOException { + // Create a temporary token file (but we'll use static token instead) + Path tokenFile = tempDir.resolve("bearer-token.txt"); + Files.writeString(tokenFile, "file-token-value"); + + // Mock configuration with static token + OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig.class); + when(staticTokenConfig.value()).thenReturn(Optional.of("static-token-value")); + + OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.class); + when(bearerTokenConfig.type()).thenReturn("static-token"); + when(bearerTokenConfig.staticToken()).thenReturn(Optional.of(staticTokenConfig)); + when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); + + OpaAuthorizationConfig.AuthenticationConfig authConfig = + mock(OpaAuthorizationConfig.AuthenticationConfig.class); + when(authConfig.type()).thenReturn("bearer"); + when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); + + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); + + OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); + when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } + + @Test + public void testFactoryWithNoTokenConfiguration() { + // Mock configuration with "none" authentication (no tokens) + OpaAuthorizationConfig.AuthenticationConfig authConfig = + mock(OpaAuthorizationConfig.AuthenticationConfig.class); + when(authConfig.type()).thenReturn("none"); + when(authConfig.bearer()).thenReturn(Optional.empty()); + + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); + + OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); + when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertNotNull(authorizer); + } + + @Test + public void testFactoryValidatesConfiguration() { + // Mock configuration that would fail validation (invalid bearer type) + OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = + mock(OpaAuthorizationConfig.BearerTokenConfig.class); + when(bearerTokenConfig.type()).thenReturn("invalid-type"); + when(bearerTokenConfig.staticToken()).thenReturn(Optional.empty()); + when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); + + OpaAuthorizationConfig.AuthenticationConfig authConfig = + mock(OpaAuthorizationConfig.AuthenticationConfig.class); + when(authConfig.type()).thenReturn("bearer"); + when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); + + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); + + OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); + when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); + when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); + when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + + // The factory constructor should work fine + OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + + assertNotNull(factory); + } + + private OpaAuthorizationConfig.HttpConfig createMockHttpConfig() { + OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); + when(httpConfig.timeoutMs()).thenReturn(2000); + when(httpConfig.verifySsl()).thenReturn(true); + when(httpConfig.trustStorePath()).thenReturn(Optional.empty()); + when(httpConfig.trustStorePassword()).thenReturn(Optional.empty()); + return httpConfig; + } +} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java similarity index 93% rename from polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java rename to extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java index f064859111..67e6055b23 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/OpaPolarisAuthorizerTest.java +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,10 +43,14 @@ import java.util.Set; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.core5.http.HttpEntity; import org.apache.iceberg.exceptions.ForbiddenException; +import org.apache.polaris.core.auth.BearerTokenProvider; +import org.apache.polaris.core.auth.PolarisAuthorizableOperation; +import org.apache.polaris.core.auth.PolarisPrincipal; +import org.apache.polaris.core.auth.StaticBearerTokenProvider; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.entity.PolarisEntity; import org.apache.polaris.core.entity.PolarisEntityType; @@ -70,7 +74,8 @@ void testOpaInputJsonFormat() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); PolarisPrincipal principal = PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); @@ -107,7 +112,8 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); // Set up a realistic principal PolarisPrincipal principal = @@ -244,7 +250,8 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); // Set up a realistic principal PolarisPrincipal principal = @@ -392,7 +399,8 @@ void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); @@ -419,7 +427,8 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { String url = "http://localhost:" + server.getAddress().getPort(); OpaPolarisAuthorizer authorizer = - createWithStringToken(url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); PolarisPrincipal principal = PolarisPrincipal.of("bob", Map.of(), Set.of("user")); @@ -442,7 +451,11 @@ void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { @Test public void testCreateWithBearerTokenAndHttps() { OpaPolarisAuthorizer authorizer = - createWithStringToken("https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", HttpClients.createDefault()); + createWithStringToken( + "https://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + HttpClients.createDefault()); assertTrue(authorizer != null); } @@ -450,7 +463,11 @@ public void testCreateWithBearerTokenAndHttps() { @Test public void testCreateWithBearerTokenAndHttpsNoSslVerification() { OpaPolarisAuthorizer authorizer = - createWithStringToken("https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", HttpClients.createDefault()); + createWithStringToken( + "https://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + HttpClients.createDefault()); assertTrue(authorizer != null); } @@ -458,7 +475,11 @@ public void testCreateWithBearerTokenAndHttpsNoSslVerification() { @Test public void testCreateWithHttpsAndSslVerificationDisabled() { OpaPolarisAuthorizer authorizer = - createWithStringToken("https://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", HttpClients.createDefault()); + createWithStringToken( + "https://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + HttpClients.createDefault()); assertTrue(authorizer != null); } @@ -477,7 +498,11 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); OpaPolarisAuthorizer authorizer = - createWithStringToken("http://opa.example.com:8181", "/v1/data/polaris/authz", "test-bearer-token", mockHttpClient); + createWithStringToken( + "http://opa.example.com:8181", + "/v1/data/polaris/authz", + "test-bearer-token", + mockHttpClient); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -506,7 +531,8 @@ public void testAuthorizationFailsWithoutBearerToken() throws IOException { when(mockResponse.getCode()).thenReturn(401); OpaPolarisAuthorizer authorizer = - createWithStringToken("http://opa.example.com:8181", "/v1/data/polaris/authz", (String) null, mockHttpClient); + createWithStringToken( + "http://opa.example.com:8181", "/v1/data/polaris/authz", (String) null, mockHttpClient); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -545,10 +571,7 @@ public void testBearerTokenFromBearerTokenProvider() throws IOException { // Create authorizer with the token provider instead of static token OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( - "http://opa.example.com:8181", - "/v1/data/polaris/authz", - tokenProvider, - mockHttpClient); + "http://opa.example.com:8181", "/v1/data/polaris/authz", tokenProvider, mockHttpClient); // Create mock principal and entities PolarisPrincipal mockPrincipal = @@ -591,10 +614,7 @@ public void testNullTokenFromBearerTokenProvider() throws IOException { OpaPolarisAuthorizer authorizer = OpaPolarisAuthorizer.create( - "http://opa.example.com:8181", - "/v1/data/polaris/authz", - tokenProvider, - mockHttpClient); + "http://opa.example.com:8181", "/v1/data/polaris/authz", tokenProvider, mockHttpClient); // Create mock principal and entities PolarisPrincipal mockPrincipal = @@ -713,15 +733,8 @@ private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, Strin * provides the same API as the removed String-based create method for test convenience. */ private static OpaPolarisAuthorizer createWithStringToken( - String opaServerUrl, - String opaPolicyPath, - String bearerToken, - CloseableHttpClient client) { + String opaServerUrl, String opaPolicyPath, String bearerToken, CloseableHttpClient client) { BearerTokenProvider tokenProvider = new StaticBearerTokenProvider(bearerToken); - return OpaPolarisAuthorizer.create( - opaServerUrl, - opaPolicyPath, - tokenProvider, - client); + return OpaPolarisAuthorizer.create(opaServerUrl, opaPolicyPath, tokenProvider, client); } } diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index f6c46f8be6..864a7eaeac 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -43,6 +43,7 @@ polaris-version=tools/version polaris-misc-types=tools/misc-types polaris-extensions-federation-hadoop=extensions/federation/hadoop polaris-extensions-federation-hive=extensions/federation/hive +polaris-extensions-auth-opa=extensions/auth/opa polaris-config-docs-annotations=tools/config-docs/annotations polaris-config-docs-generator=tools/config-docs/generator diff --git a/runtime/defaults/src/main/resources/application-it.properties b/runtime/defaults/src/main/resources/application-it.properties index 7c3c70f3d2..f5a29a40c6 100644 --- a/runtime/defaults/src/main/resources/application-it.properties +++ b/runtime/defaults/src/main/resources/application-it.properties @@ -56,3 +56,7 @@ polaris.realm-context.realms=POLARIS,OTHER polaris.storage.gcp.token=token polaris.storage.gcp.lifespan=PT1H +# Index OPA extension for configuration mapping discovery during integration tests +quarkus.index-dependency.opa.group-id=org.apache.polaris +quarkus.index-dependency.opa.artifact-id=polaris-extensions-auth-opa + diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 81b2463c02..6eee0603c5 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -198,8 +198,8 @@ polaris.oidc.principal-roles-mapper.type=default # polaris.storage.gcp.lifespan=PT1H # Polaris authorization type settings -# Which authorizer to use: "default" (PolarisAuthorizerImpl) or "opa" (OpaPolarisAuthorizer) -polaris.authorization.type=default +# Which authorizer to use: "internal" (PolarisAuthorizerImpl) or "opa" (OpaPolarisAuthorizer) +polaris.authorization.type=internal # OPA Authorizer Configuration: effective only if polaris.authorization.type=opa # NOTE: The OPA Authorizer is currently in Beta and is not a stable release. @@ -207,25 +207,29 @@ polaris.authorization.type=default # polaris.authorization.opa.url=http://localhost:8181 # polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow -# HTTP Configuration -# polaris.authorization.opa.http.timeout-ms=2000 -# polaris.authorization.opa.http.verify-ssl=true -# polaris.authorization.opa.http.trust-store-path=/path/to/truststore.jks -# polaris.authorization.opa.http.trust-store-password=truststore-password - -# Bearer token authentication (choose one of the following approaches): -# polaris.authorization.opa.bearer-token.enabled=false - -# Option 1: Static bearer token value (takes precedence if both are set) -# polaris.authorization.opa.bearer-token.static-value=your-bearer-token-here - -# Option 2: File-based bearer token (useful for Kubernetes token rotation) -# polaris.authorization.opa.bearer-token.file-path=/var/run/secrets/tokens/opa-token -# polaris.authorization.opa.bearer-token.refresh-interval=300 - -# JWT Expiration Support (for file-based tokens): -# polaris.authorization.opa.bearer-token.jwt-expiration-refresh=true -# polaris.authorization.opa.bearer-token.jwt-expiration-buffer=60 +# OPA HTTP configuration +# polaris.authorization.opa.http.timeout-ms=5000 +# polaris.authorization.opa.http.verify-ssl=false + +# OPA Authentication configuration +# Default is no authentication (type=none) +# To enable bearer token authentication,, use type=bearer +# polaris.authorization.opa.auth.type=none +# To enable bearer token authentication, uncomment the following: +# polaris.authorization.opa.auth.type=bearer + +# OPA Bearer token configuration (only used when type=bearer) +# polaris.authorization.opa.auth.bearer.type=static-token + +# Static bearer token configuration: +# polaris.authorization.opa.auth.bearer.static-token.value=my-static-token + +# Alternative file-based bearer token configuration: +# polaris.authorization.opa.auth.bearer.type=file-based +# polaris.authorization.opa.auth.bearer.file-based.path=/path/to/token/file +# polaris.authorization.opa.auth.bearer.file-based.refresh-interval=300 +# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-refresh=true +# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-buffer=60 # Polaris Credential Manager Config polaris.credential-manager.type=default diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index f51aefb0d6..bfc0a4daaa 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -139,6 +139,9 @@ dependencies { testImplementation(project(":polaris-runtime-test-common")) testImplementation(project(":polaris-container-spec-helper")) + // OPA Authorization Extension for testing + testImplementation(project(":polaris-extensions-auth-opa")) + testImplementation(libs.threeten.extra) testImplementation(libs.hawkular.agent.prometheus.scraper) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java new file mode 100644 index 0000000000..1e67be2080 --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth.opa; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.fail; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; +import io.quarkus.test.junit.TestProfile; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.polaris.test.commons.OpaTestResource; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) +public class OpaFileTokenIntegrationTest { + + public static class FileTokenOpaProfile implements QuarkusTestProfile { + private static volatile Path tokenFile; + + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.http.timeout-ms", "2000"); + + // Create temporary token file for testing + try { + tokenFile = Files.createTempFile("opa-test-token", ".txt"); + Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); + tokenFile.toFile().deleteOnExit(); + } catch (IOException e) { + throw new RuntimeException("Failed to create test token file", e); + } + + // Configure OPA server authentication with file-based bearer token + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); + config.put("polaris.authorization.opa.auth.bearer.file-based.path", tokenFile.toString()); + config.put( + "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", + "300"); // 300 seconds for testing + config.put( + "polaris.authorization.opa.http.verify-ssl", + "false"); // Disable SSL verification for tests + + // TODO: Add tests for OIDC and federated principal + config.put("polaris.authentication.type", "internal"); + + return config; + } + + @Override + public List testResources() { + String customRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + + # Deny stranger user explicitly (though default is false) + allow { + input.actor.principal == "stranger" + false + } + """; + + return List.of( + new TestResourceEntry( + OpaTestResource.class, + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); + } + + public static Path getTokenFile() { + return tokenFile; + } + } + + /** + * Test demonstrates OPA integration with file-based bearer token authentication. This test + * verifies that the FileBearerTokenProvider correctly reads tokens from a file and that the full + * integration works with file-based configuration. + */ + @Test + void testOpaAllowsRootUserWithFileToken() { + // Test demonstrates the complete integration flow with file-based tokens: + // 1. OAuth token acquisition with internal authentication + // 2. OPA policy allowing root users + // 3. Bearer token read from file by FileBearerTokenProvider + + // Get a token using the catalog service OAuth endpoint + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + // Parse JSON response to get access_token + String accessToken = extractJsonValue(response, "access_token"); + + if (accessToken == null) { + fail("Failed to parse access_token from OAuth response: " + response); + } + + // Use the Bearer token to test OPA authorization + // The JWT token has principal "root" which our policy allows + given() + .header("Authorization", "Bearer " + accessToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - "root" user is allowed by policy + } + + @Test + void testFileTokenRefresh() throws IOException, InterruptedException { + // This test verifies that the FileBearerTokenProvider refreshes tokens from the file + + // First verify the system works with the initial token + String rootToken = getRootToken(); + + given() + .header("Authorization", "Bearer " + rootToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); + + // Update the token file with a new value + // Note: In a real test, we'd need to coordinate with the OPA server to accept the new token + // For this demo, we'll just verify the file can be updated + var tokenFile = FileTokenOpaProfile.getTokenFile(); + if (tokenFile != null && Files.exists(tokenFile)) { + String originalContent = Files.readString(tokenFile); + + // Update the file content + Files.writeString(tokenFile, "test-opa-bearer-token-updated-12345"); + + // Wait for refresh interval (1 second as configured) + Thread.sleep(1500); + + // Verify the file was updated + String updatedContent = Files.readString(tokenFile); + if (updatedContent.equals(originalContent)) { + fail("Token file was not updated as expected"); + } + + // Note: We can't test that OPA actually receives the new token without + // coordinating with the OPA test container, but we've verified the file mechanism works + } + } + + @Test + void testOpaPolicyDeniesStrangerUserWithFileToken() { + // Create a "stranger" principal and get its access token + String strangerToken = createPrincipalAndGetToken("stranger"); + + // Use the stranger token to test OPA authorization - should be denied + given() + .header("Authorization", "Bearer " + strangerToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(403); // Should be forbidden by OPA policy - stranger is denied + } + + @Test + void testOpaAllowsAdminUserWithFileToken() { + // Create an "admin" principal and get its access token + String adminToken = createPrincipalAndGetToken("admin"); + + // Use the admin token to test OPA authorization - should be allowed + given() + .header("Authorization", "Bearer " + adminToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - admin user is allowed by policy + } + + /** Helper method to create a principal and get an OAuth access token for that principal */ + private String createPrincipalAndGetToken(String principalName) { + // First get root token to create the principal + String rootToken = getRootToken(); + + // Create the principal using the root token + String createResponse = + given() + .contentType("application/json") + .header("Authorization", "Bearer " + rootToken) + .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") + .when() + .post("/api/management/v1/principals") + .then() + .statusCode(201) + .extract() + .body() + .asString(); + + // Parse the principal's credentials from the response + String clientId = extractJsonValue(createResponse, "clientId"); + String clientSecret = extractJsonValue(createResponse, "clientSecret"); + + if (clientId == null || clientSecret == null) { + fail("Could not parse principal credentials from response: " + createResponse); + } + + // Get access token for the newly created principal + String tokenResponse = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", clientId) + .formParam("client_secret", clientSecret) + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(tokenResponse, "access_token"); + if (accessToken == null) { + fail("Could not get access token for principal " + principalName); + } + + return accessToken; + } + + /** Helper method to get root access token */ + private String getRootToken() { + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(response, "access_token"); + if (accessToken == null) { + fail("Failed to parse access_token from admin OAuth response: " + response); + } + return accessToken; + } + + /** Simple JSON value extractor */ + private String extractJsonValue(String json, String key) { + String searchKey = "\"" + key + "\""; + if (json.contains(searchKey)) { + String value = json.substring(json.indexOf(searchKey) + searchKey.length()); + value = value.substring(value.indexOf("\"") + 1); + value = value.substring(0, value.indexOf("\"")); + return value; + } + return null; + } +} diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java new file mode 100644 index 0000000000..1724a8532e --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth.opa; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.fail; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; +import io.quarkus.test.junit.TestProfile; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.polaris.test.commons.OpaTestResource; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class) +public class OpaIntegrationTest { + + /** + * Test demonstrates OPA integration with bearer token authentication. The OPA container runs with + * HTTP for simplicity in CI environments. The OpaPolarisAuthorizer is configured to disable SSL + * verification for test purposes. + */ + public static class StaticTokenOpaProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.http.timeout-ms", "2000"); + + // Configure OPA server authentication with static bearer token + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put("polaris.authorization.opa.auth.bearer.type", "static-token"); + config.put( + "polaris.authorization.opa.auth.bearer.static-token.value", + "test-opa-bearer-token-12345"); + config.put( + "polaris.authorization.opa.http.verify-ssl", + "false"); // Disable SSL verification for tests + + // TODO: Add tests for OIDC and federated principal + config.put("polaris.authentication.type", "internal"); + + return config; + } + + @Override + public List testResources() { + String customRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + + # Deny stranger user explicitly (though default is false) + allow { + input.actor.principal == "stranger" + false + } + """; + + return List.of( + new TestResourceEntry( + OpaTestResource.class, + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); + } + } + + @Test + void testOpaAllowsRootUser() { + // Test demonstrates the complete integration flow: + // 1. OAuth token acquisition with internal authentication + // 2. OPA policy allowing root users + + // Get a token using the catalog service OAuth endpoint + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + // Parse JSON response to get access_token + String accessToken = null; + if (response.contains("\"access_token\"")) { + accessToken = response.substring(response.indexOf("\"access_token\"") + 15); + accessToken = accessToken.substring(accessToken.indexOf("\"") + 1); + accessToken = accessToken.substring(0, accessToken.indexOf("\"")); + } + + if (accessToken == null) { + fail("Failed to parse access_token from OAuth response: " + response); + } + + // Use the Bearer token to test OPA authorization + // The JWT token has principal "root" which our policy allows + given() + .header("Authorization", "Bearer " + accessToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - "root" user is allowed by policy + } + + @Test + void testOpaPolicyDeniesStrangerUser() { + // Create a "stranger" principal and get its access token + String strangerToken = createPrincipalAndGetToken("stranger"); + + // Use the stranger token to test OPA authorization - should be denied + given() + .header("Authorization", "Bearer " + strangerToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(403); // Should be forbidden by OPA policy - stranger is denied + } + + @Test + void testOpaAllowsAdminUser() { + // Create an "admin" principal and get its access token + String adminToken = createPrincipalAndGetToken("admin"); + + // Use the admin token to test OPA authorization - should be allowed + given() + .header("Authorization", "Bearer " + adminToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - admin user is allowed by policy + } + + @Test + void testOpaBearerTokenAuthentication() { + // Test that OpaPolarisAuthorizer is configured to send bearer tokens + // and can handle HTTP connections for testing + String rootToken = getRootToken(); + + given() + .header("Authorization", "Bearer " + rootToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); + } + + /** Helper method to create a principal and get an OAuth access token for that principal */ + private String createPrincipalAndGetToken(String principalName) { + // First get root token to create the principal + String rootToken = getRootToken(); + + // Create the principal using the root token + String createResponse = + given() + .contentType("application/json") + .header("Authorization", "Bearer " + rootToken) + .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") + .when() + .post("/api/management/v1/principals") + .then() + .statusCode(201) + .extract() + .body() + .asString(); + + // Parse the principal's credentials from the response + String clientId = extractJsonValue(createResponse, "clientId"); + String clientSecret = extractJsonValue(createResponse, "clientSecret"); + + if (clientId == null || clientSecret == null) { + fail("Could not parse principal credentials from response: " + createResponse); + } + + // Get access token for the newly created principal + String tokenResponse = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", clientId) + .formParam("client_secret", clientSecret) + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(tokenResponse, "access_token"); + if (accessToken == null) { + fail("Could not get access token for principal " + principalName); + } + + return accessToken; + } + + /** Helper method to get root access token */ + private String getRootToken() { + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(response, "access_token"); + if (accessToken == null) { + fail("Failed to parse access_token from admin OAuth response: " + response); + } + return accessToken; + } + + /** Simple JSON value extractor */ + private String extractJsonValue(String json, String key) { + String searchKey = "\"" + key + "\""; + if (json.contains(searchKey)) { + String value = json.substring(json.indexOf(searchKey) + searchKey.length()); + value = value.substring(value.indexOf("\"") + 1); + value = value.substring(0, value.indexOf("\"")); + return value; + } + return null; + } +} diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java index 3b02ac1163..0469cebd6f 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java @@ -28,28 +28,54 @@ import org.apache.polaris.core.auth.PolarisAuthorizer; import org.junit.jupiter.api.Test; -@QuarkusTest -@io.quarkus.test.junit.TestProfile(ServiceProducersIT.InlineConfig.class) public class ServiceProducersIT { - public static class InlineConfig implements QuarkusTestProfile { + public static class InternalAuthorizationConfig implements QuarkusTestProfile { @Override public Map getConfigOverrides() { Map config = new HashMap<>(); - config.put("polaris.authorization.type", "default"); + config.put("polaris.authorization.type", "internal"); + return config; + } + } + + public static class OpaAuthorizationConfig implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); config.put("polaris.authorization.opa.url", "http://localhost:8181"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/allow"); - config.put("polaris.authorization.opa.timeout-ms", "2000"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.auth.type", "none"); return config; } } - @Inject PolarisAuthorizer polarisAuthorizer; + @QuarkusTest + @io.quarkus.test.junit.TestProfile(ServiceProducersIT.InternalAuthorizationConfig.class) + public static class InternalAuthorizationTest { - @Test - void testPolarisAuthorizerProduced() { - assertNotNull(polarisAuthorizer, "PolarisAuthorizer should be produced"); - // Verify it's the correct implementation for default config - assertNotNull(polarisAuthorizer, "PolarisAuthorizer should not be null"); + @Inject PolarisAuthorizer polarisAuthorizer; + + @Test + void testInternalPolarisAuthorizerProduced() { + assertNotNull(polarisAuthorizer, "PolarisAuthorizer should be produced"); + // Verify it's the correct implementation for internal config + assertNotNull(polarisAuthorizer, "Internal PolarisAuthorizer should not be null"); + } + } + + @QuarkusTest + @io.quarkus.test.junit.TestProfile(ServiceProducersIT.OpaAuthorizationConfig.class) + public static class OpaAuthorizationTest { + + @Inject PolarisAuthorizer polarisAuthorizer; + + @Test + void testOpaPolarisAuthorizerProduced() { + assertNotNull(polarisAuthorizer, "PolarisAuthorizer should be produced"); + // Verify it's the correct implementation for OPA config + assertNotNull(polarisAuthorizer, "OPA PolarisAuthorizer should not be null"); + } } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java index e99263bb1c..83f251c44f 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java @@ -27,7 +27,7 @@ /** Factory for creating the default Polaris authorizer implementation. */ @ApplicationScoped -@Identifier("default") +@Identifier("internal") public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { @Override diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java deleted file mode 100644 index f08f1e5959..0000000000 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactory.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.service.auth; - -import io.smallrye.common.annotation.Identifier; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.polaris.core.auth.BearerTokenProvider; -import org.apache.polaris.core.auth.OpaPolarisAuthorizer; -import org.apache.polaris.core.auth.PolarisAuthorizer; -import org.apache.polaris.core.auth.PolarisAuthorizerFactory; -import org.apache.polaris.core.config.RealmConfig; -import org.apache.polaris.service.config.AuthorizationConfiguration; - -/** Factory for creating OPA-based Polaris authorizer implementations. */ -@ApplicationScoped -@Identifier("opa") -public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { - - private final AuthorizationConfiguration authorizationConfig; - private final CloseableHttpClient httpClient; - private final BearerTokenProvider tokenProvider; - - @Inject - public OpaPolarisAuthorizerFactory( - AuthorizationConfiguration authorizationConfig, - @Identifier("opa-http-client") CloseableHttpClient httpClient, - @Identifier("opa-bearer-token-provider") BearerTokenProvider tokenProvider) { - this.authorizationConfig = authorizationConfig; - this.httpClient = httpClient; - this.tokenProvider = tokenProvider; - } - - @Override - public PolarisAuthorizer create(RealmConfig realmConfig) { - AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); - - return OpaPolarisAuthorizer.create( - opa.url().orElse(null), - opa.policyPath().orElse(null), - tokenProvider, - httpClient); - } -} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 8ddd051b0f..2e8caa99d0 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -18,110 +18,11 @@ */ package org.apache.polaris.service.config; -import com.google.common.base.Strings; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; -import java.util.Optional; @ConfigMapping(prefix = "polaris.authorization") public interface AuthorizationConfiguration { - @WithDefault("default") + @WithDefault("internal") String type(); - - OpaConfig opa(); - - /** - * Configuration for OPA (Open Policy Agent) authorization. - * - *

    Beta Feature: OPA authorization is currently in Beta and is not a stable - * release. It may undergo breaking changes in future versions. Use with caution in production - * environments. - */ - interface OpaConfig { - Optional url(); - - Optional policyPath(); - - BearerTokenConfig bearerToken(); - - HttpConfig http(); - - /** - * HTTP client configuration for OPA communication. - */ - interface HttpConfig { - @WithDefault("2000") - int timeoutMs(); - - @WithDefault("true") - boolean verifySsl(); - - Optional trustStorePath(); - - Optional trustStorePassword(); - } - } - - interface BearerTokenConfig { - /** Whether bearer token authentication is enabled */ - @WithDefault("false") - boolean enabled(); - - /** Static bearer token value (takes precedence over file-based token) */ - Optional staticValue(); - - /** Path to file containing bearer token (used if staticValue is not set) */ - Optional filePath(); - - /** How often to refresh file-based bearer tokens (in seconds) */ - @WithDefault("300") - int refreshInterval(); - - /** - * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If - * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on - * the expiration time minus the buffer, rather than the fixed refresh interval. - */ - @WithDefault("true") - boolean jwtExpirationRefresh(); - - /** - * Buffer time in seconds before JWT expiration to refresh the token. Only used when - * jwtExpirationRefresh is true and the token is a valid JWT. Default is 60 seconds. - */ - @WithDefault("60") - int jwtExpirationBuffer(); - - default void validate() { - if (!enabled()) { - // Skip validation if bearer token authentication is disabled - return; - } - - // If enabled, ensure at least one token source is configured - if (staticValue().isEmpty() && filePath().isEmpty()) { - throw new IllegalArgumentException( - "Bearer token authentication is enabled but neither staticValue nor filePath is configured"); - } - - // If staticValue is provided, ensure it's not null or empty - if (staticValue().isPresent() && Strings.isNullOrEmpty(staticValue().get())) { - throw new IllegalArgumentException( - "staticValue cannot be null or empty when bearer token authentication is enabled"); - } - - // If filePath is provided, ensure it's not null or empty - if (filePath().isPresent() && Strings.isNullOrEmpty(filePath().get())) { - throw new IllegalArgumentException( - "filePath cannot be null or empty when bearer token authentication is enabled"); - } - - if (refreshInterval() <= 0) { - throw new IllegalArgumentException("refreshInterval must be greater than 0"); - } - if (jwtExpirationBuffer() <= 0) { - throw new IllegalArgumentException("jwtExpirationBuffer must be greater than 0"); - } - } - } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index f79ae445b5..74d012fbb7 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -33,18 +33,12 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.Context; import java.time.Clock; -import java.time.Duration; import java.util.stream.Collectors; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.polaris.core.PolarisCallContext; import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; import org.apache.polaris.core.PolarisDiagnostics; -import org.apache.polaris.core.auth.BearerTokenProvider; -import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; -import org.apache.polaris.core.auth.StaticBearerTokenProvider; import org.apache.polaris.core.config.PolarisConfigurationStore; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.core.context.CallContext; @@ -255,64 +249,6 @@ public StsClientsPool stsClientsPool( return new StsClientsPool(config.effectiveClientsCacheMaxSize(), httpClient, meterRegistry); } - @Produces - @Singleton - @Identifier("opa-http-client") - public CloseableHttpClient opaHttpClient(AuthorizationConfiguration authorizationConfig) { - AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); - - try { - return OpaHttpClientFactory.createHttpClient(opa.http()); - } catch (Exception e) { - LOGGER.warn("Failed to create OPA HTTP client with SSL configuration, falling back to simple client", e); - return HttpClients.custom().build(); - } - } - - public void closeOpaHttpClient( - @Disposes @Identifier("opa-http-client") CloseableHttpClient client) { - try { - client.close(); - } catch (Exception e) { - LOGGER.warn("Error closing OPA HTTP client", e); - } - } - - @Produces - @Singleton - @Identifier("opa-bearer-token-provider") - public BearerTokenProvider opaBearerTokenProvider( - AuthorizationConfiguration authorizationConfig) { - AuthorizationConfiguration.OpaConfig opa = authorizationConfig.opa(); - AuthorizationConfiguration.BearerTokenConfig bearerToken = opa.bearerToken(); - - // Validate configuration before using it - bearerToken.validate(); - - // Check if bearer token authentication is enabled - if (!bearerToken.enabled()) { - return new StaticBearerTokenProvider(""); - } - - // Static token takes precedence - if (bearerToken.staticValue().isPresent()) { - return new StaticBearerTokenProvider(bearerToken.staticValue().get()); - } - - // File-based token as fallback - if (bearerToken.filePath().isPresent()) { - Duration refreshInterval = Duration.ofSeconds(bearerToken.refreshInterval()); - boolean jwtExpirationRefresh = bearerToken.jwtExpirationRefresh(); - Duration jwtExpirationBuffer = Duration.ofSeconds(bearerToken.jwtExpirationBuffer()); - - return new FileBearerTokenProvider( - bearerToken.filePath().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); - } - - // No token configured (this shouldn't happen due to validation, but it's here as fallback) - return new StaticBearerTokenProvider(""); - } - /** * Eagerly initialize the in-memory default realm on startup, so that users can check the * credentials printed to stdout immediately. diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java deleted file mode 100644 index 89eaae682e..0000000000 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaPolarisAuthorizerFactoryTest.java +++ /dev/null @@ -1,279 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.service.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Optional; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.polaris.core.auth.BearerTokenProvider; -import org.apache.polaris.core.auth.FileBearerTokenProvider; -import org.apache.polaris.core.auth.OpaPolarisAuthorizer; -import org.apache.polaris.core.config.RealmConfig; -import org.apache.polaris.service.config.AuthorizationConfiguration; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -public class OpaPolarisAuthorizerFactoryTest { - - @TempDir Path tempDir; - - @Test - public void testFactoryCreatesStaticTokenProvider() { - // Mock configuration for static token - AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = - mock(AuthorizationConfiguration.BearerTokenConfig.class); - when(bearerTokenConfig.staticValue()).thenReturn(Optional.of("static-token-value")); - when(bearerTokenConfig.filePath()).thenReturn(Optional.empty()); - when(bearerTokenConfig.refreshInterval()).thenReturn(300); - when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); - when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); - - AuthorizationConfiguration.OpaConfig opaConfig = - mock(AuthorizationConfiguration.OpaConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.http()).thenReturn(createMockHttpConfig()); - - AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); - when(authConfig.opa()).thenReturn(opaConfig); - - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory( - authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); - - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - assertNotNull(authorizer); - } - - @Test - public void testFactoryCreatesFileBearerTokenProvider() throws IOException { - // Create a temporary token file - Path tokenFile = tempDir.resolve("bearer-token.txt"); - String tokenValue = "file-based-token-value"; - Files.writeString(tokenFile, tokenValue); - - // Mock configuration for file-based token - AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = - mock(AuthorizationConfiguration.BearerTokenConfig.class); - when(bearerTokenConfig.staticValue()).thenReturn(Optional.empty()); // No static token - when(bearerTokenConfig.filePath()).thenReturn(Optional.of(tokenFile.toString())); - when(bearerTokenConfig.refreshInterval()).thenReturn(300); - when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); - when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); - - AuthorizationConfiguration.OpaConfig opaConfig = - mock(AuthorizationConfiguration.OpaConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.http()).thenReturn(createMockHttpConfig()); - - AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); - when(authConfig.opa()).thenReturn(opaConfig); - - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory( - authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); - - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - assertNotNull(authorizer); - } - - @Test - public void testFileBearerTokenProviderActuallyReadsFromFile() throws IOException { - // Create a temporary token file - Path tokenFile = tempDir.resolve("bearer-token.txt"); - String tokenValue = "file-based-token-from-disk"; - Files.writeString(tokenFile, tokenValue); - - // Create FileBearerTokenProvider directly to test it reads the file - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); - - // Verify the token is read from the file - String actualToken = provider.getToken(); - assertEquals(tokenValue, actualToken); - - provider.close(); - } - - @Test - public void testFactoryPrefersStaticTokenOverFileToken() throws IOException { - // Create a temporary token file - Path tokenFile = tempDir.resolve("bearer-token.txt"); - Files.writeString(tokenFile, "file-token-value"); - - // Mock configuration with BOTH static and file tokens - AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = - mock(AuthorizationConfiguration.BearerTokenConfig.class); - when(bearerTokenConfig.staticValue()) - .thenReturn(Optional.of("static-token-value")); // Static token present - when(bearerTokenConfig.filePath()) - .thenReturn(Optional.of(tokenFile.toString())); // File token also present - when(bearerTokenConfig.refreshInterval()).thenReturn(300); - when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); - when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); - - AuthorizationConfiguration.OpaConfig opaConfig = - mock(AuthorizationConfiguration.OpaConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.http()).thenReturn(createMockHttpConfig()); - - AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); - when(authConfig.opa()).thenReturn(opaConfig); - - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory( - authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); - - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - assertNotNull(authorizer); - // Note: We can't easily test which token provider is used without exposing internals, - // but we can verify that the authorizer was created successfully. - } - - @Test - public void testFactoryWithNoTokenConfiguration() { - // Mock configuration with no tokens - AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = - mock(AuthorizationConfiguration.BearerTokenConfig.class); - when(bearerTokenConfig.staticValue()).thenReturn(Optional.empty()); - when(bearerTokenConfig.filePath()).thenReturn(Optional.empty()); - when(bearerTokenConfig.refreshInterval()).thenReturn(300); - when(bearerTokenConfig.jwtExpirationRefresh()).thenReturn(true); - when(bearerTokenConfig.jwtExpirationBuffer()).thenReturn(60); - - AuthorizationConfiguration.OpaConfig opaConfig = - mock(AuthorizationConfiguration.OpaConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.http()).thenReturn(createMockHttpConfig()); - - AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); - when(authConfig.opa()).thenReturn(opaConfig); - - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory( - authConfig, mock(CloseableHttpClient.class), mock(BearerTokenProvider.class)); - - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - assertNotNull(authorizer); - } - - @Test - public void testFactoryValidatesConfiguration() { - // Create a mock BearerTokenProvider that throws an exception - BearerTokenProvider mockTokenProvider = mock(BearerTokenProvider.class); - when(mockTokenProvider.getToken()) - .thenThrow(new IllegalArgumentException("refreshInterval must be greater than 0")); - - // Create a real implementation of BearerTokenConfig that will have invalid values - AuthorizationConfiguration.BearerTokenConfig bearerTokenConfig = - new AuthorizationConfiguration.BearerTokenConfig() { - @Override - public boolean enabled() { - return true; - } - - @Override - public Optional staticValue() { - return Optional.empty(); - } - - @Override - public Optional filePath() { - return Optional.empty(); - } - - @Override - public int refreshInterval() { - return -1; - } // Invalid: negative value - - @Override - public boolean jwtExpirationRefresh() { - return true; - } - - @Override - public int jwtExpirationBuffer() { - return 60; - } - }; - - AuthorizationConfiguration.OpaConfig opaConfig = - mock(AuthorizationConfiguration.OpaConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.bearerToken()).thenReturn(bearerTokenConfig); - when(opaConfig.http()).thenReturn(createMockHttpConfig()); - - AuthorizationConfiguration authConfig = mock(AuthorizationConfiguration.class); - when(authConfig.opa()).thenReturn(opaConfig); - - CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(authConfig, mockHttpClient, mockTokenProvider); - - // Create authorizer instance - this should succeed since validation happens at token provider - // level - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - // The authorizer should be created successfully - assertNotNull(authorizer); - - // Note: Validation of bearer token configuration now happens when the BearerTokenProvider - // is created by the CDI system, not when the factory creates the authorizer. - } - - private AuthorizationConfiguration.OpaConfig.HttpConfig createMockHttpConfig() { - AuthorizationConfiguration.OpaConfig.HttpConfig httpConfig = - mock(AuthorizationConfiguration.OpaConfig.HttpConfig.class); - when(httpConfig.timeoutMs()).thenReturn(2000); - when(httpConfig.verifySsl()).thenReturn(true); - when(httpConfig.trustStorePath()).thenReturn(Optional.empty()); - when(httpConfig.trustStorePassword()).thenReturn(Optional.empty()); - return httpConfig; - } -} From 4252a44e85feb76e646deee03d4b0e144ac19159 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:17:50 +0000 Subject: [PATCH 25/40] fix opa tests --- .../service/auth/OpaFileTokenIntegrationTest.java | 10 ++++++---- .../polaris/service/auth/OpaIntegrationTest.java | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java index 33bed96054..432787635c 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java @@ -46,7 +46,7 @@ public Map getConfigOverrides() { Map config = new HashMap<>(); config.put("polaris.authorization.type", "opa"); config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.timeout-ms", "2000"); + config.put("polaris.authorization.opa.http.timeout-ms", "2000"); // Create temporary token file for testing try { @@ -58,12 +58,14 @@ public Map getConfigOverrides() { } // Configure OPA server authentication with file-based bearer token - config.put("polaris.authorization.opa.bearer-token.file-path", tokenFile.toString()); + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); + config.put("polaris.authorization.opa.auth.bearer.file-based.path", tokenFile.toString()); config.put( - "polaris.authorization.opa.bearer-token.refresh-interval", + "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "1"); // 1 second for fast testing config.put( - "polaris.authorization.opa.verify-ssl", "false"); // Disable SSL verification for tests + "polaris.authorization.opa.http.verify-ssl", "false"); // Disable SSL verification for tests // TODO: Add tests for OIDC and federated principal config.put("polaris.authentication.type", "internal"); diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index 03184bf8ab..147cf32735 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -46,13 +46,15 @@ public Map getConfigOverrides() { Map config = new HashMap<>(); config.put("polaris.authorization.type", "opa"); config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.timeout-ms", "2000"); + config.put("polaris.authorization.opa.http.timeout-ms", "2000"); // Configure OPA server authentication with static bearer token + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put("polaris.authorization.opa.auth.bearer.type", "static-token"); config.put( - "polaris.authorization.opa.bearer-token.static-value", "test-opa-bearer-token-12345"); + "polaris.authorization.opa.auth.bearer.static-token.value", "test-opa-bearer-token-12345"); config.put( - "polaris.authorization.opa.verify-ssl", "false"); // Disable SSL verification for tests + "polaris.authorization.opa.http.verify-ssl", "false"); // Disable SSL verification for tests // TODO: Add tests for OIDC and federated principal config.put("polaris.authentication.type", "internal"); From c0053f90e397d9dcc28962d8ab82c69e6ac138f0 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 10 Oct 2025 22:26:00 +0000 Subject: [PATCH 26/40] lint --- .../polaris/service/auth/OpaFileTokenIntegrationTest.java | 3 ++- .../org/apache/polaris/service/auth/OpaIntegrationTest.java | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java index 432787635c..25c1d00ef1 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java @@ -65,7 +65,8 @@ public Map getConfigOverrides() { "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "1"); // 1 second for fast testing config.put( - "polaris.authorization.opa.http.verify-ssl", "false"); // Disable SSL verification for tests + "polaris.authorization.opa.http.verify-ssl", + "false"); // Disable SSL verification for tests // TODO: Add tests for OIDC and federated principal config.put("polaris.authentication.type", "internal"); diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java index 147cf32735..60d6238488 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java @@ -52,9 +52,11 @@ public Map getConfigOverrides() { config.put("polaris.authorization.opa.auth.type", "bearer"); config.put("polaris.authorization.opa.auth.bearer.type", "static-token"); config.put( - "polaris.authorization.opa.auth.bearer.static-token.value", "test-opa-bearer-token-12345"); + "polaris.authorization.opa.auth.bearer.static-token.value", + "test-opa-bearer-token-12345"); config.put( - "polaris.authorization.opa.http.verify-ssl", "false"); // Disable SSL verification for tests + "polaris.authorization.opa.http.verify-ssl", + "false"); // Disable SSL verification for tests // TODO: Add tests for OIDC and federated principal config.put("polaris.authentication.type", "internal"); From 0eb0a977ff13623de5fbe80f9d9ca8b91dd77dac Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Sat, 11 Oct 2025 04:28:20 +0000 Subject: [PATCH 27/40] refactoring and cleaning up dependencies --- extensions/auth/opa/build.gradle.kts | 2 +- .../auth/opa/OpaHttpClientFactory.java | 51 ++++++++------ .../auth/opa/OpaPolarisAuthorizer.java | 2 +- .../auth/opa/OpaPolarisAuthorizerFactory.java | 47 +++++++------ .../auth/opa/token}/BearerTokenProvider.java | 2 +- .../opa/token}/FileBearerTokenProvider.java | 2 +- .../opa/token}/StaticBearerTokenProvider.java | 2 +- .../opa/OpaPolarisAuthorizerFactoryTest.java | 2 +- .../auth/opa/OpaPolarisAuthorizerTest.java | 4 +- .../token}/FileBearerTokenProviderTest.java | 2 +- .../token}/StaticBearerTokenProviderTest.java | 2 +- polaris-core/build.gradle.kts | 2 - .../main/resources/application-it.properties | 4 +- runtime/server/build.gradle.kts | 1 + runtime/service/build.gradle.kts | 1 - .../auth/opa/OpaFileTokenIntegrationTest.java | 41 ++++-------- .../auth/opa/OpaFileTokenTestResource.java | 67 +++++++++++++++++++ .../service/config/ServiceProducers.java | 14 ++-- 18 files changed, 157 insertions(+), 91 deletions(-) rename {polaris-core/src/main/java/org/apache/polaris/core/auth => extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token}/BearerTokenProvider.java (96%) rename {polaris-core/src/main/java/org/apache/polaris/core/auth => extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token}/FileBearerTokenProvider.java (99%) rename {polaris-core/src/main/java/org/apache/polaris/core/auth => extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token}/StaticBearerTokenProvider.java (95%) rename {polaris-core/src/test/java/org/apache/polaris/core/auth => extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token}/FileBearerTokenProviderTest.java (99%) rename {polaris-core/src/test/java/org/apache/polaris/core/auth => extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token}/StaticBearerTokenProviderTest.java (96%) create mode 100644 runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java diff --git a/extensions/auth/opa/build.gradle.kts b/extensions/auth/opa/build.gradle.kts index b3334792d3..33c4c7cd83 100644 --- a/extensions/auth/opa/build.gradle.kts +++ b/extensions/auth/opa/build.gradle.kts @@ -24,13 +24,13 @@ plugins { dependencies { implementation(project(":polaris-core")) - implementation(project(":polaris-runtime-service")) implementation(libs.apache.httpclient5) implementation(platform(libs.jackson.bom)) implementation("com.fasterxml.jackson.core:jackson-core") implementation("com.fasterxml.jackson.core:jackson-databind") implementation(libs.guava) implementation(libs.slf4j.api) + implementation(libs.auth0.jwt) // Iceberg dependency for ForbiddenException implementation(platform(libs.iceberg.bom)) diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java index 5d611be906..df2ae87a60 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java @@ -56,29 +56,42 @@ public static CloseableHttpClient createHttpClient(OpaAuthorizationConfig.HttpCo .setResponseTimeout(Timeout.ofMilliseconds(config.timeoutMs())) .build(); - if (!config.verifySsl()) { - // Create connection manager with custom TLS strategy (for development/testing) - try { - SSLContext sslContext = createSslContext(config); - DefaultClientTlsStrategy tlsStrategy = - new DefaultClientTlsStrategy(sslContext, NoopHostnameVerifier.INSTANCE); + try { + // Create TLS strategy based on configuration + DefaultClientTlsStrategy tlsStrategy = createTlsStrategy(config); - var connectionManager = - PoolingHttpClientConnectionManagerBuilder.create() - .setTlsSocketStrategy(tlsStrategy) - .build(); + // Create connection manager with the TLS strategy + var connectionManager = + PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(tlsStrategy) + .build(); - return HttpClients.custom() - .setConnectionManager(connectionManager) - .setDefaultRequestConfig(requestConfig) - .build(); - } catch (Exception e) { - throw new RuntimeException("Failed to create SSL context for OPA client", e); - } + return HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create HTTP client for OPA communication", e); } + } - // For SSL verification enabled, use default configuration - return HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); + /** + * Creates a TLS strategy based on the configuration. + * + * @param config HTTP configuration containing SSL settings + * @return DefaultClientTlsStrategy for HTTPS connections + */ + private static DefaultClientTlsStrategy createTlsStrategy( + OpaAuthorizationConfig.HttpConfig config) throws Exception { + SSLContext sslContext = createSslContext(config); + + if (!config.verifySsl()) { + // Disable hostname verification when SSL verification is disabled + return new DefaultClientTlsStrategy(sslContext, NoopHostnameVerifier.INSTANCE); + } else { + // Use default hostname verification when SSL verification is enabled + return new DefaultClientTlsStrategy(sslContext); + } } /** diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index b808a16fa6..c526a90dd1 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -37,12 +37,12 @@ import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.iceberg.exceptions.ForbiddenException; -import org.apache.polaris.core.auth.BearerTokenProvider; import org.apache.polaris.core.auth.PolarisAuthorizableOperation; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisPrincipal; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; /** * OPA-based implementation of {@link PolarisAuthorizer}. diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index 21c0b26484..012b5abe86 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -19,17 +19,18 @@ package org.apache.polaris.extension.auth.opa; import io.smallrye.common.annotation.Identifier; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.time.Duration; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.polaris.core.auth.BearerTokenProvider; -import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; -import org.apache.polaris.core.auth.StaticBearerTokenProvider; import org.apache.polaris.core.config.RealmConfig; +import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; +import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider; +import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; /** Factory for creating OPA-based Polaris authorizer implementations. */ @ApplicationScoped @@ -37,41 +38,43 @@ public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { private final OpaAuthorizationConfig opaConfig; + private CloseableHttpClient httpClient; + private BearerTokenProvider bearerTokenProvider; @Inject public OpaPolarisAuthorizerFactory(OpaAuthorizationConfig opaConfig) { this.opaConfig = opaConfig; } + @PostConstruct + public void initialize() { + // Validate configuration once during startup + opaConfig.validate(); + + // Create HTTP client once during startup + httpClient = createHttpClient(); + + // Create bearer token provider once during startup + bearerTokenProvider = createBearerTokenProvider(opaConfig.auth().get()); + } + @Override public PolarisAuthorizer create(RealmConfig realmConfig) { - // Validate configuration before creating authorizer - opaConfig.validate(); + // All components are now pre-initialized, just create the authorizer + return OpaPolarisAuthorizer.create( + opaConfig.url().get(), opaConfig.policyPath().get(), bearerTokenProvider, httpClient); + } - // Create HTTP client directly - CloseableHttpClient httpClient; + private CloseableHttpClient createHttpClient() { try { if (opaConfig.http().isEmpty()) { throw new IllegalStateException("HTTP configuration is required"); } - httpClient = OpaHttpClientFactory.createHttpClient(opaConfig.http().get()); + return OpaHttpClientFactory.createHttpClient(opaConfig.http().get()); } catch (Exception e) { // Fallback to simple client - httpClient = HttpClients.custom().build(); - } - - // Create bearer token provider directly - if (opaConfig.auth().isEmpty()) { - throw new IllegalStateException("Authentication configuration is required"); + return HttpClients.custom().build(); } - BearerTokenProvider tokenProvider = createBearerTokenProvider(opaConfig.auth().get()); - - if (opaConfig.url().isEmpty() || opaConfig.policyPath().isEmpty()) { - throw new IllegalStateException("URL and policy path are required"); - } - - return OpaPolarisAuthorizer.create( - opaConfig.url().get(), opaConfig.policyPath().get(), tokenProvider, httpClient); } private BearerTokenProvider createBearerTokenProvider( diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/BearerTokenProvider.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java similarity index 96% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/BearerTokenProvider.java rename to extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java index 15a6b17fc0..977c11506f 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/BearerTokenProvider.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa.token; import jakarta.annotation.Nullable; diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java similarity index 99% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java rename to extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 529fa6b47e..b1d67fd0d0 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/FileBearerTokenProvider.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa.token; import com.auth0.jwt.JWT; import com.auth0.jwt.exceptions.JWTDecodeException; diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticBearerTokenProvider.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java similarity index 95% rename from polaris-core/src/main/java/org/apache/polaris/core/auth/StaticBearerTokenProvider.java rename to extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java index 4f6c44ad14..d4d8137e0f 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/StaticBearerTokenProvider.java +++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa.token; /** A simple token provider that returns a static string value. */ public class StaticBearerTokenProvider implements BearerTokenProvider { diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index ad81d02125..f2870074c5 100644 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -28,8 +28,8 @@ import java.nio.file.Path; import java.time.Duration; import java.util.Optional; -import org.apache.polaris.core.auth.FileBearerTokenProvider; import org.apache.polaris.core.config.RealmConfig; +import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java index 67e6055b23..f32ddc712b 100644 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java @@ -47,15 +47,15 @@ import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.core5.http.HttpEntity; import org.apache.iceberg.exceptions.ForbiddenException; -import org.apache.polaris.core.auth.BearerTokenProvider; import org.apache.polaris.core.auth.PolarisAuthorizableOperation; import org.apache.polaris.core.auth.PolarisPrincipal; -import org.apache.polaris.core.auth.StaticBearerTokenProvider; import org.apache.polaris.core.entity.PolarisBaseEntity; import org.apache.polaris.core.entity.PolarisEntity; import org.apache.polaris.core.entity.PolarisEntityType; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; import org.apache.polaris.core.persistence.ResolvedPolarisEntity; +import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; +import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileBearerTokenProviderTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java similarity index 99% rename from polaris-core/src/test/java/org/apache/polaris/core/auth/FileBearerTokenProviderTest.java rename to extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java index af8592c4a4..a59ee6b5e2 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/FileBearerTokenProviderTest.java +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa.token; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticBearerTokenProviderTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java similarity index 96% rename from polaris-core/src/test/java/org/apache/polaris/core/auth/StaticBearerTokenProviderTest.java rename to extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java index 559add872e..4fa12e3110 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/StaticBearerTokenProviderTest.java +++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.core.auth; +package org.apache.polaris.extension.auth.opa.token; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index 29790494e5..ba5335701e 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -24,7 +24,6 @@ plugins { dependencies { implementation(project(":polaris-api-management-model")) - implementation(libs.apache.httpclient5) implementation(platform(libs.iceberg.bom)) implementation("org.apache.iceberg:iceberg-api") @@ -45,7 +44,6 @@ dependencies { implementation(libs.caffeine) implementation(libs.guava) implementation(libs.slf4j.api) - implementation(libs.auth0.jwt) compileOnly(project(":polaris-immutables")) annotationProcessor(project(":polaris-immutables", configuration = "processor")) diff --git a/runtime/defaults/src/main/resources/application-it.properties b/runtime/defaults/src/main/resources/application-it.properties index f5a29a40c6..f2cca1283e 100644 --- a/runtime/defaults/src/main/resources/application-it.properties +++ b/runtime/defaults/src/main/resources/application-it.properties @@ -56,7 +56,5 @@ polaris.realm-context.realms=POLARIS,OTHER polaris.storage.gcp.token=token polaris.storage.gcp.lifespan=PT1H -# Index OPA extension for configuration mapping discovery during integration tests -quarkus.index-dependency.opa.group-id=org.apache.polaris -quarkus.index-dependency.opa.artifact-id=polaris-extensions-auth-opa + diff --git a/runtime/server/build.gradle.kts b/runtime/server/build.gradle.kts index 34f75dd14b..eba921a5d9 100644 --- a/runtime/server/build.gradle.kts +++ b/runtime/server/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { runtimeOnly(project(":polaris-relational-jdbc")) runtimeOnly("io.quarkus:quarkus-jdbc-postgresql") runtimeOnly(project(":polaris-extensions-federation-hadoop")) + runtimeOnly(project(":polaris-extensions-auth-opa")) if ((project.findProperty("NonRESTCatalogs") as String?)?.contains("HIVE") == true) { runtimeOnly(project(":polaris-extensions-federation-hive")) diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index bfc0a4daaa..231c41f82d 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -139,7 +139,6 @@ dependencies { testImplementation(project(":polaris-runtime-test-common")) testImplementation(project(":polaris-container-spec-helper")) - // OPA Authorization Extension for testing testImplementation(project(":polaris-extensions-auth-opa")) testImplementation(libs.threeten.extra) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java index 1e67be2080..32965ce502 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java @@ -32,14 +32,17 @@ import java.util.List; import java.util.Map; import org.apache.polaris.test.commons.OpaTestResource; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.junit.jupiter.api.Test; @QuarkusTest @TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) public class OpaFileTokenIntegrationTest { + @ConfigProperty(name = "polaris.authorization.opa.auth.bearer.file-based.path") + String tokenFilePath; + public static class FileTokenOpaProfile implements QuarkusTestProfile { - private static volatile Path tokenFile; @Override public Map getConfigOverrides() { @@ -48,22 +51,10 @@ public Map getConfigOverrides() { config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); config.put("polaris.authorization.opa.http.timeout-ms", "2000"); - // Create temporary token file for testing - try { - tokenFile = Files.createTempFile("opa-test-token", ".txt"); - Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); - tokenFile.toFile().deleteOnExit(); - } catch (IOException e) { - throw new RuntimeException("Failed to create test token file", e); - } - // Configure OPA server authentication with file-based bearer token config.put("polaris.authorization.opa.auth.type", "bearer"); config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); - config.put("polaris.authorization.opa.auth.bearer.file-based.path", tokenFile.toString()); - config.put( - "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", - "300"); // 300 seconds for testing + // Token file path will be provided by OpaFileTokenTestResource config.put( "polaris.authorization.opa.http.verify-ssl", "false"); // Disable SSL verification for tests @@ -102,11 +93,8 @@ public List testResources() { return List.of( new TestResourceEntry( OpaTestResource.class, - Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); - } - - public static Path getTokenFile() { - return tokenFile; + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy)), + new TestResourceEntry(OpaFileTokenTestResource.class)); } } @@ -169,27 +157,22 @@ void testFileTokenRefresh() throws IOException, InterruptedException { .then() .statusCode(200); - // Update the token file with a new value - // Note: In a real test, we'd need to coordinate with the OPA server to accept the new token - // For this demo, we'll just verify the file can be updated - var tokenFile = FileTokenOpaProfile.getTokenFile(); - if (tokenFile != null && Files.exists(tokenFile)) { + // Get the token file path from injected configuration + Path tokenFile = Path.of(tokenFilePath); + if (Files.exists(tokenFile)) { String originalContent = Files.readString(tokenFile); // Update the file content Files.writeString(tokenFile, "test-opa-bearer-token-updated-12345"); - // Wait for refresh interval (1 second as configured) - Thread.sleep(1500); + // Wait for refresh interval (1 second as configured) plus some buffer + Thread.sleep(1500); // 1.5 seconds to ensure refresh happens // Verify the file was updated String updatedContent = Files.readString(tokenFile); if (updatedContent.equals(originalContent)) { fail("Token file was not updated as expected"); } - - // Note: We can't test that OPA actually receives the new token without - // coordinating with the OPA test container, but we've verified the file mechanism works } } diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java new file mode 100644 index 0000000000..39c53580ad --- /dev/null +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.service.auth.opa; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** Test resource that manages a temporary token file for OPA authentication testing. */ +public class OpaFileTokenTestResource implements QuarkusTestResourceLifecycleManager { + private Path tokenFile; + + @Override + public Map start() { + try { + // Create temporary token file + tokenFile = Files.createTempFile("opa-test-token", ".txt"); + Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); + + // Return configuration that will be added to the test + Map config = new HashMap<>(); + config.put("polaris.authorization.opa.auth.bearer.file-based.path", tokenFile.toString()); + config.put( + "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", + "1"); // 1 second for testing + + return config; + } catch (IOException e) { + throw new RuntimeException("Failed to create test token file", e); + } + } + + @Override + public void stop() { + if (tokenFile != null) { + try { + Files.deleteIfExists(tokenFile); + } catch (IOException e) { + System.err.println("Warning: Failed to delete test token file: " + e.getMessage()); + } + } + } + + /** Get the token file path for tests that need to modify it. */ + public Path getTokenFile() { + return tokenFile; + } +} diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index 74d012fbb7..211949c99d 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -140,13 +140,17 @@ public RealmConfig realmConfig(CallContext callContext) { } @Produces - @RequestScoped - public PolarisAuthorizer polarisAuthorizer( + @ApplicationScoped + public PolarisAuthorizerFactory polarisAuthorizerFactory( AuthorizationConfiguration authorizationConfig, - RealmConfig realmConfig, @Any Instance authorizerFactories) { - PolarisAuthorizerFactory factory = - authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get(); + return authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get(); + } + + @Produces + @RequestScoped + public PolarisAuthorizer polarisAuthorizer( + PolarisAuthorizerFactory factory, RealmConfig realmConfig) { return factory.create(realmConfig); } From 7b61eee4cd6469d9af685c8dce0e1f995f35cac2 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Sat, 11 Oct 2025 22:49:38 +0000 Subject: [PATCH 28/40] remove old integration test files --- .../auth/OpaFileTokenIntegrationTest.java | 310 ------------------ .../service/auth/OpaIntegrationTest.java | 271 --------------- 2 files changed, 581 deletions(-) delete mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java delete mode 100644 runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java deleted file mode 100644 index 25c1d00ef1..0000000000 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaFileTokenIntegrationTest.java +++ /dev/null @@ -1,310 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.service.auth; - -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.fail; - -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; -import io.quarkus.test.junit.TestProfile; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.apache.polaris.test.commons.OpaTestResource; -import org.junit.jupiter.api.Test; - -@QuarkusTest -@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) -public class OpaFileTokenIntegrationTest { - - public static class FileTokenOpaProfile implements QuarkusTestProfile { - private static volatile Path tokenFile; - - @Override - public Map getConfigOverrides() { - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.http.timeout-ms", "2000"); - - // Create temporary token file for testing - try { - tokenFile = Files.createTempFile("opa-test-token", ".txt"); - Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); - tokenFile.toFile().deleteOnExit(); - } catch (IOException e) { - throw new RuntimeException("Failed to create test token file", e); - } - - // Configure OPA server authentication with file-based bearer token - config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); - config.put("polaris.authorization.opa.auth.bearer.file-based.path", tokenFile.toString()); - config.put( - "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", - "1"); // 1 second for fast testing - config.put( - "polaris.authorization.opa.http.verify-ssl", - "false"); // Disable SSL verification for tests - - // TODO: Add tests for OIDC and federated principal - config.put("polaris.authentication.type", "internal"); - - return config; - } - - @Override - public List testResources() { - String customRegoPolicy = - """ - package polaris.authz - - default allow := false - - # Allow root user for all operations - allow { - input.actor.principal == "root" - } - - # Allow admin user for all operations - allow { - input.actor.principal == "admin" - } - - # Deny stranger user explicitly (though default is false) - allow { - input.actor.principal == "stranger" - false - } - """; - - return List.of( - new TestResourceEntry( - OpaTestResource.class, - Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); - } - - public static Path getTokenFile() { - return tokenFile; - } - } - - /** - * Test demonstrates OPA integration with file-based bearer token authentication. This test - * verifies that the FileBearerTokenProvider correctly reads tokens from a file and that the full - * integration works with file-based configuration. - */ - @Test - void testOpaAllowsRootUserWithFileToken() { - // Test demonstrates the complete integration flow with file-based tokens: - // 1. OAuth token acquisition with internal authentication - // 2. OPA policy allowing root users - // 3. Bearer token read from file by FileBearerTokenProvider - - // Get a token using the catalog service OAuth endpoint - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - // Parse JSON response to get access_token - String accessToken = extractJsonValue(response, "access_token"); - - if (accessToken == null) { - fail("Failed to parse access_token from OAuth response: " + response); - } - - // Use the Bearer token to test OPA authorization - // The JWT token has principal "root" which our policy allows - given() - .header("Authorization", "Bearer " + accessToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); // Should succeed - "root" user is allowed by policy - } - - @Test - void testFileTokenRefresh() throws IOException, InterruptedException { - // This test verifies that the FileBearerTokenProvider refreshes tokens from the file - - // First verify the system works with the initial token - String rootToken = getRootToken(); - - given() - .header("Authorization", "Bearer " + rootToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); - - // Update the token file with a new value - // Note: In a real test, we'd need to coordinate with the OPA server to accept the new token - // For this demo, we'll just verify the file can be updated - var tokenFile = FileTokenOpaProfile.getTokenFile(); - if (tokenFile != null && Files.exists(tokenFile)) { - String originalContent = Files.readString(tokenFile); - - // Update the file content - Files.writeString(tokenFile, "test-opa-bearer-token-updated-12345"); - - // Wait for refresh interval (1 second as configured) - Thread.sleep(1500); - - // Verify the file was updated - String updatedContent = Files.readString(tokenFile); - if (updatedContent.equals(originalContent)) { - fail("Token file was not updated as expected"); - } - - // Note: We can't test that OPA actually receives the new token without - // coordinating with the OPA test container, but we've verified the file mechanism works - } - } - - @Test - void testOpaPolicyDeniesStrangerUserWithFileToken() { - // Create a "stranger" principal and get its access token - String strangerToken = createPrincipalAndGetToken("stranger"); - - // Use the stranger token to test OPA authorization - should be denied - given() - .header("Authorization", "Bearer " + strangerToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(403); // Should be forbidden by OPA policy - stranger is denied - } - - @Test - void testOpaAllowsAdminUserWithFileToken() { - // Create an "admin" principal and get its access token - String adminToken = createPrincipalAndGetToken("admin"); - - // Use the admin token to test OPA authorization - should be allowed - given() - .header("Authorization", "Bearer " + adminToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); // Should succeed - admin user is allowed by policy - } - - /** Helper method to create a principal and get an OAuth access token for that principal */ - private String createPrincipalAndGetToken(String principalName) { - // First get root token to create the principal - String rootToken = getRootToken(); - - // Create the principal using the root token - String createResponse = - given() - .contentType("application/json") - .header("Authorization", "Bearer " + rootToken) - .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") - .when() - .post("/api/management/v1/principals") - .then() - .statusCode(201) - .extract() - .body() - .asString(); - - // Parse the principal's credentials from the response - String clientId = extractJsonValue(createResponse, "clientId"); - String clientSecret = extractJsonValue(createResponse, "clientSecret"); - - if (clientId == null || clientSecret == null) { - fail("Could not parse principal credentials from response: " + createResponse); - } - - // Get access token for the newly created principal - String tokenResponse = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", clientId) - .formParam("client_secret", clientSecret) - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(tokenResponse, "access_token"); - if (accessToken == null) { - fail("Could not get access token for principal " + principalName); - } - - return accessToken; - } - - /** Helper method to get root access token */ - private String getRootToken() { - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(response, "access_token"); - if (accessToken == null) { - fail("Failed to parse access_token from admin OAuth response: " + response); - } - return accessToken; - } - - /** Simple JSON value extractor */ - private String extractJsonValue(String json, String key) { - String searchKey = "\"" + key + "\""; - if (json.contains(searchKey)) { - String value = json.substring(json.indexOf(searchKey) + searchKey.length()); - value = value.substring(value.indexOf("\"") + 1); - value = value.substring(0, value.indexOf("\"")); - return value; - } - return null; - } -} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java deleted file mode 100644 index 60d6238488..0000000000 --- a/runtime/service/src/test/java/org/apache/polaris/service/auth/OpaIntegrationTest.java +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.service.auth; - -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.fail; - -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; -import io.quarkus.test.junit.TestProfile; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.apache.polaris.test.commons.OpaTestResource; -import org.junit.jupiter.api.Test; - -@QuarkusTest -@TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class) -public class OpaIntegrationTest { - - /** - * Test demonstrates OPA integration with bearer token authentication. The OPA container runs with - * HTTP for simplicity in CI environments. The OpaPolarisAuthorizer is configured to disable SSL - * verification for test purposes. - */ - public static class StaticTokenOpaProfile implements QuarkusTestProfile { - @Override - public Map getConfigOverrides() { - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.http.timeout-ms", "2000"); - - // Configure OPA server authentication with static bearer token - config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put("polaris.authorization.opa.auth.bearer.type", "static-token"); - config.put( - "polaris.authorization.opa.auth.bearer.static-token.value", - "test-opa-bearer-token-12345"); - config.put( - "polaris.authorization.opa.http.verify-ssl", - "false"); // Disable SSL verification for tests - - // TODO: Add tests for OIDC and federated principal - config.put("polaris.authentication.type", "internal"); - - return config; - } - - @Override - public List testResources() { - String customRegoPolicy = - """ - package polaris.authz - - default allow := false - - # Allow root user for all operations - allow { - input.actor.principal == "root" - } - - # Allow admin user for all operations - allow { - input.actor.principal == "admin" - } - - # Deny stranger user explicitly (though default is false) - allow { - input.actor.principal == "stranger" - false - } - """; - - return List.of( - new TestResourceEntry( - OpaTestResource.class, - Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); - } - } - - @Test - void testOpaAllowsRootUser() { - // Test demonstrates the complete integration flow: - // 1. OAuth token acquisition with internal authentication - // 2. OPA policy allowing root users - - // Get a token using the catalog service OAuth endpoint - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - // Parse JSON response to get access_token - String accessToken = null; - if (response.contains("\"access_token\"")) { - accessToken = response.substring(response.indexOf("\"access_token\"") + 15); - accessToken = accessToken.substring(accessToken.indexOf("\"") + 1); - accessToken = accessToken.substring(0, accessToken.indexOf("\"")); - } - - if (accessToken == null) { - fail("Failed to parse access_token from OAuth response: " + response); - } - - // Use the Bearer token to test OPA authorization - // The JWT token has principal "root" which our policy allows - given() - .header("Authorization", "Bearer " + accessToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); // Should succeed - "root" user is allowed by policy - } - - @Test - void testOpaPolicyDeniesStrangerUser() { - // Create a "stranger" principal and get its access token - String strangerToken = createPrincipalAndGetToken("stranger"); - - // Use the stranger token to test OPA authorization - should be denied - given() - .header("Authorization", "Bearer " + strangerToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(403); // Should be forbidden by OPA policy - stranger is denied - } - - @Test - void testOpaAllowsAdminUser() { - // Create an "admin" principal and get its access token - String adminToken = createPrincipalAndGetToken("admin"); - - // Use the admin token to test OPA authorization - should be allowed - given() - .header("Authorization", "Bearer " + adminToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); // Should succeed - admin user is allowed by policy - } - - @Test - void testOpaBearerTokenAuthentication() { - // Test that OpaPolarisAuthorizer is configured to send bearer tokens - // and can handle HTTP connections for testing - String rootToken = getRootToken(); - - given() - .header("Authorization", "Bearer " + rootToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); - } - - /** Helper method to create a principal and get an OAuth access token for that principal */ - private String createPrincipalAndGetToken(String principalName) { - // First get root token to create the principal - String rootToken = getRootToken(); - - // Create the principal using the root token - String createResponse = - given() - .contentType("application/json") - .header("Authorization", "Bearer " + rootToken) - .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") - .when() - .post("/api/management/v1/principals") - .then() - .statusCode(201) - .extract() - .body() - .asString(); - - // Parse the principal's credentials from the response - String clientId = extractJsonValue(createResponse, "clientId"); - String clientSecret = extractJsonValue(createResponse, "clientSecret"); - - if (clientId == null || clientSecret == null) { - fail("Could not parse principal credentials from response: " + createResponse); - } - - // Get access token for the newly created principal - String tokenResponse = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", clientId) - .formParam("client_secret", clientSecret) - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(tokenResponse, "access_token"); - if (accessToken == null) { - fail("Could not get access token for principal " + principalName); - } - - return accessToken; - } - - /** Helper method to get root access token */ - private String getRootToken() { - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(response, "access_token"); - if (accessToken == null) { - fail("Failed to parse access_token from admin OAuth response: " + response); - } - return accessToken; - } - - /** Simple JSON value extractor */ - private String extractJsonValue(String json, String key) { - String searchKey = "\"" + key + "\""; - if (json.contains(searchKey)) { - String value = json.substring(json.indexOf(searchKey) + searchKey.length()); - value = value.substring(value.indexOf("\"") + 1); - value = value.substring(0, value.indexOf("\"")); - return value; - } - return null; - } -} From 44921ea1517a141e7df6569b633fb0caa1e732ba Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Sat, 18 Oct 2025 05:23:56 +0000 Subject: [PATCH 29/40] adopt review feedback and move integration tests into extensions/auth/opa --- extensions/auth/opa/impl/build.gradle.kts | 50 ++ .../auth/opa/OpaAuthorizationConfig.java | 87 +- .../auth/opa/OpaHttpClientFactory.java | 11 +- .../auth/opa/OpaPolarisAuthorizer.java | 57 +- .../auth/opa/OpaPolarisAuthorizerFactory.java | 81 +- .../auth/opa/token/BearerTokenProvider.java | 3 +- .../opa/token/FileBearerTokenProvider.java | 131 +++- .../opa/token/StaticBearerTokenProvider.java | 5 + .../auth/opa/OpaHttpClientFactoryTest.java | 28 +- .../opa/OpaPolarisAuthorizerFactoryTest.java | 125 +-- .../auth/opa/OpaPolarisAuthorizerTest.java | 629 +++++++++++++++ .../token/FileBearerTokenProviderTest.java | 240 +++--- .../token/StaticBearerTokenProviderTest.java | 11 +- .../auth/opa/OpaPolarisAuthorizerTest.java | 740 ------------------ extensions/auth/opa/tests/build.gradle.kts | 60 ++ .../auth/opa/OpaFileTokenIntegrationTest.java | 4 +- .../auth/opa/OpaFileTokenTestResource.java | 2 +- .../auth/opa/OpaIntegrationTest.java | 4 +- .../extension/auth/opa}/OpaTestResource.java | 8 +- .../extension/auth/opa/Dockerfile-opa-version | 1 + gradle/libs.versions.toml | 3 +- gradle/projects.main.properties | 3 +- .../main/resources/application-it.properties | 1 - .../src/main/resources/application.properties | 2 +- .../auth/DefaultPolarisAuthorizerFactory.java | 2 +- 25 files changed, 1167 insertions(+), 1121 deletions(-) create mode 100644 extensions/auth/opa/impl/build.gradle.kts rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java (71%) rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java (94%) rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java (89%) rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java (64%) rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java (95%) rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java (64%) rename extensions/auth/opa/{ => impl}/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java (86%) rename extensions/auth/opa/{ => impl}/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java (75%) rename extensions/auth/opa/{ => impl}/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java (52%) create mode 100644 extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java rename extensions/auth/opa/{ => impl}/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java (57%) rename extensions/auth/opa/{ => impl}/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java (77%) delete mode 100644 extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java create mode 100644 extensions/auth/opa/tests/build.gradle.kts rename {runtime/service/src/intTest/java/org/apache/polaris/service => extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension}/auth/opa/OpaFileTokenIntegrationTest.java (98%) rename {runtime/service/src/intTest/java/org/apache/polaris/service => extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension}/auth/opa/OpaFileTokenTestResource.java (98%) rename {runtime/service/src/intTest/java/org/apache/polaris/service => extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension}/auth/opa/OpaIntegrationTest.java (98%) rename {runtime/test-common/src/main/java/org/apache/polaris/test/commons => extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa}/OpaTestResource.java (93%) create mode 100644 extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version diff --git a/extensions/auth/opa/impl/build.gradle.kts b/extensions/auth/opa/impl/build.gradle.kts new file mode 100644 index 0000000000..75c82118b5 --- /dev/null +++ b/extensions/auth/opa/impl/build.gradle.kts @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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. + */ + +plugins { + id("polaris-server") + id("org.kordamp.gradle.jandex") +} + +dependencies { + implementation(project(":polaris-core")) + implementation(libs.apache.httpclient5) + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation(libs.guava) + implementation(libs.slf4j.api) + implementation(libs.auth0.jwt) + + // Iceberg dependency for ForbiddenException + implementation(platform(libs.iceberg.bom)) + implementation("org.apache.iceberg:iceberg-api") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.smallrye.config.core) + + testImplementation(testFixtures(project(":polaris-core"))) + testImplementation(platform(libs.junit.bom)) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.core) + testImplementation(libs.threeten.extra) +} diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java similarity index 71% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java index d99cb1317c..261c685645 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java @@ -20,8 +20,12 @@ import static com.google.common.base.Preconditions.checkArgument; +import com.google.common.base.Strings; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; import java.util.Optional; /** @@ -33,9 +37,40 @@ */ @ConfigMapping(prefix = "polaris.authorization.opa") public interface OpaAuthorizationConfig { - Optional url(); - Optional policyPath(); + /** Authentication types supported by OPA authorization */ + enum AuthenticationType { + NONE("none"), + BEARER("bearer"); + + private final String value; + + AuthenticationType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** Bearer token configuration types */ + enum BearerTokenType { + STATIC_TOKEN("static-token"), + FILE_BASED("file-based"); + + private final String value; + + BearerTokenType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + Optional policyUri(); Optional auth(); @@ -43,10 +78,7 @@ public interface OpaAuthorizationConfig { /** Validates the complete OPA configuration */ default void validate() { - checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); - checkArgument( - policyPath().isPresent() && !policyPath().get().isBlank(), - "OPA policy path cannot be null or empty"); + checkArgument(policyUri().isPresent(), "OPA policy URI cannot be null"); checkArgument(auth().isPresent(), "Authentication configuration is required"); auth().get().validate(); @@ -60,7 +92,7 @@ interface HttpConfig { @WithDefault("true") boolean verifySsl(); - Optional trustStorePath(); + Optional trustStorePath(); Optional trustStorePassword(); } @@ -69,19 +101,19 @@ interface HttpConfig { interface AuthenticationConfig { /** Type of authentication */ @WithDefault("none") - String type(); + AuthenticationType type(); /** Bearer token authentication configuration */ Optional bearer(); default void validate() { switch (type()) { - case "bearer": + case BEARER: checkArgument( bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); bearer().get().validate(); break; - case "none": + case NONE: // No authentication - nothing to validate break; default: @@ -94,7 +126,7 @@ default void validate() { interface BearerTokenConfig { /** Type of bearer token configuration */ @WithDefault("static-token") - String type(); + BearerTokenType type(); /** Static bearer token configuration */ Optional staticToken(); @@ -104,13 +136,13 @@ interface BearerTokenConfig { default void validate() { switch (type()) { - case "static-token": + case STATIC_TOKEN: checkArgument( staticToken().isPresent(), "Static token configuration is required when type is 'static-token'"); staticToken().get().validate(); break; - case "file-based": + case FILE_BASED: checkArgument( fileBased().isPresent(), "File-based configuration is required when type is 'file-based'"); @@ -125,23 +157,22 @@ default void validate() { /** Configuration for static bearer tokens */ interface StaticTokenConfig { /** Static bearer token value */ - Optional value(); + String value(); default void validate() { checkArgument( - value().isPresent() && !value().get().isBlank(), - "Static bearer token value cannot be null or empty"); + !Strings.isNullOrEmpty(value()), "Static bearer token value cannot be null or empty"); } } /** Configuration for file-based bearer tokens */ interface FileBasedConfig { /** Path to file containing bearer token */ - Optional path(); + Path path(); - /** How often to refresh file-based bearer tokens (in seconds) */ - @WithDefault("300") - int refreshInterval(); + /** How often to refresh file-based bearer tokens */ + @WithDefault("PT5M") + Duration refreshInterval(); /** * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If @@ -152,18 +183,16 @@ interface FileBasedConfig { boolean jwtExpirationRefresh(); /** - * Buffer time in seconds before JWT expiration to refresh the token. Only used when - * jwtExpirationRefresh is true and the token is a valid JWT. Default is 60 seconds. + * Buffer time before JWT expiration to refresh the token. Only used when jwtExpirationRefresh + * is true and the token is a valid JWT. Default is 60 seconds. */ - @WithDefault("60") - int jwtExpirationBuffer(); + @WithDefault("PT1M") + Duration jwtExpirationBuffer(); default void validate() { - checkArgument( - path().isPresent() && !path().get().isBlank(), - "Bearer token file path cannot be null or empty"); - checkArgument(refreshInterval() > 0, "refreshInterval must be greater than 0"); - checkArgument(jwtExpirationBuffer() > 0, "jwtExpirationBuffer must be greater than 0"); + checkArgument(path() != null, "Bearer token file path cannot be null"); + checkArgument(refreshInterval().isPositive(), "refreshInterval must be positive"); + checkArgument(jwtExpirationBuffer().isPositive(), "jwtExpirationBuffer must be positive"); } } } diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java similarity index 94% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java index df2ae87a60..75921408b7 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java @@ -18,8 +18,8 @@ */ package org.apache.polaris.extension.auth.opa; -import com.google.common.base.Strings; import java.io.FileInputStream; +import java.nio.file.Path; import java.security.KeyStore; import java.security.cert.X509Certificate; import javax.net.ssl.SSLContext; @@ -41,7 +41,7 @@ * configuration, timeout settings, and connection pooling for communicating with Open Policy Agent * (OPA) servers. */ -public class OpaHttpClientFactory { +class OpaHttpClientFactory { private static final Logger LOGGER = LoggerFactory.getLogger(OpaHttpClientFactory.class); /** @@ -110,13 +110,12 @@ private static SSLContext createSslContext(OpaAuthorizationConfig.HttpConfig con .loadTrustMaterial( null, (X509Certificate[] chain, String authType) -> true) // trust all certificates .build(); - } else if (config.trustStorePath().isPresent() - && !Strings.isNullOrEmpty(config.trustStorePath().get())) { + } else if (config.trustStorePath().isPresent()) { // Load custom trust store for SSL verification - String trustStorePath = config.trustStorePath().get(); + Path trustStorePath = config.trustStorePath().get(); LOGGER.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath); KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath)) { + try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath.toFile())) { String trustStorePassword = config.trustStorePassword().orElse(null); trustStore.load( trustStoreStream, trustStorePassword != null ? trustStorePassword.toCharArray() : null); diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java similarity index 89% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index c526a90dd1..2d5e2d748c 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -22,10 +22,10 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Preconditions; -import com.google.common.base.Strings; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.IOException; +import java.net.URI; import java.util.List; import java.util.Set; import org.apache.hc.client5.http.classic.methods.HttpPost; @@ -55,55 +55,40 @@ * release. It may undergo breaking changes in future versions. Use with caution in production * environments. */ -public class OpaPolarisAuthorizer implements PolarisAuthorizer { +class OpaPolarisAuthorizer implements PolarisAuthorizer { private final String opaServerUrl; private final String opaPolicyPath; private final BearerTokenProvider tokenProvider; private final CloseableHttpClient httpClient; private final ObjectMapper objectMapper; - /** Private constructor for factory method and advanced wiring. */ - private OpaPolarisAuthorizer( - String opaServerUrl, - String opaPolicyPath, - BearerTokenProvider tokenProvider, - CloseableHttpClient httpClient, - ObjectMapper objectMapper) { - this.opaServerUrl = opaServerUrl; - this.opaPolicyPath = opaPolicyPath; - this.tokenProvider = tokenProvider; - this.httpClient = httpClient; - this.objectMapper = objectMapper; - } - /** - * Static factory that accepts a BearerTokenProvider for advanced token management. + * Public constructor that accepts a complete policy URI. * - * @param opaServerUrl OPA server URL - * @param opaPolicyPath OPA policy path - * @param tokenProvider Token provider for authentication (optional) - * @param client Apache HttpClient (required, injected by CDI). SSL configuration should be + * @param policyUri The required URI for the OPA endpoint. For example, + * https://opa.example.com/v1/polaris/allow + * @param httpClient Apache HttpClient (required, injected by CDI). SSL configuration should be * handled by the CDI producer. - * @return OpaPolarisAuthorizer instance + * @param tokenProvider Token provider for authentication (optional) */ - public static OpaPolarisAuthorizer create( - String opaServerUrl, - String opaPolicyPath, - BearerTokenProvider tokenProvider, - @Nonnull CloseableHttpClient client) { + public OpaPolarisAuthorizer( + @Nonnull URI policyUri, + @Nonnull CloseableHttpClient httpClient, + @Nullable BearerTokenProvider tokenProvider) { + Preconditions.checkArgument(policyUri != null, "policyUri cannot be null"); Preconditions.checkArgument( - !Strings.isNullOrEmpty(opaServerUrl), "opaServerUrl cannot be null or empty"); + policyUri.getScheme() != null && policyUri.getAuthority() != null, + "Policy URI must have a valid scheme and authority"); Preconditions.checkArgument( - !Strings.isNullOrEmpty(opaPolicyPath), "opaPolicyPath cannot be null or empty"); + policyUri.getPath() != null && !policyUri.getPath().isEmpty(), + "Policy URI must have a non-empty path"); - try { - ObjectMapper objectMapperWithDefaults = new ObjectMapper(); - return new OpaPolarisAuthorizer( - opaServerUrl, opaPolicyPath, tokenProvider, client, objectMapperWithDefaults); - } catch (Exception e) { - throw new RuntimeException("Failed to create OpaPolarisAuthorizer", e); - } + this.opaServerUrl = policyUri.getScheme() + "://" + policyUri.getAuthority(); + this.opaPolicyPath = policyUri.getPath(); + this.tokenProvider = tokenProvider; + this.httpClient = httpClient; + this.objectMapper = new ObjectMapper(); } /** diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java similarity index 64% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index 012b5abe86..4394f2c0a0 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -20,8 +20,11 @@ import io.smallrye.common.annotation.Identifier; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import java.io.IOException; +import java.net.URI; import java.time.Duration; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -31,11 +34,15 @@ import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider; import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Factory for creating OPA-based Polaris authorizer implementations. */ @ApplicationScoped @Identifier("opa") -public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { +class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { + + private static final Logger logger = LoggerFactory.getLogger(OpaPolarisAuthorizerFactory.class); private final OpaAuthorizationConfig opaConfig; private CloseableHttpClient httpClient; @@ -54,15 +61,41 @@ public void initialize() { // Create HTTP client once during startup httpClient = createHttpClient(); - // Create bearer token provider once during startup - bearerTokenProvider = createBearerTokenProvider(opaConfig.auth().get()); + // Setup authentication once during startup + setupAuthentication(opaConfig.auth().get()); } @Override public PolarisAuthorizer create(RealmConfig realmConfig) { // All components are now pre-initialized, just create the authorizer - return OpaPolarisAuthorizer.create( - opaConfig.url().get(), opaConfig.policyPath().get(), bearerTokenProvider, httpClient); + URI policyUri = opaConfig.policyUri().get(); + + return new OpaPolarisAuthorizer(policyUri, httpClient, bearerTokenProvider); + } + + @PreDestroy + public void cleanup() { + // Clean up bearer token provider resources + if (bearerTokenProvider != null) { + try { + bearerTokenProvider.close(); + logger.info("Bearer token provider closed successfully"); + } catch (Exception e) { + // Log but don't throw - we're shutting down anyway + logger.warn("Error closing bearer token provider: {}", e.getMessage(), e); + } + } + + // Clean up HTTP client resources + if (httpClient != null) { + try { + httpClient.close(); + logger.info("HTTP client closed successfully"); + } catch (IOException e) { + // Log but don't throw - we're shutting down anyway + logger.warn("Error closing HTTP client: {}", e.getMessage(), e); + } + } } private CloseableHttpClient createHttpClient() { @@ -77,16 +110,24 @@ private CloseableHttpClient createHttpClient() { } } - private BearerTokenProvider createBearerTokenProvider( - OpaAuthorizationConfig.AuthenticationConfig authConfig) { + /** + * Sets up authentication based on the configuration. + * + *

    This method handles different authentication types and configures the appropriate + * authentication mechanism. Future authentication types (e.g., TLS mutual authentication) can be + * added as additional cases. + */ + private void setupAuthentication(OpaAuthorizationConfig.AuthenticationConfig authConfig) { switch (authConfig.type()) { - case "bearer": + case BEARER: if (authConfig.bearer().isEmpty()) { throw new IllegalStateException("Bearer configuration is required when type is 'bearer'"); } - return createBearerTokenProvider(authConfig.bearer().get()); - case "none": - return new StaticBearerTokenProvider(""); + this.bearerTokenProvider = createBearerTokenProvider(authConfig.bearer().get()); + break; + case NONE: + this.bearerTokenProvider = null; // No authentication + break; default: throw new IllegalStateException("Unsupported authentication type: " + authConfig.type()); } @@ -95,33 +136,27 @@ private BearerTokenProvider createBearerTokenProvider( private BearerTokenProvider createBearerTokenProvider( OpaAuthorizationConfig.BearerTokenConfig bearerToken) { switch (bearerToken.type()) { - case "static-token": + case STATIC_TOKEN: if (bearerToken.staticToken().isEmpty()) { throw new IllegalStateException( "Static token configuration is required when type is 'static-token'"); } OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticConfig = bearerToken.staticToken().get(); - if (staticConfig.value().isEmpty()) { - throw new IllegalStateException("Static token value is required"); - } - return new StaticBearerTokenProvider(staticConfig.value().get()); + return new StaticBearerTokenProvider(staticConfig.value()); - case "file-based": + case FILE_BASED: if (bearerToken.fileBased().isEmpty()) { throw new IllegalStateException( "File-based configuration is required when type is 'file-based'"); } OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig = bearerToken.fileBased().get(); - if (fileConfig.path().isEmpty()) { - throw new IllegalStateException("File-based token path is required"); - } - Duration refreshInterval = Duration.ofSeconds(fileConfig.refreshInterval()); + Duration refreshInterval = fileConfig.refreshInterval(); boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh(); - Duration jwtExpirationBuffer = Duration.ofSeconds(fileConfig.jwtExpirationBuffer()); + Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer(); return new FileBearerTokenProvider( - fileConfig.path().get(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); + fileConfig.path(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); default: throw new IllegalStateException("Unsupported bearer token type: " + bearerToken.type()); diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java similarity index 95% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java index 977c11506f..3d5c1b4f76 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java @@ -31,7 +31,7 @@ *

  • External token services * */ -public interface BearerTokenProvider { +public interface BearerTokenProvider extends AutoCloseable { /** * Get the current bearer token. @@ -45,6 +45,7 @@ public interface BearerTokenProvider { * Clean up any resources used by this token provider. Should be called when the provider is no * longer needed. */ + @Override default void close() { // Default implementation does nothing } diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java similarity index 64% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index b1d67fd0d0..1cb13f6726 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -26,7 +26,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Date; @@ -58,6 +58,7 @@ public class FileBearerTokenProvider implements BearerTokenProvider { private final Duration refreshInterval; private final boolean jwtExpirationRefresh; private final Duration jwtExpirationBuffer; + private final Clock clock; private final ReadWriteLock lock = new ReentrantReadWriteLock(); private volatile String cachedToken; @@ -66,36 +67,51 @@ public class FileBearerTokenProvider implements BearerTokenProvider { private volatile boolean closed = false; /** - * Create a new file-based token provider with basic refresh interval. + * Create a new file-based token provider with JWT expiration support. * * @param tokenFilePath path to the file containing the bearer token - * @param refreshInterval how often to check for token file changes + * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) + * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing + * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token */ - public FileBearerTokenProvider(String tokenFilePath, Duration refreshInterval) { - this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); + public FileBearerTokenProvider( + Path tokenFilePath, + Duration refreshInterval, + boolean jwtExpirationRefresh, + Duration jwtExpirationBuffer) { + this( + tokenFilePath, + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer, + Clock.systemUTC()); } /** - * Create a new file-based token provider with JWT expiration support. + * Create a new file-based token provider with JWT expiration support and custom clock. + * Package-private constructor for testing purposes. * * @param tokenFilePath path to the file containing the bearer token * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + * @param clock clock instance for time operations (useful for testing) */ - public FileBearerTokenProvider( - String tokenFilePath, + FileBearerTokenProvider( + Path tokenFilePath, Duration refreshInterval, boolean jwtExpirationRefresh, - Duration jwtExpirationBuffer) { - this.tokenFilePath = Paths.get(tokenFilePath); + Duration jwtExpirationBuffer, + Clock clock) { + this.tokenFilePath = tokenFilePath; this.refreshInterval = refreshInterval; this.jwtExpirationRefresh = jwtExpirationRefresh; this.jwtExpirationBuffer = jwtExpirationBuffer; + this.clock = clock; this.lastRefresh = Instant.MIN; // Force initial load - this.nextRefresh = Instant.MIN; // Force initial calculation + this.nextRefresh = Instant.MIN; // Force initial refresh - logger.info( + logger.debug( "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", tokenFilePath, refreshInterval, @@ -137,7 +153,7 @@ public void close() { } private boolean shouldRefresh() { - return Instant.now().isAfter(nextRefresh); + return clock.instant().isAfter(nextRefresh); } private void refreshToken() { @@ -149,16 +165,30 @@ private void refreshToken() { } String newToken = loadTokenFromFile(); - cachedToken = newToken; - lastRefresh = Instant.now(); - // Calculate next refresh time based on JWT expiration or fixed interval - nextRefresh = calculateNextRefresh(newToken); + // If we couldn't load a token and have no cached token, this is a fatal error + if (newToken == null && cachedToken == null) { + throw new RuntimeException( + "Unable to load bearer token from file: " + + tokenFilePath + + ". This is required for OPA authorization."); + } + + // Only update cached token if we successfully loaded a new one + if (newToken != null) { + cachedToken = newToken; + } + // If newToken is null but cachedToken exists, we keep using the cached token + + lastRefresh = clock.instant(); + + // Calculate next refresh time based on current token (may be cached) + nextRefresh = calculateNextRefresh(cachedToken); logger.debug( "Token refreshed from file: {} (token present: {}), next refresh: {}", tokenFilePath, - newToken != null && !newToken.isEmpty(), + cachedToken != null && !cachedToken.isEmpty(), nextRefresh); } finally { @@ -181,7 +211,7 @@ private Instant calculateNextRefresh(@Nullable String token) { Instant refreshTime = expiration.get().minus(jwtExpirationBuffer); // Ensure refresh time is in the future and not too soon (at least 1 second) - Instant minRefreshTime = Instant.now().plus(Duration.ofSeconds(1)); + Instant minRefreshTime = clock.instant().plus(Duration.ofSeconds(1)); if (refreshTime.isBefore(minRefreshTime)) { logger.warn( "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get()); @@ -202,30 +232,59 @@ private Instant calculateNextRefresh(@Nullable String token) { @Nullable private String loadTokenFromFile() { - try { - if (!Files.exists(tokenFilePath)) { - logger.warn("Token file does not exist: {}", tokenFilePath); - return null; - } + int attempts = 0; + long deadlineMs = + clock.millis() + + 3000; // 3 second deadline for retries (reasonable for CSI driver file mounts) + String currentCachedToken = cachedToken; // Snapshot of current cache - if (!Files.isReadable(tokenFilePath)) { - logger.warn("Token file is not readable: {}", tokenFilePath); - return null; + while (true) { + try { + // Check if file is readable first + if (!Files.isReadable(tokenFilePath)) { + // Treat as transient error and retry (could be CSI driver not ready yet) + throw new IOException("File is not readable: " + tokenFilePath); + } + + String token = Files.readString(tokenFilePath, StandardCharsets.UTF_8).trim(); + if (!token.isEmpty()) { + return token; + } + + // Empty token - treat as transient issue (could be file being written) + throw new IOException("File contains only whitespace: " + tokenFilePath); + } catch (IOException e) { + // Treat file system exceptions as transient (file being rotated, temporary unavailability) + logger.debug( + "Transient error reading token file (attempt {}): {}", attempts + 1, e.getMessage()); } - String content = Files.readString(tokenFilePath, StandardCharsets.UTF_8); - String token = content.trim(); + // If we have a cached token and it's safe to use, fall back to it + if (currentCachedToken != null && !currentCachedToken.isEmpty()) { + logger.warn( + "Failed to read new token from file after {} attempts, using cached token", + attempts + 1); + return currentCachedToken; + } - if (token.isEmpty()) { - logger.warn("Token file is empty: {}", tokenFilePath); + // No cached token available and we can't read from file + attempts++; + if (attempts >= 5 || clock.millis() > deadlineMs) { + // Return null to let refreshToken() decide how to handle this based on cached token state + logger.debug("Token unavailable after {} attempts", attempts); return null; } - return token; - - } catch (IOException e) { - logger.error("Failed to read token from file: {}", tokenFilePath, e); - return null; + // Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1000ms (reasonable for CSI driver + // scenarios) + long delayMs = Math.min(100L << (attempts - 1), 1000); + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + logger.debug("Interrupted while reading token, returning null"); + return null; + } } } diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java similarity index 86% rename from extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java rename to extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java index d4d8137e0f..0fd8663df0 100644 --- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java @@ -18,12 +18,17 @@ */ package org.apache.polaris.extension.auth.opa.token; +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; + /** A simple token provider that returns a static string value. */ public class StaticBearerTokenProvider implements BearerTokenProvider { private final String token; public StaticBearerTokenProvider(String token) { + checkArgument(!Strings.isNullOrEmpty(token), "Token cannot be null or empty"); this.token = token; } diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java similarity index 75% rename from extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java rename to extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java index 364882261a..2b410bc176 100644 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.nio.file.Paths; import java.util.Optional; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.junit.jupiter.api.Test; @@ -33,30 +34,18 @@ public class OpaHttpClientFactoryTest { void testCreateHttpClientWithHttpUrl() throws Exception { OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); - CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); - - assertNotNull(client); - client.close(); + try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { + assertNotNull(client); + } } @Test void testCreateHttpClientWithHttpsUrl() throws Exception { OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, false, null, null); - CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); - - assertNotNull(client); - client.close(); - } - - @Test - void testCreateHttpClientWithCustomTimeout() throws Exception { - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(10000, true, null, null); - - CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); - - assertNotNull(client); - client.close(); + try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { + assertNotNull(client); + } } private OpaAuthorizationConfig.HttpConfig createMockHttpConfig( @@ -64,7 +53,8 @@ private OpaAuthorizationConfig.HttpConfig createMockHttpConfig( OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); when(httpConfig.timeoutMs()).thenReturn(timeoutMs); when(httpConfig.verifySsl()).thenReturn(verifySsl); - when(httpConfig.trustStorePath()).thenReturn(Optional.ofNullable(trustStorePath)); + when(httpConfig.trustStorePath()) + .thenReturn(Optional.ofNullable(trustStorePath != null ? Paths.get(trustStorePath) : null)); when(httpConfig.trustStorePassword()).thenReturn(Optional.ofNullable(trustStorePassword)); return httpConfig; } diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java similarity index 52% rename from extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java rename to extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index f2870074c5..5f45f3ca09 100644 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.when; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -38,28 +39,28 @@ public class OpaPolarisAuthorizerFactoryTest { @TempDir Path tempDir; @Test - public void testFactoryCreatesStaticTokenProvider() { + public void testFactoryWithStaticTokenConfiguration() { // Mock configuration for static token OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig.class); - when(staticTokenConfig.value()).thenReturn(Optional.of("static-token-value")); + when(staticTokenConfig.value()).thenReturn("static-token-value"); OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.type()).thenReturn("static-token"); + when(bearerTokenConfig.type()).thenReturn(OpaAuthorizationConfig.BearerTokenType.STATIC_TOKEN); when(bearerTokenConfig.staticToken()).thenReturn(Optional.of(staticTokenConfig)); when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); OpaAuthorizationConfig.AuthenticationConfig authConfig = mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn("bearer"); + when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.BEARER); when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.policyUri()) + .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); @@ -73,7 +74,7 @@ public void testFactoryCreatesStaticTokenProvider() { } @Test - public void testFactoryCreatesFileBearerTokenProvider() throws IOException { + public void testFactoryWithFileBasedTokenConfiguration() throws IOException { // Create a temporary token file Path tokenFile = tempDir.resolve("bearer-token.txt"); String tokenValue = "file-based-token-value"; @@ -82,27 +83,27 @@ public void testFactoryCreatesFileBearerTokenProvider() throws IOException { // Mock configuration for file-based token OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig.class); - when(fileTokenConfig.path()).thenReturn(Optional.of(tokenFile.toString())); - when(fileTokenConfig.refreshInterval()).thenReturn(300); + when(fileTokenConfig.path()).thenReturn(tokenFile); + when(fileTokenConfig.refreshInterval()).thenReturn(Duration.ofMinutes(5)); when(fileTokenConfig.jwtExpirationRefresh()).thenReturn(true); - when(fileTokenConfig.jwtExpirationBuffer()).thenReturn(60); + when(fileTokenConfig.jwtExpirationBuffer()).thenReturn(Duration.ofMinutes(1)); OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.type()).thenReturn("file-based"); + when(bearerTokenConfig.type()).thenReturn(OpaAuthorizationConfig.BearerTokenType.FILE_BASED); when(bearerTokenConfig.staticToken()).thenReturn(Optional.empty()); when(bearerTokenConfig.fileBased()).thenReturn(Optional.of(fileTokenConfig)); OpaAuthorizationConfig.AuthenticationConfig authConfig = mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn("bearer"); + when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.BEARER); when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.policyUri()) + .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); @@ -113,63 +114,15 @@ public void testFactoryCreatesFileBearerTokenProvider() throws IOException { OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); assertNotNull(authorizer); - } - - @Test - public void testFileBearerTokenProviderActuallyReadsFromFile() throws IOException { - // Create a temporary token file - Path tokenFile = tempDir.resolve("bearer-token.txt"); - String tokenValue = "file-based-token-from-disk"; - Files.writeString(tokenFile, tokenValue); - // Create FileBearerTokenProvider directly to test it reads the file - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + // Also verify that the token provider actually reads from the file + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { - // Verify the token is read from the file - String actualToken = provider.getToken(); - assertEquals(tokenValue, actualToken); - - provider.close(); - } - - @Test - public void testFactoryWithStaticTokenConfiguration() throws IOException { - // Create a temporary token file (but we'll use static token instead) - Path tokenFile = tempDir.resolve("bearer-token.txt"); - Files.writeString(tokenFile, "file-token-value"); - - // Mock configuration with static token - OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig.class); - when(staticTokenConfig.value()).thenReturn(Optional.of("static-token-value")); - - OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.type()).thenReturn("static-token"); - when(bearerTokenConfig.staticToken()).thenReturn(Optional.of(staticTokenConfig)); - when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); - - OpaAuthorizationConfig.AuthenticationConfig authConfig = - mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn("bearer"); - when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); - - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); - - OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); - when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); - - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); - - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - assertNotNull(authorizer); + String actualToken = provider.getToken(); + assertEquals(tokenValue, actualToken); + } } @Test @@ -177,14 +130,14 @@ public void testFactoryWithNoTokenConfiguration() { // Mock configuration with "none" authentication (no tokens) OpaAuthorizationConfig.AuthenticationConfig authConfig = mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn("none"); + when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.NONE); when(authConfig.bearer()).thenReturn(Optional.empty()); OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); + when(opaConfig.policyUri()) + .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); @@ -197,34 +150,6 @@ public void testFactoryWithNoTokenConfiguration() { assertNotNull(authorizer); } - @Test - public void testFactoryValidatesConfiguration() { - // Mock configuration that would fail validation (invalid bearer type) - OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.type()).thenReturn("invalid-type"); - when(bearerTokenConfig.staticToken()).thenReturn(Optional.empty()); - when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); - - OpaAuthorizationConfig.AuthenticationConfig authConfig = - mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn("bearer"); - when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); - - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); - - OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.url()).thenReturn(Optional.of("http://localhost:8181")); - when(opaConfig.policyPath()).thenReturn(Optional.of("/v1/data/polaris/authz/allow")); - when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); - when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); - - // The factory constructor should work fine - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); - - assertNotNull(factory); - } - private OpaAuthorizationConfig.HttpConfig createMockHttpConfig() { OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); when(httpConfig.timeoutMs()).thenReturn(2000); diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java new file mode 100644 index 0000000000..20f2ecf2d1 --- /dev/null +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java @@ -0,0 +1,629 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.extension.auth.opa; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.polaris.core.auth.PolarisAuthorizableOperation; +import org.apache.polaris.core.auth.PolarisPrincipal; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.apache.polaris.core.persistence.ResolvedPolarisEntity; +import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; +import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication + */ +public class OpaPolarisAuthorizerTest { + + @Test + void testOpaInputJsonFormat() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + try { + // Use the dynamically assigned port from the local server + URI policyUri = + URI.create( + "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + + PolarisPrincipal principal = + PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); + + Set entities = Set.of(); + PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, target, secondary)); + + // Parse and verify JSON structure from captured request + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + } finally { + server.stop(0); + } + } + + @Test + void testOpaRequestJsonWithHierarchicalResource() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + try { + URI policyUri = + URI.create( + "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + + // Set up a realistic principal + PolarisPrincipal principal = + PolarisPrincipal.of( + "alice", + Map.of("department", "analytics", "level", "senior"), + Set.of("data_engineer", "analyst")); + + // Create a hierarchical resource structure: catalog.namespace.table + // Create catalog entity using builder pattern + PolarisEntity catalogEntity = + new PolarisEntity.Builder() + .setName("prod_catalog") + .setType(PolarisEntityType.CATALOG) + .setId(100L) + .setCatalogId(100L) + .setParentId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create namespace entity using builder pattern + PolarisEntity namespaceEntity = + new PolarisEntity.Builder() + .setName("sales_data") + .setType(PolarisEntityType.NAMESPACE) + .setId(200L) + .setCatalogId(100L) + .setParentId(100L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create table entity using builder pattern + PolarisEntity tableEntity = + new PolarisEntity.Builder() + .setName("customer_orders") + .setType(PolarisEntityType.TABLE_LIKE) + .setId(300L) + .setCatalogId(100L) + .setParentId(200L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create hierarchical path: catalog -> namespace -> table + // Build a realistic resolved path using ResolvedPolarisEntity objects + List resolvedPath = + List.of( + createResolvedEntity(catalogEntity), + createResolvedEntity(namespaceEntity), + createResolvedEntity(tableEntity)); + PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath); + + Set entities = Set.of(catalogEntity, namespaceEntity, tableEntity); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); + + // Parse and verify the complete JSON structure + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + + // Verify top-level structure + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + + // Verify actor details + var actor = input.get("actor"); + assertTrue(actor.has("principal"), "Actor should have 'principal' field"); + assertEquals("alice", actor.get("principal").asText()); + assertTrue(actor.has("roles"), "Actor should have 'roles' field"); + assertTrue(actor.get("roles").isArray(), "Roles should be an array"); + assertEquals(2, actor.get("roles").size()); + + // Verify action + var action = input.get("action"); + assertEquals("LOAD_TABLE", action.asText()); + + // Verify resource structure - this is the key part for hierarchical resources + var resource = input.get("resource"); + assertTrue(resource.has("targets"), "Resource should have 'targets' field"); + assertTrue(resource.has("secondaries"), "Resource should have 'secondaries' field"); + + var targets = resource.get("targets"); + assertTrue(targets.isArray(), "Targets should be an array"); + assertEquals(1, targets.size(), "Should have exactly one target"); + + var target = targets.get(0); + // Verify the target entity (table) details + assertTrue(target.isObject(), "Target should be an object"); + assertTrue(target.has("type"), "Target should have 'type' field"); + assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); + assertTrue(target.has("name"), "Target should have 'name' field"); + assertEquals( + "customer_orders", target.get("name").asText(), "Target name should be customer_orders"); + + // Verify the hierarchical parents array + assertTrue(target.has("parents"), "Target should have 'parents' field"); + var parents = target.get("parents"); + assertTrue(parents.isArray(), "Parents should be an array"); + assertEquals(2, parents.size(), "Should have 2 parents (catalog and namespace)"); + + // Verify catalog parent (first in the hierarchy) + var catalogParent = parents.get(0); + assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); + assertEquals( + "prod_catalog", + catalogParent.get("name").asText(), + "Catalog name should be prod_catalog"); + + // Verify namespace parent (second in the hierarchy) + var namespaceParent = parents.get(1); + assertEquals( + "NAMESPACE", namespaceParent.get("type").asText(), "Second parent should be namespace"); + assertEquals( + "sales_data", + namespaceParent.get("name").asText(), + "Namespace name should be sales_data"); + + var secondaries = resource.get("secondaries"); + assertTrue(secondaries.isArray(), "Secondaries should be an array"); + assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); + } finally { + server.stop(0); + } + } + + @Test + void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + try { + URI policyUri = + URI.create( + "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + + // Set up a realistic principal + PolarisPrincipal principal = + PolarisPrincipal.of( + "bob", + Map.of("team", "ml", "project", "forecasting"), + Set.of("data_scientist", "analyst")); + + // Create a multi-level namespace structure: catalog.department.team.table + // Create catalog entity + PolarisEntity catalogEntity = + new PolarisEntity.Builder() + .setName("analytics_catalog") + .setType(PolarisEntityType.CATALOG) + .setId(100L) + .setCatalogId(100L) + .setParentId(0L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create first-level namespace entity (department) + PolarisEntity departmentEntity = + new PolarisEntity.Builder() + .setName("engineering") + .setType(PolarisEntityType.NAMESPACE) + .setId(200L) + .setCatalogId(100L) + .setParentId(100L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create second-level namespace entity (team) + PolarisEntity teamEntity = + new PolarisEntity.Builder() + .setName("machine_learning") + .setType(PolarisEntityType.NAMESPACE) + .setId(300L) + .setCatalogId(100L) + .setParentId(200L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create table entity + PolarisEntity tableEntity = + new PolarisEntity.Builder() + .setName("feature_store") + .setType(PolarisEntityType.TABLE_LIKE) + .setId(400L) + .setCatalogId(100L) + .setParentId(300L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + // Create hierarchical path: catalog -> department -> team -> table + List resolvedPath = + List.of( + createResolvedEntity(catalogEntity), + createResolvedEntity(departmentEntity), + createResolvedEntity(teamEntity), + createResolvedEntity(tableEntity)); + PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath); + + Set entities = + Set.of(catalogEntity, departmentEntity, teamEntity, tableEntity); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); + + // Parse and verify the complete JSON structure + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + + // Verify top-level structure + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + + // Verify actor details + var actor = input.get("actor"); + assertEquals("bob", actor.get("principal").asText()); + assertEquals(2, actor.get("roles").size()); + + // Verify action + var action = input.get("action"); + assertEquals("LOAD_TABLE", action.asText()); + + // Verify resource structure with multi-level namespace hierarchy + var resource = input.get("resource"); + var targets = resource.get("targets"); + assertEquals(1, targets.size(), "Should have exactly one target"); + + var target = targets.get(0); + // Verify the target entity (table) details + assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); + assertEquals( + "feature_store", target.get("name").asText(), "Target name should be feature_store"); + + // Verify the multi-level hierarchical parents array + assertTrue(target.has("parents"), "Target should have 'parents' field"); + var parents = target.get("parents"); + assertTrue(parents.isArray(), "Parents should be an array"); + assertEquals(3, parents.size(), "Should have 3 parents (catalog, department, team)"); + + // Verify catalog parent (first in the hierarchy) + var catalogParent = parents.get(0); + assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); + assertEquals( + "analytics_catalog", + catalogParent.get("name").asText(), + "Catalog name should be analytics_catalog"); + + // Verify department namespace parent (second in the hierarchy) + var departmentParent = parents.get(1); + assertEquals( + "NAMESPACE", departmentParent.get("type").asText(), "Second parent should be namespace"); + assertEquals( + "engineering", + departmentParent.get("name").asText(), + "Department name should be engineering"); + + // Verify team namespace parent (third in the hierarchy) + var teamParent = parents.get(2); + assertEquals( + "NAMESPACE", teamParent.get("type").asText(), "Third parent should be namespace"); + assertEquals( + "machine_learning", + teamParent.get("name").asText(), + "Team name should be machine_learning"); + + var secondaries = resource.get("secondaries"); + assertTrue(secondaries.isArray(), "Secondaries should be an array"); + assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); + } finally { + server.stop(0); + } + } + + @Test + void testAuthorizeOrThrowWithEmptyTargetsAndSecondaries() throws Exception { + HttpServer server = createServerWithAllowResponse(); + try { + URI policyUri = + URI.create( + "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); + + Set entities = Set.of(); + + PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.CREATE_CATALOG, + target, + secondary)); + + // Test multiple targets + PolarisResolvedPathWrapper target1 = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper target2 = new PolarisResolvedPathWrapper(List.of()); + List targets = List.of(target1, target2); + List secondaries = List.of(); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.LOAD_VIEW, + targets, + secondaries)); + } finally { + server.stop(0); + } + } + + @Test + public void testCreateWithHttpsAndBearerToken() { + // Test that OpaPolarisAuthorizer can be created with HTTPS URLs and bearer tokens + BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token"); + URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow"); + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), tokenProvider); + + assertTrue(authorizer != null); + } + + @Test + public void testBearerTokenIsAddedToHttpRequest() throws IOException { + URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow"); + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); + HttpEntity mockEntity = mock(HttpEntity.class); + + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()) + .thenReturn( + new ByteArrayInputStream( + "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); + + BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token"); + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, mockHttpClient, tokenProvider); + + PolarisPrincipal mockPrincipal = + PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); + + PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; + assertDoesNotThrow( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); + + // Verify the Authorization header with static bearer token + verifyAuthorizationHeader(mockHttpClient, "test-bearer-token"); + } + + @Test + public void testBearerTokenFromBearerTokenProvider() throws IOException { + // Mock HTTP client and response + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); + HttpEntity mockEntity = mock(HttpEntity.class); + + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()) + .thenReturn( + new ByteArrayInputStream( + "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); + + // Create token provider that returns a dynamic token + BearerTokenProvider tokenProvider = () -> "dynamic-token-12345"; + URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow"); + // Create authorizer with the token provider instead of static token + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer(policyUri, mockHttpClient, tokenProvider); + + // Create mock principal and entities + PolarisPrincipal mockPrincipal = + PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); + + PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; + + // Execute authorization (should not throw since we mocked allow=true) + assertDoesNotThrow( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); + + // Verify the Authorization header with bearer token from provider + verifyAuthorizationHeader(mockHttpClient, "dynamic-token-12345"); + } + + private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) { + return new ResolvedPolarisEntity(entity, List.of(), List.of()); + } + + /** + * Helper method to create and start an HTTP server that captures request bodies. + * + * @param capturedRequestBody Array to store the captured request body + * @return Started HttpServer instance + */ + private HttpServer createServerWithRequestCapture(String[] capturedRequestBody) + throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/v1/data/polaris/allow", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + // Capture request body + byte[] requestBytes = exchange.getRequestBody().readAllBytes(); + capturedRequestBody[0] = new String(requestBytes, StandardCharsets.UTF_8); + + String response = "{\"result\":{\"allow\":true}}"; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes(StandardCharsets.UTF_8)); + } + } + }); + server.start(); + return server; + } + + /** + * Helper method to create and start an HTTP server that returns a simple allow response. + * + * @return Started HttpServer instance + */ + private HttpServer createServerWithAllowResponse() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/v1/data/polaris/allow", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + String response = "{\"result\":{\"allow\":true}}"; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes(StandardCharsets.UTF_8)); + } + } + }); + server.start(); + return server; + } + + /** + * Helper method to capture and verify HTTP request Authorization header. + * + * @param mockHttpClient The mocked HTTP client to verify against + * @param expectedToken The expected bearer token value, or null if no Authorization header + * expected + */ + private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, String expectedToken) + throws IOException { + // Capture the HTTP request to verify bearer token header + ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(httpPostCaptor.capture()); + + HttpPost capturedRequest = httpPostCaptor.getValue(); + + if (expectedToken != null) { + // Verify the Authorization header is present and contains the expected token + assertTrue( + capturedRequest.containsHeader("Authorization"), + "Authorization header should be present when bearer token is provided"); + String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); + assertEquals( + "Bearer " + expectedToken, + authHeader, + "Authorization header should contain the correct bearer token"); + } else { + // Verify no Authorization header is present when token is null + assertTrue( + !capturedRequest.containsHeader("Authorization"), + "Authorization header should not be present when token provider returns null"); + } + } +} diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java similarity index 57% rename from extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java rename to extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java index a59ee6b5e2..9cfc4beb9a 100644 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java @@ -20,19 +20,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; import java.util.Base64; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.threeten.extra.MutableClock; public class FileBearerTokenProviderTest { @@ -46,14 +50,14 @@ public void testLoadTokenFromFile() throws IOException { Files.writeString(tokenFile, expectedToken); // Create file token provider - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); - - // Test token retrieval - String actualToken = provider.getToken(); - assertEquals(expectedToken, actualToken); + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { - provider.close(); + // Test token retrieval + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + } } @Test @@ -65,56 +69,61 @@ public void testLoadTokenFromFileWithWhitespace() throws IOException { Files.writeString(tokenFile, tokenWithWhitespace); // Create file token provider - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); - - // Test token retrieval (should trim whitespace) - String actualToken = provider.getToken(); - assertEquals(expectedToken, actualToken); + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { - provider.close(); + // Test token retrieval (should trim whitespace) + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + } } @Test - public void testTokenRefresh() throws IOException, InterruptedException { + public void testTokenRefresh() throws IOException { // Create a temporary token file Path tokenFile = tempDir.resolve("token.txt"); String initialToken = "initial-token"; Files.writeString(tokenFile, initialToken); - // Create file token provider with short refresh interval - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMillis(100)); + // Create mutable clock for deterministic time control + MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); - // Test initial token - String token1 = provider.getToken(); - assertEquals(initialToken, token1); + // Create file token provider with short refresh interval + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile, Duration.ofMillis(100), false, Duration.ofMinutes(1), clock)) { - // Wait for refresh interval to pass - Thread.sleep(200); + // Test initial token + String token1 = provider.getToken(); + assertEquals(initialToken, token1); - // Update the file - String updatedToken = "updated-token"; - Files.writeString(tokenFile, updatedToken); + // Advance time past refresh interval + clock.add(Duration.ofMillis(200)); - // Test that token is refreshed - String token2 = provider.getToken(); - assertEquals(updatedToken, token2); + // Update the file + String updatedToken = "updated-token"; + Files.writeString(tokenFile, updatedToken); - provider.close(); + // Test that token is refreshed + String token2 = provider.getToken(); + assertEquals(updatedToken, token2); + } } @Test - public void testNonExistentFile() { + public void testNonExistentFileThrows() { // Create file token provider for non-existent file - FileBearerTokenProvider provider = - new FileBearerTokenProvider("/non/existent/file.txt", Duration.ofMinutes(5)); - - // Test token retrieval (should return null) - String token = provider.getToken(); - assertNull(token); + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + Paths.get("/non/existent/file.txt"), + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1))) { - provider.close(); + // Test token retrieval (should throw exception when no cached token exists) + assertThrows(RuntimeException.class, provider::getToken); + } } @Test @@ -124,14 +133,13 @@ public void testEmptyFile() throws IOException { Files.writeString(tokenFile, ""); // Create file token provider - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); - - // Test token retrieval (should return null for empty file) - String token = provider.getToken(); - assertNull(token); + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { - provider.close(); + // Test token retrieval (should throw exception for empty file when no cached token exists) + assertThrows(RuntimeException.class, provider::getToken); + } } @Test @@ -140,9 +148,9 @@ public void testClosedProvider() throws IOException { Path tokenFile = tempDir.resolve("token.txt"); Files.writeString(tokenFile, "test-token"); - // Create and close file token provider + // Create file token provider and explicitly close it to test closed behavior FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + new FileBearerTokenProvider(tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1)); provider.close(); // Test token retrieval after closing (should return null) @@ -151,94 +159,100 @@ public void testClosedProvider() throws IOException { } @Test - public void testJwtExpirationRefresh() throws IOException, InterruptedException { - // Create a temporary token file with a JWT that expires in 10 seconds + public void testJwtExpirationRefresh() throws IOException { + // Create mutable clock for deterministic time control + MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + + // Create a temporary token file with a JWT that expires in 10 seconds from clock time Path tokenFile = tempDir.resolve("jwt-token.txt"); - String jwtToken = createJwtWithExpiration(Instant.now().plusSeconds(10)); + String jwtToken = createJwtWithExpiration(clock.instant().plusSeconds(10)); Files.writeString(tokenFile, jwtToken); // Create file token provider with JWT expiration refresh enabled // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds) - FileBearerTokenProvider provider = + try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile.toString(), Duration.ofMinutes(10), true, Duration.ofSeconds(3)); + tokenFile, Duration.ofMinutes(10), true, Duration.ofSeconds(3), clock)) { - // Test initial token - String token1 = provider.getToken(); - assertEquals(jwtToken, token1); + // Test initial token + String token1 = provider.getToken(); + assertEquals(jwtToken, token1); - // Wait for 7.1 seconds (should trigger refresh due to 3 second buffer) - Thread.sleep(7100); + // Advance time by 7.1 seconds (should trigger refresh due to 3 second buffer) + clock.add(Duration.ofMillis(7100)); - // Update the file with a new JWT - String newJwtToken = createJwtWithExpiration(Instant.now().plusSeconds(20)); - Files.writeString(tokenFile, newJwtToken); + // Update the file with a new JWT + String newJwtToken = createJwtWithExpiration(clock.instant().plusSeconds(20)); + Files.writeString(tokenFile, newJwtToken); - // Test that token is refreshed - String token2 = provider.getToken(); - assertEquals(newJwtToken, token2); - - provider.close(); + // Test that token is refreshed + String token2 = provider.getToken(); + assertEquals(newJwtToken, token2); + } } @Test - public void testJwtExpirationRefreshDisabled() throws IOException, InterruptedException { - // Create a temporary token file with a JWT that expires in 1 second + public void testJwtExpirationRefreshDisabled() throws IOException { + // Create mutable clock for deterministic time control + MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + + // Create a temporary token file with a JWT that expires in 1 second from clock time Path tokenFile = tempDir.resolve("jwt-token.txt"); - String jwtToken = createJwtWithExpiration(Instant.now().plusSeconds(1)); + String jwtToken = createJwtWithExpiration(clock.instant().plusSeconds(1)); Files.writeString(tokenFile, jwtToken); // Create file token provider with JWT expiration refresh disabled - FileBearerTokenProvider provider = + try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile.toString(), Duration.ofMillis(100), false, Duration.ofSeconds(1)); + tokenFile, Duration.ofMillis(100), false, Duration.ofSeconds(1), clock)) { - // Test initial token - String token1 = provider.getToken(); - assertEquals(jwtToken, token1); + // Test initial token + String token1 = provider.getToken(); + assertEquals(jwtToken, token1); - // Wait for fixed refresh interval (100ms) - Thread.sleep(150); + // Advance time past fixed refresh interval (150ms) + clock.add(Duration.ofMillis(150)); - // Update the file - String newToken = "updated-non-jwt-token"; - Files.writeString(tokenFile, newToken); + // Update the file + String newToken = "updated-non-jwt-token"; + Files.writeString(tokenFile, newToken); - // Test that token is refreshed based on fixed interval, not JWT expiration - String token2 = provider.getToken(); - assertEquals(newToken, token2); - - provider.close(); + // Test that token is refreshed based on fixed interval, not JWT expiration + String token2 = provider.getToken(); + assertEquals(newToken, token2); + } } @Test - public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException, InterruptedException { + public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException { + // Create mutable clock for deterministic time control + MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + // Create a temporary token file with a non-JWT token Path tokenFile = tempDir.resolve("token.txt"); - String nonJwtToken = "not-a-jwt-token"; + String nonJwtToken = "plain-text-token"; Files.writeString(tokenFile, nonJwtToken); // Create file token provider with JWT expiration refresh enabled - FileBearerTokenProvider provider = + try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile.toString(), Duration.ofMillis(100), true, Duration.ofSeconds(1)); + tokenFile, Duration.ofMillis(100), true, Duration.ofSeconds(1), clock)) { - // Test initial token - String token1 = provider.getToken(); - assertEquals(nonJwtToken, token1); + // Test initial token + String token1 = provider.getToken(); + assertEquals(nonJwtToken, token1); - // Wait for fallback refresh interval - Thread.sleep(150); + // Advance time past fallback refresh interval + clock.add(Duration.ofMillis(150)); - // Update the file - String updatedToken = "updated-non-jwt-token"; - Files.writeString(tokenFile, updatedToken); + // Update the file + String updatedToken = "updated-non-jwt-token"; + Files.writeString(tokenFile, updatedToken); - // Test that token is refreshed using fallback interval - String token2 = provider.getToken(); - assertEquals(updatedToken, token2); - - provider.close(); + // Test that token is refreshed using fallback interval + String token2 = provider.getToken(); + assertEquals(updatedToken, token2); + } } @Test @@ -249,15 +263,14 @@ public void testJwtExpirationTooSoon() throws IOException { Files.writeString(tokenFile, expiredJwtToken); // Create file token provider with JWT expiration refresh enabled - FileBearerTokenProvider provider = + try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile.toString(), Duration.ofMinutes(5), true, Duration.ofSeconds(60)); + tokenFile, Duration.ofMinutes(5), true, Duration.ofSeconds(60))) { - // Should fall back to fixed interval when JWT expires too soon - String token = provider.getToken(); - assertEquals(expiredJwtToken, token); - - provider.close(); + // Should fall back to fixed interval when JWT expires too soon + String token = provider.getToken(); + assertEquals(expiredJwtToken, token); + } } @Test @@ -268,15 +281,14 @@ public void testJwtWithoutExpirationClaim() throws IOException { Files.writeString(tokenFile, jwtWithoutExp); // Create file token provider with JWT expiration refresh enabled - FileBearerTokenProvider provider = + try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile.toString(), Duration.ofMillis(100), true, Duration.ofSeconds(1)); - - // Should fall back to fixed interval when JWT has no expiration - String token = provider.getToken(); - assertEquals(jwtWithoutExp, token); + tokenFile, Duration.ofMillis(100), true, Duration.ofSeconds(1))) { - provider.close(); + // Should fall back to fixed interval when JWT has no expiration + String token = provider.getToken(); + assertEquals(jwtWithoutExp, token); + } } /** Helper method to create a JWT with a specific expiration time. */ diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java similarity index 77% rename from extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java rename to extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java index 4fa12e3110..e25cde6938 100644 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java @@ -19,6 +19,7 @@ package org.apache.polaris.extension.auth.opa.token; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; @@ -35,9 +36,13 @@ public void testStaticBearerTokenProvider() { @Test public void testStaticBearerTokenProviderWithEmptyString() { - StaticBearerTokenProvider provider = new StaticBearerTokenProvider(""); + // Empty strings should be rejected + assertThrows(IllegalArgumentException.class, () -> new StaticBearerTokenProvider("")); + } - String token = provider.getToken(); - assertEquals("", token); + @Test + public void testStaticBearerTokenProviderWithNullString() { + // Null tokens should be rejected + assertThrows(IllegalArgumentException.class, () -> new StaticBearerTokenProvider(null)); } } diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java deleted file mode 100644 index f32ddc712b..0000000000 --- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java +++ /dev/null @@ -1,740 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.extension.auth.opa; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.HttpEntity; -import org.apache.iceberg.exceptions.ForbiddenException; -import org.apache.polaris.core.auth.PolarisAuthorizableOperation; -import org.apache.polaris.core.auth.PolarisPrincipal; -import org.apache.polaris.core.entity.PolarisBaseEntity; -import org.apache.polaris.core.entity.PolarisEntity; -import org.apache.polaris.core.entity.PolarisEntityType; -import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; -import org.apache.polaris.core.persistence.ResolvedPolarisEntity; -import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; -import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -/** - * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication - */ -public class OpaPolarisAuthorizerTest { - - @Test - void testOpaInputJsonFormat() throws Exception { - // Capture the request body for verification - final String[] capturedRequestBody = new String[1]; - - HttpServer server = createServerWithRequestCapture(capturedRequestBody); - - String url = "http://localhost:" + server.getAddress().getPort(); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); - - PolarisPrincipal principal = - PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); - - Set entities = Set.of(); - PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); - PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); - - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, target, secondary)); - - // Parse and verify JSON structure from captured request - ObjectMapper mapper = new ObjectMapper(); - JsonNode root = mapper.readTree(capturedRequestBody[0]); - assertTrue(root.has("input"), "Root should have 'input' field"); - var input = root.get("input"); - assertTrue(input.has("actor"), "Input should have 'actor' field"); - assertTrue(input.has("action"), "Input should have 'action' field"); - assertTrue(input.has("resource"), "Input should have 'resource' field"); - assertTrue(input.has("context"), "Input should have 'context' field"); - - server.stop(0); - } - - @Test - void testOpaRequestJsonWithHierarchicalResource() throws Exception { - // Capture the request body for verification - final String[] capturedRequestBody = new String[1]; - - HttpServer server = createServerWithRequestCapture(capturedRequestBody); - - String url = "http://localhost:" + server.getAddress().getPort(); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); - - // Set up a realistic principal - PolarisPrincipal principal = - PolarisPrincipal.of( - "alice", - Map.of("department", "analytics", "level", "senior"), - Set.of("data_engineer", "analyst")); - - // Create a hierarchical resource structure: catalog.namespace.table - // Create catalog entity using builder pattern - PolarisEntity catalogEntity = - new PolarisEntity.Builder() - .setName("prod_catalog") - .setType(PolarisEntityType.CATALOG) - .setId(100L) - .setCatalogId(100L) - .setParentId(0L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create namespace entity using builder pattern - PolarisEntity namespaceEntity = - new PolarisEntity.Builder() - .setName("sales_data") - .setType(PolarisEntityType.NAMESPACE) - .setId(200L) - .setCatalogId(100L) - .setParentId(100L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create table entity using builder pattern - PolarisEntity tableEntity = - new PolarisEntity.Builder() - .setName("customer_orders") - .setType(PolarisEntityType.TABLE_LIKE) - .setId(300L) - .setCatalogId(100L) - .setParentId(200L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create hierarchical path: catalog -> namespace -> table - // Build a realistic resolved path using ResolvedPolarisEntity objects - List resolvedPath = - List.of( - createResolvedEntity(catalogEntity), - createResolvedEntity(namespaceEntity), - createResolvedEntity(tableEntity)); - PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath); - - Set entities = Set.of(catalogEntity, namespaceEntity, tableEntity); - - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); - - // Parse and verify the complete JSON structure - ObjectMapper mapper = new ObjectMapper(); - JsonNode root = mapper.readTree(capturedRequestBody[0]); - - // Verify top-level structure - assertTrue(root.has("input"), "Root should have 'input' field"); - var input = root.get("input"); - assertTrue(input.has("actor"), "Input should have 'actor' field"); - assertTrue(input.has("action"), "Input should have 'action' field"); - assertTrue(input.has("resource"), "Input should have 'resource' field"); - assertTrue(input.has("context"), "Input should have 'context' field"); - - // Verify actor details - var actor = input.get("actor"); - assertTrue(actor.has("principal"), "Actor should have 'principal' field"); - assertEquals("alice", actor.get("principal").asText()); - assertTrue(actor.has("roles"), "Actor should have 'roles' field"); - assertTrue(actor.get("roles").isArray(), "Roles should be an array"); - assertEquals(2, actor.get("roles").size()); - - // Verify action - var action = input.get("action"); - assertEquals("LOAD_TABLE", action.asText()); - - // Verify resource structure - this is the key part for hierarchical resources - var resource = input.get("resource"); - assertTrue(resource.has("targets"), "Resource should have 'targets' field"); - assertTrue(resource.has("secondaries"), "Resource should have 'secondaries' field"); - - var targets = resource.get("targets"); - assertTrue(targets.isArray(), "Targets should be an array"); - assertEquals(1, targets.size(), "Should have exactly one target"); - - var target = targets.get(0); - // Verify the target entity (table) details - assertTrue(target.isObject(), "Target should be an object"); - assertTrue(target.has("type"), "Target should have 'type' field"); - assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); - assertTrue(target.has("name"), "Target should have 'name' field"); - assertEquals( - "customer_orders", target.get("name").asText(), "Target name should be customer_orders"); - - // Verify the hierarchical parents array - assertTrue(target.has("parents"), "Target should have 'parents' field"); - var parents = target.get("parents"); - assertTrue(parents.isArray(), "Parents should be an array"); - assertEquals(2, parents.size(), "Should have 2 parents (catalog and namespace)"); - - // Verify catalog parent (first in the hierarchy) - var catalogParent = parents.get(0); - assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); - assertEquals( - "prod_catalog", catalogParent.get("name").asText(), "Catalog name should be prod_catalog"); - - // Verify namespace parent (second in the hierarchy) - var namespaceParent = parents.get(1); - assertEquals( - "NAMESPACE", namespaceParent.get("type").asText(), "Second parent should be namespace"); - assertEquals( - "sales_data", namespaceParent.get("name").asText(), "Namespace name should be sales_data"); - - var secondaries = resource.get("secondaries"); - assertTrue(secondaries.isArray(), "Secondaries should be an array"); - assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); - - server.stop(0); - } - - @Test - void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { - // Capture the request body for verification - final String[] capturedRequestBody = new String[1]; - - HttpServer server = createServerWithRequestCapture(capturedRequestBody); - - String url = "http://localhost:" + server.getAddress().getPort(); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); - - // Set up a realistic principal - PolarisPrincipal principal = - PolarisPrincipal.of( - "bob", - Map.of("team", "ml", "project", "forecasting"), - Set.of("data_scientist", "analyst")); - - // Create a multi-level namespace structure: catalog.department.team.table - // Create catalog entity - PolarisEntity catalogEntity = - new PolarisEntity.Builder() - .setName("analytics_catalog") - .setType(PolarisEntityType.CATALOG) - .setId(100L) - .setCatalogId(100L) - .setParentId(0L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create first-level namespace entity (department) - PolarisEntity departmentEntity = - new PolarisEntity.Builder() - .setName("engineering") - .setType(PolarisEntityType.NAMESPACE) - .setId(200L) - .setCatalogId(100L) - .setParentId(100L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create second-level namespace entity (team) - PolarisEntity teamEntity = - new PolarisEntity.Builder() - .setName("machine_learning") - .setType(PolarisEntityType.NAMESPACE) - .setId(300L) - .setCatalogId(100L) - .setParentId(200L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create table entity - PolarisEntity tableEntity = - new PolarisEntity.Builder() - .setName("feature_store") - .setType(PolarisEntityType.TABLE_LIKE) - .setId(400L) - .setCatalogId(100L) - .setParentId(300L) - .setCreateTimestamp(System.currentTimeMillis()) - .build(); - - // Create hierarchical path: catalog -> department -> team -> table - List resolvedPath = - List.of( - createResolvedEntity(catalogEntity), - createResolvedEntity(departmentEntity), - createResolvedEntity(teamEntity), - createResolvedEntity(tableEntity)); - PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath); - - Set entities = - Set.of(catalogEntity, departmentEntity, teamEntity, tableEntity); - - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); - - // Parse and verify the complete JSON structure - ObjectMapper mapper = new ObjectMapper(); - JsonNode root = mapper.readTree(capturedRequestBody[0]); - - // Verify top-level structure - assertTrue(root.has("input"), "Root should have 'input' field"); - var input = root.get("input"); - assertTrue(input.has("actor"), "Input should have 'actor' field"); - assertTrue(input.has("action"), "Input should have 'action' field"); - assertTrue(input.has("resource"), "Input should have 'resource' field"); - assertTrue(input.has("context"), "Input should have 'context' field"); - - // Verify actor details - var actor = input.get("actor"); - assertEquals("bob", actor.get("principal").asText()); - assertEquals(2, actor.get("roles").size()); - - // Verify action - var action = input.get("action"); - assertEquals("LOAD_TABLE", action.asText()); - - // Verify resource structure with multi-level namespace hierarchy - var resource = input.get("resource"); - var targets = resource.get("targets"); - assertEquals(1, targets.size(), "Should have exactly one target"); - - var target = targets.get(0); - // Verify the target entity (table) details - assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); - assertEquals( - "feature_store", target.get("name").asText(), "Target name should be feature_store"); - - // Verify the multi-level hierarchical parents array - assertTrue(target.has("parents"), "Target should have 'parents' field"); - var parents = target.get("parents"); - assertTrue(parents.isArray(), "Parents should be an array"); - assertEquals(3, parents.size(), "Should have 3 parents (catalog, department, team)"); - - // Verify catalog parent (first in the hierarchy) - var catalogParent = parents.get(0); - assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); - assertEquals( - "analytics_catalog", - catalogParent.get("name").asText(), - "Catalog name should be analytics_catalog"); - - // Verify department namespace parent (second in the hierarchy) - var departmentParent = parents.get(1); - assertEquals( - "NAMESPACE", departmentParent.get("type").asText(), "Second parent should be namespace"); - assertEquals( - "engineering", - departmentParent.get("name").asText(), - "Department name should be engineering"); - - // Verify team namespace parent (third in the hierarchy) - var teamParent = parents.get(2); - assertEquals("NAMESPACE", teamParent.get("type").asText(), "Third parent should be namespace"); - assertEquals( - "machine_learning", - teamParent.get("name").asText(), - "Team name should be machine_learning"); - - var secondaries = resource.get("secondaries"); - assertTrue(secondaries.isArray(), "Secondaries should be an array"); - assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); - - server.stop(0); - } - - @Test - void testAuthorizeOrThrowSingleTargetSecondary() throws Exception { - HttpServer server = createServerWithAllowResponse(); - - String url = "http://localhost:" + server.getAddress().getPort(); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); - - PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); - - Set entities = Set.of(); - PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); - PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); - - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, - entities, - PolarisAuthorizableOperation.CREATE_CATALOG, - target, - secondary)); - - server.stop(0); - } - - @Test - void testAuthorizeOrThrowMultiTargetSecondary() throws Exception { - HttpServer server = createServerWithAllowResponse(); - - String url = "http://localhost:" + server.getAddress().getPort(); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); - - PolarisPrincipal principal = PolarisPrincipal.of("bob", Map.of(), Set.of("user")); - - Set entities = Set.of(); - PolarisResolvedPathWrapper target1 = new PolarisResolvedPathWrapper(List.of()); - PolarisResolvedPathWrapper target2 = new PolarisResolvedPathWrapper(List.of()); - List targets = List.of(target1, target2); - List secondaries = List.of(); - - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, targets, secondaries)); - - server.stop(0); - } - - // ===== Bearer Token and HTTPS Tests ===== - - @Test - public void testCreateWithBearerTokenAndHttps() { - OpaPolarisAuthorizer authorizer = - createWithStringToken( - "https://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - HttpClients.createDefault()); - - assertTrue(authorizer != null); - } - - @Test - public void testCreateWithBearerTokenAndHttpsNoSslVerification() { - OpaPolarisAuthorizer authorizer = - createWithStringToken( - "https://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - HttpClients.createDefault()); - - assertTrue(authorizer != null); - } - - @Test - public void testCreateWithHttpsAndSslVerificationDisabled() { - OpaPolarisAuthorizer authorizer = - createWithStringToken( - "https://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - HttpClients.createDefault()); - assertTrue(authorizer != null); - } - - @Test - public void testBearerTokenIsAddedToHttpRequest() throws IOException { - CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); - CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - HttpEntity mockEntity = mock(HttpEntity.class); - - when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getCode()).thenReturn(200); - when(mockResponse.getEntity()).thenReturn(mockEntity); - when(mockEntity.getContent()) - .thenReturn( - new ByteArrayInputStream( - "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - "http://opa.example.com:8181", - "/v1/data/polaris/authz", - "test-bearer-token", - mockHttpClient); - - PolarisPrincipal mockPrincipal = - PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - - PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; - assertDoesNotThrow( - () -> { - authorizer.authorizeOrThrow( - mockPrincipal, - Collections.emptySet(), - mockOperation, - (PolarisResolvedPathWrapper) null, - (PolarisResolvedPathWrapper) null); - }); - - // Verify the Authorization header with static bearer token - verifyAuthorizationHeader(mockHttpClient, "test-bearer-token"); - } - - @Test - public void testAuthorizationFailsWithoutBearerToken() throws IOException { - CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); - CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - - when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getCode()).thenReturn(401); - - OpaPolarisAuthorizer authorizer = - createWithStringToken( - "http://opa.example.com:8181", "/v1/data/polaris/authz", (String) null, mockHttpClient); - - PolarisPrincipal mockPrincipal = - PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - - PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; - assertThrows( - ForbiddenException.class, - () -> { - authorizer.authorizeOrThrow( - mockPrincipal, - Collections.emptySet(), - mockOperation, - (PolarisResolvedPathWrapper) null, - (PolarisResolvedPathWrapper) null); - }); - } - - @Test - public void testBearerTokenFromBearerTokenProvider() throws IOException { - // Mock HTTP client and response - CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); - CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - HttpEntity mockEntity = mock(HttpEntity.class); - - when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getCode()).thenReturn(200); - when(mockResponse.getEntity()).thenReturn(mockEntity); - when(mockEntity.getContent()) - .thenReturn( - new ByteArrayInputStream( - "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); - - // Create token provider that returns a dynamic token - BearerTokenProvider tokenProvider = () -> "dynamic-token-12345"; - - // Create authorizer with the token provider instead of static token - OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( - "http://opa.example.com:8181", "/v1/data/polaris/authz", tokenProvider, mockHttpClient); - - // Create mock principal and entities - PolarisPrincipal mockPrincipal = - PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - - PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; - - // Execute authorization (should not throw since we mocked allow=true) - assertDoesNotThrow( - () -> { - authorizer.authorizeOrThrow( - mockPrincipal, - Collections.emptySet(), - mockOperation, - (PolarisResolvedPathWrapper) null, - (PolarisResolvedPathWrapper) null); - }); - - // Verify the Authorization header with bearer token from provider - verifyAuthorizationHeader(mockHttpClient, "dynamic-token-12345"); - } - - @Test - public void testNullTokenFromBearerTokenProvider() throws IOException { - // Mock HTTP client and response - CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); - CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class); - HttpEntity mockEntity = mock(HttpEntity.class); - - when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); - when(mockResponse.getCode()).thenReturn(200); - when(mockResponse.getEntity()).thenReturn(mockEntity); - when(mockEntity.getContent()) - .thenReturn( - new ByteArrayInputStream( - "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8))); - - // Create a token provider that returns null - BearerTokenProvider tokenProvider = new StaticBearerTokenProvider(null); - - OpaPolarisAuthorizer authorizer = - OpaPolarisAuthorizer.create( - "http://opa.example.com:8181", "/v1/data/polaris/authz", tokenProvider, mockHttpClient); - - // Create mock principal and entities - PolarisPrincipal mockPrincipal = - PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); - - PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; - - // Execute authorization (should not throw since we mocked allow=true) - assertDoesNotThrow( - () -> { - authorizer.authorizeOrThrow( - mockPrincipal, - Collections.emptySet(), - mockOperation, - (PolarisResolvedPathWrapper) null, - (PolarisResolvedPathWrapper) null); - }); - - // Verify no Authorization header is present when token provider returns null - verifyAuthorizationHeader(mockHttpClient, null); - } - - private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) { - return new ResolvedPolarisEntity(entity, List.of(), List.of()); - } - - /** - * Helper method to create and start an HTTP server that captures request bodies. - * - * @param capturedRequestBody Array to store the captured request body - * @return Started HttpServer instance - */ - private HttpServer createServerWithRequestCapture(String[] capturedRequestBody) - throws IOException { - HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext( - "/v1/data/polaris/authz/allow", - new HttpHandler() { - @Override - public void handle(HttpExchange exchange) throws IOException { - // Capture request body - byte[] requestBytes = exchange.getRequestBody().readAllBytes(); - capturedRequestBody[0] = new String(requestBytes, StandardCharsets.UTF_8); - - String response = "{\"result\":{\"allow\":true}}"; - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, response.length()); - try (OutputStream os = exchange.getResponseBody()) { - os.write(response.getBytes(StandardCharsets.UTF_8)); - } - } - }); - server.start(); - return server; - } - - /** - * Helper method to create and start an HTTP server that returns a simple allow response. - * - * @return Started HttpServer instance - */ - private HttpServer createServerWithAllowResponse() throws IOException { - HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext( - "/v1/data/polaris/authz/allow", - new HttpHandler() { - @Override - public void handle(HttpExchange exchange) throws IOException { - String response = "{\"result\":{\"allow\":true}}"; - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, response.length()); - try (OutputStream os = exchange.getResponseBody()) { - os.write(response.getBytes(StandardCharsets.UTF_8)); - } - } - }); - server.start(); - return server; - } - - /** - * Helper method to capture and verify HTTP request Authorization header. - * - * @param mockHttpClient The mocked HTTP client to verify against - * @param expectedToken The expected bearer token value, or null if no Authorization header - * expected - */ - private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, String expectedToken) - throws IOException { - // Capture the HTTP request to verify bearer token header - ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class); - verify(mockHttpClient).execute(httpPostCaptor.capture()); - - HttpPost capturedRequest = httpPostCaptor.getValue(); - - if (expectedToken != null) { - // Verify the Authorization header is present and contains the expected token - assertTrue( - capturedRequest.containsHeader("Authorization"), - "Authorization header should be present when bearer token is provided"); - String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); - assertEquals( - "Bearer " + expectedToken, - authHeader, - "Authorization header should contain the correct bearer token"); - } else { - // Verify no Authorization header is present when token is null - assertTrue( - !capturedRequest.containsHeader("Authorization"), - "Authorization header should not be present when token provider returns null"); - } - } - - /** - * Convenience helper method for creating OpaPolarisAuthorizer with String bearer token. This - * provides the same API as the removed String-based create method for test convenience. - */ - private static OpaPolarisAuthorizer createWithStringToken( - String opaServerUrl, String opaPolicyPath, String bearerToken, CloseableHttpClient client) { - BearerTokenProvider tokenProvider = new StaticBearerTokenProvider(bearerToken); - return OpaPolarisAuthorizer.create(opaServerUrl, opaPolicyPath, tokenProvider, client); - } -} diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts new file mode 100644 index 0000000000..8111bac92c --- /dev/null +++ b/extensions/auth/opa/tests/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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. + */ + +plugins { + alias(libs.plugins.quarkus) + id("org.kordamp.gradle.jandex") + id("polaris-runtime") +} + +dependencies { + // Quarkus platform + implementation(platform(libs.quarkus.bom)) + implementation("io.quarkus:quarkus-rest-jackson") + + // Add the OPA implementation as RUNTIME dependency to include in Quarkus app + implementation(project(":polaris-extensions-auth-opa")) + + // Include all runtime-service dependencies + implementation(project(":polaris-runtime-service")) + + // Test common for integration testing + testImplementation(project(":polaris-runtime-test-common")) + + // Test dependencies + intTestImplementation("io.quarkus:quarkus-junit5") + intTestImplementation("io.rest-assured:rest-assured") + + // Test container dependencies + intTestImplementation(platform(libs.testcontainers.bom)) + intTestImplementation("org.testcontainers:junit-jupiter") + intTestImplementation(project(":polaris-container-spec-helper")) +} + +tasks.named("javadoc") { dependsOn("jandex") } + +tasks.withType { + if (System.getenv("AWS_REGION") == null) { + environment("AWS_REGION", "us-west-2") + } + environment("POLARIS_BOOTSTRAP_CREDENTIALS", "POLARIS,test-admin,test-secret") + jvmArgs("--add-exports", "java.base/sun.nio.ch=ALL-UNNAMED") + systemProperty("java.security.manager", "allow") + maxParallelForks = 1 +} \ No newline at end of file diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java similarity index 98% rename from runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java index 32965ce502..c39ff1c54b 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.auth.opa; +package org.apache.polaris.extension.auth.opa; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.fail; @@ -31,7 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.apache.polaris.test.commons.OpaTestResource; +import org.apache.polaris.extension.auth.opa.OpaTestResource; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.junit.jupiter.api.Test; diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java similarity index 98% rename from runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java index 39c53580ad..38155d8bd0 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenTestResource.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.auth.opa; +package org.apache.polaris.extension.auth.opa; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; import java.io.IOException; diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java similarity index 98% rename from runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java index 1724a8532e..bb1ce2f70b 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.auth.opa; +package org.apache.polaris.extension.auth.opa; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.fail; @@ -28,7 +28,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.apache.polaris.test.commons.OpaTestResource; +import org.apache.polaris.extension.auth.opa.OpaTestResource; import org.junit.jupiter.api.Test; @QuarkusTest diff --git a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java similarity index 93% rename from runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java index 6ceafc125a..b3ce5b04f7 100644 --- a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.test.commons; +package org.apache.polaris.extension.auth.opa; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; import java.io.OutputStream; @@ -26,9 +26,9 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; +import org.apache.polaris.containerspec.ContainerSpecHelper; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.utility.DockerImageName; public class OpaTestResource implements QuarkusTestResourceLifecycleManager { private static GenericContainer opa; @@ -46,7 +46,9 @@ public Map start() { // Reuse container across tests to speed up execution if (opa == null || !opa.isRunning()) { opa = - new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:0.63.0")) + new GenericContainer<>( + ContainerSpecHelper.containerSpecHelper("opa", OpaTestResource.class) + .dockerImageName(null)) .withExposedPorts(8181) .withReuse(true) .withCommand("run", "--server", "--addr=0.0.0.0:8181") diff --git a/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version new file mode 100644 index 0000000000..361f99dc27 --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version @@ -0,0 +1 @@ +FROM docker.io/openpolicyagent/opa:0.63.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5acf440fe3..9835105978 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,6 @@ # [versions] -apache-httpclient5 = "5.5" checkstyle = "10.25.0" hadoop = "3.4.2" hive = "3.1.3" @@ -42,7 +41,7 @@ swagger = "1.6.16" # quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-services-bom", version.ref="quarkus" } antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests -apache-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version.ref = "apache-httpclient5" } +apache-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.5.1" } assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" } auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" } awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.35.0" } diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index 864a7eaeac..8a5b3fe08b 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -43,7 +43,8 @@ polaris-version=tools/version polaris-misc-types=tools/misc-types polaris-extensions-federation-hadoop=extensions/federation/hadoop polaris-extensions-federation-hive=extensions/federation/hive -polaris-extensions-auth-opa=extensions/auth/opa +polaris-extensions-auth-opa=extensions/auth/opa/impl +polaris-extensions-auth-opa-tests=extensions/auth/opa/tests polaris-config-docs-annotations=tools/config-docs/annotations polaris-config-docs-generator=tools/config-docs/generator diff --git a/runtime/defaults/src/main/resources/application-it.properties b/runtime/defaults/src/main/resources/application-it.properties index f2cca1283e..1fdd60ae51 100644 --- a/runtime/defaults/src/main/resources/application-it.properties +++ b/runtime/defaults/src/main/resources/application-it.properties @@ -57,4 +57,3 @@ polaris.storage.gcp.token=token polaris.storage.gcp.lifespan=PT1H - diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 6eee0603c5..9f0cd2e2dd 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -199,7 +199,7 @@ polaris.oidc.principal-roles-mapper.type=default # Polaris authorization type settings # Which authorizer to use: "internal" (PolarisAuthorizerImpl) or "opa" (OpaPolarisAuthorizer) -polaris.authorization.type=internal +# polaris.authorization.type=internal # OPA Authorizer Configuration: effective only if polaris.authorization.type=opa # NOTE: The OPA Authorizer is currently in Beta and is not a stable release. diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java index 83f251c44f..7f11b9ce10 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java @@ -28,7 +28,7 @@ /** Factory for creating the default Polaris authorizer implementation. */ @ApplicationScoped @Identifier("internal") -public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { +class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { @Override public PolarisAuthorizer create(RealmConfig realmConfig) { From 652f82708db970c1a5ed34b0b7eab74875e9205b Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Mon, 20 Oct 2025 03:12:12 +0000 Subject: [PATCH 30/40] fix tests --- .../auth/opa/OpaAuthorizationConfig.java | 24 +- .../auth/opa/OpaPolarisAuthorizerFactory.java | 13 +- .../opa/OpaPolarisAuthorizerFactoryTest.java | 6 +- extensions/auth/opa/tests/build.gradle.kts | 8 +- .../auth/opa/OpaFileTokenIntegrationTest.java | 257 ++++-------------- .../auth/opa/OpaFileTokenTestResource.java | 67 ----- .../auth/opa/OpaIntegrationTest.java | 196 ++----------- .../auth/opa/OpaIntegrationTestBase.java | 131 +++++++++ .../extension/auth/opa/OpaTestResource.java | 48 ++-- .../extension/auth/opa/Dockerfile-opa-version | 18 ++ runtime/service/build.gradle.kts | 2 - .../service/it/ServiceProducersIT.java | 26 -- .../config/AuthorizationConfiguration.java | 19 +- .../service/config/ServiceProducers.java | 5 +- 14 files changed, 298 insertions(+), 522 deletions(-) delete mode 100644 extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java create mode 100644 extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java index 261c685645..1d5daf5fd0 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java @@ -170,29 +170,31 @@ interface FileBasedConfig { /** Path to file containing bearer token */ Path path(); - /** How often to refresh file-based bearer tokens */ - @WithDefault("PT5M") - Duration refreshInterval(); + /** How often to refresh file-based bearer tokens (defaults to 5 minutes if not specified) */ + Optional refreshInterval(); /** * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on - * the expiration time minus the buffer, rather than the fixed refresh interval. + * the expiration time minus the buffer, rather than the fixed refresh interval. Defaults to + * true if not specified. */ - @WithDefault("true") - boolean jwtExpirationRefresh(); + Optional jwtExpirationRefresh(); /** * Buffer time before JWT expiration to refresh the token. Only used when jwtExpirationRefresh - * is true and the token is a valid JWT. Default is 60 seconds. + * is true and the token is a valid JWT. Defaults to 1 minute if not specified. */ - @WithDefault("PT1M") - Duration jwtExpirationBuffer(); + Optional jwtExpirationBuffer(); default void validate() { checkArgument(path() != null, "Bearer token file path cannot be null"); - checkArgument(refreshInterval().isPositive(), "refreshInterval must be positive"); - checkArgument(jwtExpirationBuffer().isPositive(), "jwtExpirationBuffer must be positive"); + checkArgument( + refreshInterval().isEmpty() || refreshInterval().get().isPositive(), + "refreshInterval must be positive"); + checkArgument( + jwtExpirationBuffer().isEmpty() || jwtExpirationBuffer().get().isPositive(), + "jwtExpirationBuffer must be positive"); } } } diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index 4394f2c0a0..7f2d499639 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -152,11 +152,16 @@ private BearerTokenProvider createBearerTokenProvider( } OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig = bearerToken.fileBased().get(); - Duration refreshInterval = fileConfig.refreshInterval(); - boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh(); - Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer(); + + Duration refreshInterval = fileConfig.refreshInterval().orElse(Duration.ofMinutes(5)); + boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh().orElse(true); + Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer().orElse(Duration.ofMinutes(1)); + return new FileBearerTokenProvider( - fileConfig.path(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer); + fileConfig.path(), + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer); default: throw new IllegalStateException("Unsupported bearer token type: " + bearerToken.type()); diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index 5f45f3ca09..e942c0d9b1 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -84,9 +84,9 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig.class); when(fileTokenConfig.path()).thenReturn(tokenFile); - when(fileTokenConfig.refreshInterval()).thenReturn(Duration.ofMinutes(5)); - when(fileTokenConfig.jwtExpirationRefresh()).thenReturn(true); - when(fileTokenConfig.jwtExpirationBuffer()).thenReturn(Duration.ofMinutes(1)); + when(fileTokenConfig.refreshInterval()).thenReturn(Optional.of(Duration.ofMinutes(5))); + when(fileTokenConfig.jwtExpirationRefresh()).thenReturn(Optional.of(true)); + when(fileTokenConfig.jwtExpirationBuffer()).thenReturn(Optional.of(Duration.ofMinutes(1))); OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.class); diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts index 8111bac92c..e30f70a7be 100644 --- a/extensions/auth/opa/tests/build.gradle.kts +++ b/extensions/auth/opa/tests/build.gradle.kts @@ -27,16 +27,16 @@ dependencies { // Quarkus platform implementation(platform(libs.quarkus.bom)) implementation("io.quarkus:quarkus-rest-jackson") - + // Add the OPA implementation as RUNTIME dependency to include in Quarkus app implementation(project(":polaris-extensions-auth-opa")) - + // Include all runtime-service dependencies implementation(project(":polaris-runtime-service")) - + // Test common for integration testing testImplementation(project(":polaris-runtime-test-common")) - + // Test dependencies intTestImplementation("io.quarkus:quarkus-junit5") intTestImplementation("io.rest-assured:rest-assured") diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java index c39ff1c54b..1c97d3606b 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java @@ -19,7 +19,7 @@ package org.apache.polaris.extension.auth.opa; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; @@ -31,153 +31,81 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.apache.polaris.extension.auth.opa.OpaTestResource; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.junit.jupiter.api.Test; +/** + * Integration tests for OPA with file-based bearer token authentication. + * + *

    These tests verify that OpaPolarisAuthorizer correctly reads bearer tokens from a file and + * uses them to authenticate with OPA. + */ @QuarkusTest @TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) -public class OpaFileTokenIntegrationTest { - - @ConfigProperty(name = "polaris.authorization.opa.auth.bearer.file-based.path") - String tokenFilePath; +public class OpaFileTokenIntegrationTest extends OpaIntegrationTestBase { + /** + * Test profile for OPA integration with file-based bearer token authentication. The OPA + * container runs with HTTP for simplicity in CI environments. + */ public static class FileTokenOpaProfile implements QuarkusTestProfile { + // Static field to hold token file path for test access + public static Path tokenFilePath; @Override public Map getConfigOverrides() { - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.http.timeout-ms", "2000"); - - // Configure OPA server authentication with file-based bearer token - config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); - // Token file path will be provided by OpaFileTokenTestResource - config.put( - "polaris.authorization.opa.http.verify-ssl", - "false"); // Disable SSL verification for tests - - // TODO: Add tests for OIDC and federated principal - config.put("polaris.authentication.type", "internal"); - - return config; + try { + // Create token file early so SmallRye Config validation sees the property + tokenFilePath = Files.createTempFile("opa-test-token", ".txt"); + Files.writeString(tokenFilePath, "test-opa-bearer-token-from-file"); + + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + + // Configure file-based bearer token authentication + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); + config.put( + "polaris.authorization.opa.auth.bearer.file-based.path", tokenFilePath.toString()); + config.put("polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "PT1S"); + + return config; + } catch (IOException e) { + throw new RuntimeException("Failed to create test token file", e); + } } @Override public List testResources() { - String customRegoPolicy = - """ - package polaris.authz - - default allow := false - - # Allow root user for all operations - allow { - input.actor.principal == "root" - } - - # Allow admin user for all operations - allow { - input.actor.principal == "admin" - } - - # Deny stranger user explicitly (though default is false) - allow { - input.actor.principal == "stranger" - false - } - """; - - return List.of( - new TestResourceEntry( - OpaTestResource.class, - Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy)), - new TestResourceEntry(OpaFileTokenTestResource.class)); + return List.of(new TestResourceEntry(OpaTestResource.class)); } } - /** - * Test demonstrates OPA integration with file-based bearer token authentication. This test - * verifies that the FileBearerTokenProvider correctly reads tokens from a file and that the full - * integration works with file-based configuration. - */ @Test - void testOpaAllowsRootUserWithFileToken() { - // Test demonstrates the complete integration flow with file-based tokens: - // 1. OAuth token acquisition with internal authentication - // 2. OPA policy allowing root users - // 3. Bearer token read from file by FileBearerTokenProvider - - // Get a token using the catalog service OAuth endpoint - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - // Parse JSON response to get access_token - String accessToken = extractJsonValue(response, "access_token"); - - if (accessToken == null) { - fail("Failed to parse access_token from OAuth response: " + response); - } + void testOpaAllowsRootUser() { + String rootToken = getRootToken(); // Use the Bearer token to test OPA authorization // The JWT token has principal "root" which our policy allows given() - .header("Authorization", "Bearer " + accessToken) + .header("Authorization", "Bearer " + rootToken) .when() - .get("/api/management/v1/principals") + .get("api/management/v1/catalogs") .then() .statusCode(200); // Should succeed - "root" user is allowed by policy } @Test - void testFileTokenRefresh() throws IOException, InterruptedException { - // This test verifies that the FileBearerTokenProvider refreshes tokens from the file - - // First verify the system works with the initial token - String rootToken = getRootToken(); - - given() - .header("Authorization", "Bearer " + rootToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); - - // Get the token file path from injected configuration - Path tokenFile = Path.of(tokenFilePath); - if (Files.exists(tokenFile)) { - String originalContent = Files.readString(tokenFile); - - // Update the file content - Files.writeString(tokenFile, "test-opa-bearer-token-updated-12345"); - - // Wait for refresh interval (1 second as configured) plus some buffer - Thread.sleep(1500); // 1.5 seconds to ensure refresh happens - - // Verify the file was updated - String updatedContent = Files.readString(tokenFile); - if (updatedContent.equals(originalContent)) { - fail("Token file was not updated as expected"); - } - } + void testCreatePrincipalAndGetToken() { + // Test the helper method createPrincipalAndGetToken + // useful for debugging and ensuring that the helper method works correctly + assertDoesNotThrow( + () -> { + createPrincipalAndGetToken("test-user"); + }); } @Test - void testOpaPolicyDeniesStrangerUserWithFileToken() { + void testOpaPolicyDeniesStrangerUser() { // Create a "stranger" principal and get its access token String strangerToken = createPrincipalAndGetToken("stranger"); @@ -185,13 +113,13 @@ void testOpaPolicyDeniesStrangerUserWithFileToken() { given() .header("Authorization", "Bearer " + strangerToken) .when() - .get("/api/management/v1/principals") + .get("/api/management/v1/catalogs") .then() .statusCode(403); // Should be forbidden by OPA policy - stranger is denied } @Test - void testOpaAllowsAdminUserWithFileToken() { + void testOpaAllowsAdminUser() { // Create an "admin" principal and get its access token String adminToken = createPrincipalAndGetToken("admin"); @@ -199,95 +127,8 @@ void testOpaAllowsAdminUserWithFileToken() { given() .header("Authorization", "Bearer " + adminToken) .when() - .get("/api/management/v1/principals") + .get("/api/management/v1/catalogs") .then() .statusCode(200); // Should succeed - admin user is allowed by policy } - - /** Helper method to create a principal and get an OAuth access token for that principal */ - private String createPrincipalAndGetToken(String principalName) { - // First get root token to create the principal - String rootToken = getRootToken(); - - // Create the principal using the root token - String createResponse = - given() - .contentType("application/json") - .header("Authorization", "Bearer " + rootToken) - .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") - .when() - .post("/api/management/v1/principals") - .then() - .statusCode(201) - .extract() - .body() - .asString(); - - // Parse the principal's credentials from the response - String clientId = extractJsonValue(createResponse, "clientId"); - String clientSecret = extractJsonValue(createResponse, "clientSecret"); - - if (clientId == null || clientSecret == null) { - fail("Could not parse principal credentials from response: " + createResponse); - } - - // Get access token for the newly created principal - String tokenResponse = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", clientId) - .formParam("client_secret", clientSecret) - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(tokenResponse, "access_token"); - if (accessToken == null) { - fail("Could not get access token for principal " + principalName); - } - - return accessToken; - } - - /** Helper method to get root access token */ - private String getRootToken() { - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(response, "access_token"); - if (accessToken == null) { - fail("Failed to parse access_token from admin OAuth response: " + response); - } - return accessToken; - } - - /** Simple JSON value extractor */ - private String extractJsonValue(String json, String key) { - String searchKey = "\"" + key + "\""; - if (json.contains(searchKey)) { - String value = json.substring(json.indexOf(searchKey) + searchKey.length()); - value = value.substring(value.indexOf("\"") + 1); - value = value.substring(0, value.indexOf("\"")); - return value; - } - return null; - } } diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java deleted file mode 100644 index 38155d8bd0..0000000000 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenTestResource.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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 org.apache.polaris.extension.auth.opa; - -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -/** Test resource that manages a temporary token file for OPA authentication testing. */ -public class OpaFileTokenTestResource implements QuarkusTestResourceLifecycleManager { - private Path tokenFile; - - @Override - public Map start() { - try { - // Create temporary token file - tokenFile = Files.createTempFile("opa-test-token", ".txt"); - Files.writeString(tokenFile, "test-opa-bearer-token-from-file-67890"); - - // Return configuration that will be added to the test - Map config = new HashMap<>(); - config.put("polaris.authorization.opa.auth.bearer.file-based.path", tokenFile.toString()); - config.put( - "polaris.authorization.opa.auth.bearer.file-based.refresh-interval", - "1"); // 1 second for testing - - return config; - } catch (IOException e) { - throw new RuntimeException("Failed to create test token file", e); - } - } - - @Override - public void stop() { - if (tokenFile != null) { - try { - Files.deleteIfExists(tokenFile); - } catch (IOException e) { - System.err.println("Warning: Failed to delete test token file: " + e.getMessage()); - } - } - } - - /** Get the token file path for tests that need to modify it. */ - public Path getTokenFile() { - return tokenFile; - } -} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java index bb1ce2f70b..cbd8e7bf4a 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java @@ -19,7 +19,7 @@ package org.apache.polaris.extension.auth.opa; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; @@ -28,12 +28,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.apache.polaris.extension.auth.opa.OpaTestResource; import org.junit.jupiter.api.Test; @QuarkusTest @TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class) -public class OpaIntegrationTest { +public class OpaIntegrationTest extends OpaIntegrationTestBase { /** * Test demonstrates OPA integration with bearer token authentication. The OPA container runs with @@ -45,101 +44,45 @@ public static class StaticTokenOpaProfile implements QuarkusTestProfile { public Map getConfigOverrides() { Map config = new HashMap<>(); config.put("polaris.authorization.type", "opa"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.http.timeout-ms", "2000"); - // Configure OPA server authentication with static bearer token + // Override default configuration for static token authentication (if needed) config.put("polaris.authorization.opa.auth.type", "bearer"); config.put("polaris.authorization.opa.auth.bearer.type", "static-token"); - config.put( - "polaris.authorization.opa.auth.bearer.static-token.value", - "test-opa-bearer-token-12345"); - config.put( - "polaris.authorization.opa.http.verify-ssl", - "false"); // Disable SSL verification for tests - - // TODO: Add tests for OIDC and federated principal - config.put("polaris.authentication.type", "internal"); + config.put("polaris.authorization.opa.auth.bearer.static-token.value", "test-opa-bearer-token-12345"); return config; } @Override public List testResources() { - String customRegoPolicy = - """ - package polaris.authz - - default allow := false - - # Allow root user for all operations - allow { - input.actor.principal == "root" - } - - # Allow admin user for all operations - allow { - input.actor.principal == "admin" - } - - # Deny stranger user explicitly (though default is false) - allow { - input.actor.principal == "stranger" - false - } - """; - return List.of( - new TestResourceEntry( - OpaTestResource.class, - Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy))); + new TestResourceEntry(OpaTestResource.class)); } } @Test void testOpaAllowsRootUser() { - // Test demonstrates the complete integration flow: - // 1. OAuth token acquisition with internal authentication - // 2. OPA policy allowing root users - - // Get a token using the catalog service OAuth endpoint - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - // Parse JSON response to get access_token - String accessToken = null; - if (response.contains("\"access_token\"")) { - accessToken = response.substring(response.indexOf("\"access_token\"") + 15); - accessToken = accessToken.substring(accessToken.indexOf("\"") + 1); - accessToken = accessToken.substring(0, accessToken.indexOf("\"")); - } - - if (accessToken == null) { - fail("Failed to parse access_token from OAuth response: " + response); - } + String rootToken = getRootToken(); // Use the Bearer token to test OPA authorization // The JWT token has principal "root" which our policy allows given() - .header("Authorization", "Bearer " + accessToken) + .header("Authorization", "Bearer " + rootToken) .when() - .get("/api/management/v1/principals") + .get("api/management/v1/catalogs") .then() .statusCode(200); // Should succeed - "root" user is allowed by policy } + @Test + void testCreatePrincipalAndGetToken() { + // Test the helper method createPrincipalAndGetToken + // useful for debugging and ensuring that the helper method works correctly + assertDoesNotThrow(() -> { + createPrincipalAndGetToken("test-user"); + }); + } + @Test void testOpaPolicyDeniesStrangerUser() { // Create a "stranger" principal and get its access token @@ -149,7 +92,7 @@ void testOpaPolicyDeniesStrangerUser() { given() .header("Authorization", "Bearer " + strangerToken) .when() - .get("/api/management/v1/principals") + .get("/api/management/v1/catalogs") .then() .statusCode(403); // Should be forbidden by OPA policy - stranger is denied } @@ -163,109 +106,8 @@ void testOpaAllowsAdminUser() { given() .header("Authorization", "Bearer " + adminToken) .when() - .get("/api/management/v1/principals") + .get("/api/management/v1/catalogs") .then() .statusCode(200); // Should succeed - admin user is allowed by policy } - - @Test - void testOpaBearerTokenAuthentication() { - // Test that OpaPolarisAuthorizer is configured to send bearer tokens - // and can handle HTTP connections for testing - String rootToken = getRootToken(); - - given() - .header("Authorization", "Bearer " + rootToken) - .when() - .get("/api/management/v1/principals") - .then() - .statusCode(200); - } - - /** Helper method to create a principal and get an OAuth access token for that principal */ - private String createPrincipalAndGetToken(String principalName) { - // First get root token to create the principal - String rootToken = getRootToken(); - - // Create the principal using the root token - String createResponse = - given() - .contentType("application/json") - .header("Authorization", "Bearer " + rootToken) - .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") - .when() - .post("/api/management/v1/principals") - .then() - .statusCode(201) - .extract() - .body() - .asString(); - - // Parse the principal's credentials from the response - String clientId = extractJsonValue(createResponse, "clientId"); - String clientSecret = extractJsonValue(createResponse, "clientSecret"); - - if (clientId == null || clientSecret == null) { - fail("Could not parse principal credentials from response: " + createResponse); - } - - // Get access token for the newly created principal - String tokenResponse = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", clientId) - .formParam("client_secret", clientSecret) - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(tokenResponse, "access_token"); - if (accessToken == null) { - fail("Could not get access token for principal " + principalName); - } - - return accessToken; - } - - /** Helper method to get root access token */ - private String getRootToken() { - String response = - given() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", "client_credentials") - .formParam("client_id", "test-admin") - .formParam("client_secret", "test-secret") - .formParam("scope", "PRINCIPAL_ROLE:ALL") - .when() - .post("/api/catalog/v1/oauth/tokens") - .then() - .statusCode(200) - .extract() - .body() - .asString(); - - String accessToken = extractJsonValue(response, "access_token"); - if (accessToken == null) { - fail("Failed to parse access_token from admin OAuth response: " + response); - } - return accessToken; - } - - /** Simple JSON value extractor */ - private String extractJsonValue(String json, String key) { - String searchKey = "\"" + key + "\""; - if (json.contains(searchKey)) { - String value = json.substring(json.indexOf(searchKey) + searchKey.length()); - value = value.substring(value.indexOf("\"") + 1); - value = value.substring(0, value.indexOf("\"")); - return value; - } - return null; - } } diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java new file mode 100644 index 0000000000..e97faf7e17 --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.extension.auth.opa; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Base class for OPA integration tests providing common helper methods for authentication and + * principal management. + */ +public abstract class OpaIntegrationTestBase { + + /** + * Helper method to get root access token using the default test admin credentials. + * + * @return the access token for the root user + */ + protected String getRootToken() { + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(response, "access_token"); + if (accessToken == null) { + fail("Failed to parse access_token from admin OAuth response: " + response); + } + return accessToken; + } + + /** + * Helper method to create a principal and get an OAuth access token for that principal. + * + * @param principalName the name of the principal to create + * @return the access token for the newly created principal + */ + protected String createPrincipalAndGetToken(String principalName) { + // First get root token to create the principal + String rootToken = getRootToken(); + + // Create the principal using the root token + String createResponse = + given() + .contentType("application/json") + .header("Authorization", "Bearer " + rootToken) + .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") + .when() + .post("/api/management/v1/principals") + .then() + .statusCode(201) + .extract() + .body() + .asString(); + + // Parse the principal's credentials from the response + String clientId = extractJsonValue(createResponse, "clientId"); + String clientSecret = extractJsonValue(createResponse, "clientSecret"); + + if (clientId == null || clientSecret == null) { + fail("Could not parse principal credentials from response: " + createResponse); + } + + // Get access token for the newly created principal + String tokenResponse = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", clientId) + .formParam("client_secret", clientSecret) + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + String accessToken = extractJsonValue(tokenResponse, "access_token"); + if (accessToken == null) { + fail("Could not get access token for principal " + principalName); + } + + return accessToken; + } + + /** + * Simple JSON value extractor for parsing values from JSON responses. + * + * @param json the JSON string to parse + * @param key the key to extract the value for + * @return the extracted value, or null if not found + */ + protected String extractJsonValue(String json, String key) { + String searchKey = "\"" + key + "\""; + if (json.contains(searchKey)) { + String value = json.substring(json.indexOf(searchKey) + searchKey.length()); + value = value.substring(value.indexOf("\"") + 1); + value = value.substring(0, value.indexOf("\"")); + return value; + } + return null; + } +} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java index b3ce5b04f7..e0ea86e6ac 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java @@ -47,8 +47,8 @@ public Map start() { if (opa == null || !opa.isRunning()) { opa = new GenericContainer<>( - ContainerSpecHelper.containerSpecHelper("opa", OpaTestResource.class) - .dockerImageName(null)) + ContainerSpecHelper.containerSpecHelper("opa", OpaTestResource.class) + .dockerImageName(null)) .withExposedPorts(8181) .withReuse(true) .withCommand("run", "--server", "--addr=0.0.0.0:8181") @@ -65,11 +65,28 @@ public Map start() { String containerHost = opa.getHost(); String baseUrl = "http://" + containerHost + ":" + mappedPort; - // Load Rego policy into OPA - loadRegoPolicy(baseUrl, "policy-name", "rego-policy"); + // Load Opa Polaris Authorizer Rego policy into OPA + String polarisPolicyName = "polaris-authz"; + String polarisRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + """; + loadRegoPolicy(baseUrl, polarisPolicyName, polarisRegoPolicy); Map config = new HashMap<>(); - config.put("polaris.authorization.opa.url", baseUrl); + config.put("polaris.authorization.opa.policy-uri", baseUrl + "/v1/data/polaris/authz"); return config; @@ -78,21 +95,12 @@ public Map start() { } } - private void loadRegoPolicy(String baseUrl, String policyNameKey, String regoPolicyKey) { - String policyName = resourceConfig.get(policyNameKey); - String regoPolicy = resourceConfig.get(regoPolicyKey); - - if (policyName == null) { - throw new IllegalArgumentException( - policyNameKey + " parameter is required for OpaTestResource"); - } - if (regoPolicy == null) { - throw new IllegalArgumentException( - regoPolicyKey + " parameter is required for OpaTestResource"); - } - + private void loadRegoPolicy(String baseUrl, String policyName, String regoPolicy) { + // Hardcode the policy directly instead of loading through QuarkusTestProfile try { URL url = new URL(baseUrl + "/v1/policies/" + policyName); + System.out.println("Uploading policy to: " + url); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("PUT"); conn.setDoOutput(true); @@ -103,9 +111,13 @@ private void loadRegoPolicy(String baseUrl, String policyNameKey, String regoPol } int code = conn.getResponseCode(); + System.out.println("OPA policy upload response code: " + code); + if (code < 200 || code >= 300) { throw new RuntimeException("OPA policy upload failed, HTTP " + code); } + + System.out.println("Successfully uploaded policy to OPA"); } catch (Exception e) { // Surface container logs to help debug on CI String logs = ""; diff --git a/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version index 361f99dc27..701452b6c5 100644 --- a/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version +++ b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version @@ -1 +1,19 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF 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 +# +# 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. +# FROM docker.io/openpolicyagent/opa:0.63.0 \ No newline at end of file diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index 231c41f82d..f51aefb0d6 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -139,8 +139,6 @@ dependencies { testImplementation(project(":polaris-runtime-test-common")) testImplementation(project(":polaris-container-spec-helper")) - testImplementation(project(":polaris-extensions-auth-opa")) - testImplementation(libs.threeten.extra) testImplementation(libs.hawkular.agent.prometheus.scraper) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java index 0469cebd6f..34171b32f0 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java @@ -39,18 +39,6 @@ public Map getConfigOverrides() { } } - public static class OpaAuthorizationConfig implements QuarkusTestProfile { - @Override - public Map getConfigOverrides() { - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - config.put("polaris.authorization.opa.url", "http://localhost:8181"); - config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); - config.put("polaris.authorization.opa.auth.type", "none"); - return config; - } - } - @QuarkusTest @io.quarkus.test.junit.TestProfile(ServiceProducersIT.InternalAuthorizationConfig.class) public static class InternalAuthorizationTest { @@ -64,18 +52,4 @@ void testInternalPolarisAuthorizerProduced() { assertNotNull(polarisAuthorizer, "Internal PolarisAuthorizer should not be null"); } } - - @QuarkusTest - @io.quarkus.test.junit.TestProfile(ServiceProducersIT.OpaAuthorizationConfig.class) - public static class OpaAuthorizationTest { - - @Inject PolarisAuthorizer polarisAuthorizer; - - @Test - void testOpaPolarisAuthorizerProduced() { - assertNotNull(polarisAuthorizer, "PolarisAuthorizer should be produced"); - // Verify it's the correct implementation for OPA config - assertNotNull(polarisAuthorizer, "OPA PolarisAuthorizer should not be null"); - } - } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 2e8caa99d0..01da1c72ae 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -23,6 +23,23 @@ @ConfigMapping(prefix = "polaris.authorization") public interface AuthorizationConfiguration { + + /** Authorization types supported by Polaris */ + enum AuthorizationType { + INTERNAL("internal"), + OPA("opa"); + + private final String value; + + AuthorizationType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + @WithDefault("internal") - String type(); + AuthorizationType type(); } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index 211949c99d..c04a0cbad3 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -144,7 +144,10 @@ public RealmConfig realmConfig(CallContext callContext) { public PolarisAuthorizerFactory polarisAuthorizerFactory( AuthorizationConfiguration authorizationConfig, @Any Instance authorizerFactories) { - return authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get(); + PolarisAuthorizerFactory factory = authorizerFactories + .select(Identifier.Literal.of(authorizationConfig.type().getValue())) + .get(); + return factory; } @Produces From 30bd623b3138c48d45b4c73906fddd7ba470ddc0 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:02:34 +0000 Subject: [PATCH 31/40] adopt review feedback --- build.gradle.kts | 2 +- .../auth/opa/OpaAuthorizationConfig.java | 61 +++------ .../auth/opa/OpaHttpClientFactory.java | 2 +- .../auth/opa/OpaPolarisAuthorizerFactory.java | 66 ++++------ .../opa/OpaProductionReadinessChecks.java | 56 ++++++++ .../opa/token/FileBearerTokenProvider.java | 122 +++--------------- .../auth/opa/OpaHttpClientFactoryTest.java | 11 +- .../opa/OpaPolarisAuthorizerFactoryTest.java | 34 ++--- .../token/FileBearerTokenProviderTest.java | 31 ++--- .../token/StaticBearerTokenProviderTest.java | 8 +- extensions/auth/opa/tests/build.gradle.kts | 2 +- .../OpaFileTokenIntegrationTest.java | 7 +- .../opa/{ => test}/OpaIntegrationTest.java | 19 +-- .../{ => test}/OpaIntegrationTestBase.java | 2 +- .../auth/opa/{ => test}/OpaTestResource.java | 16 +-- .../opa/{ => test}/Dockerfile-opa-version | 0 .../src/main/resources/application.properties | 14 +- .../service/config/ServiceProducers.java | 7 +- 18 files changed, 187 insertions(+), 273 deletions(-) create mode 100644 extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java rename extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/{ => test}/OpaFileTokenIntegrationTest.java (95%) rename extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/{ => test}/OpaIntegrationTest.java (88%) rename extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/{ => test}/OpaIntegrationTestBase.java (98%) rename extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/{ => test}/OpaTestResource.java (95%) rename extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/{ => test}/Dockerfile-opa-version (100%) diff --git a/build.gradle.kts b/build.gradle.kts index 0b70dee989..86593e405f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -89,7 +89,7 @@ tasks.named("rat").configure { // Misc build artifacts excludes.add(".java-version") excludes.add("**/.keep") - excludes.add("logs/**") + excludes.add("extensions/auth/opa/tests/logs/**") excludes.add("**/*.lock") // Polaris service startup banner diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java index 1d5daf5fd0..630d9eeb9d 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java @@ -54,40 +54,22 @@ public String getValue() { } } - /** Bearer token configuration types */ - enum BearerTokenType { - STATIC_TOKEN("static-token"), - FILE_BASED("file-based"); + URI policyUri(); - private final String value; - - BearerTokenType(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - } - - Optional policyUri(); + AuthenticationConfig auth(); - Optional auth(); - - Optional http(); + HttpConfig http(); /** Validates the complete OPA configuration */ default void validate() { - checkArgument(policyUri().isPresent(), "OPA policy URI cannot be null"); - checkArgument(auth().isPresent(), "Authentication configuration is required"); - auth().get().validate(); + auth().validate(); } /** HTTP client configuration for OPA communication. */ interface HttpConfig { - @WithDefault("2000") - int timeoutMs(); + @WithDefault("PT2S") + Duration timeout(); @WithDefault("true") boolean verifySsl(); @@ -124,10 +106,6 @@ default void validate() { } interface BearerTokenConfig { - /** Type of bearer token configuration */ - @WithDefault("static-token") - BearerTokenType type(); - /** Static bearer token configuration */ Optional staticToken(); @@ -135,22 +113,16 @@ interface BearerTokenConfig { Optional fileBased(); default void validate() { - switch (type()) { - case STATIC_TOKEN: - checkArgument( - staticToken().isPresent(), - "Static token configuration is required when type is 'static-token'"); - staticToken().get().validate(); - break; - case FILE_BASED: - checkArgument( - fileBased().isPresent(), - "File-based configuration is required when type is 'file-based'"); - fileBased().get().validate(); - break; - default: - throw new IllegalArgumentException( - "Invalid bearer token type: " + type() + ". Must be 'static-token' or 'file-based'"); + // Ensure exactly one bearer token configuration is present (mutually exclusive) + checkArgument( + staticToken().isPresent() ^ fileBased().isPresent(), + "Exactly one of 'static-token' or 'file-based' bearer token configuration must be specified"); + + // Validate the present configuration + if (staticToken().isPresent()) { + staticToken().get().validate(); + } else { + fileBased().get().validate(); } } @@ -188,7 +160,6 @@ interface FileBasedConfig { Optional jwtExpirationBuffer(); default void validate() { - checkArgument(path() != null, "Bearer token file path cannot be null"); checkArgument( refreshInterval().isEmpty() || refreshInterval().get().isPositive(), "refreshInterval must be positive"); diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java index 75921408b7..bc698a44b2 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java @@ -53,7 +53,7 @@ class OpaHttpClientFactory { public static CloseableHttpClient createHttpClient(OpaAuthorizationConfig.HttpConfig config) { RequestConfig requestConfig = RequestConfig.custom() - .setResponseTimeout(Timeout.ofMilliseconds(config.timeoutMs())) + .setResponseTimeout(Timeout.ofMilliseconds(config.timeout().toMillis())) .build(); try { diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index 7f2d499639..405f612a42 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -25,6 +25,7 @@ import jakarta.inject.Inject; import java.io.IOException; import java.net.URI; +import java.time.Clock; import java.time.Duration; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -45,12 +46,14 @@ class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { private static final Logger logger = LoggerFactory.getLogger(OpaPolarisAuthorizerFactory.class); private final OpaAuthorizationConfig opaConfig; + private final Clock clock; private CloseableHttpClient httpClient; private BearerTokenProvider bearerTokenProvider; @Inject - public OpaPolarisAuthorizerFactory(OpaAuthorizationConfig opaConfig) { + public OpaPolarisAuthorizerFactory(OpaAuthorizationConfig opaConfig, Clock clock) { this.opaConfig = opaConfig; + this.clock = clock; } @PostConstruct @@ -62,13 +65,13 @@ public void initialize() { httpClient = createHttpClient(); // Setup authentication once during startup - setupAuthentication(opaConfig.auth().get()); + setupAuthentication(opaConfig.auth()); } @Override public PolarisAuthorizer create(RealmConfig realmConfig) { // All components are now pre-initialized, just create the authorizer - URI policyUri = opaConfig.policyUri().get(); + URI policyUri = opaConfig.policyUri(); return new OpaPolarisAuthorizer(policyUri, httpClient, bearerTokenProvider); } @@ -79,7 +82,7 @@ public void cleanup() { if (bearerTokenProvider != null) { try { bearerTokenProvider.close(); - logger.info("Bearer token provider closed successfully"); + logger.debug("Bearer token provider closed successfully"); } catch (Exception e) { // Log but don't throw - we're shutting down anyway logger.warn("Error closing bearer token provider: {}", e.getMessage(), e); @@ -90,7 +93,7 @@ public void cleanup() { if (httpClient != null) { try { httpClient.close(); - logger.info("HTTP client closed successfully"); + logger.debug("HTTP client closed successfully"); } catch (IOException e) { // Log but don't throw - we're shutting down anyway logger.warn("Error closing HTTP client: {}", e.getMessage(), e); @@ -100,10 +103,7 @@ public void cleanup() { private CloseableHttpClient createHttpClient() { try { - if (opaConfig.http().isEmpty()) { - throw new IllegalStateException("HTTP configuration is required"); - } - return OpaHttpClientFactory.createHttpClient(opaConfig.http().get()); + return OpaHttpClientFactory.createHttpClient(opaConfig.http()); } catch (Exception e) { // Fallback to simple client return HttpClients.custom().build(); @@ -135,36 +135,24 @@ private void setupAuthentication(OpaAuthorizationConfig.AuthenticationConfig aut private BearerTokenProvider createBearerTokenProvider( OpaAuthorizationConfig.BearerTokenConfig bearerToken) { - switch (bearerToken.type()) { - case STATIC_TOKEN: - if (bearerToken.staticToken().isEmpty()) { - throw new IllegalStateException( - "Static token configuration is required when type is 'static-token'"); - } - OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticConfig = - bearerToken.staticToken().get(); - return new StaticBearerTokenProvider(staticConfig.value()); - - case FILE_BASED: - if (bearerToken.fileBased().isEmpty()) { - throw new IllegalStateException( - "File-based configuration is required when type is 'file-based'"); - } - OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig = - bearerToken.fileBased().get(); - - Duration refreshInterval = fileConfig.refreshInterval().orElse(Duration.ofMinutes(5)); - boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh().orElse(true); - Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer().orElse(Duration.ofMinutes(1)); - - return new FileBearerTokenProvider( - fileConfig.path(), - refreshInterval, - jwtExpirationRefresh, - jwtExpirationBuffer); - - default: - throw new IllegalStateException("Unsupported bearer token type: " + bearerToken.type()); + // Check which configuration is present + if (bearerToken.staticToken().isPresent()) { + OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticConfig = + bearerToken.staticToken().get(); + return new StaticBearerTokenProvider(staticConfig.value()); + } else if (bearerToken.fileBased().isPresent()) { + OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig = + bearerToken.fileBased().get(); + + Duration refreshInterval = fileConfig.refreshInterval().orElse(Duration.ofMinutes(5)); + boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh().orElse(true); + Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer().orElse(Duration.ofMinutes(1)); + + return new FileBearerTokenProvider( + fileConfig.path(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer, clock); + } else { + throw new IllegalStateException( + "No bearer token configuration found. Must specify either 'static-token' or 'file-based'"); } } } diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java new file mode 100644 index 0000000000..19c6d7671b --- /dev/null +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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 org.apache.polaris.extension.auth.opa; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.config.ProductionReadinessCheck; +import org.apache.polaris.core.config.ProductionReadinessCheck.Error; + +@ApplicationScoped +public class OpaProductionReadinessChecks { + + @Produces + public ProductionReadinessCheck checkBetaStatus(PolarisAuthorizerFactory authorizerFactory) { + + if (authorizerFactory instanceof OpaPolarisAuthorizerFactory) { + return ProductionReadinessCheck.of( + Error.of( + "OPA authorization is currently a Beta feature and is not a stable release. Breaking changes may be introduced in future versions. Use with caution in production environments.", + "polaris.authorization.type")); + } + + return ProductionReadinessCheck.OK; + } + + @Produces + public ProductionReadinessCheck checkSslVerification( + PolarisAuthorizerFactory authorizerFactory, OpaAuthorizationConfig config) { + + if ((authorizerFactory instanceof OpaPolarisAuthorizerFactory) && !config.http().verifySsl()) { + return ProductionReadinessCheck.of( + Error.ofSevere( + "SSL certificate verification is disabled for OPA communication. This exposes the service to man-in-the-middle attacks and other severe security risks.", + "polaris.authorization.opa.http.verify-ssl")); + } + + return ProductionReadinessCheck.OK; + } +} diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 1cb13f6726..3568c5afe9 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -18,6 +18,8 @@ */ package org.apache.polaris.extension.auth.opa.token; +import static com.google.common.base.Preconditions.checkState; + import com.auth0.jwt.JWT; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.DecodedJWT; @@ -31,8 +33,7 @@ import java.time.Instant; import java.util.Date; import java.util.Optional; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,7 +60,7 @@ public class FileBearerTokenProvider implements BearerTokenProvider { private final boolean jwtExpirationRefresh; private final Duration jwtExpirationBuffer; private final Clock clock; - private final ReadWriteLock lock = new ReentrantReadWriteLock(); + private final AtomicBoolean refreshLock = new AtomicBoolean(); private volatile String cachedToken; private volatile Instant lastRefresh; @@ -73,31 +74,9 @@ public class FileBearerTokenProvider implements BearerTokenProvider { * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + * @param clock clock instance for time operations */ public FileBearerTokenProvider( - Path tokenFilePath, - Duration refreshInterval, - boolean jwtExpirationRefresh, - Duration jwtExpirationBuffer) { - this( - tokenFilePath, - refreshInterval, - jwtExpirationRefresh, - jwtExpirationBuffer, - Clock.systemUTC()); - } - - /** - * Create a new file-based token provider with JWT expiration support and custom clock. - * Package-private constructor for testing purposes. - * - * @param tokenFilePath path to the file containing the bearer token - * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) - * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing - * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token - * @param clock clock instance for time operations (useful for testing) - */ - FileBearerTokenProvider( Path tokenFilePath, Duration refreshInterval, boolean jwtExpirationRefresh, @@ -122,34 +101,20 @@ public FileBearerTokenProvider( @Override @Nullable public String getToken() { - if (closed) { - logger.warn("Token provider is closed, returning null"); - return null; - } + checkState(!closed, "Token provider is closed"); // Check if we need to refresh if (shouldRefresh()) { refreshToken(); } - lock.readLock().lock(); - try { - return cachedToken; - } finally { - lock.readLock().unlock(); - } + return cachedToken; } @Override public void close() { closed = true; - lock.writeLock().lock(); - try { - cachedToken = null; - logger.info("File token provider closed"); - } finally { - lock.writeLock().unlock(); - } + cachedToken = null; } private boolean shouldRefresh() { @@ -157,13 +122,10 @@ private boolean shouldRefresh() { } private void refreshToken() { - lock.writeLock().lock(); + if (!refreshLock.compareAndSet(false, true)) { + return; + } try { - // Double-check pattern - another thread might have refreshed while we waited for the lock - if (!shouldRefresh()) { - return; - } - String newToken = loadTokenFromFile(); // If we couldn't load a token and have no cached token, this is a fatal error @@ -190,9 +152,8 @@ private void refreshToken() { tokenFilePath, cachedToken != null && !cachedToken.isEmpty(), nextRefresh); - } finally { - lock.writeLock().unlock(); + refreshLock.set(false); } } @@ -232,60 +193,15 @@ private Instant calculateNextRefresh(@Nullable String token) { @Nullable private String loadTokenFromFile() { - int attempts = 0; - long deadlineMs = - clock.millis() - + 3000; // 3 second deadline for retries (reasonable for CSI driver file mounts) - String currentCachedToken = cachedToken; // Snapshot of current cache - - while (true) { - try { - // Check if file is readable first - if (!Files.isReadable(tokenFilePath)) { - // Treat as transient error and retry (could be CSI driver not ready yet) - throw new IOException("File is not readable: " + tokenFilePath); - } - - String token = Files.readString(tokenFilePath, StandardCharsets.UTF_8).trim(); - if (!token.isEmpty()) { - return token; - } - - // Empty token - treat as transient issue (could be file being written) - throw new IOException("File contains only whitespace: " + tokenFilePath); - } catch (IOException e) { - // Treat file system exceptions as transient (file being rotated, temporary unavailability) - logger.debug( - "Transient error reading token file (attempt {}): {}", attempts + 1, e.getMessage()); - } - - // If we have a cached token and it's safe to use, fall back to it - if (currentCachedToken != null && !currentCachedToken.isEmpty()) { - logger.warn( - "Failed to read new token from file after {} attempts, using cached token", - attempts + 1); - return currentCachedToken; - } - - // No cached token available and we can't read from file - attempts++; - if (attempts >= 5 || clock.millis() > deadlineMs) { - // Return null to let refreshToken() decide how to handle this based on cached token state - logger.debug("Token unavailable after {} attempts", attempts); - return null; - } - - // Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1000ms (reasonable for CSI driver - // scenarios) - long delayMs = Math.min(100L << (attempts - 1), 1000); - try { - Thread.sleep(delayMs); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - logger.debug("Interrupted while reading token, returning null"); - return null; + try { + String token = Files.readString(tokenFilePath, StandardCharsets.UTF_8).trim(); + if (!token.isEmpty()) { + return token; } + } catch (IOException e) { + logger.debug("Failed to read token from file", e); } + return null; } /** diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java index 2b410bc176..ce3da717b3 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.when; import java.nio.file.Paths; +import java.time.Duration; import java.util.Optional; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.junit.jupiter.api.Test; @@ -32,7 +33,8 @@ public class OpaHttpClientFactoryTest { @Test void testCreateHttpClientWithHttpUrl() throws Exception { - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); + OpaAuthorizationConfig.HttpConfig httpConfig = + createMockHttpConfig(Duration.ofSeconds(5), true, null, null); try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { assertNotNull(client); @@ -41,7 +43,8 @@ void testCreateHttpClientWithHttpUrl() throws Exception { @Test void testCreateHttpClientWithHttpsUrl() throws Exception { - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, false, null, null); + OpaAuthorizationConfig.HttpConfig httpConfig = + createMockHttpConfig(Duration.ofSeconds(5), false, null, null); try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { assertNotNull(client); @@ -49,9 +52,9 @@ void testCreateHttpClientWithHttpsUrl() throws Exception { } private OpaAuthorizationConfig.HttpConfig createMockHttpConfig( - int timeoutMs, boolean verifySsl, String trustStorePath, String trustStorePassword) { + Duration timeout, boolean verifySsl, String trustStorePath, String trustStorePassword) { OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); - when(httpConfig.timeoutMs()).thenReturn(timeoutMs); + when(httpConfig.timeout()).thenReturn(timeout); when(httpConfig.verifySsl()).thenReturn(verifySsl); when(httpConfig.trustStorePath()) .thenReturn(Optional.ofNullable(trustStorePath != null ? Paths.get(trustStorePath) : null)); diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index e942c0d9b1..742aa05ee0 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -27,6 +27,7 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Clock; import java.time.Duration; import java.util.Optional; import org.apache.polaris.core.config.RealmConfig; @@ -47,7 +48,6 @@ public void testFactoryWithStaticTokenConfiguration() { OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.type()).thenReturn(OpaAuthorizationConfig.BearerTokenType.STATIC_TOKEN); when(bearerTokenConfig.staticToken()).thenReturn(Optional.of(staticTokenConfig)); when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); @@ -60,11 +60,12 @@ public void testFactoryWithStaticTokenConfiguration() { OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); when(opaConfig.policyUri()) - .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); - when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); - when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + .thenReturn(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(authConfig); + when(opaConfig.http()).thenReturn(httpConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC()); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -90,7 +91,6 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.type()).thenReturn(OpaAuthorizationConfig.BearerTokenType.FILE_BASED); when(bearerTokenConfig.staticToken()).thenReturn(Optional.empty()); when(bearerTokenConfig.fileBased()).thenReturn(Optional.of(fileTokenConfig)); @@ -103,11 +103,12 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); when(opaConfig.policyUri()) - .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); - when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); - when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + .thenReturn(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(authConfig); + when(opaConfig.http()).thenReturn(httpConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC()); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -118,7 +119,7 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { // Also verify that the token provider actually reads from the file try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { String actualToken = provider.getToken(); assertEquals(tokenValue, actualToken); @@ -137,11 +138,12 @@ public void testFactoryWithNoTokenConfiguration() { OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); when(opaConfig.policyUri()) - .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); - when(opaConfig.auth()).thenReturn(Optional.of(authConfig)); - when(opaConfig.http()).thenReturn(Optional.of(httpConfig)); + .thenReturn(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")); + when(opaConfig.auth()).thenReturn(authConfig); + when(opaConfig.http()).thenReturn(httpConfig); - OpaPolarisAuthorizerFactory factory = new OpaPolarisAuthorizerFactory(opaConfig); + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC()); // Create authorizer RealmConfig realmConfig = mock(RealmConfig.class); @@ -152,7 +154,7 @@ public void testFactoryWithNoTokenConfiguration() { private OpaAuthorizationConfig.HttpConfig createMockHttpConfig() { OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); - when(httpConfig.timeoutMs()).thenReturn(2000); + when(httpConfig.timeout()).thenReturn(Duration.ofSeconds(2)); when(httpConfig.verifySsl()).thenReturn(true); when(httpConfig.trustStorePath()).thenReturn(Optional.empty()); when(httpConfig.trustStorePassword()).thenReturn(Optional.empty()); diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java index 9cfc4beb9a..7989da805a 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java @@ -19,7 +19,6 @@ package org.apache.polaris.extension.auth.opa.token; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,6 +27,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; @@ -52,7 +52,7 @@ public void testLoadTokenFromFile() throws IOException { // Create file token provider try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { // Test token retrieval String actualToken = provider.getToken(); @@ -71,7 +71,7 @@ public void testLoadTokenFromFileWithWhitespace() throws IOException { // Create file token provider try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { // Test token retrieval (should trim whitespace) String actualToken = provider.getToken(); @@ -119,7 +119,8 @@ public void testNonExistentFileThrows() { Paths.get("/non/existent/file.txt"), Duration.ofMinutes(5), true, - Duration.ofMinutes(1))) { + Duration.ofMinutes(1), + Clock.systemUTC())) { // Test token retrieval (should throw exception when no cached token exists) assertThrows(RuntimeException.class, provider::getToken); @@ -135,29 +136,13 @@ public void testEmptyFile() throws IOException { // Create file token provider try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1))) { + tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { // Test token retrieval (should throw exception for empty file when no cached token exists) assertThrows(RuntimeException.class, provider::getToken); } } - @Test - public void testClosedProvider() throws IOException { - // Create a temporary token file - Path tokenFile = tempDir.resolve("token.txt"); - Files.writeString(tokenFile, "test-token"); - - // Create file token provider and explicitly close it to test closed behavior - FileBearerTokenProvider provider = - new FileBearerTokenProvider(tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1)); - provider.close(); - - // Test token retrieval after closing (should return null) - String token = provider.getToken(); - assertNull(token); - } - @Test public void testJwtExpirationRefresh() throws IOException { // Create mutable clock for deterministic time control @@ -265,7 +250,7 @@ public void testJwtExpirationTooSoon() throws IOException { // Create file token provider with JWT expiration refresh enabled try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofSeconds(60))) { + tokenFile, Duration.ofMinutes(5), true, Duration.ofSeconds(60), Clock.systemUTC())) { // Should fall back to fixed interval when JWT expires too soon String token = provider.getToken(); @@ -283,7 +268,7 @@ public void testJwtWithoutExpirationClaim() throws IOException { // Create file token provider with JWT expiration refresh enabled try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMillis(100), true, Duration.ofSeconds(1))) { + tokenFile, Duration.ofMillis(100), true, Duration.ofSeconds(1), Clock.systemUTC())) { // Should fall back to fixed interval when JWT has no expiration String token = provider.getToken(); diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java index e25cde6938..5a76a3edee 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java @@ -28,10 +28,10 @@ public class StaticBearerTokenProviderTest { @Test public void testStaticBearerTokenProvider() { String expectedToken = "static-bearer-token"; - StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken); - - String actualToken = provider.getToken(); - assertEquals(expectedToken, actualToken); + try (StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken)) { + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + } } @Test diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts index e30f70a7be..7f4ee3275d 100644 --- a/extensions/auth/opa/tests/build.gradle.kts +++ b/extensions/auth/opa/tests/build.gradle.kts @@ -57,4 +57,4 @@ tasks.withType { jvmArgs("--add-exports", "java.base/sun.nio.ch=ALL-UNNAMED") systemProperty("java.security.manager", "allow") maxParallelForks = 1 -} \ No newline at end of file +} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java similarity index 95% rename from extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java index 1c97d3606b..418f3921f9 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaFileTokenIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.extension.auth.opa; +package org.apache.polaris.extension.auth.opa.test; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -44,8 +44,8 @@ public class OpaFileTokenIntegrationTest extends OpaIntegrationTestBase { /** - * Test profile for OPA integration with file-based bearer token authentication. The OPA - * container runs with HTTP for simplicity in CI environments. + * Test profile for OPA integration with file-based bearer token authentication. The OPA container + * runs with HTTP for simplicity in CI environments. */ public static class FileTokenOpaProfile implements QuarkusTestProfile { // Static field to hold token file path for test access @@ -63,7 +63,6 @@ public Map getConfigOverrides() { // Configure file-based bearer token authentication config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); config.put( "polaris.authorization.opa.auth.bearer.file-based.path", tokenFilePath.toString()); config.put("polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "PT1S"); diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java similarity index 88% rename from extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java index cbd8e7bf4a..809cab6451 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.extension.auth.opa; +package org.apache.polaris.extension.auth.opa.test; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -45,18 +45,18 @@ public Map getConfigOverrides() { Map config = new HashMap<>(); config.put("polaris.authorization.type", "opa"); - // Override default configuration for static token authentication (if needed) + // Configure static token authentication config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put("polaris.authorization.opa.auth.bearer.type", "static-token"); - config.put("polaris.authorization.opa.auth.bearer.static-token.value", "test-opa-bearer-token-12345"); + config.put( + "polaris.authorization.opa.auth.bearer.static-token.value", + "test-opa-bearer-token-12345"); return config; } @Override public List testResources() { - return List.of( - new TestResourceEntry(OpaTestResource.class)); + return List.of(new TestResourceEntry(OpaTestResource.class)); } } @@ -78,9 +78,10 @@ void testOpaAllowsRootUser() { void testCreatePrincipalAndGetToken() { // Test the helper method createPrincipalAndGetToken // useful for debugging and ensuring that the helper method works correctly - assertDoesNotThrow(() -> { - createPrincipalAndGetToken("test-user"); - }); + assertDoesNotThrow( + () -> { + createPrincipalAndGetToken("test-user"); + }); } @Test diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java similarity index 98% rename from extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java index e97faf7e17..9bbd326d9e 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaIntegrationTestBase.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.extension.auth.opa; +package org.apache.polaris.extension.auth.opa.test; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.fail; diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java similarity index 95% rename from extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java rename to extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java index e0ea86e6ac..0bc0385b94 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/OpaTestResource.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.extension.auth.opa; +package org.apache.polaris.extension.auth.opa.test; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; import java.io.OutputStream; @@ -33,12 +33,6 @@ public class OpaTestResource implements QuarkusTestResourceLifecycleManager { private static GenericContainer opa; private int mappedPort; - private Map resourceConfig; - - @Override - public void init(Map initArgs) { - this.resourceConfig = initArgs; - } @Override public Map start() { @@ -68,7 +62,7 @@ public Map start() { // Load Opa Polaris Authorizer Rego policy into OPA String polarisPolicyName = "polaris-authz"; String polarisRegoPolicy = - """ + """ package polaris.authz default allow := false @@ -100,7 +94,7 @@ private void loadRegoPolicy(String baseUrl, String policyName, String regoPolicy try { URL url = new URL(baseUrl + "/v1/policies/" + policyName); System.out.println("Uploading policy to: " + url); - + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("PUT"); conn.setDoOutput(true); @@ -112,11 +106,11 @@ private void loadRegoPolicy(String baseUrl, String policyName, String regoPolicy int code = conn.getResponseCode(); System.out.println("OPA policy upload response code: " + code); - + if (code < 200 || code >= 300) { throw new RuntimeException("OPA policy upload failed, HTTP " + code); } - + System.out.println("Successfully uploaded policy to OPA"); } catch (Exception e) { // Surface container logs to help debug on CI diff --git a/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version similarity index 100% rename from extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/Dockerfile-opa-version rename to extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index 9f0cd2e2dd..badb6f37b2 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -208,28 +208,26 @@ polaris.oidc.principal-roles-mapper.type=default # polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow # OPA HTTP configuration -# polaris.authorization.opa.http.timeout-ms=5000 +# polaris.authorization.opa.http.timeout=PT2S # polaris.authorization.opa.http.verify-ssl=false +# polaris.authorization.opa.http.trust-store-path=/path/to/truststore +# polaris.authorization.opa.http.trust-store-password=my-trust-store-password # OPA Authentication configuration # Default is no authentication (type=none) -# To enable bearer token authentication,, use type=bearer +# To enable bearer token authentication, use type=bearer # polaris.authorization.opa.auth.type=none # To enable bearer token authentication, uncomment the following: # polaris.authorization.opa.auth.type=bearer -# OPA Bearer token configuration (only used when type=bearer) -# polaris.authorization.opa.auth.bearer.type=static-token - # Static bearer token configuration: # polaris.authorization.opa.auth.bearer.static-token.value=my-static-token # Alternative file-based bearer token configuration: -# polaris.authorization.opa.auth.bearer.type=file-based # polaris.authorization.opa.auth.bearer.file-based.path=/path/to/token/file -# polaris.authorization.opa.auth.bearer.file-based.refresh-interval=300 +# polaris.authorization.opa.auth.bearer.file-based.refresh-interval=PT5M # polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-refresh=true -# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-buffer=60 +# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-buffer=PT1M # Polaris Credential Manager Config polaris.credential-manager.type=default diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index c04a0cbad3..95287d4f34 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -144,9 +144,10 @@ public RealmConfig realmConfig(CallContext callContext) { public PolarisAuthorizerFactory polarisAuthorizerFactory( AuthorizationConfiguration authorizationConfig, @Any Instance authorizerFactories) { - PolarisAuthorizerFactory factory = authorizerFactories - .select(Identifier.Literal.of(authorizationConfig.type().getValue())) - .get(); + PolarisAuthorizerFactory factory = + authorizerFactories + .select(Identifier.Literal.of(authorizationConfig.type().getValue())) + .get(); return factory; } From 00d2e5d77268b12986e9ef621e6a613123b04daa Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:54:40 +0000 Subject: [PATCH 32/40] fix tests --- .../auth/opa/OpaHttpClientFactory.java | 8 ++--- .../opa/OpaProductionReadinessChecks.java | 30 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java index bc698a44b2..db9febb62c 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java @@ -42,7 +42,7 @@ * (OPA) servers. */ class OpaHttpClientFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(OpaHttpClientFactory.class); + private static final Logger logger = LoggerFactory.getLogger(OpaHttpClientFactory.class); /** * Creates a configured HTTP client for OPA communication. @@ -104,7 +104,7 @@ private static SSLContext createSslContext(OpaAuthorizationConfig.HttpConfig con throws Exception { if (!config.verifySsl()) { // Disable SSL verification (for development/testing) - LOGGER.warn( + logger.warn( "SSL verification is disabled for OPA server. This should only be used in development/testing environments."); return SSLContexts.custom() .loadTrustMaterial( @@ -113,7 +113,7 @@ private static SSLContext createSslContext(OpaAuthorizationConfig.HttpConfig con } else if (config.trustStorePath().isPresent()) { // Load custom trust store for SSL verification Path trustStorePath = config.trustStorePath().get(); - LOGGER.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath); + logger.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath); KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath.toFile())) { String trustStorePassword = config.trustStorePassword().orElse(null); @@ -123,7 +123,7 @@ private static SSLContext createSslContext(OpaAuthorizationConfig.HttpConfig con return SSLContexts.custom().loadTrustMaterial(trustStore, null).build(); } else { // Use default system trust store for SSL verification - LOGGER.debug("Using default system trust store for OPA SSL verification"); + logger.debug("Using default system trust store for OPA SSL verification"); return SSLContexts.createDefault(); } } diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java index 19c6d7671b..82ba2b3e2a 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java @@ -20,6 +20,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; +import java.util.ArrayList; +import java.util.List; import org.apache.polaris.core.auth.PolarisAuthorizerFactory; import org.apache.polaris.core.config.ProductionReadinessCheck; import org.apache.polaris.core.config.ProductionReadinessCheck.Error; @@ -28,29 +30,25 @@ public class OpaProductionReadinessChecks { @Produces - public ProductionReadinessCheck checkBetaStatus(PolarisAuthorizerFactory authorizerFactory) { - + public ProductionReadinessCheck checkOpaAuthorization( + PolarisAuthorizerFactory authorizerFactory, OpaAuthorizationConfig config) { if (authorizerFactory instanceof OpaPolarisAuthorizerFactory) { - return ProductionReadinessCheck.of( + List errors = new ArrayList<>(); + + errors.add( Error.of( "OPA authorization is currently a Beta feature and is not a stable release. Breaking changes may be introduced in future versions. Use with caution in production environments.", "polaris.authorization.type")); - } - - return ProductionReadinessCheck.OK; - } - @Produces - public ProductionReadinessCheck checkSslVerification( - PolarisAuthorizerFactory authorizerFactory, OpaAuthorizationConfig config) { + if (!config.http().verifySsl()) { + errors.add( + Error.ofSevere( + "SSL certificate verification is disabled for OPA communication. This exposes the service to man-in-the-middle attacks and other severe security risks.", + "polaris.authorization.opa.http.verify-ssl")); + } - if ((authorizerFactory instanceof OpaPolarisAuthorizerFactory) && !config.http().verifySsl()) { - return ProductionReadinessCheck.of( - Error.ofSevere( - "SSL certificate verification is disabled for OPA communication. This exposes the service to man-in-the-middle attacks and other severe security risks.", - "polaris.authorization.opa.http.verify-ssl")); + return ProductionReadinessCheck.of(errors); } - return ProductionReadinessCheck.OK; } } From 0b030e6c625dda78c4cce98bcc3f524504e3f181 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 22 Oct 2025 01:18:41 +0000 Subject: [PATCH 33/40] fix --- .../auth/opa/OpaPolarisAuthorizer.java | 6 +++++- .../auth/opa/OpaPolarisAuthorizerFactory.java | 14 +++++++++++++- .../opa/OpaProductionReadinessChecks.java | 6 ++++-- .../auth/opa/OpaPolarisAuthorizerTest.java | 19 ++++++++++++------- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index 2d5e2d748c..df0ff37d00 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -69,11 +69,14 @@ class OpaPolarisAuthorizer implements PolarisAuthorizer { * https://opa.example.com/v1/polaris/allow * @param httpClient Apache HttpClient (required, injected by CDI). SSL configuration should be * handled by the CDI producer. + * @param objectMapper Jackson ObjectMapper for JSON serialization (required). Shared across + * authorizer instances to avoid initialization overhead. * @param tokenProvider Token provider for authentication (optional) */ public OpaPolarisAuthorizer( @Nonnull URI policyUri, @Nonnull CloseableHttpClient httpClient, + @Nonnull ObjectMapper objectMapper, @Nullable BearerTokenProvider tokenProvider) { Preconditions.checkArgument(policyUri != null, "policyUri cannot be null"); @@ -83,12 +86,13 @@ public OpaPolarisAuthorizer( Preconditions.checkArgument( policyUri.getPath() != null && !policyUri.getPath().isEmpty(), "Policy URI must have a non-empty path"); + Preconditions.checkArgument(objectMapper != null, "objectMapper cannot be null"); this.opaServerUrl = policyUri.getScheme() + "://" + policyUri.getAuthority(); this.opaPolicyPath = policyUri.getPath(); this.tokenProvider = tokenProvider; this.httpClient = httpClient; - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; } /** diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index 405f612a42..3661638d6e 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -18,6 +18,7 @@ */ package org.apache.polaris.extension.auth.opa; +import com.fasterxml.jackson.databind.ObjectMapper; import io.smallrye.common.annotation.Identifier; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -49,11 +50,22 @@ class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { private final Clock clock; private CloseableHttpClient httpClient; private BearerTokenProvider bearerTokenProvider; + private ObjectMapper objectMapper; @Inject public OpaPolarisAuthorizerFactory(OpaAuthorizationConfig opaConfig, Clock clock) { this.opaConfig = opaConfig; this.clock = clock; + this.objectMapper = new ObjectMapper(); + } + + /** + * Gets the OPA authorization configuration. Used by OpaProductionReadinessCheck + * + * @return the OPA configuration + */ + OpaAuthorizationConfig getConfig() { + return opaConfig; } @PostConstruct @@ -73,7 +85,7 @@ public PolarisAuthorizer create(RealmConfig realmConfig) { // All components are now pre-initialized, just create the authorizer URI policyUri = opaConfig.policyUri(); - return new OpaPolarisAuthorizer(policyUri, httpClient, bearerTokenProvider); + return new OpaPolarisAuthorizer(policyUri, httpClient, objectMapper, bearerTokenProvider); } @PreDestroy diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java index 82ba2b3e2a..af6696743e 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java @@ -31,8 +31,10 @@ public class OpaProductionReadinessChecks { @Produces public ProductionReadinessCheck checkOpaAuthorization( - PolarisAuthorizerFactory authorizerFactory, OpaAuthorizationConfig config) { - if (authorizerFactory instanceof OpaPolarisAuthorizerFactory) { + PolarisAuthorizerFactory authorizerFactory) { + if (authorizerFactory instanceof OpaPolarisAuthorizerFactory opaFactory) { + OpaAuthorizationConfig config = opaFactory.getConfig(); + List errors = new ArrayList<>(); errors.add( diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java index 20f2ecf2d1..38c701f356 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java @@ -75,7 +75,8 @@ void testOpaInputJsonFormat() throws Exception { URI.create( "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + new OpaPolarisAuthorizer( + policyUri, HttpClients.createDefault(), new ObjectMapper(), null); PolarisPrincipal principal = PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); @@ -114,7 +115,8 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { URI.create( "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + new OpaPolarisAuthorizer( + policyUri, HttpClients.createDefault(), new ObjectMapper(), null); // Set up a realistic principal PolarisPrincipal principal = @@ -257,7 +259,8 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { URI.create( "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + new OpaPolarisAuthorizer( + policyUri, HttpClients.createDefault(), new ObjectMapper(), null); // Set up a realistic principal PolarisPrincipal principal = @@ -408,7 +411,8 @@ void testAuthorizeOrThrowWithEmptyTargetsAndSecondaries() throws Exception { URI.create( "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow"); OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), null); + new OpaPolarisAuthorizer( + policyUri, HttpClients.createDefault(), new ObjectMapper(), null); PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin")); @@ -451,7 +455,8 @@ public void testCreateWithHttpsAndBearerToken() { BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token"); URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow"); OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, HttpClients.createDefault(), tokenProvider); + new OpaPolarisAuthorizer( + policyUri, HttpClients.createDefault(), new ObjectMapper(), tokenProvider); assertTrue(authorizer != null); } @@ -473,7 +478,7 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token"); OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, mockHttpClient, tokenProvider); + new OpaPolarisAuthorizer(policyUri, mockHttpClient, new ObjectMapper(), tokenProvider); PolarisPrincipal mockPrincipal = PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); @@ -513,7 +518,7 @@ public void testBearerTokenFromBearerTokenProvider() throws IOException { URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow"); // Create authorizer with the token provider instead of static token OpaPolarisAuthorizer authorizer = - new OpaPolarisAuthorizer(policyUri, mockHttpClient, tokenProvider); + new OpaPolarisAuthorizer(policyUri, mockHttpClient, new ObjectMapper(), tokenProvider); // Create mock principal and entities PolarisPrincipal mockPrincipal = From 5e024654b07ac537754970c413837a838cd89f98 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 22 Oct 2025 02:50:40 +0000 Subject: [PATCH 34/40] fix regtest --- .../auth/opa/OpaAuthorizationConfig.java | 10 +++++++++- .../auth/opa/OpaPolarisAuthorizer.java | 20 ++++--------------- .../auth/opa/OpaPolarisAuthorizerFactory.java | 8 +++++++- .../opa/OpaPolarisAuthorizerFactoryTest.java | 8 ++++---- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java index 630d9eeb9d..73cff7e90a 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java @@ -54,7 +54,7 @@ public String getValue() { } } - URI policyUri(); + Optional policyUri(); AuthenticationConfig auth(); @@ -62,6 +62,14 @@ public String getValue() { /** Validates the complete OPA configuration */ default void validate() { + checkArgument( + policyUri().isPresent(), "polaris.authorization.opa.policy-uri must be configured"); + + URI uri = policyUri().get(); + String scheme = uri.getScheme(); + checkArgument( + "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme), + "polaris.authorization.opa.policy-uri must use http or https scheme, but got: " + scheme); auth().validate(); } diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index df0ff37d00..7e0f0037a0 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -56,8 +56,7 @@ * environments. */ class OpaPolarisAuthorizer implements PolarisAuthorizer { - private final String opaServerUrl; - private final String opaPolicyPath; + private final URI policyUri; private final BearerTokenProvider tokenProvider; private final CloseableHttpClient httpClient; private final ObjectMapper objectMapper; @@ -79,17 +78,7 @@ public OpaPolarisAuthorizer( @Nonnull ObjectMapper objectMapper, @Nullable BearerTokenProvider tokenProvider) { - Preconditions.checkArgument(policyUri != null, "policyUri cannot be null"); - Preconditions.checkArgument( - policyUri.getScheme() != null && policyUri.getAuthority() != null, - "Policy URI must have a valid scheme and authority"); - Preconditions.checkArgument( - policyUri.getPath() != null && !policyUri.getPath().isEmpty(), - "Policy URI must have a non-empty path"); - Preconditions.checkArgument(objectMapper != null, "objectMapper cannot be null"); - - this.opaServerUrl = policyUri.getScheme() + "://" + policyUri.getAuthority(); - this.opaPolicyPath = policyUri.getPath(); + this.policyUri = policyUri; this.tokenProvider = tokenProvider; this.httpClient = httpClient; this.objectMapper = objectMapper; @@ -172,7 +161,7 @@ private boolean queryOpa( String inputJson = buildOpaInputJson(principal, entities, op, targets, secondaries); // Create HTTP POST request using Apache HttpComponents - HttpPost httpPost = new HttpPost(opaServerUrl + opaPolicyPath); + HttpPost httpPost = new HttpPost(policyUri); httpPost.setHeader("Content-Type", "application/json"); // Add bearer token authentication if provided @@ -319,13 +308,12 @@ private ObjectNode buildSingleResourceNode(PolarisResolvedPathWrapper wrapper) { /** * Builds the context section of the OPA input JSON. * - *

    Includes only timestamp and request ID. + *

    Includes a request ID for correlating OPA server requests with logs. * * @return the context node for OPA input */ private ObjectNode buildContextNode() { ObjectNode context = objectMapper.createObjectNode(); - context.put("time", java.time.ZonedDateTime.now().toString()); context.put("request_id", java.util.UUID.randomUUID().toString()); return context; } diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index 3661638d6e..ec261b3138 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -83,7 +83,13 @@ public void initialize() { @Override public PolarisAuthorizer create(RealmConfig realmConfig) { // All components are now pre-initialized, just create the authorizer - URI policyUri = opaConfig.policyUri(); + URI policyUri = + opaConfig + .policyUri() + .orElseThrow( + () -> + new IllegalStateException( + "OPA policy URI must be configured via polaris.authorization.opa.policy-uri")); return new OpaPolarisAuthorizer(policyUri, httpClient, objectMapper, bearerTokenProvider); } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index 742aa05ee0..eff2a5689d 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -60,7 +60,7 @@ public void testFactoryWithStaticTokenConfiguration() { OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); when(opaConfig.policyUri()) - .thenReturn(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")); + .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); when(opaConfig.auth()).thenReturn(authConfig); when(opaConfig.http()).thenReturn(httpConfig); @@ -103,7 +103,7 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); when(opaConfig.policyUri()) - .thenReturn(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")); + .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); when(opaConfig.auth()).thenReturn(authConfig); when(opaConfig.http()).thenReturn(httpConfig); @@ -128,7 +128,7 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { @Test public void testFactoryWithNoTokenConfiguration() { - // Mock configuration with "none" authentication (no tokens) + // Mock configuration with no authentication OpaAuthorizationConfig.AuthenticationConfig authConfig = mock(OpaAuthorizationConfig.AuthenticationConfig.class); when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.NONE); @@ -138,7 +138,7 @@ public void testFactoryWithNoTokenConfiguration() { OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); when(opaConfig.policyUri()) - .thenReturn(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")); + .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); when(opaConfig.auth()).thenReturn(authConfig); when(opaConfig.http()).thenReturn(httpConfig); From 80ec27ccca4b2ac1583debcd4749d810d51e7b24 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:27:56 +0000 Subject: [PATCH 35/40] adopt more feedback --- .../auth/opa/OpaPolarisAuthorizer.java | 1 - .../opa/token/FileBearerTokenProvider.java | 29 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index 7e0f0037a0..ab985abfea 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.base.Preconditions; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.IOException; diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 3568c5afe9..5bc6fec4e6 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -23,6 +23,7 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.DecodedJWT; +import com.google.common.base.Strings; import jakarta.annotation.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -99,7 +100,6 @@ public FileBearerTokenProvider( } @Override - @Nullable public String getToken() { checkState(!closed, "Token provider is closed"); @@ -108,6 +108,13 @@ public String getToken() { refreshToken(); } + // If we couldn't load a token and have no cached token, this is a fatal error + if (Strings.isNullOrEmpty(cachedToken)) { + throw new RuntimeException( + "Unable to load bearer token from file: " + + tokenFilePath + + ". This is required for OPA authorization."); + } return cachedToken; } @@ -128,19 +135,12 @@ private void refreshToken() { try { String newToken = loadTokenFromFile(); - // If we couldn't load a token and have no cached token, this is a fatal error - if (newToken == null && cachedToken == null) { - throw new RuntimeException( - "Unable to load bearer token from file: " - + tokenFilePath - + ". This is required for OPA authorization."); - } - // Only update cached token if we successfully loaded a new one - if (newToken != null) { - cachedToken = newToken; + if (newToken == null) { + logger.debug("Couldn't load new bearer token from {}, will retry.", tokenFilePath); + return; } - // If newToken is null but cachedToken exists, we keep using the cached token + cachedToken = newToken; lastRefresh = clock.instant(); @@ -158,9 +158,8 @@ private void refreshToken() { } /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */ - private Instant calculateNextRefresh(@Nullable String token) { - if (token == null || !jwtExpirationRefresh) { - // Use fixed interval + private Instant calculateNextRefresh(String token) { + if (!jwtExpirationRefresh) { return lastRefresh.plus(refreshInterval); } From fd38aa3f93b0bc794a4860fe79b6cc247ff84a5f Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 22 Oct 2025 19:17:06 +0000 Subject: [PATCH 36/40] add comment --- runtime/defaults/src/main/resources/application.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties index badb6f37b2..879bdf14a6 100644 --- a/runtime/defaults/src/main/resources/application.properties +++ b/runtime/defaults/src/main/resources/application.properties @@ -209,6 +209,8 @@ polaris.oidc.principal-roles-mapper.type=default # OPA HTTP configuration # polaris.authorization.opa.http.timeout=PT2S +# NOTE: Setting verify-ssl=false will trigger a severe production readiness check error +# as this exposes the service to security risks. # polaris.authorization.opa.http.verify-ssl=false # polaris.authorization.opa.http.trust-store-path=/path/to/truststore # polaris.authorization.opa.http.trust-store-password=my-trust-store-password From fe8e450d0ec13cac5996b8d792ed0ec9f27b4c55 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Wed, 22 Oct 2025 22:29:45 +0000 Subject: [PATCH 37/40] adopt feedback --- build.gradle.kts | 2 +- extensions/auth/opa/build.gradle.kts | 56 ---- .../opa/token/FileBearerTokenProvider.java | 33 +- .../auth/opa/OpaHttpClientFactoryTest.java | 6 +- .../opa/OpaPolarisAuthorizerFactoryTest.java | 11 +- .../auth/opa/OpaPolarisAuthorizerTest.java | 301 ++++++++++-------- .../token/FileBearerTokenProviderTest.java | 70 ++-- .../token/StaticBearerTokenProviderTest.java | 12 +- extensions/auth/opa/tests/build.gradle.kts | 13 + .../opa/test/OpaFileTokenIntegrationTest.java | 7 +- .../auth/opa/test/OpaIntegrationTest.java | 7 +- .../main/resources/application-it.properties | 1 - runtime/service/build.gradle.kts | 1 - .../service/it/ServiceProducersIT.java | 9 +- .../config/AuthorizationConfiguration.java | 18 +- .../service/config/ServiceProducers.java | 4 +- 16 files changed, 259 insertions(+), 292 deletions(-) delete mode 100644 extensions/auth/opa/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 86593e405f..0b70dee989 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -89,7 +89,7 @@ tasks.named("rat").configure { // Misc build artifacts excludes.add(".java-version") excludes.add("**/.keep") - excludes.add("extensions/auth/opa/tests/logs/**") + excludes.add("logs/**") excludes.add("**/*.lock") // Polaris service startup banner diff --git a/extensions/auth/opa/build.gradle.kts b/extensions/auth/opa/build.gradle.kts deleted file mode 100644 index 33c4c7cd83..0000000000 --- a/extensions/auth/opa/build.gradle.kts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF 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 - * - * 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. - */ - -plugins { - id("polaris-server") - id("org.kordamp.gradle.jandex") -} - -dependencies { - implementation(project(":polaris-core")) - implementation(libs.apache.httpclient5) - implementation(platform(libs.jackson.bom)) - implementation("com.fasterxml.jackson.core:jackson-core") - implementation("com.fasterxml.jackson.core:jackson-databind") - implementation(libs.guava) - implementation(libs.slf4j.api) - implementation(libs.auth0.jwt) - - // Iceberg dependency for ForbiddenException - implementation(platform(libs.iceberg.bom)) - implementation("org.apache.iceberg:iceberg-api") - - compileOnly(libs.jakarta.annotation.api) - compileOnly(libs.jakarta.enterprise.cdi.api) - compileOnly(libs.jakarta.inject.api) - compileOnly(libs.smallrye.config.core) - - testImplementation(testFixtures(project(":polaris-core"))) - testImplementation(project(":polaris-runtime-test-common")) - testImplementation(platform(libs.junit.bom)) - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation(libs.assertj.core) - testImplementation(libs.mockito.core) - testImplementation(platform(libs.quarkus.bom)) - testImplementation("io.quarkus:quarkus-junit5") - testImplementation("io.rest-assured:rest-assured") - testImplementation("com.github.tomakehurst:wiremock:3.0.1") - testImplementation(platform(libs.testcontainers.bom)) - testImplementation("org.testcontainers:junit-jupiter") -} diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 5bc6fec4e6..66bbd4f702 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -18,8 +18,6 @@ */ package org.apache.polaris.extension.auth.opa.token; -import static com.google.common.base.Preconditions.checkState; - import com.auth0.jwt.JWT; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.DecodedJWT; @@ -66,7 +64,6 @@ public class FileBearerTokenProvider implements BearerTokenProvider { private volatile String cachedToken; private volatile Instant lastRefresh; private volatile Instant nextRefresh; - private volatile boolean closed = false; /** * Create a new file-based token provider with JWT expiration support. @@ -76,6 +73,7 @@ public class FileBearerTokenProvider implements BearerTokenProvider { * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token * @param clock clock instance for time operations + * @throws IllegalStateException if the initial token cannot be loaded from the file */ public FileBearerTokenProvider( Path tokenFilePath, @@ -88,39 +86,45 @@ public FileBearerTokenProvider( this.jwtExpirationRefresh = jwtExpirationRefresh; this.jwtExpirationBuffer = jwtExpirationBuffer; this.clock = clock; - this.lastRefresh = Instant.MIN; // Force initial load - this.nextRefresh = Instant.MIN; // Force initial refresh + + // Load initial token eagerly to avoid race conditions during first getToken() calls + this.cachedToken = loadTokenFromFile(); + if (Strings.isNullOrEmpty(this.cachedToken)) { + throw new IllegalStateException( + "Failed to load initial bearer token from file: " + + tokenFilePath + + ". This is required for OPA authorization."); + } + + this.lastRefresh = clock.instant(); + this.nextRefresh = calculateNextRefresh(this.cachedToken); logger.debug( - "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", + "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}, next refresh: {}", tokenFilePath, refreshInterval, jwtExpirationRefresh, - jwtExpirationBuffer); + jwtExpirationBuffer, + nextRefresh); } @Override public String getToken() { - checkState(!closed, "Token provider is closed"); - // Check if we need to refresh if (shouldRefresh()) { refreshToken(); } - // If we couldn't load a token and have no cached token, this is a fatal error + // Token is guaranteed to be present after construction, but check anyway for safety if (Strings.isNullOrEmpty(cachedToken)) { throw new RuntimeException( - "Unable to load bearer token from file: " - + tokenFilePath - + ". This is required for OPA authorization."); + "Bearer token is unexpectedly empty. This should not happen after successful construction."); } return cachedToken; } @Override public void close() { - closed = true; cachedToken = null; } @@ -129,6 +133,7 @@ private boolean shouldRefresh() { } private void refreshToken() { + // Only one thread should refresh at a time. Other threads will use the cached token. if (!refreshLock.compareAndSet(false, true)) { return; } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java index ce3da717b3..601a0c285c 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java @@ -18,7 +18,7 @@ */ package org.apache.polaris.extension.auth.opa; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -37,7 +37,7 @@ void testCreateHttpClientWithHttpUrl() throws Exception { createMockHttpConfig(Duration.ofSeconds(5), true, null, null); try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { - assertNotNull(client); + assertThat(client).isNotNull(); } } @@ -47,7 +47,7 @@ void testCreateHttpClientWithHttpsUrl() throws Exception { createMockHttpConfig(Duration.ofSeconds(5), false, null, null); try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { - assertNotNull(client); + assertThat(client).isNotNull(); } } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index eff2a5689d..cbe339d175 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -18,8 +18,7 @@ */ package org.apache.polaris.extension.auth.opa; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -71,7 +70,7 @@ public void testFactoryWithStaticTokenConfiguration() { RealmConfig realmConfig = mock(RealmConfig.class); OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - assertNotNull(authorizer); + assertThat(authorizer).isNotNull(); } @Test @@ -114,7 +113,7 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { RealmConfig realmConfig = mock(RealmConfig.class); OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - assertNotNull(authorizer); + assertThat(authorizer).isNotNull(); // Also verify that the token provider actually reads from the file try (FileBearerTokenProvider provider = @@ -122,7 +121,7 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { String actualToken = provider.getToken(); - assertEquals(tokenValue, actualToken); + assertThat(actualToken).isEqualTo(tokenValue); } } @@ -149,7 +148,7 @@ public void testFactoryWithNoTokenConfiguration() { RealmConfig realmConfig = mock(RealmConfig.class); OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - assertNotNull(authorizer); + assertThat(authorizer).isNotNull(); } private OpaAuthorizationConfig.HttpConfig createMockHttpConfig() { diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java index 38c701f356..1c359ade2a 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java @@ -18,9 +18,8 @@ */ package org.apache.polaris.extension.auth.opa; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -85,20 +84,25 @@ void testOpaInputJsonFormat() throws Exception { PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, target, secondary)); + assertThatNoException() + .isThrownBy( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.LOAD_VIEW, + target, + secondary)); // Parse and verify JSON structure from captured request ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(capturedRequestBody[0]); - assertTrue(root.has("input"), "Root should have 'input' field"); + assertThat(root.has("input")).as("Root should have 'input' field").isTrue(); var input = root.get("input"); - assertTrue(input.has("actor"), "Input should have 'actor' field"); - assertTrue(input.has("action"), "Input should have 'action' field"); - assertTrue(input.has("resource"), "Input should have 'resource' field"); - assertTrue(input.has("context"), "Input should have 'context' field"); + assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue(); + assertThat(input.has("action")).as("Input should have 'action' field").isTrue(); + assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue(); + assertThat(input.has("context")).as("Input should have 'context' field").isTrue(); } finally { server.stop(0); } @@ -170,79 +174,90 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { Set entities = Set.of(catalogEntity, namespaceEntity, tableEntity); - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); + assertThatNoException() + .isThrownBy( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.LOAD_TABLE, + tablePath, + null)); // Parse and verify the complete JSON structure ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(capturedRequestBody[0]); // Verify top-level structure - assertTrue(root.has("input"), "Root should have 'input' field"); + assertThat(root.has("input")).as("Root should have 'input' field").isTrue(); var input = root.get("input"); - assertTrue(input.has("actor"), "Input should have 'actor' field"); - assertTrue(input.has("action"), "Input should have 'action' field"); - assertTrue(input.has("resource"), "Input should have 'resource' field"); - assertTrue(input.has("context"), "Input should have 'context' field"); + assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue(); + assertThat(input.has("action")).as("Input should have 'action' field").isTrue(); + assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue(); + assertThat(input.has("context")).as("Input should have 'context' field").isTrue(); // Verify actor details var actor = input.get("actor"); - assertTrue(actor.has("principal"), "Actor should have 'principal' field"); - assertEquals("alice", actor.get("principal").asText()); - assertTrue(actor.has("roles"), "Actor should have 'roles' field"); - assertTrue(actor.get("roles").isArray(), "Roles should be an array"); - assertEquals(2, actor.get("roles").size()); + assertThat(actor.has("principal")).as("Actor should have 'principal' field").isTrue(); + assertThat(actor.get("principal").asText()).isEqualTo("alice"); + assertThat(actor.has("roles")).as("Actor should have 'roles' field").isTrue(); + assertThat(actor.get("roles").isArray()).as("Roles should be an array").isTrue(); + assertThat(actor.get("roles").size()).isEqualTo(2); // Verify action var action = input.get("action"); - assertEquals("LOAD_TABLE", action.asText()); + assertThat(action.asText()).isEqualTo("LOAD_TABLE"); // Verify resource structure - this is the key part for hierarchical resources var resource = input.get("resource"); - assertTrue(resource.has("targets"), "Resource should have 'targets' field"); - assertTrue(resource.has("secondaries"), "Resource should have 'secondaries' field"); + assertThat(resource.has("targets")).as("Resource should have 'targets' field").isTrue(); + assertThat(resource.has("secondaries")) + .as("Resource should have 'secondaries' field") + .isTrue(); var targets = resource.get("targets"); - assertTrue(targets.isArray(), "Targets should be an array"); - assertEquals(1, targets.size(), "Should have exactly one target"); + assertThat(targets.isArray()).as("Targets should be an array").isTrue(); + assertThat(targets.size()).as("Should have exactly one target").isEqualTo(1); var target = targets.get(0); // Verify the target entity (table) details - assertTrue(target.isObject(), "Target should be an object"); - assertTrue(target.has("type"), "Target should have 'type' field"); - assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); - assertTrue(target.has("name"), "Target should have 'name' field"); - assertEquals( - "customer_orders", target.get("name").asText(), "Target name should be customer_orders"); + assertThat(target.isObject()).as("Target should be an object").isTrue(); + assertThat(target.has("type")).as("Target should have 'type' field").isTrue(); + assertThat(target.get("type").asText()) + .as("Target type should be TABLE_LIKE") + .isEqualTo("TABLE_LIKE"); + assertThat(target.has("name")).as("Target should have 'name' field").isTrue(); + assertThat(target.get("name").asText()) + .as("Target name should be customer_orders") + .isEqualTo("customer_orders"); // Verify the hierarchical parents array - assertTrue(target.has("parents"), "Target should have 'parents' field"); + assertThat(target.has("parents")).as("Target should have 'parents' field").isTrue(); var parents = target.get("parents"); - assertTrue(parents.isArray(), "Parents should be an array"); - assertEquals(2, parents.size(), "Should have 2 parents (catalog and namespace)"); + assertThat(parents.isArray()).as("Parents should be an array").isTrue(); + assertThat(parents.size()).as("Should have 2 parents (catalog and namespace)").isEqualTo(2); // Verify catalog parent (first in the hierarchy) var catalogParent = parents.get(0); - assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); - assertEquals( - "prod_catalog", - catalogParent.get("name").asText(), - "Catalog name should be prod_catalog"); + assertThat(catalogParent.get("type").asText()) + .as("First parent should be catalog") + .isEqualTo("CATALOG"); + assertThat(catalogParent.get("name").asText()) + .as("Catalog name should be prod_catalog") + .isEqualTo("prod_catalog"); // Verify namespace parent (second in the hierarchy) var namespaceParent = parents.get(1); - assertEquals( - "NAMESPACE", namespaceParent.get("type").asText(), "Second parent should be namespace"); - assertEquals( - "sales_data", - namespaceParent.get("name").asText(), - "Namespace name should be sales_data"); + assertThat(namespaceParent.get("type").asText()) + .as("Second parent should be namespace") + .isEqualTo("NAMESPACE"); + assertThat(namespaceParent.get("name").asText()) + .as("Namespace name should be sales_data") + .isEqualTo("sales_data"); var secondaries = resource.get("secondaries"); - assertTrue(secondaries.isArray(), "Secondaries should be an array"); - assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); + assertThat(secondaries.isArray()).as("Secondaries should be an array").isTrue(); + assertThat(secondaries.size()).as("Should have no secondaries in this test").isEqualTo(0); } finally { server.stop(0); } @@ -326,78 +341,89 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { Set entities = Set.of(catalogEntity, departmentEntity, teamEntity, tableEntity); - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, entities, PolarisAuthorizableOperation.LOAD_TABLE, tablePath, null)); + assertThatNoException() + .isThrownBy( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.LOAD_TABLE, + tablePath, + null)); // Parse and verify the complete JSON structure ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(capturedRequestBody[0]); // Verify top-level structure - assertTrue(root.has("input"), "Root should have 'input' field"); + assertThat(root.has("input")).as("Root should have 'input' field").isTrue(); var input = root.get("input"); - assertTrue(input.has("actor"), "Input should have 'actor' field"); - assertTrue(input.has("action"), "Input should have 'action' field"); - assertTrue(input.has("resource"), "Input should have 'resource' field"); - assertTrue(input.has("context"), "Input should have 'context' field"); + assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue(); + assertThat(input.has("action")).as("Input should have 'action' field").isTrue(); + assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue(); + assertThat(input.has("context")).as("Input should have 'context' field").isTrue(); // Verify actor details var actor = input.get("actor"); - assertEquals("bob", actor.get("principal").asText()); - assertEquals(2, actor.get("roles").size()); + assertThat(actor.get("principal").asText()).isEqualTo("bob"); + assertThat(actor.get("roles").size()).isEqualTo(2); // Verify action var action = input.get("action"); - assertEquals("LOAD_TABLE", action.asText()); + assertThat(action.asText()).isEqualTo("LOAD_TABLE"); // Verify resource structure with multi-level namespace hierarchy var resource = input.get("resource"); var targets = resource.get("targets"); - assertEquals(1, targets.size(), "Should have exactly one target"); + assertThat(targets.size()).as("Should have exactly one target").isEqualTo(1); var target = targets.get(0); // Verify the target entity (table) details - assertEquals("TABLE_LIKE", target.get("type").asText(), "Target type should be TABLE_LIKE"); - assertEquals( - "feature_store", target.get("name").asText(), "Target name should be feature_store"); + assertThat(target.get("type").asText()) + .as("Target type should be TABLE_LIKE") + .isEqualTo("TABLE_LIKE"); + assertThat(target.get("name").asText()) + .as("Target name should be feature_store") + .isEqualTo("feature_store"); // Verify the multi-level hierarchical parents array - assertTrue(target.has("parents"), "Target should have 'parents' field"); + assertThat(target.has("parents")).as("Target should have 'parents' field").isTrue(); var parents = target.get("parents"); - assertTrue(parents.isArray(), "Parents should be an array"); - assertEquals(3, parents.size(), "Should have 3 parents (catalog, department, team)"); + assertThat(parents.isArray()).as("Parents should be an array").isTrue(); + assertThat(parents.size()) + .as("Should have 3 parents (catalog, department, team)") + .isEqualTo(3); // Verify catalog parent (first in the hierarchy) var catalogParent = parents.get(0); - assertEquals("CATALOG", catalogParent.get("type").asText(), "First parent should be catalog"); - assertEquals( - "analytics_catalog", - catalogParent.get("name").asText(), - "Catalog name should be analytics_catalog"); + assertThat(catalogParent.get("type").asText()) + .as("First parent should be catalog") + .isEqualTo("CATALOG"); + assertThat(catalogParent.get("name").asText()) + .as("Catalog name should be analytics_catalog") + .isEqualTo("analytics_catalog"); // Verify department namespace parent (second in the hierarchy) var departmentParent = parents.get(1); - assertEquals( - "NAMESPACE", departmentParent.get("type").asText(), "Second parent should be namespace"); - assertEquals( - "engineering", - departmentParent.get("name").asText(), - "Department name should be engineering"); + assertThat(departmentParent.get("type").asText()) + .as("Second parent should be namespace") + .isEqualTo("NAMESPACE"); + assertThat(departmentParent.get("name").asText()) + .as("Department name should be engineering") + .isEqualTo("engineering"); // Verify team namespace parent (third in the hierarchy) var teamParent = parents.get(2); - assertEquals( - "NAMESPACE", teamParent.get("type").asText(), "Third parent should be namespace"); - assertEquals( - "machine_learning", - teamParent.get("name").asText(), - "Team name should be machine_learning"); + assertThat(teamParent.get("type").asText()) + .as("Third parent should be namespace") + .isEqualTo("NAMESPACE"); + assertThat(teamParent.get("name").asText()) + .as("Team name should be machine_learning") + .isEqualTo("machine_learning"); var secondaries = resource.get("secondaries"); - assertTrue(secondaries.isArray(), "Secondaries should be an array"); - assertEquals(0, secondaries.size(), "Should have no secondaries in this test"); + assertThat(secondaries.isArray()).as("Secondaries should be an array").isTrue(); + assertThat(secondaries.size()).as("Should have no secondaries in this test").isEqualTo(0); } finally { server.stop(0); } @@ -421,14 +447,15 @@ void testAuthorizeOrThrowWithEmptyTargetsAndSecondaries() throws Exception { PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, - entities, - PolarisAuthorizableOperation.CREATE_CATALOG, - target, - secondary)); + assertThatNoException() + .isThrownBy( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.CREATE_CATALOG, + target, + secondary)); // Test multiple targets PolarisResolvedPathWrapper target1 = new PolarisResolvedPathWrapper(List.of()); @@ -436,14 +463,15 @@ void testAuthorizeOrThrowWithEmptyTargetsAndSecondaries() throws Exception { List targets = List.of(target1, target2); List secondaries = List.of(); - assertDoesNotThrow( - () -> - authorizer.authorizeOrThrow( - principal, - entities, - PolarisAuthorizableOperation.LOAD_VIEW, - targets, - secondaries)); + assertThatNoException() + .isThrownBy( + () -> + authorizer.authorizeOrThrow( + principal, + entities, + PolarisAuthorizableOperation.LOAD_VIEW, + targets, + secondaries)); } finally { server.stop(0); } @@ -458,7 +486,7 @@ public void testCreateWithHttpsAndBearerToken() { new OpaPolarisAuthorizer( policyUri, HttpClients.createDefault(), new ObjectMapper(), tokenProvider); - assertTrue(authorizer != null); + assertThat(authorizer).isNotNull(); } @Test @@ -484,15 +512,16 @@ public void testBearerTokenIsAddedToHttpRequest() throws IOException { PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet()); PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; - assertDoesNotThrow( - () -> { - authorizer.authorizeOrThrow( - mockPrincipal, - Collections.emptySet(), - mockOperation, - (PolarisResolvedPathWrapper) null, - (PolarisResolvedPathWrapper) null); - }); + assertThatNoException() + .isThrownBy( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); // Verify the Authorization header with static bearer token verifyAuthorizationHeader(mockHttpClient, "test-bearer-token"); @@ -527,15 +556,16 @@ public void testBearerTokenFromBearerTokenProvider() throws IOException { PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE; // Execute authorization (should not throw since we mocked allow=true) - assertDoesNotThrow( - () -> { - authorizer.authorizeOrThrow( - mockPrincipal, - Collections.emptySet(), - mockOperation, - (PolarisResolvedPathWrapper) null, - (PolarisResolvedPathWrapper) null); - }); + assertThatNoException() + .isThrownBy( + () -> { + authorizer.authorizeOrThrow( + mockPrincipal, + Collections.emptySet(), + mockOperation, + (PolarisResolvedPathWrapper) null, + (PolarisResolvedPathWrapper) null); + }); // Verify the Authorization header with bearer token from provider verifyAuthorizationHeader(mockHttpClient, "dynamic-token-12345"); @@ -616,19 +646,18 @@ private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, Strin if (expectedToken != null) { // Verify the Authorization header is present and contains the expected token - assertTrue( - capturedRequest.containsHeader("Authorization"), - "Authorization header should be present when bearer token is provided"); + assertThat(capturedRequest.containsHeader("Authorization")) + .as("Authorization header should be present when bearer token is provided") + .isTrue(); String authHeader = capturedRequest.getFirstHeader("Authorization").getValue(); - assertEquals( - "Bearer " + expectedToken, - authHeader, - "Authorization header should contain the correct bearer token"); + assertThat(authHeader) + .as("Authorization header should contain the correct bearer token") + .isEqualTo("Bearer " + expectedToken); } else { // Verify no Authorization header is present when token is null - assertTrue( - !capturedRequest.containsHeader("Authorization"), - "Authorization header should not be present when token provider returns null"); + assertThat(capturedRequest.containsHeader("Authorization")) + .as("Authorization header should not be present when token provider returns null") + .isFalse(); } } } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java index 7989da805a..51fa194c82 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java @@ -18,8 +18,8 @@ */ package org.apache.polaris.extension.auth.opa.token; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -56,7 +56,7 @@ public void testLoadTokenFromFile() throws IOException { // Test token retrieval String actualToken = provider.getToken(); - assertEquals(expectedToken, actualToken); + assertThat(actualToken).isEqualTo(expectedToken); } } @@ -75,7 +75,7 @@ public void testLoadTokenFromFileWithWhitespace() throws IOException { // Test token retrieval (should trim whitespace) String actualToken = provider.getToken(); - assertEquals(expectedToken, actualToken); + assertThat(actualToken).isEqualTo(expectedToken); } } @@ -96,7 +96,7 @@ public void testTokenRefresh() throws IOException { // Test initial token String token1 = provider.getToken(); - assertEquals(initialToken, token1); + assertThat(token1).isEqualTo(initialToken); // Advance time past refresh interval clock.add(Duration.ofMillis(200)); @@ -107,24 +107,23 @@ public void testTokenRefresh() throws IOException { // Test that token is refreshed String token2 = provider.getToken(); - assertEquals(updatedToken, token2); + assertThat(token2).isEqualTo(updatedToken); } } @Test public void testNonExistentFileThrows() { - // Create file token provider for non-existent file - try (FileBearerTokenProvider provider = - new FileBearerTokenProvider( - Paths.get("/non/existent/file.txt"), - Duration.ofMinutes(5), - true, - Duration.ofMinutes(1), - Clock.systemUTC())) { - - // Test token retrieval (should throw exception when no cached token exists) - assertThrows(RuntimeException.class, provider::getToken); - } + // Constructor should throw exception when token file doesn't exist + assertThatThrownBy( + () -> + new FileBearerTokenProvider( + Paths.get("/non/existent/file.txt"), + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1), + Clock.systemUTC())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to load initial bearer token from file"); } @Test @@ -133,14 +132,17 @@ public void testEmptyFile() throws IOException { Path tokenFile = tempDir.resolve("empty.txt"); Files.writeString(tokenFile, ""); - // Create file token provider - try (FileBearerTokenProvider provider = - new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { - - // Test token retrieval (should throw exception for empty file when no cached token exists) - assertThrows(RuntimeException.class, provider::getToken); - } + // Constructor should throw exception when token file is empty + assertThatThrownBy( + () -> + new FileBearerTokenProvider( + tokenFile, + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1), + Clock.systemUTC())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to load initial bearer token from file"); } @Test @@ -161,7 +163,7 @@ public void testJwtExpirationRefresh() throws IOException { // Test initial token String token1 = provider.getToken(); - assertEquals(jwtToken, token1); + assertThat(token1).isEqualTo(jwtToken); // Advance time by 7.1 seconds (should trigger refresh due to 3 second buffer) clock.add(Duration.ofMillis(7100)); @@ -172,7 +174,7 @@ public void testJwtExpirationRefresh() throws IOException { // Test that token is refreshed String token2 = provider.getToken(); - assertEquals(newJwtToken, token2); + assertThat(token2).isEqualTo(newJwtToken); } } @@ -193,7 +195,7 @@ public void testJwtExpirationRefreshDisabled() throws IOException { // Test initial token String token1 = provider.getToken(); - assertEquals(jwtToken, token1); + assertThat(token1).isEqualTo(jwtToken); // Advance time past fixed refresh interval (150ms) clock.add(Duration.ofMillis(150)); @@ -204,7 +206,7 @@ public void testJwtExpirationRefreshDisabled() throws IOException { // Test that token is refreshed based on fixed interval, not JWT expiration String token2 = provider.getToken(); - assertEquals(newToken, token2); + assertThat(token2).isEqualTo(newToken); } } @@ -225,7 +227,7 @@ public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException { // Test initial token String token1 = provider.getToken(); - assertEquals(nonJwtToken, token1); + assertThat(token1).isEqualTo(nonJwtToken); // Advance time past fallback refresh interval clock.add(Duration.ofMillis(150)); @@ -236,7 +238,7 @@ public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException { // Test that token is refreshed using fallback interval String token2 = provider.getToken(); - assertEquals(updatedToken, token2); + assertThat(token2).isEqualTo(updatedToken); } } @@ -254,7 +256,7 @@ public void testJwtExpirationTooSoon() throws IOException { // Should fall back to fixed interval when JWT expires too soon String token = provider.getToken(); - assertEquals(expiredJwtToken, token); + assertThat(token).isEqualTo(expiredJwtToken); } } @@ -272,7 +274,7 @@ public void testJwtWithoutExpirationClaim() throws IOException { // Should fall back to fixed interval when JWT has no expiration String token = provider.getToken(); - assertEquals(jwtWithoutExp, token); + assertThat(token).isEqualTo(jwtWithoutExp); } } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java index 5a76a3edee..7886d8feec 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java @@ -18,8 +18,8 @@ */ package org.apache.polaris.extension.auth.opa.token; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.Test; @@ -30,19 +30,21 @@ public void testStaticBearerTokenProvider() { String expectedToken = "static-bearer-token"; try (StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken)) { String actualToken = provider.getToken(); - assertEquals(expectedToken, actualToken); + assertThat(actualToken).isEqualTo(expectedToken); } } @Test public void testStaticBearerTokenProviderWithEmptyString() { // Empty strings should be rejected - assertThrows(IllegalArgumentException.class, () -> new StaticBearerTokenProvider("")); + assertThatThrownBy(() -> new StaticBearerTokenProvider("")) + .isInstanceOf(IllegalArgumentException.class); } @Test public void testStaticBearerTokenProviderWithNullString() { // Null tokens should be rejected - assertThrows(IllegalArgumentException.class, () -> new StaticBearerTokenProvider(null)); + assertThatThrownBy(() -> new StaticBearerTokenProvider(null)) + .isInstanceOf(IllegalArgumentException.class); } } diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts index 7f4ee3275d..bab1a4421e 100644 --- a/extensions/auth/opa/tests/build.gradle.kts +++ b/extensions/auth/opa/tests/build.gradle.kts @@ -57,4 +57,17 @@ tasks.withType { jvmArgs("--add-exports", "java.base/sun.nio.ch=ALL-UNNAMED") systemProperty("java.security.manager", "allow") maxParallelForks = 1 + + val logsDir = project.layout.buildDirectory.get().asFile.resolve("logs") + + jvmArgumentProviders.add( + CommandLineArgumentProvider { + listOf("-Dquarkus.log.file.path=${logsDir.resolve("polaris.log").absolutePath}") + } + ) + + doFirst { + logsDir.deleteRecursively() + project.layout.buildDirectory.get().asFile.resolve("quarkus.log").delete() + } } diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java index 418f3921f9..da4ba92720 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java @@ -19,7 +19,7 @@ package org.apache.polaris.extension.auth.opa.test; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.assertj.core.api.Assertions.assertThatNoException; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; @@ -97,10 +97,7 @@ void testOpaAllowsRootUser() { void testCreatePrincipalAndGetToken() { // Test the helper method createPrincipalAndGetToken // useful for debugging and ensuring that the helper method works correctly - assertDoesNotThrow( - () -> { - createPrincipalAndGetToken("test-user"); - }); + assertThatNoException().isThrownBy(() -> createPrincipalAndGetToken("test-user")); } @Test diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java index 809cab6451..2156f696ee 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java @@ -19,7 +19,7 @@ package org.apache.polaris.extension.auth.opa.test; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.assertj.core.api.Assertions.assertThatNoException; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; @@ -78,10 +78,7 @@ void testOpaAllowsRootUser() { void testCreatePrincipalAndGetToken() { // Test the helper method createPrincipalAndGetToken // useful for debugging and ensuring that the helper method works correctly - assertDoesNotThrow( - () -> { - createPrincipalAndGetToken("test-user"); - }); + assertThatNoException().isThrownBy(() -> createPrincipalAndGetToken("test-user")); } @Test diff --git a/runtime/defaults/src/main/resources/application-it.properties b/runtime/defaults/src/main/resources/application-it.properties index 1fdd60ae51..7c3c70f3d2 100644 --- a/runtime/defaults/src/main/resources/application-it.properties +++ b/runtime/defaults/src/main/resources/application-it.properties @@ -56,4 +56,3 @@ polaris.realm-context.realms=POLARIS,OTHER polaris.storage.gcp.token=token polaris.storage.gcp.lifespan=PT1H - diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index f51aefb0d6..97a559f77a 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -75,7 +75,6 @@ dependencies { implementation(libs.auth0.jwt) - implementation(libs.apache.httpclient5) implementation(libs.smallrye.common.annotation) implementation(libs.swagger.jaxrs) implementation(libs.microprofile.fault.tolerance.api) diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java index 34171b32f0..e65c60bcde 100644 --- a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java +++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java @@ -18,10 +18,11 @@ */ package org.apache.polaris.service.it; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.assertj.core.api.Assertions.assertThat; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; import jakarta.inject.Inject; import java.util.HashMap; import java.util.Map; @@ -40,16 +41,14 @@ public Map getConfigOverrides() { } @QuarkusTest - @io.quarkus.test.junit.TestProfile(ServiceProducersIT.InternalAuthorizationConfig.class) + @TestProfile(ServiceProducersIT.InternalAuthorizationConfig.class) public static class InternalAuthorizationTest { @Inject PolarisAuthorizer polarisAuthorizer; @Test void testInternalPolarisAuthorizerProduced() { - assertNotNull(polarisAuthorizer, "PolarisAuthorizer should be produced"); - // Verify it's the correct implementation for internal config - assertNotNull(polarisAuthorizer, "Internal PolarisAuthorizer should not be null"); + assertThat(polarisAuthorizer).isNotNull(); } } } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java index 01da1c72ae..438dee1adf 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java @@ -24,22 +24,6 @@ @ConfigMapping(prefix = "polaris.authorization") public interface AuthorizationConfiguration { - /** Authorization types supported by Polaris */ - enum AuthorizationType { - INTERNAL("internal"), - OPA("opa"); - - private final String value; - - AuthorizationType(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - } - @WithDefault("internal") - AuthorizationType type(); + String type(); } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java index 95287d4f34..4ee7c7965c 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java @@ -145,9 +145,7 @@ public PolarisAuthorizerFactory polarisAuthorizerFactory( AuthorizationConfiguration authorizationConfig, @Any Instance authorizerFactories) { PolarisAuthorizerFactory factory = - authorizerFactories - .select(Identifier.Literal.of(authorizationConfig.type().getValue())) - .get(); + authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get(); return factory; } From 615cd9e1ae097db1cefe3fee1c68838e3138ad15 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 23 Oct 2025 10:51:10 +0200 Subject: [PATCH 38/40] Make token-refresh asynchronous ... ... leveraging Polaris' `AsyncExec`. This provides: * Only one thread will ever refresh * Call sites only block requests when the initial refresh hasn't successfully finished. * Short-ish retries on "torn" or "short" reads, if files are rewritten in-place (not a symlink-switch, which may or may not be atomic) * Less overhead in `getToken()` Added tests to verify refresh-task submissions. --- extensions/auth/opa/impl/build.gradle.kts | 4 + .../auth/opa/OpaPolarisAuthorizerFactory.java | 16 +- .../opa/token/FileBearerTokenProvider.java | 124 ++++++---- .../opa/OpaPolarisAuthorizerFactoryTest.java | 69 +++--- .../token/FileBearerTokenProviderTest.java | 217 ++++++++++++++---- runtime/service/build.gradle.kts | 2 + 6 files changed, 302 insertions(+), 130 deletions(-) diff --git a/extensions/auth/opa/impl/build.gradle.kts b/extensions/auth/opa/impl/build.gradle.kts index 75c82118b5..3a1a917239 100644 --- a/extensions/auth/opa/impl/build.gradle.kts +++ b/extensions/auth/opa/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.guava) implementation(libs.slf4j.api) implementation(libs.auth0.jwt) + implementation(project(":polaris-async-api")) // Iceberg dependency for ForbiddenException implementation(platform(libs.iceberg.bom)) @@ -47,4 +48,7 @@ dependencies { testImplementation(libs.assertj.core) testImplementation(libs.mockito.core) testImplementation(libs.threeten.extra) + testImplementation(testFixtures(project(":polaris-async-api"))) + testImplementation(project(":polaris-async-java")) + testImplementation(project(":polaris-idgen-mocks")) } diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index ec261b3138..ced9351433 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -36,6 +36,7 @@ import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider; import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; +import org.apache.polaris.nosql.async.AsyncExec; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,14 +49,17 @@ class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { private final OpaAuthorizationConfig opaConfig; private final Clock clock; + private final ObjectMapper objectMapper; + private final AsyncExec asyncExec; private CloseableHttpClient httpClient; private BearerTokenProvider bearerTokenProvider; - private ObjectMapper objectMapper; @Inject - public OpaPolarisAuthorizerFactory(OpaAuthorizationConfig opaConfig, Clock clock) { + public OpaPolarisAuthorizerFactory( + OpaAuthorizationConfig opaConfig, Clock clock, AsyncExec asyncExec) { this.opaConfig = opaConfig; this.clock = clock; + this.asyncExec = asyncExec; this.objectMapper = new ObjectMapper(); } @@ -167,7 +171,13 @@ private BearerTokenProvider createBearerTokenProvider( Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer().orElse(Duration.ofMinutes(1)); return new FileBearerTokenProvider( - fileConfig.path(), refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer, clock); + fileConfig.path(), + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer, + Duration.ofSeconds(5), + asyncExec, + clock::instant); } else { throw new IllegalStateException( "No bearer token configuration found. Must specify either 'static-token' or 'file-based'"); diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 66bbd4f702..65bb7b3030 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -18,21 +18,26 @@ */ package org.apache.polaris.extension.auth.opa.token; +import static com.google.common.base.Preconditions.checkState; + import com.auth0.jwt.JWT; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.DecodedJWT; -import com.google.common.base.Strings; import jakarta.annotation.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.apache.polaris.nosql.async.AsyncExec; +import org.apache.polaris.nosql.async.Cancelable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,12 +63,16 @@ public class FileBearerTokenProvider implements BearerTokenProvider { private final Duration refreshInterval; private final boolean jwtExpirationRefresh; private final Duration jwtExpirationBuffer; - private final Clock clock; + private final Supplier clock; private final AtomicBoolean refreshLock = new AtomicBoolean(); + private final AsyncExec asyncExec; + private final CompletableFuture initialTokenFuture = new CompletableFuture<>(); + private final long initialTokenWaitMillis; private volatile String cachedToken; private volatile Instant lastRefresh; private volatile Instant nextRefresh; + private volatile Cancelable refreshTask; /** * Create a new file-based token provider with JWT expiration support. @@ -80,24 +89,23 @@ public FileBearerTokenProvider( Duration refreshInterval, boolean jwtExpirationRefresh, Duration jwtExpirationBuffer, - Clock clock) { + Duration initialTokenWait, + AsyncExec asyncExec, + Supplier clock) { this.tokenFilePath = tokenFilePath; this.refreshInterval = refreshInterval; this.jwtExpirationRefresh = jwtExpirationRefresh; this.jwtExpirationBuffer = jwtExpirationBuffer; + this.initialTokenWaitMillis = initialTokenWait.toMillis(); this.clock = clock; + this.asyncExec = asyncExec; - // Load initial token eagerly to avoid race conditions during first getToken() calls - this.cachedToken = loadTokenFromFile(); - if (Strings.isNullOrEmpty(this.cachedToken)) { - throw new IllegalStateException( - "Failed to load initial bearer token from file: " - + tokenFilePath - + ". This is required for OPA authorization."); - } + this.nextRefresh = Instant.MIN; + this.lastRefresh = Instant.MIN; + // start refreshing the token (immediately) + scheduleRefreshAttempt(Duration.ZERO); - this.lastRefresh = clock.instant(); - this.nextRefresh = calculateNextRefresh(this.cachedToken); + checkState(Files.isReadable(tokenFilePath), "OPA token file does not exist or is not readable"); logger.debug( "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}, next refresh: {}", @@ -110,56 +118,74 @@ public FileBearerTokenProvider( @Override public String getToken() { - // Check if we need to refresh - if (shouldRefresh()) { - refreshToken(); + String token = cachedToken; + if (token != null) { + // Regular case, we have a cached token + return cachedToken; } - - // Token is guaranteed to be present after construction, but check anyway for safety - if (Strings.isNullOrEmpty(cachedToken)) { - throw new RuntimeException( - "Bearer token is unexpectedly empty. This should not happen after successful construction."); + // We get here if the cached token is null, which means that the initial token + // has not been loaded yet. + // In this case we wait for the configured amount of time + // (5 seconds in production, much lower in tests). + try { + return initialTokenFuture.get(initialTokenWaitMillis, TimeUnit.MILLISECONDS); + } catch (Exception e) { + throw new IllegalStateException("Failed to read initial OPA bearer token", e); } - return cachedToken; } @Override public void close() { cachedToken = null; + Cancelable task = refreshTask; + if (task != null) { + refreshTask.cancel(); + } + } + + private void refreshTokenAttempt() { + boolean isInitialRefresh = cachedToken == null; + Duration delay; + if (doRefreshToken()) { + delay = Duration.between(clock.get(), nextRefresh); + if (isInitialRefresh) { + // If we have never cached a token, complete the initial token-future to "unblock" + // getToken() call sites waiting for it. + initialTokenFuture.complete(cachedToken); + } + } else { + // Token refresh did not succeed, retry soon + delay = Duration.ofSeconds(1); // TODO configurable ? + } + scheduleRefreshAttempt(delay); } - private boolean shouldRefresh() { - return clock.instant().isAfter(nextRefresh); + private void scheduleRefreshAttempt(Duration delay) { + this.refreshTask = asyncExec.schedule(this::refreshTokenAttempt, delay); } - private void refreshToken() { - // Only one thread should refresh at a time. Other threads will use the cached token. - if (!refreshLock.compareAndSet(false, true)) { - return; + private boolean doRefreshToken() { + String newToken = loadTokenFromFile(); + + // Only update cached token if we successfully loaded a new one + if (newToken == null) { + logger.debug("Couldn't load new bearer token from {}, will retry.", tokenFilePath); + return false; } - try { - String newToken = loadTokenFromFile(); + cachedToken = newToken; - // Only update cached token if we successfully loaded a new one - if (newToken == null) { - logger.debug("Couldn't load new bearer token from {}, will retry.", tokenFilePath); - return; - } - cachedToken = newToken; + lastRefresh = clock.get(); - lastRefresh = clock.instant(); + // Calculate next refresh time based on current token (may be cached) + nextRefresh = calculateNextRefresh(cachedToken); - // Calculate next refresh time based on current token (may be cached) - nextRefresh = calculateNextRefresh(cachedToken); + logger.debug( + "Token refreshed from file: {} (token present: {}), next refresh: {}", + tokenFilePath, + cachedToken != null && !cachedToken.isEmpty(), + nextRefresh); - logger.debug( - "Token refreshed from file: {} (token present: {}), next refresh: {}", - tokenFilePath, - cachedToken != null && !cachedToken.isEmpty(), - nextRefresh); - } finally { - refreshLock.set(false); - } + return true; } /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */ @@ -176,7 +202,7 @@ private Instant calculateNextRefresh(String token) { Instant refreshTime = expiration.get().minus(jwtExpirationBuffer); // Ensure refresh time is in the future and not too soon (at least 1 second) - Instant minRefreshTime = clock.instant().plus(Duration.ofSeconds(1)); + Instant minRefreshTime = clock.get().plus(Duration.ofSeconds(1)); if (refreshTime.isBefore(minRefreshTime)) { logger.warn( "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get()); diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index cbe339d175..e0aa6ad3df 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -31,6 +31,7 @@ import java.util.Optional; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider; +import org.apache.polaris.nosql.async.java.JavaPoolAsyncExec; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -63,14 +64,16 @@ public void testFactoryWithStaticTokenConfiguration() { when(opaConfig.auth()).thenReturn(authConfig); when(opaConfig.http()).thenReturn(httpConfig); - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC()); + try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) { + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec); - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - assertThat(authorizer).isNotNull(); + assertThat(authorizer).isNotNull(); + } } @Test @@ -106,22 +109,30 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { when(opaConfig.auth()).thenReturn(authConfig); when(opaConfig.http()).thenReturn(httpConfig); - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC()); - - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - - assertThat(authorizer).isNotNull(); - - // Also verify that the token provider actually reads from the file - try (FileBearerTokenProvider provider = - new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { - - String actualToken = provider.getToken(); - assertThat(actualToken).isEqualTo(tokenValue); + try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) { + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec); + + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + + assertThat(authorizer).isNotNull(); + + // Also verify that the token provider actually reads from the file + try (FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile, + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1), + Duration.ofSeconds(10), + asyncExec, + Clock.systemUTC()::instant)) { + + String actualToken = provider.getToken(); + assertThat(actualToken).isEqualTo(tokenValue); + } } } @@ -141,14 +152,16 @@ public void testFactoryWithNoTokenConfiguration() { when(opaConfig.auth()).thenReturn(authConfig); when(opaConfig.http()).thenReturn(httpConfig); - OpaPolarisAuthorizerFactory factory = - new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC()); + try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) { + OpaPolarisAuthorizerFactory factory = + new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec); - // Create authorizer - RealmConfig realmConfig = mock(RealmConfig.class); - OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); + // Create authorizer + RealmConfig realmConfig = mock(RealmConfig.class); + OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig); - assertThat(authorizer).isNotNull(); + assertThat(authorizer).isNotNull(); + } } private OpaAuthorizationConfig.HttpConfig createMockHttpConfig() { diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java index 51fa194c82..1651dc5e3f 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java @@ -19,6 +19,7 @@ package org.apache.polaris.extension.auth.opa.token; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,32 +28,73 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.time.Clock; import java.time.Duration; import java.time.Instant; -import java.time.ZoneOffset; import java.util.Base64; import java.util.HashMap; import java.util.Map; +import org.apache.polaris.ids.mocks.MutableMonotonicClock; +import org.apache.polaris.nosql.async.MockAsyncExec; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.threeten.extra.MutableClock; public class FileBearerTokenProviderTest { @TempDir Path tempDir; @Test - public void testLoadTokenFromFile() throws IOException { + public void testInitialRefresh() throws IOException { // Create a temporary token file Path tokenFile = tempDir.resolve("token.txt"); - String expectedToken = "test-bearer-token-123"; - Files.writeString(tokenFile, expectedToken); + Files.writeString(tokenFile, ""); + + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); // Create file token provider try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { + tokenFile, + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + + // initial refresh has not happened yet, getToken() times out waiting for the initial token + assertThatIllegalStateException() + .isThrownBy(provider::getToken) + .withMessage("Failed to read initial OPA bearer token"); + + // initial refresh should have been scheduled, run it + assertThat(asyncExec.readyCount()).isEqualTo(1); + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); + assertThat(asyncExec.readyCount()).isEqualTo(0); + + // Token file is still empty, getToken() still times out waiting for the initial token + assertThatIllegalStateException() + .isThrownBy(provider::getToken) + .withMessage("Failed to read initial OPA bearer token"); + + monotonicClock.advanceBoth(Duration.ofSeconds(1)); + // refresh should have been scheduled, run it + assertThat(asyncExec.readyCount()).isEqualTo(1); + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); + assertThat(asyncExec.readyCount()).isEqualTo(0); + + // Token file is still empty, getToken() still times out waiting for the initial token + assertThatIllegalStateException() + .isThrownBy(provider::getToken) + .withMessage("Failed to read initial OPA bearer token"); + + String expectedToken = "test-bearer-token-123"; + Files.writeString(tokenFile, expectedToken); + + monotonicClock.advanceBoth(Duration.ofSeconds(1)); + // refresh should have been scheduled, run it + assertThat(asyncExec.readyCount()).isEqualTo(1); + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Test token retrieval String actualToken = provider.getToken(); @@ -68,10 +110,22 @@ public void testLoadTokenFromFileWithWhitespace() throws IOException { String expectedToken = "test-bearer-token-456"; Files.writeString(tokenFile, tokenWithWhitespace); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); + // Create file token provider try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofMinutes(1), Clock.systemUTC())) { + tokenFile, + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Test token retrieval (should trim whitespace) String actualToken = provider.getToken(); @@ -87,24 +141,39 @@ public void testTokenRefresh() throws IOException { Files.writeString(tokenFile, initialToken); // Create mutable clock for deterministic time control - MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); // Create file token provider with short refresh interval try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMillis(100), false, Duration.ofMinutes(1), clock)) { + tokenFile, + Duration.ofMillis(100), + false, + Duration.ofMinutes(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Test initial token String token1 = provider.getToken(); assertThat(token1).isEqualTo(initialToken); // Advance time past refresh interval - clock.add(Duration.ofMillis(200)); + monotonicClock.advanceBoth(Duration.ofMillis(200)); // Update the file String updatedToken = "updated-token"; Files.writeString(tokenFile, updatedToken); + // refresh task didn't run yet, so token should still be the same + assertThat(token1).isEqualTo(initialToken); + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); + // Test that token is refreshed String token2 = provider.getToken(); assertThat(token2).isEqualTo(updatedToken); @@ -113,65 +182,64 @@ public void testTokenRefresh() throws IOException { @Test public void testNonExistentFileThrows() { - // Constructor should throw exception when token file doesn't exist - assertThatThrownBy( - () -> - new FileBearerTokenProvider( - Paths.get("/non/existent/file.txt"), - Duration.ofMinutes(5), - true, - Duration.ofMinutes(1), - Clock.systemUTC())) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Failed to load initial bearer token from file"); - } - - @Test - public void testEmptyFile() throws IOException { - // Create an empty token file - Path tokenFile = tempDir.resolve("empty.txt"); - Files.writeString(tokenFile, ""); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); - // Constructor should throw exception when token file is empty + // Constructor should throw exception when token file doesn't exist assertThatThrownBy( () -> new FileBearerTokenProvider( - tokenFile, - Duration.ofMinutes(5), - true, - Duration.ofMinutes(1), - Clock.systemUTC())) + Paths.get("/non/existent/file.txt"), + Duration.ofMinutes(5), + true, + Duration.ofMinutes(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant) + .close()) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Failed to load initial bearer token from file"); + .hasMessageContaining("OPA token file does not exist or is not readable"); } @Test public void testJwtExpirationRefresh() throws IOException { // Create mutable clock for deterministic time control - MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); // Create a temporary token file with a JWT that expires in 10 seconds from clock time Path tokenFile = tempDir.resolve("jwt-token.txt"); - String jwtToken = createJwtWithExpiration(clock.instant().plusSeconds(10)); + String jwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(10)); Files.writeString(tokenFile, jwtToken); // Create file token provider with JWT expiration refresh enabled // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds) try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(10), true, Duration.ofSeconds(3), clock)) { + tokenFile, + Duration.ofMinutes(10), + true, + Duration.ofSeconds(3), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Test initial token String token1 = provider.getToken(); assertThat(token1).isEqualTo(jwtToken); // Advance time by 7.1 seconds (should trigger refresh due to 3 second buffer) - clock.add(Duration.ofMillis(7100)); + monotonicClock.advanceBoth(Duration.ofMillis(7100)); // Update the file with a new JWT - String newJwtToken = createJwtWithExpiration(clock.instant().plusSeconds(20)); + String newJwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(20)); Files.writeString(tokenFile, newJwtToken); + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); + // Test that token is refreshed String token2 = provider.getToken(); assertThat(token2).isEqualTo(newJwtToken); @@ -181,29 +249,43 @@ public void testJwtExpirationRefresh() throws IOException { @Test public void testJwtExpirationRefreshDisabled() throws IOException { // Create mutable clock for deterministic time control - MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); // Create a temporary token file with a JWT that expires in 1 second from clock time Path tokenFile = tempDir.resolve("jwt-token.txt"); - String jwtToken = createJwtWithExpiration(clock.instant().plusSeconds(1)); + String jwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(1)); Files.writeString(tokenFile, jwtToken); // Create file token provider with JWT expiration refresh disabled try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMillis(100), false, Duration.ofSeconds(1), clock)) { + tokenFile, + Duration.ofMillis(100), + false, + Duration.ofSeconds(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Test initial token String token1 = provider.getToken(); assertThat(token1).isEqualTo(jwtToken); // Advance time past fixed refresh interval (150ms) - clock.add(Duration.ofMillis(150)); + monotonicClock.advanceBoth(Duration.ofMillis(150)); // Update the file String newToken = "updated-non-jwt-token"; Files.writeString(tokenFile, newToken); + // refresh task didn't run yet, so token should still be the same + assertThat(provider.getToken()).isEqualTo(token1); + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); + // Test that token is refreshed based on fixed interval, not JWT expiration String token2 = provider.getToken(); assertThat(token2).isEqualTo(newToken); @@ -213,7 +295,8 @@ public void testJwtExpirationRefreshDisabled() throws IOException { @Test public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException { // Create mutable clock for deterministic time control - MutableClock clock = MutableClock.of(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); // Create a temporary token file with a non-JWT token Path tokenFile = tempDir.resolve("token.txt"); @@ -223,19 +306,31 @@ public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException { // Create file token provider with JWT expiration refresh enabled try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMillis(100), true, Duration.ofSeconds(1), clock)) { + tokenFile, + Duration.ofMillis(100), + true, + Duration.ofSeconds(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Test initial token String token1 = provider.getToken(); assertThat(token1).isEqualTo(nonJwtToken); // Advance time past fallback refresh interval - clock.add(Duration.ofMillis(150)); + monotonicClock.advanceBoth(Duration.ofMillis(150)); // Update the file String updatedToken = "updated-non-jwt-token"; Files.writeString(tokenFile, updatedToken); + assertThat(provider.getToken()).isEqualTo(token1); + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); + // Test that token is refreshed using fallback interval String token2 = provider.getToken(); assertThat(token2).isEqualTo(updatedToken); @@ -249,10 +344,21 @@ public void testJwtExpirationTooSoon() throws IOException { String expiredJwtToken = createJwtWithExpiration(Instant.now().minusSeconds(1)); Files.writeString(tokenFile, expiredJwtToken); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); + // Create file token provider with JWT expiration refresh enabled try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMinutes(5), true, Duration.ofSeconds(60), Clock.systemUTC())) { + tokenFile, + Duration.ofMinutes(5), + true, + Duration.ofSeconds(60), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Should fall back to fixed interval when JWT expires too soon String token = provider.getToken(); @@ -267,10 +373,21 @@ public void testJwtWithoutExpirationClaim() throws IOException { String jwtWithoutExp = createJwtWithoutExpiration(); Files.writeString(tokenFile, jwtWithoutExp); + MutableMonotonicClock monotonicClock = new MutableMonotonicClock(); + MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock); + // Create file token provider with JWT expiration refresh enabled try (FileBearerTokenProvider provider = new FileBearerTokenProvider( - tokenFile, Duration.ofMillis(100), true, Duration.ofSeconds(1), Clock.systemUTC())) { + tokenFile, + Duration.ofMillis(100), + true, + Duration.ofSeconds(1), + Duration.ofMillis(1), + asyncExec, + monotonicClock::currentInstant)) { + // run outstanding token-refresh task + asyncExec.readyCallables().forEach(MockAsyncExec.Task::call); // Should fall back to fixed interval when JWT has no expiration String token = provider.getToken(); diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts index 97a559f77a..4a2d3b0500 100644 --- a/runtime/service/build.gradle.kts +++ b/runtime/service/build.gradle.kts @@ -106,6 +106,8 @@ dependencies { implementation(libs.jakarta.servlet.api) + runtimeOnly(project(":polaris-async-vertx")) + testFixturesApi(project(":polaris-tests")) { // exclude all spark dependencies exclude(group = "org.apache.iceberg", module = "iceberg-spark-3.5_2.12") From 615d731d6d84d49425b1d689b78e93800dff6928 Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 24 Oct 2025 02:27:59 +0000 Subject: [PATCH 39/40] thanks snazy --- extensions/auth/opa/impl/build.gradle.kts | 6 + .../auth/opa/OpaAuthorizationConfig.java | 7 + .../auth/opa/OpaPolarisAuthorizerFactory.java | 2 +- .../opa/token/FileBearerTokenProvider.java | 9 +- .../auth/opa/OpaHttpClientFactoryTest.java | 19 +-- .../opa/OpaPolarisAuthorizerFactoryTest.java | 130 ++++++++---------- polaris-core/build.gradle.kts | 1 + .../auth/DefaultPolarisAuthorizerFactory.java | 7 +- 8 files changed, 82 insertions(+), 99 deletions(-) rename {runtime/service/src/main/java/org/apache/polaris/service => polaris-core/src/main/java/org/apache/polaris/core}/auth/DefaultPolarisAuthorizerFactory.java (80%) diff --git a/extensions/auth/opa/impl/build.gradle.kts b/extensions/auth/opa/impl/build.gradle.kts index 3a1a917239..9dd95259d8 100644 --- a/extensions/auth/opa/impl/build.gradle.kts +++ b/extensions/auth/opa/impl/build.gradle.kts @@ -37,11 +37,17 @@ dependencies { implementation(platform(libs.iceberg.bom)) implementation("org.apache.iceberg:iceberg-api") + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + compileOnly(libs.jakarta.annotation.api) compileOnly(libs.jakarta.enterprise.cdi.api) compileOnly(libs.jakarta.inject.api) compileOnly(libs.smallrye.config.core) + testCompileOnly(project(":polaris-immutables")) + testAnnotationProcessor(project(":polaris-immutables", configuration = "processor")) + testImplementation(testFixtures(project(":polaris-core"))) testImplementation(platform(libs.junit.bom)) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java index 73cff7e90a..0eed8f09ac 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java @@ -27,6 +27,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; /** * Configuration for OPA (Open Policy Agent) authorization. @@ -35,6 +36,7 @@ * release. It may undergo breaking changes in future versions. Use with caution in production * environments. */ +@PolarisImmutable @ConfigMapping(prefix = "polaris.authorization.opa") public interface OpaAuthorizationConfig { @@ -75,6 +77,7 @@ default void validate() { } /** HTTP client configuration for OPA communication. */ + @PolarisImmutable interface HttpConfig { @WithDefault("PT2S") Duration timeout(); @@ -88,6 +91,7 @@ interface HttpConfig { } /** Authentication configuration for OPA communication. */ + @PolarisImmutable interface AuthenticationConfig { /** Type of authentication */ @WithDefault("none") @@ -113,6 +117,7 @@ default void validate() { } } + @PolarisImmutable interface BearerTokenConfig { /** Static bearer token configuration */ Optional staticToken(); @@ -135,6 +140,7 @@ default void validate() { } /** Configuration for static bearer tokens */ + @PolarisImmutable interface StaticTokenConfig { /** Static bearer token value */ String value(); @@ -146,6 +152,7 @@ default void validate() { } /** Configuration for file-based bearer tokens */ + @PolarisImmutable interface FileBasedConfig { /** Path to file containing bearer token */ Path path(); diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java index ced9351433..c3f72b4fad 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java @@ -175,7 +175,7 @@ private BearerTokenProvider createBearerTokenProvider( refreshInterval, jwtExpirationRefresh, jwtExpirationBuffer, - Duration.ofSeconds(5), + Duration.ofSeconds(5), // TODO: make configurable asyncExec, clock::instant); } else { diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 65bb7b3030..323254da57 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -118,8 +118,8 @@ public FileBearerTokenProvider( @Override public String getToken() { - String token = cachedToken; - if (token != null) { + ; + if (cachedToken != null) { // Regular case, we have a cached token return cachedToken; } @@ -155,7 +155,7 @@ private void refreshTokenAttempt() { } } else { // Token refresh did not succeed, retry soon - delay = Duration.ofSeconds(1); // TODO configurable ? + delay = Duration.ofSeconds(1); // TODO: make configurable } scheduleRefreshAttempt(delay); } @@ -166,7 +166,6 @@ private void scheduleRefreshAttempt(Duration delay) { private boolean doRefreshToken() { String newToken = loadTokenFromFile(); - // Only update cached token if we successfully loaded a new one if (newToken == null) { logger.debug("Couldn't load new bearer token from {}, will retry.", tokenFilePath); @@ -182,7 +181,7 @@ private boolean doRefreshToken() { logger.debug( "Token refreshed from file: {} (token present: {}), next refresh: {}", tokenFilePath, - cachedToken != null && !cachedToken.isEmpty(), + cachedToken != null, nextRefresh); return true; diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java index 601a0c285c..ee4d3d0bd7 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java @@ -19,12 +19,8 @@ package org.apache.polaris.extension.auth.opa; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import java.nio.file.Paths; import java.time.Duration; -import java.util.Optional; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.junit.jupiter.api.Test; @@ -34,7 +30,7 @@ public class OpaHttpClientFactoryTest { @Test void testCreateHttpClientWithHttpUrl() throws Exception { OpaAuthorizationConfig.HttpConfig httpConfig = - createMockHttpConfig(Duration.ofSeconds(5), true, null, null); + ImmutableHttpConfig.builder().timeout(Duration.ofSeconds(5)).verifySsl(true).build(); try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { assertThat(client).isNotNull(); @@ -44,21 +40,10 @@ void testCreateHttpClientWithHttpUrl() throws Exception { @Test void testCreateHttpClientWithHttpsUrl() throws Exception { OpaAuthorizationConfig.HttpConfig httpConfig = - createMockHttpConfig(Duration.ofSeconds(5), false, null, null); + ImmutableHttpConfig.builder().timeout(Duration.ofSeconds(5)).verifySsl(false).build(); try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) { assertThat(client).isNotNull(); } } - - private OpaAuthorizationConfig.HttpConfig createMockHttpConfig( - Duration timeout, boolean verifySsl, String trustStorePath, String trustStorePassword) { - OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); - when(httpConfig.timeout()).thenReturn(timeout); - when(httpConfig.verifySsl()).thenReturn(verifySsl); - when(httpConfig.trustStorePath()) - .thenReturn(Optional.ofNullable(trustStorePath != null ? Paths.get(trustStorePath) : null)); - when(httpConfig.trustStorePassword()).thenReturn(Optional.ofNullable(trustStorePassword)); - return httpConfig; - } } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java index e0aa6ad3df..982d81b980 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java @@ -20,7 +20,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import java.io.IOException; import java.net.URI; @@ -28,7 +27,6 @@ import java.nio.file.Path; import java.time.Clock; import java.time.Duration; -import java.util.Optional; import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider; import org.apache.polaris.nosql.async.java.JavaPoolAsyncExec; @@ -41,28 +39,27 @@ public class OpaPolarisAuthorizerFactoryTest { @Test public void testFactoryWithStaticTokenConfiguration() { - // Mock configuration for static token - OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig.class); - when(staticTokenConfig.value()).thenReturn("static-token-value"); - - OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.staticToken()).thenReturn(Optional.of(staticTokenConfig)); - when(bearerTokenConfig.fileBased()).thenReturn(Optional.empty()); - - OpaAuthorizationConfig.AuthenticationConfig authConfig = - mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.BEARER); - when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); - - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); - - OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.policyUri()) - .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); - when(opaConfig.auth()).thenReturn(authConfig); - when(opaConfig.http()).thenReturn(httpConfig); + // Build configuration for static token + OpaAuthorizationConfig opaConfig = + ImmutableOpaAuthorizationConfig.builder() + .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")) + .auth( + ImmutableAuthenticationConfig.builder() + .type(OpaAuthorizationConfig.AuthenticationType.BEARER) + .bearer( + ImmutableBearerTokenConfig.builder() + .staticToken( + ImmutableStaticTokenConfig.builder() + .value("static-token-value") + .build()) + .build()) + .build()) + .http( + ImmutableHttpConfig.builder() + .timeout(Duration.ofSeconds(2)) + .verifySsl(true) + .build()) + .build(); try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) { OpaPolarisAuthorizerFactory factory = @@ -83,31 +80,30 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { String tokenValue = "file-based-token-value"; Files.writeString(tokenFile, tokenValue); - // Mock configuration for file-based token - OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig.class); - when(fileTokenConfig.path()).thenReturn(tokenFile); - when(fileTokenConfig.refreshInterval()).thenReturn(Optional.of(Duration.ofMinutes(5))); - when(fileTokenConfig.jwtExpirationRefresh()).thenReturn(Optional.of(true)); - when(fileTokenConfig.jwtExpirationBuffer()).thenReturn(Optional.of(Duration.ofMinutes(1))); - - OpaAuthorizationConfig.BearerTokenConfig bearerTokenConfig = - mock(OpaAuthorizationConfig.BearerTokenConfig.class); - when(bearerTokenConfig.staticToken()).thenReturn(Optional.empty()); - when(bearerTokenConfig.fileBased()).thenReturn(Optional.of(fileTokenConfig)); - - OpaAuthorizationConfig.AuthenticationConfig authConfig = - mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.BEARER); - when(authConfig.bearer()).thenReturn(Optional.of(bearerTokenConfig)); - - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); - - OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.policyUri()) - .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); - when(opaConfig.auth()).thenReturn(authConfig); - when(opaConfig.http()).thenReturn(httpConfig); + // Build configuration for file-based token + OpaAuthorizationConfig opaConfig = + ImmutableOpaAuthorizationConfig.builder() + .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")) + .auth( + ImmutableAuthenticationConfig.builder() + .type(OpaAuthorizationConfig.AuthenticationType.BEARER) + .bearer( + ImmutableBearerTokenConfig.builder() + .fileBased( + ImmutableFileBasedConfig.builder() + .path(tokenFile) + .refreshInterval(Duration.ofMinutes(5)) + .jwtExpirationRefresh(true) + .jwtExpirationBuffer(Duration.ofMinutes(1)) + .build()) + .build()) + .build()) + .http( + ImmutableHttpConfig.builder() + .timeout(Duration.ofSeconds(2)) + .verifySsl(true) + .build()) + .build(); try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) { OpaPolarisAuthorizerFactory factory = @@ -138,19 +134,20 @@ public void testFactoryWithFileBasedTokenConfiguration() throws IOException { @Test public void testFactoryWithNoTokenConfiguration() { - // Mock configuration with no authentication - OpaAuthorizationConfig.AuthenticationConfig authConfig = - mock(OpaAuthorizationConfig.AuthenticationConfig.class); - when(authConfig.type()).thenReturn(OpaAuthorizationConfig.AuthenticationType.NONE); - when(authConfig.bearer()).thenReturn(Optional.empty()); - - OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(); - - OpaAuthorizationConfig opaConfig = mock(OpaAuthorizationConfig.class); - when(opaConfig.policyUri()) - .thenReturn(Optional.of(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))); - when(opaConfig.auth()).thenReturn(authConfig); - when(opaConfig.http()).thenReturn(httpConfig); + // Build configuration with no authentication + OpaAuthorizationConfig opaConfig = + ImmutableOpaAuthorizationConfig.builder() + .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow")) + .auth( + ImmutableAuthenticationConfig.builder() + .type(OpaAuthorizationConfig.AuthenticationType.NONE) + .build()) + .http( + ImmutableHttpConfig.builder() + .timeout(Duration.ofSeconds(2)) + .verifySsl(true) + .build()) + .build(); try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) { OpaPolarisAuthorizerFactory factory = @@ -163,13 +160,4 @@ public void testFactoryWithNoTokenConfiguration() { assertThat(authorizer).isNotNull(); } } - - private OpaAuthorizationConfig.HttpConfig createMockHttpConfig() { - OpaAuthorizationConfig.HttpConfig httpConfig = mock(OpaAuthorizationConfig.HttpConfig.class); - when(httpConfig.timeout()).thenReturn(Duration.ofSeconds(2)); - when(httpConfig.verifySsl()).thenReturn(true); - when(httpConfig.trustStorePath()).thenReturn(Optional.empty()); - when(httpConfig.trustStorePassword()).thenReturn(Optional.empty()); - return httpConfig; - } } diff --git a/polaris-core/build.gradle.kts b/polaris-core/build.gradle.kts index ba5335701e..250d6bc530 100644 --- a/polaris-core/build.gradle.kts +++ b/polaris-core/build.gradle.kts @@ -112,6 +112,7 @@ dependencies { testFixturesApi(libs.jakarta.ws.rs.api) compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.enterprise.cdi.api) } tasks.named("javadoc") { dependsOn("jandex") } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java similarity index 80% rename from runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java rename to polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java index 7f11b9ce10..3b1f71a529 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java @@ -16,19 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.auth; +package org.apache.polaris.core.auth; import io.smallrye.common.annotation.Identifier; import jakarta.enterprise.context.ApplicationScoped; -import org.apache.polaris.core.auth.PolarisAuthorizer; -import org.apache.polaris.core.auth.PolarisAuthorizerFactory; -import org.apache.polaris.core.auth.PolarisAuthorizerImpl; import org.apache.polaris.core.config.RealmConfig; /** Factory for creating the default Polaris authorizer implementation. */ @ApplicationScoped @Identifier("internal") -class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { +public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { @Override public PolarisAuthorizer create(RealmConfig realmConfig) { From c2fe9fbdbbc644cdd7665de81c0140d1c7c6353b Mon Sep 17 00:00:00 2001 From: Sung Yun <107272191+sungwy@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:40:38 -0400 Subject: [PATCH 40/40] thanks snazy! reduce volatile reads from 2 -> 1 Co-authored-by: Robert Stupp --- .../extension/auth/opa/token/FileBearerTokenProvider.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java index 323254da57..2d72f54f72 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java @@ -118,10 +118,10 @@ public FileBearerTokenProvider( @Override public String getToken() { - ; - if (cachedToken != null) { + String token = cachedToken; + if (token != null) { // Regular case, we have a cached token - return cachedToken; + return token; } // We get here if the cached token is null, which means that the initial token // has not been loaded yet.