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: + * + *

+ */ +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