diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts
index 90281d65f1..05eee3faa4 100644
--- a/bom/build.gradle.kts
+++ b/bom/build.gradle.kts
@@ -52,6 +52,8 @@ dependencies {
api(project(":polaris-eclipselink"))
api(project(":polaris-relational-jdbc"))
+ api(project(":polaris-extensions-auth-opa"))
+
api(project(":polaris-admin"))
api(project(":polaris-runtime-common"))
api(project(":polaris-runtime-test-common"))
diff --git a/extensions/auth/opa/impl/build.gradle.kts b/extensions/auth/opa/impl/build.gradle.kts
new file mode 100644
index 0000000000..9dd95259d8
--- /dev/null
+++ b/extensions/auth/opa/impl/build.gradle.kts
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ 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)
+ implementation(project(":polaris-async-api"))
+
+ // Iceberg dependency for ForbiddenException
+ implementation(platform(libs.iceberg.bom))
+ implementation("org.apache.iceberg:iceberg-api")
+
+ compileOnly(project(":polaris-immutables"))
+ annotationProcessor(project(":polaris-immutables", configuration = "processor"))
+
+ compileOnly(libs.jakarta.annotation.api)
+ compileOnly(libs.jakarta.enterprise.cdi.api)
+ compileOnly(libs.jakarta.inject.api)
+ compileOnly(libs.smallrye.config.core)
+
+ testCompileOnly(project(":polaris-immutables"))
+ testAnnotationProcessor(project(":polaris-immutables", configuration = "processor"))
+
+ testImplementation(testFixtures(project(":polaris-core")))
+ testImplementation(platform(libs.junit.bom))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation(libs.assertj.core)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.threeten.extra)
+ testImplementation(testFixtures(project(":polaris-async-api")))
+ testImplementation(project(":polaris-async-java"))
+ testImplementation(project(":polaris-idgen-mocks"))
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java
new file mode 100644
index 0000000000..0eed8f09ac
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java
@@ -0,0 +1,187 @@
+/*
+ * 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 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;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * 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.
+ */
+@PolarisImmutable
+@ConfigMapping(prefix = "polaris.authorization.opa")
+public interface OpaAuthorizationConfig {
+
+ /** 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;
+ }
+ }
+
+ Optional policyUri();
+
+ AuthenticationConfig auth();
+
+ HttpConfig http();
+
+ /** Validates the complete OPA configuration */
+ default void validate() {
+ checkArgument(
+ policyUri().isPresent(), "polaris.authorization.opa.policy-uri must be configured");
+
+ URI uri = policyUri().get();
+ String scheme = uri.getScheme();
+ checkArgument(
+ "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme),
+ "polaris.authorization.opa.policy-uri must use http or https scheme, but got: " + scheme);
+
+ auth().validate();
+ }
+
+ /** HTTP client configuration for OPA communication. */
+ @PolarisImmutable
+ interface HttpConfig {
+ @WithDefault("PT2S")
+ Duration timeout();
+
+ @WithDefault("true")
+ boolean verifySsl();
+
+ Optional trustStorePath();
+
+ Optional trustStorePassword();
+ }
+
+ /** Authentication configuration for OPA communication. */
+ @PolarisImmutable
+ interface AuthenticationConfig {
+ /** Type of authentication */
+ @WithDefault("none")
+ AuthenticationType 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'");
+ }
+ }
+ }
+
+ @PolarisImmutable
+ interface BearerTokenConfig {
+ /** Static bearer token configuration */
+ Optional staticToken();
+
+ /** File-based bearer token configuration */
+ Optional fileBased();
+
+ default void validate() {
+ // Ensure exactly one bearer token configuration is present (mutually exclusive)
+ checkArgument(
+ staticToken().isPresent() ^ fileBased().isPresent(),
+ "Exactly one of 'static-token' or 'file-based' bearer token configuration must be specified");
+
+ // Validate the present configuration
+ if (staticToken().isPresent()) {
+ staticToken().get().validate();
+ } else {
+ fileBased().get().validate();
+ }
+ }
+
+ /** Configuration for static bearer tokens */
+ @PolarisImmutable
+ interface StaticTokenConfig {
+ /** Static bearer token value */
+ String value();
+
+ default void validate() {
+ checkArgument(
+ !Strings.isNullOrEmpty(value()), "Static bearer token value cannot be null or empty");
+ }
+ }
+
+ /** Configuration for file-based bearer tokens */
+ @PolarisImmutable
+ interface FileBasedConfig {
+ /** Path to file containing bearer token */
+ Path path();
+
+ /** How often to refresh file-based bearer tokens (defaults to 5 minutes if not specified) */
+ Optional refreshInterval();
+
+ /**
+ * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If
+ * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on
+ * the expiration time minus the buffer, rather than the fixed refresh interval. Defaults to
+ * true if not specified.
+ */
+ Optional jwtExpirationRefresh();
+
+ /**
+ * Buffer time before JWT expiration to refresh the token. Only used when jwtExpirationRefresh
+ * is true and the token is a valid JWT. Defaults to 1 minute if not specified.
+ */
+ Optional jwtExpirationBuffer();
+
+ default void validate() {
+ checkArgument(
+ refreshInterval().isEmpty() || refreshInterval().get().isPositive(),
+ "refreshInterval must be positive");
+ checkArgument(
+ jwtExpirationBuffer().isEmpty() || jwtExpirationBuffer().get().isPositive(),
+ "jwtExpirationBuffer must be positive");
+ }
+ }
+ }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java
new file mode 100644
index 0000000000..db9febb62c
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java
@@ -0,0 +1,130 @@
+/*
+ * 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 java.io.FileInputStream;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLContext;
+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.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.
+ */
+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(OpaAuthorizationConfig.HttpConfig config) {
+ RequestConfig requestConfig =
+ RequestConfig.custom()
+ .setResponseTimeout(Timeout.ofMilliseconds(config.timeout().toMillis()))
+ .build();
+
+ try {
+ // Create TLS strategy based on configuration
+ DefaultClientTlsStrategy tlsStrategy = createTlsStrategy(config);
+
+ // 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 HTTP client for OPA communication", e);
+ }
+ }
+
+ /**
+ * 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);
+ }
+ }
+
+ /**
+ * Creates an SSL context based on the configuration.
+ *
+ * @param config HTTP configuration containing SSL settings
+ * @return SSLContext for HTTPS connections
+ */
+ 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.");
+ return SSLContexts.custom()
+ .loadTrustMaterial(
+ null, (X509Certificate[] chain, String authType) -> true) // trust all certificates
+ .build();
+ } else if (config.trustStorePath().isPresent()) {
+ // Load custom trust store for SSL verification
+ Path trustStorePath = config.trustStorePath().get();
+ logger.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath);
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath.toFile())) {
+ 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();
+ }
+ }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
new file mode 100644
index 0000000000..ab985abfea
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
@@ -0,0 +1,319 @@
+/*
+ * 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 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.net.URI;
+import java.util.List;
+import java.util.Set;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.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.iceberg.exceptions.ForbiddenException;
+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}.
+ *
+ *
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.
+ */
+class OpaPolarisAuthorizer implements PolarisAuthorizer {
+ private final URI policyUri;
+ private final BearerTokenProvider tokenProvider;
+ private final CloseableHttpClient httpClient;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Public constructor that accepts a complete policy URI.
+ *
+ * @param policyUri The required URI for the OPA endpoint. For example,
+ * https://opa.example.com/v1/polaris/allow
+ * @param httpClient Apache HttpClient (required, injected by CDI). SSL configuration should be
+ * handled by the CDI producer.
+ * @param objectMapper Jackson ObjectMapper for JSON serialization (required). Shared across
+ * authorizer instances to avoid initialization overhead.
+ * @param tokenProvider Token provider for authentication (optional)
+ */
+ public OpaPolarisAuthorizer(
+ @Nonnull URI policyUri,
+ @Nonnull CloseableHttpClient httpClient,
+ @Nonnull ObjectMapper objectMapper,
+ @Nullable BearerTokenProvider tokenProvider) {
+
+ this.policyUri = policyUri;
+ this.tokenProvider = tokenProvider;
+ this.httpClient = httpClient;
+ this.objectMapper = 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 ForbiddenException 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 ForbiddenException 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 ForbiddenException("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. 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
+ * @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
+ * @see OPA REST API Documentation
+ */
+ private boolean queryOpa(
+ PolarisPrincipal principal,
+ Set entities,
+ PolarisAuthorizableOperation op,
+ List targets,
+ List secondaries) {
+ try {
+ String inputJson = buildOpaInputJson(principal, entities, op, targets, secondaries);
+
+ // Create HTTP POST request using Apache HttpComponents
+ HttpPost httpPost = new HttpPost(policyUri);
+ 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, ContentType.APPLICATION_JSON));
+
+ // Execute request
+ try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
+ int statusCode = response.getCode();
+ if (statusCode != 200) {
+ return false;
+ }
+
+ // Read and parse response
+ 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);
+ }
+ } 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.
+ *
+ *
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
+ * @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, and roles 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);
+ 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);
+ }
+ }
+ return node;
+ }
+
+ /**
+ * Builds the context section of the OPA input JSON.
+ *
+ * Includes a request ID for correlating OPA server requests with logs.
+ *
+ * @return the context node for OPA input
+ */
+ private ObjectNode buildContextNode() {
+ ObjectNode context = objectMapper.createObjectNode();
+ context.put("request_id", java.util.UUID.randomUUID().toString());
+ return context;
+ }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
new file mode 100644
index 0000000000..c3f72b4fad
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
@@ -0,0 +1,186 @@
+/*
+ * 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 com.fasterxml.jackson.databind.ObjectMapper;
+import io.smallrye.common.annotation.Identifier;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.io.IOException;
+import java.net.URI;
+import java.time.Clock;
+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.PolarisAuthorizer;
+import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
+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;
+import org.apache.polaris.nosql.async.AsyncExec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Factory for creating OPA-based Polaris authorizer implementations. */
+@ApplicationScoped
+@Identifier("opa")
+class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpaPolarisAuthorizerFactory.class);
+
+ private final OpaAuthorizationConfig opaConfig;
+ private final Clock clock;
+ private final ObjectMapper objectMapper;
+ private final AsyncExec asyncExec;
+ private CloseableHttpClient httpClient;
+ private BearerTokenProvider bearerTokenProvider;
+
+ @Inject
+ public OpaPolarisAuthorizerFactory(
+ OpaAuthorizationConfig opaConfig, Clock clock, AsyncExec asyncExec) {
+ this.opaConfig = opaConfig;
+ this.clock = clock;
+ this.asyncExec = asyncExec;
+ this.objectMapper = new ObjectMapper();
+ }
+
+ /**
+ * Gets the OPA authorization configuration. Used by OpaProductionReadinessCheck
+ *
+ * @return the OPA configuration
+ */
+ OpaAuthorizationConfig getConfig() {
+ return opaConfig;
+ }
+
+ @PostConstruct
+ public void initialize() {
+ // Validate configuration once during startup
+ opaConfig.validate();
+
+ // Create HTTP client once during startup
+ httpClient = createHttpClient();
+
+ // Setup authentication once during startup
+ setupAuthentication(opaConfig.auth());
+ }
+
+ @Override
+ public PolarisAuthorizer create(RealmConfig realmConfig) {
+ // All components are now pre-initialized, just create the authorizer
+ URI policyUri =
+ opaConfig
+ .policyUri()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "OPA policy URI must be configured via polaris.authorization.opa.policy-uri"));
+
+ return new OpaPolarisAuthorizer(policyUri, httpClient, objectMapper, bearerTokenProvider);
+ }
+
+ @PreDestroy
+ public void cleanup() {
+ // Clean up bearer token provider resources
+ if (bearerTokenProvider != null) {
+ try {
+ bearerTokenProvider.close();
+ logger.debug("Bearer token provider closed successfully");
+ } catch (Exception e) {
+ // Log but don't throw - we're shutting down anyway
+ logger.warn("Error closing bearer token provider: {}", e.getMessage(), e);
+ }
+ }
+
+ // Clean up HTTP client resources
+ if (httpClient != null) {
+ try {
+ httpClient.close();
+ logger.debug("HTTP client closed successfully");
+ } catch (IOException e) {
+ // Log but don't throw - we're shutting down anyway
+ logger.warn("Error closing HTTP client: {}", e.getMessage(), e);
+ }
+ }
+ }
+
+ private CloseableHttpClient createHttpClient() {
+ try {
+ return OpaHttpClientFactory.createHttpClient(opaConfig.http());
+ } catch (Exception e) {
+ // Fallback to simple client
+ return HttpClients.custom().build();
+ }
+ }
+
+ /**
+ * Sets up authentication based on the configuration.
+ *
+ *
This method handles different authentication types and configures the appropriate
+ * authentication mechanism. Future authentication types (e.g., TLS mutual authentication) can be
+ * added as additional cases.
+ */
+ private void setupAuthentication(OpaAuthorizationConfig.AuthenticationConfig authConfig) {
+ switch (authConfig.type()) {
+ case BEARER:
+ if (authConfig.bearer().isEmpty()) {
+ throw new IllegalStateException("Bearer configuration is required when type is 'bearer'");
+ }
+ this.bearerTokenProvider = createBearerTokenProvider(authConfig.bearer().get());
+ break;
+ case NONE:
+ this.bearerTokenProvider = null; // No authentication
+ break;
+ default:
+ throw new IllegalStateException("Unsupported authentication type: " + authConfig.type());
+ }
+ }
+
+ private BearerTokenProvider createBearerTokenProvider(
+ OpaAuthorizationConfig.BearerTokenConfig bearerToken) {
+ // Check which configuration is present
+ if (bearerToken.staticToken().isPresent()) {
+ OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticConfig =
+ bearerToken.staticToken().get();
+ return new StaticBearerTokenProvider(staticConfig.value());
+ } else if (bearerToken.fileBased().isPresent()) {
+ OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig =
+ bearerToken.fileBased().get();
+
+ Duration refreshInterval = fileConfig.refreshInterval().orElse(Duration.ofMinutes(5));
+ boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh().orElse(true);
+ Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer().orElse(Duration.ofMinutes(1));
+
+ return new FileBearerTokenProvider(
+ fileConfig.path(),
+ refreshInterval,
+ jwtExpirationRefresh,
+ jwtExpirationBuffer,
+ Duration.ofSeconds(5), // TODO: make configurable
+ asyncExec,
+ clock::instant);
+ } else {
+ throw new IllegalStateException(
+ "No bearer token configuration found. Must specify either 'static-token' or 'file-based'");
+ }
+ }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java
new file mode 100644
index 0000000000..af6696743e
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
+import org.apache.polaris.core.config.ProductionReadinessCheck;
+import org.apache.polaris.core.config.ProductionReadinessCheck.Error;
+
+@ApplicationScoped
+public class OpaProductionReadinessChecks {
+
+ @Produces
+ public ProductionReadinessCheck checkOpaAuthorization(
+ PolarisAuthorizerFactory authorizerFactory) {
+ if (authorizerFactory instanceof OpaPolarisAuthorizerFactory opaFactory) {
+ OpaAuthorizationConfig config = opaFactory.getConfig();
+
+ List errors = new ArrayList<>();
+
+ errors.add(
+ Error.of(
+ "OPA authorization is currently a Beta feature and is not a stable release. Breaking changes may be introduced in future versions. Use with caution in production environments.",
+ "polaris.authorization.type"));
+
+ if (!config.http().verifySsl()) {
+ errors.add(
+ Error.ofSevere(
+ "SSL certificate verification is disabled for OPA communication. This exposes the service to man-in-the-middle attacks and other severe security risks.",
+ "polaris.authorization.opa.http.verify-ssl"));
+ }
+
+ return ProductionReadinessCheck.of(errors);
+ }
+ return ProductionReadinessCheck.OK;
+ }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java
new file mode 100644
index 0000000000..3d5c1b4f76
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.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.extension.auth.opa.token;
+
+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 BearerTokenProvider extends AutoCloseable {
+
+ /**
+ * 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.
+ */
+ @Override
+ default void close() {
+ // Default implementation does nothing
+ }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java
new file mode 100644
index 0000000000..2d72f54f72
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java
@@ -0,0 +1,252 @@
+/*
+ * 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.token;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.exceptions.JWTDecodeException;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import jakarta.annotation.Nullable;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+import org.apache.polaris.nosql.async.AsyncExec;
+import org.apache.polaris.nosql.async.Cancelable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A token provider that reads tokens from a file and automatically reloads them based on a
+ * 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 FileBearerTokenProvider implements BearerTokenProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(FileBearerTokenProvider.class);
+
+ private final Path tokenFilePath;
+ private final Duration refreshInterval;
+ private final boolean jwtExpirationRefresh;
+ private final Duration jwtExpirationBuffer;
+ private final Supplier clock;
+ private final AtomicBoolean refreshLock = new AtomicBoolean();
+ private final AsyncExec asyncExec;
+ private final CompletableFuture initialTokenFuture = new CompletableFuture<>();
+ private final long initialTokenWaitMillis;
+
+ private volatile String cachedToken;
+ private volatile Instant lastRefresh;
+ private volatile Instant nextRefresh;
+ private volatile Cancelable> refreshTask;
+
+ /**
+ * Create a new file-based token provider with JWT expiration support.
+ *
+ * @param tokenFilePath path to the file containing the bearer token
+ * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens)
+ * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing
+ * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token
+ * @param clock clock instance for time operations
+ * @throws IllegalStateException if the initial token cannot be loaded from the file
+ */
+ public FileBearerTokenProvider(
+ Path tokenFilePath,
+ Duration refreshInterval,
+ boolean jwtExpirationRefresh,
+ Duration jwtExpirationBuffer,
+ Duration initialTokenWait,
+ AsyncExec asyncExec,
+ Supplier clock) {
+ this.tokenFilePath = tokenFilePath;
+ this.refreshInterval = refreshInterval;
+ this.jwtExpirationRefresh = jwtExpirationRefresh;
+ this.jwtExpirationBuffer = jwtExpirationBuffer;
+ this.initialTokenWaitMillis = initialTokenWait.toMillis();
+ this.clock = clock;
+ this.asyncExec = asyncExec;
+
+ this.nextRefresh = Instant.MIN;
+ this.lastRefresh = Instant.MIN;
+ // start refreshing the token (immediately)
+ scheduleRefreshAttempt(Duration.ZERO);
+
+ checkState(Files.isReadable(tokenFilePath), "OPA token file does not exist or is not readable");
+
+ logger.debug(
+ "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}, next refresh: {}",
+ tokenFilePath,
+ refreshInterval,
+ jwtExpirationRefresh,
+ jwtExpirationBuffer,
+ nextRefresh);
+ }
+
+ @Override
+ public String getToken() {
+ String token = cachedToken;
+ if (token != null) {
+ // Regular case, we have a cached token
+ return token;
+ }
+ // We get here if the cached token is null, which means that the initial token
+ // has not been loaded yet.
+ // In this case we wait for the configured amount of time
+ // (5 seconds in production, much lower in tests).
+ try {
+ return initialTokenFuture.get(initialTokenWaitMillis, TimeUnit.MILLISECONDS);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to read initial OPA bearer token", e);
+ }
+ }
+
+ @Override
+ public void close() {
+ cachedToken = null;
+ Cancelable> task = refreshTask;
+ if (task != null) {
+ refreshTask.cancel();
+ }
+ }
+
+ private void refreshTokenAttempt() {
+ boolean isInitialRefresh = cachedToken == null;
+ Duration delay;
+ if (doRefreshToken()) {
+ delay = Duration.between(clock.get(), nextRefresh);
+ if (isInitialRefresh) {
+ // If we have never cached a token, complete the initial token-future to "unblock"
+ // getToken() call sites waiting for it.
+ initialTokenFuture.complete(cachedToken);
+ }
+ } else {
+ // Token refresh did not succeed, retry soon
+ delay = Duration.ofSeconds(1); // TODO: make configurable
+ }
+ scheduleRefreshAttempt(delay);
+ }
+
+ private void scheduleRefreshAttempt(Duration delay) {
+ this.refreshTask = asyncExec.schedule(this::refreshTokenAttempt, delay);
+ }
+
+ private boolean doRefreshToken() {
+ String newToken = loadTokenFromFile();
+ // Only update cached token if we successfully loaded a new one
+ if (newToken == null) {
+ logger.debug("Couldn't load new bearer token from {}, will retry.", tokenFilePath);
+ return false;
+ }
+ cachedToken = newToken;
+
+ lastRefresh = clock.get();
+
+ // Calculate next refresh time based on current token (may be cached)
+ nextRefresh = calculateNextRefresh(cachedToken);
+
+ logger.debug(
+ "Token refreshed from file: {} (token present: {}), next refresh: {}",
+ tokenFilePath,
+ cachedToken != null,
+ nextRefresh);
+
+ return true;
+ }
+
+ /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */
+ private Instant calculateNextRefresh(String token) {
+ if (!jwtExpirationRefresh) {
+ return lastRefresh.plus(refreshInterval);
+ }
+
+ // Attempt to parse as JWT and extract expiration
+ Optional expiration = getJwtExpirationTime(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 = clock.get().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 {
+ String token = Files.readString(tokenFilePath, StandardCharsets.UTF_8).trim();
+ if (!token.isEmpty()) {
+ return token;
+ }
+ } catch (IOException e) {
+ logger.debug("Failed to read token from file", e);
+ }
+ return null;
+ }
+
+ /**
+ * 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/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java
new file mode 100644
index 0000000000..0fd8663df0
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java
@@ -0,0 +1,39 @@
+/*
+ * 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.token;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+
+/** A simple token provider that returns a static string value. */
+public class StaticBearerTokenProvider implements BearerTokenProvider {
+
+ private final String token;
+
+ public StaticBearerTokenProvider(String token) {
+ checkArgument(!Strings.isNullOrEmpty(token), "Token cannot be null or empty");
+ this.token = token;
+ }
+
+ @Override
+ public String getToken() {
+ return token;
+ }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java
new file mode 100644
index 0000000000..ee4d3d0bd7
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+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 {
+ OpaAuthorizationConfig.HttpConfig httpConfig =
+ ImmutableHttpConfig.builder().timeout(Duration.ofSeconds(5)).verifySsl(true).build();
+
+ try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) {
+ assertThat(client).isNotNull();
+ }
+ }
+
+ @Test
+ void testCreateHttpClientWithHttpsUrl() throws Exception {
+ OpaAuthorizationConfig.HttpConfig httpConfig =
+ ImmutableHttpConfig.builder().timeout(Duration.ofSeconds(5)).verifySsl(false).build();
+
+ try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) {
+ assertThat(client).isNotNull();
+ }
+ }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
new file mode 100644
index 0000000000..982d81b980
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Duration;
+import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider;
+import org.apache.polaris.nosql.async.java.JavaPoolAsyncExec;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class OpaPolarisAuthorizerFactoryTest {
+
+ @TempDir Path tempDir;
+
+ @Test
+ public void testFactoryWithStaticTokenConfiguration() {
+ // Build configuration for static token
+ OpaAuthorizationConfig opaConfig =
+ ImmutableOpaAuthorizationConfig.builder()
+ .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))
+ .auth(
+ ImmutableAuthenticationConfig.builder()
+ .type(OpaAuthorizationConfig.AuthenticationType.BEARER)
+ .bearer(
+ ImmutableBearerTokenConfig.builder()
+ .staticToken(
+ ImmutableStaticTokenConfig.builder()
+ .value("static-token-value")
+ .build())
+ .build())
+ .build())
+ .http(
+ ImmutableHttpConfig.builder()
+ .timeout(Duration.ofSeconds(2))
+ .verifySsl(true)
+ .build())
+ .build();
+
+ try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+ OpaPolarisAuthorizerFactory factory =
+ new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+
+ // Create authorizer
+ RealmConfig realmConfig = mock(RealmConfig.class);
+ OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig);
+
+ assertThat(authorizer).isNotNull();
+ }
+ }
+
+ @Test
+ public void testFactoryWithFileBasedTokenConfiguration() throws IOException {
+ // Create a temporary token file
+ Path tokenFile = tempDir.resolve("bearer-token.txt");
+ String tokenValue = "file-based-token-value";
+ Files.writeString(tokenFile, tokenValue);
+
+ // Build configuration for file-based token
+ OpaAuthorizationConfig opaConfig =
+ ImmutableOpaAuthorizationConfig.builder()
+ .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))
+ .auth(
+ ImmutableAuthenticationConfig.builder()
+ .type(OpaAuthorizationConfig.AuthenticationType.BEARER)
+ .bearer(
+ ImmutableBearerTokenConfig.builder()
+ .fileBased(
+ ImmutableFileBasedConfig.builder()
+ .path(tokenFile)
+ .refreshInterval(Duration.ofMinutes(5))
+ .jwtExpirationRefresh(true)
+ .jwtExpirationBuffer(Duration.ofMinutes(1))
+ .build())
+ .build())
+ .build())
+ .http(
+ ImmutableHttpConfig.builder()
+ .timeout(Duration.ofSeconds(2))
+ .verifySsl(true)
+ .build())
+ .build();
+
+ try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+ OpaPolarisAuthorizerFactory factory =
+ new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+
+ // Create authorizer
+ RealmConfig realmConfig = mock(RealmConfig.class);
+ OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig);
+
+ assertThat(authorizer).isNotNull();
+
+ // Also verify that the token provider actually reads from the file
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMinutes(5),
+ true,
+ Duration.ofMinutes(1),
+ Duration.ofSeconds(10),
+ asyncExec,
+ Clock.systemUTC()::instant)) {
+
+ String actualToken = provider.getToken();
+ assertThat(actualToken).isEqualTo(tokenValue);
+ }
+ }
+ }
+
+ @Test
+ public void testFactoryWithNoTokenConfiguration() {
+ // Build configuration with no authentication
+ OpaAuthorizationConfig opaConfig =
+ ImmutableOpaAuthorizationConfig.builder()
+ .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))
+ .auth(
+ ImmutableAuthenticationConfig.builder()
+ .type(OpaAuthorizationConfig.AuthenticationType.NONE)
+ .build())
+ .http(
+ ImmutableHttpConfig.builder()
+ .timeout(Duration.ofSeconds(2))
+ .verifySsl(true)
+ .build())
+ .build();
+
+ try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+ OpaPolarisAuthorizerFactory factory =
+ new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+
+ // Create authorizer
+ RealmConfig realmConfig = mock(RealmConfig.class);
+ OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig);
+
+ assertThat(authorizer).isNotNull();
+ }
+ }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
new file mode 100644
index 0000000000..1c359ade2a
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
@@ -0,0 +1,663 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import org.apache.polaris.core.entity.PolarisEntity;
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
+import org.apache.polaris.core.persistence.ResolvedPolarisEntity;
+import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider;
+import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication
+ */
+public class OpaPolarisAuthorizerTest {
+
+ @Test
+ void testOpaInputJsonFormat() throws Exception {
+ // Capture the request body for verification
+ final String[] capturedRequestBody = new String[1];
+
+ HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+ try {
+ // Use the dynamically assigned port from the local server
+ URI policyUri =
+ URI.create(
+ "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(
+ policyUri, HttpClients.createDefault(), new ObjectMapper(), null);
+
+ PolarisPrincipal principal =
+ PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor"));
+
+ Set entities = Set.of();
+ PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of());
+ PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of());
+
+ assertThatNoException()
+ .isThrownBy(
+ () ->
+ authorizer.authorizeOrThrow(
+ principal,
+ entities,
+ PolarisAuthorizableOperation.LOAD_VIEW,
+ target,
+ secondary));
+
+ // Parse and verify JSON structure from captured request
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode root = mapper.readTree(capturedRequestBody[0]);
+ assertThat(root.has("input")).as("Root should have 'input' field").isTrue();
+ var input = root.get("input");
+ assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue();
+ assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
+ assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
+ assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void testOpaRequestJsonWithHierarchicalResource() throws Exception {
+ // Capture the request body for verification
+ final String[] capturedRequestBody = new String[1];
+
+ HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+ try {
+ URI policyUri =
+ URI.create(
+ "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(
+ policyUri, HttpClients.createDefault(), new ObjectMapper(), 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);
+
+ assertThatNoException()
+ .isThrownBy(
+ () ->
+ authorizer.authorizeOrThrow(
+ principal,
+ entities,
+ PolarisAuthorizableOperation.LOAD_TABLE,
+ tablePath,
+ null));
+
+ // Parse and verify the complete JSON structure
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode root = mapper.readTree(capturedRequestBody[0]);
+
+ // Verify top-level structure
+ assertThat(root.has("input")).as("Root should have 'input' field").isTrue();
+ var input = root.get("input");
+ assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue();
+ assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
+ assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
+ assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+
+ // Verify actor details
+ var actor = input.get("actor");
+ assertThat(actor.has("principal")).as("Actor should have 'principal' field").isTrue();
+ assertThat(actor.get("principal").asText()).isEqualTo("alice");
+ assertThat(actor.has("roles")).as("Actor should have 'roles' field").isTrue();
+ assertThat(actor.get("roles").isArray()).as("Roles should be an array").isTrue();
+ assertThat(actor.get("roles").size()).isEqualTo(2);
+
+ // Verify action
+ var action = input.get("action");
+ assertThat(action.asText()).isEqualTo("LOAD_TABLE");
+
+ // Verify resource structure - this is the key part for hierarchical resources
+ var resource = input.get("resource");
+ assertThat(resource.has("targets")).as("Resource should have 'targets' field").isTrue();
+ assertThat(resource.has("secondaries"))
+ .as("Resource should have 'secondaries' field")
+ .isTrue();
+
+ var targets = resource.get("targets");
+ assertThat(targets.isArray()).as("Targets should be an array").isTrue();
+ assertThat(targets.size()).as("Should have exactly one target").isEqualTo(1);
+
+ var target = targets.get(0);
+ // Verify the target entity (table) details
+ assertThat(target.isObject()).as("Target should be an object").isTrue();
+ assertThat(target.has("type")).as("Target should have 'type' field").isTrue();
+ assertThat(target.get("type").asText())
+ .as("Target type should be TABLE_LIKE")
+ .isEqualTo("TABLE_LIKE");
+ assertThat(target.has("name")).as("Target should have 'name' field").isTrue();
+ assertThat(target.get("name").asText())
+ .as("Target name should be customer_orders")
+ .isEqualTo("customer_orders");
+
+ // Verify the hierarchical parents array
+ assertThat(target.has("parents")).as("Target should have 'parents' field").isTrue();
+ var parents = target.get("parents");
+ assertThat(parents.isArray()).as("Parents should be an array").isTrue();
+ assertThat(parents.size()).as("Should have 2 parents (catalog and namespace)").isEqualTo(2);
+
+ // Verify catalog parent (first in the hierarchy)
+ var catalogParent = parents.get(0);
+ assertThat(catalogParent.get("type").asText())
+ .as("First parent should be catalog")
+ .isEqualTo("CATALOG");
+ assertThat(catalogParent.get("name").asText())
+ .as("Catalog name should be prod_catalog")
+ .isEqualTo("prod_catalog");
+
+ // Verify namespace parent (second in the hierarchy)
+ var namespaceParent = parents.get(1);
+ assertThat(namespaceParent.get("type").asText())
+ .as("Second parent should be namespace")
+ .isEqualTo("NAMESPACE");
+ assertThat(namespaceParent.get("name").asText())
+ .as("Namespace name should be sales_data")
+ .isEqualTo("sales_data");
+
+ var secondaries = resource.get("secondaries");
+ assertThat(secondaries.isArray()).as("Secondaries should be an array").isTrue();
+ assertThat(secondaries.size()).as("Should have no secondaries in this test").isEqualTo(0);
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception {
+ // Capture the request body for verification
+ final String[] capturedRequestBody = new String[1];
+
+ HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+ try {
+ URI policyUri =
+ URI.create(
+ "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(
+ policyUri, HttpClients.createDefault(), new ObjectMapper(), 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);
+
+ assertThatNoException()
+ .isThrownBy(
+ () ->
+ authorizer.authorizeOrThrow(
+ principal,
+ entities,
+ PolarisAuthorizableOperation.LOAD_TABLE,
+ tablePath,
+ null));
+
+ // Parse and verify the complete JSON structure
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode root = mapper.readTree(capturedRequestBody[0]);
+
+ // Verify top-level structure
+ assertThat(root.has("input")).as("Root should have 'input' field").isTrue();
+ var input = root.get("input");
+ assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue();
+ assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
+ assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
+ assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+
+ // Verify actor details
+ var actor = input.get("actor");
+ assertThat(actor.get("principal").asText()).isEqualTo("bob");
+ assertThat(actor.get("roles").size()).isEqualTo(2);
+
+ // Verify action
+ var action = input.get("action");
+ assertThat(action.asText()).isEqualTo("LOAD_TABLE");
+
+ // Verify resource structure with multi-level namespace hierarchy
+ var resource = input.get("resource");
+ var targets = resource.get("targets");
+ assertThat(targets.size()).as("Should have exactly one target").isEqualTo(1);
+
+ var target = targets.get(0);
+ // Verify the target entity (table) details
+ assertThat(target.get("type").asText())
+ .as("Target type should be TABLE_LIKE")
+ .isEqualTo("TABLE_LIKE");
+ assertThat(target.get("name").asText())
+ .as("Target name should be feature_store")
+ .isEqualTo("feature_store");
+
+ // Verify the multi-level hierarchical parents array
+ assertThat(target.has("parents")).as("Target should have 'parents' field").isTrue();
+ var parents = target.get("parents");
+ assertThat(parents.isArray()).as("Parents should be an array").isTrue();
+ assertThat(parents.size())
+ .as("Should have 3 parents (catalog, department, team)")
+ .isEqualTo(3);
+
+ // Verify catalog parent (first in the hierarchy)
+ var catalogParent = parents.get(0);
+ assertThat(catalogParent.get("type").asText())
+ .as("First parent should be catalog")
+ .isEqualTo("CATALOG");
+ assertThat(catalogParent.get("name").asText())
+ .as("Catalog name should be analytics_catalog")
+ .isEqualTo("analytics_catalog");
+
+ // Verify department namespace parent (second in the hierarchy)
+ var departmentParent = parents.get(1);
+ assertThat(departmentParent.get("type").asText())
+ .as("Second parent should be namespace")
+ .isEqualTo("NAMESPACE");
+ assertThat(departmentParent.get("name").asText())
+ .as("Department name should be engineering")
+ .isEqualTo("engineering");
+
+ // Verify team namespace parent (third in the hierarchy)
+ var teamParent = parents.get(2);
+ assertThat(teamParent.get("type").asText())
+ .as("Third parent should be namespace")
+ .isEqualTo("NAMESPACE");
+ assertThat(teamParent.get("name").asText())
+ .as("Team name should be machine_learning")
+ .isEqualTo("machine_learning");
+
+ var secondaries = resource.get("secondaries");
+ assertThat(secondaries.isArray()).as("Secondaries should be an array").isTrue();
+ assertThat(secondaries.size()).as("Should have no secondaries in this test").isEqualTo(0);
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void testAuthorizeOrThrowWithEmptyTargetsAndSecondaries() throws Exception {
+ HttpServer server = createServerWithAllowResponse();
+ try {
+ URI policyUri =
+ URI.create(
+ "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(
+ policyUri, HttpClients.createDefault(), new ObjectMapper(), null);
+
+ PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin"));
+
+ Set entities = Set.of();
+
+ PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of());
+ PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of());
+
+ assertThatNoException()
+ .isThrownBy(
+ () ->
+ authorizer.authorizeOrThrow(
+ principal,
+ entities,
+ PolarisAuthorizableOperation.CREATE_CATALOG,
+ target,
+ secondary));
+
+ // Test multiple targets
+ PolarisResolvedPathWrapper target1 = new PolarisResolvedPathWrapper(List.of());
+ PolarisResolvedPathWrapper target2 = new PolarisResolvedPathWrapper(List.of());
+ List targets = List.of(target1, target2);
+ List secondaries = List.of();
+
+ assertThatNoException()
+ .isThrownBy(
+ () ->
+ authorizer.authorizeOrThrow(
+ principal,
+ entities,
+ PolarisAuthorizableOperation.LOAD_VIEW,
+ targets,
+ secondaries));
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ public void testCreateWithHttpsAndBearerToken() {
+ // Test that OpaPolarisAuthorizer can be created with HTTPS URLs and bearer tokens
+ BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token");
+ URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(
+ policyUri, HttpClients.createDefault(), new ObjectMapper(), tokenProvider);
+
+ assertThat(authorizer).isNotNull();
+ }
+
+ @Test
+ public void testBearerTokenIsAddedToHttpRequest() throws IOException {
+ URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
+ CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class);
+ CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class);
+ HttpEntity mockEntity = mock(HttpEntity.class);
+
+ when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse);
+ when(mockResponse.getCode()).thenReturn(200);
+ when(mockResponse.getEntity()).thenReturn(mockEntity);
+ when(mockEntity.getContent())
+ .thenReturn(
+ new ByteArrayInputStream(
+ "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8)));
+
+ BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token");
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(policyUri, mockHttpClient, new ObjectMapper(), tokenProvider);
+
+ PolarisPrincipal mockPrincipal =
+ PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet());
+
+ PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE;
+ assertThatNoException()
+ .isThrownBy(
+ () -> {
+ authorizer.authorizeOrThrow(
+ mockPrincipal,
+ Collections.emptySet(),
+ mockOperation,
+ (PolarisResolvedPathWrapper) null,
+ (PolarisResolvedPathWrapper) null);
+ });
+
+ // Verify the Authorization header with static bearer token
+ verifyAuthorizationHeader(mockHttpClient, "test-bearer-token");
+ }
+
+ @Test
+ public void testBearerTokenFromBearerTokenProvider() throws IOException {
+ // Mock HTTP client and response
+ CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class);
+ CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class);
+ HttpEntity mockEntity = mock(HttpEntity.class);
+
+ when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse);
+ when(mockResponse.getCode()).thenReturn(200);
+ when(mockResponse.getEntity()).thenReturn(mockEntity);
+ when(mockEntity.getContent())
+ .thenReturn(
+ new ByteArrayInputStream(
+ "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8)));
+
+ // Create token provider that returns a dynamic token
+ BearerTokenProvider tokenProvider = () -> "dynamic-token-12345";
+ URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
+ // Create authorizer with the token provider instead of static token
+ OpaPolarisAuthorizer authorizer =
+ new OpaPolarisAuthorizer(policyUri, mockHttpClient, new ObjectMapper(), tokenProvider);
+
+ // Create mock principal and entities
+ PolarisPrincipal mockPrincipal =
+ PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet());
+
+ PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE;
+
+ // Execute authorization (should not throw since we mocked allow=true)
+ assertThatNoException()
+ .isThrownBy(
+ () -> {
+ authorizer.authorizeOrThrow(
+ mockPrincipal,
+ Collections.emptySet(),
+ mockOperation,
+ (PolarisResolvedPathWrapper) null,
+ (PolarisResolvedPathWrapper) null);
+ });
+
+ // Verify the Authorization header with bearer token from provider
+ verifyAuthorizationHeader(mockHttpClient, "dynamic-token-12345");
+ }
+
+ private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) {
+ return new ResolvedPolarisEntity(entity, List.of(), List.of());
+ }
+
+ /**
+ * Helper method to create and start an HTTP server that captures request bodies.
+ *
+ * @param capturedRequestBody Array to store the captured request body
+ * @return Started HttpServer instance
+ */
+ private HttpServer createServerWithRequestCapture(String[] capturedRequestBody)
+ throws IOException {
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext(
+ "/v1/data/polaris/allow",
+ new HttpHandler() {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ // Capture request body
+ byte[] requestBytes = exchange.getRequestBody().readAllBytes();
+ capturedRequestBody[0] = new String(requestBytes, StandardCharsets.UTF_8);
+
+ String response = "{\"result\":{\"allow\":true}}";
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, response.length());
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(response.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ });
+ server.start();
+ return server;
+ }
+
+ /**
+ * Helper method to create and start an HTTP server that returns a simple allow response.
+ *
+ * @return Started HttpServer instance
+ */
+ private HttpServer createServerWithAllowResponse() throws IOException {
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext(
+ "/v1/data/polaris/allow",
+ new HttpHandler() {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ String response = "{\"result\":{\"allow\":true}}";
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, response.length());
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(response.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ });
+ server.start();
+ return server;
+ }
+
+ /**
+ * Helper method to capture and verify HTTP request Authorization header.
+ *
+ * @param mockHttpClient The mocked HTTP client to verify against
+ * @param expectedToken The expected bearer token value, or null if no Authorization header
+ * expected
+ */
+ private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, String expectedToken)
+ throws IOException {
+ // Capture the HTTP request to verify bearer token header
+ ArgumentCaptor httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class);
+ verify(mockHttpClient).execute(httpPostCaptor.capture());
+
+ HttpPost capturedRequest = httpPostCaptor.getValue();
+
+ if (expectedToken != null) {
+ // Verify the Authorization header is present and contains the expected token
+ assertThat(capturedRequest.containsHeader("Authorization"))
+ .as("Authorization header should be present when bearer token is provided")
+ .isTrue();
+ String authHeader = capturedRequest.getFirstHeader("Authorization").getValue();
+ assertThat(authHeader)
+ .as("Authorization header should contain the correct bearer token")
+ .isEqualTo("Bearer " + expectedToken);
+ } else {
+ // Verify no Authorization header is present when token is null
+ assertThat(capturedRequest.containsHeader("Authorization"))
+ .as("Authorization header should not be present when token provider returns null")
+ .isFalse();
+ }
+ }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java
new file mode 100644
index 0000000000..1651dc5e3f
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java
@@ -0,0 +1,471 @@
+/*
+ * 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.token;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.polaris.ids.mocks.MutableMonotonicClock;
+import org.apache.polaris.nosql.async.MockAsyncExec;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class FileBearerTokenProviderTest {
+
+ @TempDir Path tempDir;
+
+ @Test
+ public void testInitialRefresh() throws IOException {
+ // Create a temporary token file
+ Path tokenFile = tempDir.resolve("token.txt");
+ Files.writeString(tokenFile, "");
+
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create file token provider
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMinutes(5),
+ true,
+ Duration.ofMinutes(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+
+ // initial refresh has not happened yet, getToken() times out waiting for the initial token
+ assertThatIllegalStateException()
+ .isThrownBy(provider::getToken)
+ .withMessage("Failed to read initial OPA bearer token");
+
+ // initial refresh should have been scheduled, run it
+ assertThat(asyncExec.readyCount()).isEqualTo(1);
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+ assertThat(asyncExec.readyCount()).isEqualTo(0);
+
+ // Token file is still empty, getToken() still times out waiting for the initial token
+ assertThatIllegalStateException()
+ .isThrownBy(provider::getToken)
+ .withMessage("Failed to read initial OPA bearer token");
+
+ monotonicClock.advanceBoth(Duration.ofSeconds(1));
+ // refresh should have been scheduled, run it
+ assertThat(asyncExec.readyCount()).isEqualTo(1);
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+ assertThat(asyncExec.readyCount()).isEqualTo(0);
+
+ // Token file is still empty, getToken() still times out waiting for the initial token
+ assertThatIllegalStateException()
+ .isThrownBy(provider::getToken)
+ .withMessage("Failed to read initial OPA bearer token");
+
+ String expectedToken = "test-bearer-token-123";
+ Files.writeString(tokenFile, expectedToken);
+
+ monotonicClock.advanceBoth(Duration.ofSeconds(1));
+ // refresh should have been scheduled, run it
+ assertThat(asyncExec.readyCount()).isEqualTo(1);
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test token retrieval
+ String actualToken = provider.getToken();
+ assertThat(actualToken).isEqualTo(expectedToken);
+ }
+ }
+
+ @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);
+
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create file token provider
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMinutes(5),
+ true,
+ Duration.ofMinutes(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test token retrieval (should trim whitespace)
+ String actualToken = provider.getToken();
+ assertThat(actualToken).isEqualTo(expectedToken);
+ }
+ }
+
+ @Test
+ public void testTokenRefresh() throws IOException {
+ // Create a temporary token file
+ Path tokenFile = tempDir.resolve("token.txt");
+ String initialToken = "initial-token";
+ Files.writeString(tokenFile, initialToken);
+
+ // Create mutable clock for deterministic time control
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create file token provider with short refresh interval
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMillis(100),
+ false,
+ Duration.ofMinutes(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test initial token
+ String token1 = provider.getToken();
+ assertThat(token1).isEqualTo(initialToken);
+
+ // Advance time past refresh interval
+ monotonicClock.advanceBoth(Duration.ofMillis(200));
+
+ // Update the file
+ String updatedToken = "updated-token";
+ Files.writeString(tokenFile, updatedToken);
+
+ // refresh task didn't run yet, so token should still be the same
+ assertThat(token1).isEqualTo(initialToken);
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test that token is refreshed
+ String token2 = provider.getToken();
+ assertThat(token2).isEqualTo(updatedToken);
+ }
+ }
+
+ @Test
+ public void testNonExistentFileThrows() {
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Constructor should throw exception when token file doesn't exist
+ assertThatThrownBy(
+ () ->
+ new FileBearerTokenProvider(
+ Paths.get("/non/existent/file.txt"),
+ Duration.ofMinutes(5),
+ true,
+ Duration.ofMinutes(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)
+ .close())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("OPA token file does not exist or is not readable");
+ }
+
+ @Test
+ public void testJwtExpirationRefresh() throws IOException {
+ // Create mutable clock for deterministic time control
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create a temporary token file with a JWT that expires in 10 seconds from clock time
+ Path tokenFile = tempDir.resolve("jwt-token.txt");
+ String jwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(10));
+ Files.writeString(tokenFile, jwtToken);
+
+ // Create file token provider with JWT expiration refresh enabled
+ // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds)
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMinutes(10),
+ true,
+ Duration.ofSeconds(3),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test initial token
+ String token1 = provider.getToken();
+ assertThat(token1).isEqualTo(jwtToken);
+
+ // Advance time by 7.1 seconds (should trigger refresh due to 3 second buffer)
+ monotonicClock.advanceBoth(Duration.ofMillis(7100));
+
+ // Update the file with a new JWT
+ String newJwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(20));
+ Files.writeString(tokenFile, newJwtToken);
+
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test that token is refreshed
+ String token2 = provider.getToken();
+ assertThat(token2).isEqualTo(newJwtToken);
+ }
+ }
+
+ @Test
+ public void testJwtExpirationRefreshDisabled() throws IOException {
+ // Create mutable clock for deterministic time control
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create a temporary token file with a JWT that expires in 1 second from clock time
+ Path tokenFile = tempDir.resolve("jwt-token.txt");
+ String jwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(1));
+ Files.writeString(tokenFile, jwtToken);
+
+ // Create file token provider with JWT expiration refresh disabled
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMillis(100),
+ false,
+ Duration.ofSeconds(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test initial token
+ String token1 = provider.getToken();
+ assertThat(token1).isEqualTo(jwtToken);
+
+ // Advance time past fixed refresh interval (150ms)
+ monotonicClock.advanceBoth(Duration.ofMillis(150));
+
+ // Update the file
+ String newToken = "updated-non-jwt-token";
+ Files.writeString(tokenFile, newToken);
+
+ // refresh task didn't run yet, so token should still be the same
+ assertThat(provider.getToken()).isEqualTo(token1);
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test that token is refreshed based on fixed interval, not JWT expiration
+ String token2 = provider.getToken();
+ assertThat(token2).isEqualTo(newToken);
+ }
+ }
+
+ @Test
+ public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException {
+ // Create mutable clock for deterministic time control
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create a temporary token file with a non-JWT token
+ Path tokenFile = tempDir.resolve("token.txt");
+ String nonJwtToken = "plain-text-token";
+ Files.writeString(tokenFile, nonJwtToken);
+
+ // Create file token provider with JWT expiration refresh enabled
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMillis(100),
+ true,
+ Duration.ofSeconds(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test initial token
+ String token1 = provider.getToken();
+ assertThat(token1).isEqualTo(nonJwtToken);
+
+ // Advance time past fallback refresh interval
+ monotonicClock.advanceBoth(Duration.ofMillis(150));
+
+ // Update the file
+ String updatedToken = "updated-non-jwt-token";
+ Files.writeString(tokenFile, updatedToken);
+
+ assertThat(provider.getToken()).isEqualTo(token1);
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Test that token is refreshed using fallback interval
+ String token2 = provider.getToken();
+ assertThat(token2).isEqualTo(updatedToken);
+ }
+ }
+
+ @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);
+
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create file token provider with JWT expiration refresh enabled
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMinutes(5),
+ true,
+ Duration.ofSeconds(60),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Should fall back to fixed interval when JWT expires too soon
+ String token = provider.getToken();
+ assertThat(token).isEqualTo(expiredJwtToken);
+ }
+ }
+
+ @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);
+
+ MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+ MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+ // Create file token provider with JWT expiration refresh enabled
+ try (FileBearerTokenProvider provider =
+ new FileBearerTokenProvider(
+ tokenFile,
+ Duration.ofMillis(100),
+ true,
+ Duration.ofSeconds(1),
+ Duration.ofMillis(1),
+ asyncExec,
+ monotonicClock::currentInstant)) {
+ // run outstanding token-refresh task
+ asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+ // Should fall back to fixed interval when JWT has no expiration
+ String token = provider.getToken();
+ assertThat(token).isEqualTo(jwtWithoutExp);
+ }
+ }
+
+ /** 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/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java
new file mode 100644
index 0000000000..7886d8feec
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java
@@ -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.
+ */
+package org.apache.polaris.extension.auth.opa.token;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+
+public class StaticBearerTokenProviderTest {
+
+ @Test
+ public void testStaticBearerTokenProvider() {
+ String expectedToken = "static-bearer-token";
+ try (StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken)) {
+ String actualToken = provider.getToken();
+ assertThat(actualToken).isEqualTo(expectedToken);
+ }
+ }
+
+ @Test
+ public void testStaticBearerTokenProviderWithEmptyString() {
+ // Empty strings should be rejected
+ assertThatThrownBy(() -> new StaticBearerTokenProvider(""))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ public void testStaticBearerTokenProviderWithNullString() {
+ // Null tokens should be rejected
+ assertThatThrownBy(() -> new StaticBearerTokenProvider(null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts
new file mode 100644
index 0000000000..bab1a4421e
--- /dev/null
+++ b/extensions/auth/opa/tests/build.gradle.kts
@@ -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.
+ */
+
+plugins {
+ alias(libs.plugins.quarkus)
+ id("org.kordamp.gradle.jandex")
+ id("polaris-runtime")
+}
+
+dependencies {
+ // Quarkus platform
+ implementation(platform(libs.quarkus.bom))
+ implementation("io.quarkus:quarkus-rest-jackson")
+
+ // Add the OPA implementation as RUNTIME dependency to include in Quarkus app
+ implementation(project(":polaris-extensions-auth-opa"))
+
+ // Include all runtime-service dependencies
+ implementation(project(":polaris-runtime-service"))
+
+ // Test common for integration testing
+ testImplementation(project(":polaris-runtime-test-common"))
+
+ // Test dependencies
+ intTestImplementation("io.quarkus:quarkus-junit5")
+ intTestImplementation("io.rest-assured:rest-assured")
+
+ // Test container dependencies
+ intTestImplementation(platform(libs.testcontainers.bom))
+ intTestImplementation("org.testcontainers:junit-jupiter")
+ intTestImplementation(project(":polaris-container-spec-helper"))
+}
+
+tasks.named("javadoc") { dependsOn("jandex") }
+
+tasks.withType {
+ if (System.getenv("AWS_REGION") == null) {
+ environment("AWS_REGION", "us-west-2")
+ }
+ environment("POLARIS_BOOTSTRAP_CREDENTIALS", "POLARIS,test-admin,test-secret")
+ jvmArgs("--add-exports", "java.base/sun.nio.ch=ALL-UNNAMED")
+ systemProperty("java.security.manager", "allow")
+ maxParallelForks = 1
+
+ val logsDir = project.layout.buildDirectory.get().asFile.resolve("logs")
+
+ jvmArgumentProviders.add(
+ CommandLineArgumentProvider {
+ listOf("-Dquarkus.log.file.path=${logsDir.resolve("polaris.log").absolutePath}")
+ }
+ )
+
+ doFirst {
+ logsDir.deleteRecursively()
+ project.layout.buildDirectory.get().asFile.resolve("quarkus.log").delete()
+ }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java
new file mode 100644
index 0000000000..da4ba92720
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.test;
+
+import static io.restassured.RestAssured.given;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+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.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for OPA with file-based bearer token authentication.
+ *
+ * These tests verify that OpaPolarisAuthorizer correctly reads bearer tokens from a file and
+ * uses them to authenticate with OPA.
+ */
+@QuarkusTest
+@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class)
+public class OpaFileTokenIntegrationTest extends OpaIntegrationTestBase {
+
+ /**
+ * Test profile for OPA integration with file-based bearer token authentication. The OPA container
+ * runs with HTTP for simplicity in CI environments.
+ */
+ public static class FileTokenOpaProfile implements QuarkusTestProfile {
+ // Static field to hold token file path for test access
+ public static Path tokenFilePath;
+
+ @Override
+ public Map getConfigOverrides() {
+ try {
+ // Create token file early so SmallRye Config validation sees the property
+ tokenFilePath = Files.createTempFile("opa-test-token", ".txt");
+ Files.writeString(tokenFilePath, "test-opa-bearer-token-from-file");
+
+ Map config = new HashMap<>();
+ config.put("polaris.authorization.type", "opa");
+
+ // Configure file-based bearer token authentication
+ config.put("polaris.authorization.opa.auth.type", "bearer");
+ config.put(
+ "polaris.authorization.opa.auth.bearer.file-based.path", tokenFilePath.toString());
+ config.put("polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "PT1S");
+
+ return config;
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to create test token file", e);
+ }
+ }
+
+ @Override
+ public List testResources() {
+ return List.of(new TestResourceEntry(OpaTestResource.class));
+ }
+ }
+
+ @Test
+ void testOpaAllowsRootUser() {
+ String rootToken = getRootToken();
+
+ // Use the Bearer token to test OPA authorization
+ // The JWT token has principal "root" which our policy allows
+ given()
+ .header("Authorization", "Bearer " + rootToken)
+ .when()
+ .get("api/management/v1/catalogs")
+ .then()
+ .statusCode(200); // Should succeed - "root" user is allowed by policy
+ }
+
+ @Test
+ void testCreatePrincipalAndGetToken() {
+ // Test the helper method createPrincipalAndGetToken
+ // useful for debugging and ensuring that the helper method works correctly
+ assertThatNoException().isThrownBy(() -> createPrincipalAndGetToken("test-user"));
+ }
+
+ @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/catalogs")
+ .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/catalogs")
+ .then()
+ .statusCode(200); // Should succeed - admin user is allowed by policy
+ }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java
new file mode 100644
index 0000000000..2156f696ee
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.test;
+
+import static io.restassured.RestAssured.given;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+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.junit.jupiter.api.Test;
+
+@QuarkusTest
+@TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class)
+public class OpaIntegrationTest extends OpaIntegrationTestBase {
+
+ /**
+ * 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");
+
+ // Configure static token authentication
+ config.put("polaris.authorization.opa.auth.type", "bearer");
+ config.put(
+ "polaris.authorization.opa.auth.bearer.static-token.value",
+ "test-opa-bearer-token-12345");
+
+ return config;
+ }
+
+ @Override
+ public List testResources() {
+ return List.of(new TestResourceEntry(OpaTestResource.class));
+ }
+ }
+
+ @Test
+ void testOpaAllowsRootUser() {
+ String rootToken = getRootToken();
+
+ // Use the Bearer token to test OPA authorization
+ // The JWT token has principal "root" which our policy allows
+ given()
+ .header("Authorization", "Bearer " + rootToken)
+ .when()
+ .get("api/management/v1/catalogs")
+ .then()
+ .statusCode(200); // Should succeed - "root" user is allowed by policy
+ }
+
+ @Test
+ void testCreatePrincipalAndGetToken() {
+ // Test the helper method createPrincipalAndGetToken
+ // useful for debugging and ensuring that the helper method works correctly
+ assertThatNoException().isThrownBy(() -> createPrincipalAndGetToken("test-user"));
+ }
+
+ @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/catalogs")
+ .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/catalogs")
+ .then()
+ .statusCode(200); // Should succeed - admin user is allowed by policy
+ }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java
new file mode 100644
index 0000000000..9bbd326d9e
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.test;
+
+import static io.restassured.RestAssured.given;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Base class for OPA integration tests providing common helper methods for authentication and
+ * principal management.
+ */
+public abstract class OpaIntegrationTestBase {
+
+ /**
+ * Helper method to get root access token using the default test admin credentials.
+ *
+ * @return the access token for the root user
+ */
+ protected String getRootToken() {
+ String response =
+ given()
+ .contentType("application/x-www-form-urlencoded")
+ .formParam("grant_type", "client_credentials")
+ .formParam("client_id", "test-admin")
+ .formParam("client_secret", "test-secret")
+ .formParam("scope", "PRINCIPAL_ROLE:ALL")
+ .when()
+ .post("/api/catalog/v1/oauth/tokens")
+ .then()
+ .statusCode(200)
+ .extract()
+ .body()
+ .asString();
+
+ String accessToken = extractJsonValue(response, "access_token");
+ if (accessToken == null) {
+ fail("Failed to parse access_token from admin OAuth response: " + response);
+ }
+ return accessToken;
+ }
+
+ /**
+ * Helper method to create a principal and get an OAuth access token for that principal.
+ *
+ * @param principalName the name of the principal to create
+ * @return the access token for the newly created principal
+ */
+ protected String createPrincipalAndGetToken(String principalName) {
+ // First get root token to create the principal
+ String rootToken = getRootToken();
+
+ // Create the principal using the root token
+ String createResponse =
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + rootToken)
+ .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}")
+ .when()
+ .post("/api/management/v1/principals")
+ .then()
+ .statusCode(201)
+ .extract()
+ .body()
+ .asString();
+
+ // Parse the principal's credentials from the response
+ String clientId = extractJsonValue(createResponse, "clientId");
+ String clientSecret = extractJsonValue(createResponse, "clientSecret");
+
+ if (clientId == null || clientSecret == null) {
+ fail("Could not parse principal credentials from response: " + createResponse);
+ }
+
+ // Get access token for the newly created principal
+ String tokenResponse =
+ given()
+ .contentType("application/x-www-form-urlencoded")
+ .formParam("grant_type", "client_credentials")
+ .formParam("client_id", clientId)
+ .formParam("client_secret", clientSecret)
+ .formParam("scope", "PRINCIPAL_ROLE:ALL")
+ .when()
+ .post("/api/catalog/v1/oauth/tokens")
+ .then()
+ .statusCode(200)
+ .extract()
+ .body()
+ .asString();
+
+ String accessToken = extractJsonValue(tokenResponse, "access_token");
+ if (accessToken == null) {
+ fail("Could not get access token for principal " + principalName);
+ }
+
+ return accessToken;
+ }
+
+ /**
+ * Simple JSON value extractor for parsing values from JSON responses.
+ *
+ * @param json the JSON string to parse
+ * @param key the key to extract the value for
+ * @return the extracted value, or null if not found
+ */
+ protected String extractJsonValue(String json, String key) {
+ String searchKey = "\"" + key + "\"";
+ if (json.contains(searchKey)) {
+ String value = json.substring(json.indexOf(searchKey) + searchKey.length());
+ value = value.substring(value.indexOf("\"") + 1);
+ value = value.substring(0, value.indexOf("\""));
+ return value;
+ }
+ return null;
+ }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java
new file mode 100644
index 0000000000..0bc0385b94
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.test;
+
+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.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+public class OpaTestResource implements QuarkusTestResourceLifecycleManager {
+ private static GenericContainer> opa;
+ private int mappedPort;
+
+ @Override
+ public Map start() {
+ try {
+ // Reuse container across tests to speed up execution
+ if (opa == null || !opa.isRunning()) {
+ opa =
+ new GenericContainer<>(
+ ContainerSpecHelper.containerSpecHelper("opa", OpaTestResource.class)
+ .dockerImageName(null))
+ .withExposedPorts(8181)
+ .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();
+ String baseUrl = "http://" + containerHost + ":" + mappedPort;
+
+ // Load Opa Polaris Authorizer Rego policy into OPA
+ String polarisPolicyName = "polaris-authz";
+ String polarisRegoPolicy =
+ """
+ package polaris.authz
+
+ default allow := false
+
+ # Allow root user for all operations
+ allow {
+ input.actor.principal == "root"
+ }
+
+ # Allow admin user for all operations
+ allow {
+ input.actor.principal == "admin"
+ }
+ """;
+ loadRegoPolicy(baseUrl, polarisPolicyName, polarisRegoPolicy);
+
+ Map config = new HashMap<>();
+ config.put("polaris.authorization.opa.policy-uri", baseUrl + "/v1/data/polaris/authz");
+
+ return config;
+
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to start OPA test resource", e);
+ }
+ }
+
+ private void loadRegoPolicy(String baseUrl, String policyName, String regoPolicy) {
+ // Hardcode the policy directly instead of loading through QuarkusTestProfile
+ try {
+ URL url = new URL(baseUrl + "/v1/policies/" + policyName);
+ System.out.println("Uploading policy to: " + url);
+
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("PUT");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "text/plain");
+
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(regoPolicy.getBytes(StandardCharsets.UTF_8));
+ }
+
+ int code = conn.getResponseCode();
+ System.out.println("OPA policy upload response code: " + code);
+
+ if (code < 200 || code >= 300) {
+ throw new RuntimeException("OPA policy upload failed, HTTP " + code);
+ }
+
+ System.out.println("Successfully uploaded policy to OPA");
+ } catch (Exception e) {
+ // Surface container logs to help debug on CI
+ String logs = "";
+ try {
+ logs = opa.getLogs();
+ } catch (Throwable ignored) {
+ }
+ throw new RuntimeException("Failed to load OPA policy. Container logs:\n" + logs, e);
+ }
+ }
+
+ @Override
+ public void stop() {
+ // Don't stop the container to allow reuse across tests
+ // Container will be cleaned up when the JVM exits
+ }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version
new file mode 100644
index 0000000000..701452b6c5
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+FROM docker.io/openpolicyagent/opa:0.63.0
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b6ccd70607..9835105978 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -41,6 +41,7 @@ swagger = "1.6.16"
#
quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-services-bom", version.ref="quarkus" }
antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests
+apache-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.5.1" }
assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" }
auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" }
awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.35.0" }
diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties
index f6c46f8be6..8a5b3fe08b 100644
--- a/gradle/projects.main.properties
+++ b/gradle/projects.main.properties
@@ -43,6 +43,8 @@ polaris-version=tools/version
polaris-misc-types=tools/misc-types
polaris-extensions-federation-hadoop=extensions/federation/hadoop
polaris-extensions-federation-hive=extensions/federation/hive
+polaris-extensions-auth-opa=extensions/auth/opa/impl
+polaris-extensions-auth-opa-tests=extensions/auth/opa/tests
polaris-config-docs-annotations=tools/config-docs/annotations
polaris-config-docs-generator=tools/config-docs/generator
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java
new file mode 100644
index 0000000000..a50b37223a
--- /dev/null
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java
@@ -0,0 +1,30 @@
+/*
+ * 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 for creating the default Polaris authorizer implementation. */
+public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory {
+
+ @Override
+ public PolarisAuthorizer create(RealmConfig realmConfig) {
+ return new PolarisAuthorizerImpl(realmConfig);
+ }
+}
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 9d943823a7..e98f0302a1 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
@@ -127,7 +127,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;
@@ -728,7 +727,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 e0e34fd5ba..879bdf14a6 100644
--- a/runtime/defaults/src/main/resources/application.properties
+++ b/runtime/defaults/src/main/resources/application.properties
@@ -197,6 +197,40 @@ polaris.oidc.principal-roles-mapper.type=default
# polaris.storage.gcp.token=token
# polaris.storage.gcp.lifespan=PT1H
+# Polaris authorization type settings
+# 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.
+# 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
+
+# OPA HTTP configuration
+# polaris.authorization.opa.http.timeout=PT2S
+# NOTE: Setting verify-ssl=false will trigger a severe production readiness check error
+# as this exposes the service to security risks.
+# polaris.authorization.opa.http.verify-ssl=false
+# polaris.authorization.opa.http.trust-store-path=/path/to/truststore
+# polaris.authorization.opa.http.trust-store-password=my-trust-store-password
+
+# 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
+
+# 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.file-based.path=/path/to/token/file
+# polaris.authorization.opa.auth.bearer.file-based.refresh-interval=PT5M
+# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-refresh=true
+# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-buffer=PT1M
+
# Polaris Credential Manager Config
polaris.credential-manager.type=default
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 97a559f77a..4a2d3b0500 100644
--- a/runtime/service/build.gradle.kts
+++ b/runtime/service/build.gradle.kts
@@ -106,6 +106,8 @@ dependencies {
implementation(libs.jakarta.servlet.api)
+ runtimeOnly(project(":polaris-async-vertx"))
+
testFixturesApi(project(":polaris-tests")) {
// exclude all spark dependencies
exclude(group = "org.apache.iceberg", module = "iceberg-spark-3.5_2.12")
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..e65c60bcde
--- /dev/null
+++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java
@@ -0,0 +1,54 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import jakarta.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.junit.jupiter.api.Test;
+
+public class ServiceProducersIT {
+
+ public static class InternalAuthorizationConfig implements QuarkusTestProfile {
+ @Override
+ public Map getConfigOverrides() {
+ Map config = new HashMap<>();
+ config.put("polaris.authorization.type", "internal");
+ return config;
+ }
+ }
+
+ @QuarkusTest
+ @TestProfile(ServiceProducersIT.InternalAuthorizationConfig.class)
+ public static class InternalAuthorizationTest {
+
+ @Inject PolarisAuthorizer polarisAuthorizer;
+
+ @Test
+ void testInternalPolarisAuthorizerProduced() {
+ assertThat(polarisAuthorizer).isNotNull();
+ }
+ }
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java
new file mode 100644
index 0000000000..438dee1adf
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java
@@ -0,0 +1,29 @@
+/*
+ * 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;
+import io.smallrye.config.WithDefault;
+
+@ConfigMapping(prefix = "polaris.authorization")
+public interface AuthorizationConfiguration {
+
+ @WithDefault("internal")
+ String type();
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
index 6e25dbfc18..6b5181929f 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
@@ -37,8 +37,9 @@
import org.apache.polaris.core.PolarisCallContext;
import org.apache.polaris.core.PolarisDefaultDiagServiceImpl;
import org.apache.polaris.core.PolarisDiagnostics;
+import org.apache.polaris.core.auth.DefaultPolarisAuthorizerFactory;
import org.apache.polaris.core.auth.PolarisAuthorizer;
-import org.apache.polaris.core.auth.PolarisAuthorizerImpl;
+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;
@@ -139,10 +140,28 @@ public RealmConfig realmConfig(CallContext callContext) {
return callContext.getRealmConfig();
}
+ @Produces
+ @ApplicationScoped
+ @Identifier("internal")
+ public PolarisAuthorizerFactory defaultPolarisAuthorizerFactory() {
+ return new DefaultPolarisAuthorizerFactory();
+ }
+
+ @Produces
+ @ApplicationScoped
+ public PolarisAuthorizerFactory polarisAuthorizerFactory(
+ AuthorizationConfiguration authorizationConfig,
+ @Any Instance authorizerFactories) {
+ PolarisAuthorizerFactory factory =
+ authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get();
+ return factory;
+ }
+
@Produces
@RequestScoped
- public PolarisAuthorizer polarisAuthorizer(RealmConfig realmConfig) {
- return new PolarisAuthorizerImpl(realmConfig);
+ public PolarisAuthorizer polarisAuthorizer(
+ PolarisAuthorizerFactory factory, RealmConfig realmConfig) {
+ return factory.create(realmConfig);
}
@Produces