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:
+ *
+ *
+ * - Static string values
+ *
- Files (with automatic reloading)
+ *
- External token services
+ *
+ */
+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:
+ *
+ *
+ * - If bearerToken.staticValue is set, uses StaticTokenProvider
+ *
- If bearerToken.filePath is set, uses FileTokenProvider
+ *
- 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:
- *
- *
- * - If bearerToken.staticValue is set, uses StaticTokenProvider
- *
- If bearerToken.filePath is set, uses FileTokenProvider
- *
- 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