snazy commented on code in PR #2680: URL: https://github.com/apache/polaris/pull/2680#discussion_r2439483935
########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); + + Optional<String> trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); + + /** Bearer token authentication configuration */ + Optional<BearerTokenConfig> bearer(); + + default void validate() { + switch (type()) { + case "bearer": + checkArgument( + bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); + bearer().get().validate(); + break; + case "none": + // No authentication - nothing to validate + break; + default: + throw new IllegalArgumentException( + "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'"); + } + } + } + + interface BearerTokenConfig { + /** Type of bearer token configuration */ + @WithDefault("static-token") + String type(); Review Comment: Is this property really necessary? Feels like a redundant setting because `staticToken()` and `fileBased()` are mutually exclusive. ########## runtime/test-common/src/main/java/org/apache/polaris/test/commons/OpaTestResource.java: ########## @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.test.commons; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +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.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +public class OpaTestResource implements QuarkusTestResourceLifecycleManager { + private static GenericContainer<?> opa; + private int mappedPort; + private Map<String, String> resourceConfig; + + @Override + public void init(Map<String, String> initArgs) { + this.resourceConfig = initArgs; + } + + @Override + public Map<String, String> start() { + try { + // Reuse container across tests to speed up execution + if (opa == null || !opa.isRunning()) { + opa = + new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:0.63.0")) Review Comment: Better use `ContainerSpecHelper`, see `MinioContainer` for an example. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); + + Optional<String> trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); + + /** Bearer token authentication configuration */ + Optional<BearerTokenConfig> bearer(); + + default void validate() { + switch (type()) { + case "bearer": + checkArgument( + bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); + bearer().get().validate(); + break; + case "none": + // No authentication - nothing to validate + break; + default: + throw new IllegalArgumentException( + "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'"); + } + } + } + + interface BearerTokenConfig { + /** Type of bearer token configuration */ + @WithDefault("static-token") + String type(); + + /** Static bearer token configuration */ + Optional<StaticTokenConfig> staticToken(); + + /** File-based bearer token configuration */ + Optional<FileBasedConfig> fileBased(); + + default void validate() { + switch (type()) { + case "static-token": + checkArgument( + staticToken().isPresent(), + "Static token configuration is required when type is 'static-token'"); + staticToken().get().validate(); + break; + case "file-based": + checkArgument( + fileBased().isPresent(), + "File-based configuration is required when type is 'file-based'"); + fileBased().get().validate(); + break; + default: + throw new IllegalArgumentException( + "Invalid bearer token type: " + type() + ". Must be 'static-token' or 'file-based'"); + } + } + + /** Configuration for static bearer tokens */ + interface StaticTokenConfig { + /** Static bearer token value */ + Optional<String> value(); Review Comment: ```suggestion String value(); ``` This property has to be present when using the static token provider. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); Review Comment: Mind unifying both properties and changing the type to `Optional<URI>`? ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java: ########## @@ -0,0 +1,343 @@ +/* + * 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 com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.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}. + * + * <p>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. + * + * <p><strong>Beta Feature:</strong> This implementation is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +public class OpaPolarisAuthorizer implements PolarisAuthorizer { Review Comment: Does this need to be `public`? ########## runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthorizerFactory.java: ########## @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.auth; + +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.core.auth.PolarisAuthorizer; +import org.apache.polaris.core.auth.PolarisAuthorizerFactory; +import org.apache.polaris.core.auth.PolarisAuthorizerImpl; +import org.apache.polaris.core.config.RealmConfig; + +/** Factory for creating the default Polaris authorizer implementation. */ +@ApplicationScoped +@Identifier("internal") +public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory { Review Comment: Should this go into `:polaris-core` and `PolarisAuthorizerImpl` become package-private? ########## runtime/service/src/intTest/java/org/apache/polaris/service/auth/opa/OpaFileTokenIntegrationTest.java: ########## @@ -0,0 +1,293 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.auth.opa; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.fail; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; +import io.quarkus.test.junit.TestProfile; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.polaris.test.commons.OpaTestResource; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) +public class OpaFileTokenIntegrationTest { + + @ConfigProperty(name = "polaris.authorization.opa.auth.bearer.file-based.path") + String tokenFilePath; + + public static class FileTokenOpaProfile implements QuarkusTestProfile { + + @Override + public Map<String, String> getConfigOverrides() { + Map<String, String> config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.policy-path", "/v1/data/polaris/authz"); + config.put("polaris.authorization.opa.http.timeout-ms", "2000"); + + // Configure OPA server authentication with file-based bearer token + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put("polaris.authorization.opa.auth.bearer.type", "file-based"); + // Token file path will be provided by OpaFileTokenTestResource + config.put( + "polaris.authorization.opa.http.verify-ssl", + "false"); // Disable SSL verification for tests + + // TODO: Add tests for OIDC and federated principal + config.put("polaris.authentication.type", "internal"); + + return config; + } + + @Override + public List<TestResourceEntry> testResources() { + String customRegoPolicy = + """ + package polaris.authz + + default allow := false + + # Allow root user for all operations + allow { + input.actor.principal == "root" + } + + # Allow admin user for all operations + allow { + input.actor.principal == "admin" + } + + # Deny stranger user explicitly (though default is false) + allow { + input.actor.principal == "stranger" + false + } + """; + + return List.of( + new TestResourceEntry( + OpaTestResource.class, + Map.of("policy-name", "polaris-authz", "rego-policy", customRegoPolicy)), + new TestResourceEntry(OpaFileTokenTestResource.class)); + } + } + + /** + * Test demonstrates OPA integration with file-based bearer token authentication. This test + * verifies that the FileBearerTokenProvider correctly reads tokens from a file and that the full + * integration works with file-based configuration. + */ + @Test + void testOpaAllowsRootUserWithFileToken() { + // Test demonstrates the complete integration flow with file-based tokens: + // 1. OAuth token acquisition with internal authentication + // 2. OPA policy allowing root users + // 3. Bearer token read from file by FileBearerTokenProvider + + // Get a token using the catalog service OAuth endpoint + String response = + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "client_credentials") + .formParam("client_id", "test-admin") + .formParam("client_secret", "test-secret") + .formParam("scope", "PRINCIPAL_ROLE:ALL") + .when() + .post("/api/catalog/v1/oauth/tokens") + .then() + .statusCode(200) + .extract() + .body() + .asString(); + + // Parse JSON response to get access_token + String accessToken = extractJsonValue(response, "access_token"); + + if (accessToken == null) { + fail("Failed to parse access_token from OAuth response: " + response); + } + + // Use the Bearer token to test OPA authorization + // The JWT token has principal "root" which our policy allows + given() + .header("Authorization", "Bearer " + accessToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); // Should succeed - "root" user is allowed by policy + } + + @Test + void testFileTokenRefresh() throws IOException, InterruptedException { + // This test verifies that the FileBearerTokenProvider refreshes tokens from the file + + // First verify the system works with the initial token + String rootToken = getRootToken(); + + given() + .header("Authorization", "Bearer " + rootToken) + .when() + .get("/api/management/v1/principals") + .then() + .statusCode(200); + + // Get the token file path from injected configuration + Path tokenFile = Path.of(tokenFilePath); + if (Files.exists(tokenFile)) { + String originalContent = Files.readString(tokenFile); + + // Update the file content + Files.writeString(tokenFile, "test-opa-bearer-token-updated-12345"); + + // Wait for refresh interval (1 second as configured) plus some buffer + Thread.sleep(1500); // 1.5 seconds to ensure refresh happens Review Comment: Hm, I'm not a fan of sleeps in tests at all. It's effectively a recipe for flaky tests (beside that those tests run longer than necessary). Instead let's use a `Clock` where it's needed and use `MutableClock` from tests. ########## 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. + * + * <p>This follows the standard Polaris pattern of using CDI with @Identifier annotations to select + * different implementations at runtime. + */ +public interface PolarisAuthorizerFactory { Review Comment: I like the introduction of these abstractions :) ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java: ########## @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.junit.jupiter.api.Test; + +/** Unit tests for OpaHttpClientFactory. */ +public class OpaHttpClientFactoryTest { + + @Test + void testCreateHttpClientWithHttpUrl() throws Exception { + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); + + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); + + assertNotNull(client); + client.close(); + } + + @Test + void testCreateHttpClientWithHttpsUrl() throws Exception { + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, false, null, null); + + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); + + assertNotNull(client); + client.close(); + } + + @Test + void testCreateHttpClientWithCustomTimeout() throws Exception { Review Comment: What's the effective difference to `testCreateHttpClientWithHttpUrl()`? ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java: ########## @@ -0,0 +1,355 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class FileBearerTokenProviderTest { + + @TempDir Path tempDir; + + @Test + public void testLoadTokenFromFile() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String expectedToken = "test-bearer-token-123"; + Files.writeString(tokenFile, expectedToken); + + // Create file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); + } + + @Test + public void testLoadTokenFromFileWithWhitespace() throws IOException { + // Create a temporary token file with whitespace + Path tokenFile = tempDir.resolve("token.txt"); + String tokenWithWhitespace = " test-bearer-token-456 \n\t"; + String expectedToken = "test-bearer-token-456"; + Files.writeString(tokenFile, tokenWithWhitespace); + + // Create file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval (should trim whitespace) + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); + } + + @Test + public void testTokenRefresh() throws IOException, InterruptedException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String initialToken = "initial-token"; + Files.writeString(tokenFile, initialToken); + + // Create file token provider with short refresh interval + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMillis(100)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(initialToken, token1); + + // Wait for refresh interval to pass + Thread.sleep(200); + + // Update the file + String updatedToken = "updated-token"; + Files.writeString(tokenFile, updatedToken); + + // Test that token is refreshed + String token2 = provider.getToken(); + assertEquals(updatedToken, token2); + + provider.close(); + } + + @Test + public void testNonExistentFile() { + // Create file token provider for non-existent file + FileBearerTokenProvider provider = + new FileBearerTokenProvider("/non/existent/file.txt", Duration.ofMinutes(5)); + + // Test token retrieval (should return null) + String token = provider.getToken(); + assertNull(token); + + provider.close(); + } + + @Test + public void testEmptyFile() throws IOException { + // Create an empty token file + Path tokenFile = tempDir.resolve("empty.txt"); + Files.writeString(tokenFile, ""); + + // Create file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval (should return null for empty file) + String token = provider.getToken(); + assertNull(token); + + provider.close(); + } + + @Test + public void testClosedProvider() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + Files.writeString(tokenFile, "test-token"); + + // Create and close file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + provider.close(); + + // Test token retrieval after closing (should return null) + String token = provider.getToken(); + assertNull(token); + } + + @Test + public void testJwtExpirationRefresh() throws IOException, InterruptedException { + // Create a temporary token file with a JWT that expires in 10 seconds + Path tokenFile = tempDir.resolve("jwt-token.txt"); + String jwtToken = createJwtWithExpiration(Instant.now().plusSeconds(10)); + Files.writeString(tokenFile, jwtToken); + + // Create file token provider with JWT expiration refresh enabled + // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds) + FileBearerTokenProvider provider = + new FileBearerTokenProvider( + tokenFile.toString(), Duration.ofMinutes(10), true, Duration.ofSeconds(3)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(jwtToken, token1); + + // Wait for 7.1 seconds (should trigger refresh due to 3 second buffer) + Thread.sleep(7100); Review Comment: Sorry, that's way too long for a simple unit test. Let's use `MutableClock`. ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java: ########## @@ -0,0 +1,740 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.iceberg.exceptions.ForbiddenException; +import org.apache.polaris.core.auth.PolarisAuthorizableOperation; +import org.apache.polaris.core.auth.PolarisPrincipal; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.apache.polaris.core.persistence.ResolvedPolarisEntity; +import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; +import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication + */ +public class OpaPolarisAuthorizerTest { + + @Test + void testOpaInputJsonFormat() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + + String url = "http://localhost:" + server.getAddress().getPort(); + + OpaPolarisAuthorizer authorizer = + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); Review Comment: The client should be properly closed here and in other tests. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); + + Optional<String> trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); + + /** Bearer token authentication configuration */ + Optional<BearerTokenConfig> bearer(); + + default void validate() { + switch (type()) { + case "bearer": + checkArgument( + bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); + bearer().get().validate(); + break; + case "none": + // No authentication - nothing to validate + break; + default: + throw new IllegalArgumentException( + "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'"); + } + } + } + + interface BearerTokenConfig { + /** Type of bearer token configuration */ + @WithDefault("static-token") + String type(); + + /** Static bearer token configuration */ + Optional<StaticTokenConfig> staticToken(); + + /** File-based bearer token configuration */ + Optional<FileBasedConfig> fileBased(); + + default void validate() { + switch (type()) { + case "static-token": + checkArgument( + staticToken().isPresent(), + "Static token configuration is required when type is 'static-token'"); + staticToken().get().validate(); + break; + case "file-based": + checkArgument( + fileBased().isPresent(), + "File-based configuration is required when type is 'file-based'"); + fileBased().get().validate(); + break; + default: + throw new IllegalArgumentException( + "Invalid bearer token type: " + type() + ". Must be 'static-token' or 'file-based'"); + } + } + + /** Configuration for static bearer tokens */ + interface StaticTokenConfig { + /** Static bearer token value */ + Optional<String> value(); + + default void validate() { + checkArgument( + value().isPresent() && !value().get().isBlank(), + "Static bearer token value cannot be null or empty"); + } + } + + /** Configuration for file-based bearer tokens */ + interface FileBasedConfig { + /** Path to file containing bearer token */ + Optional<String> path(); + + /** How often to refresh file-based bearer tokens (in seconds) */ + @WithDefault("300") + int refreshInterval(); Review Comment: Can use `Duration` here to remove ambiguity. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java: ########## @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa.token; + +import jakarta.annotation.Nullable; + +/** + * Interface for providing bearer tokens for authentication. + * + * <p>Implementations can provide tokens from various sources such as: + * + * <ul> + * <li>Static string values + * <li>Files (with automatic reloading) + * <li>External token services + * </ul> + */ +public interface BearerTokenProvider { + + /** + * Get the current bearer token. + * + * @return the bearer token, or null if no token is available + */ + @Nullable + String getToken(); + + /** + * Clean up any resources used by this token provider. Should be called when the provider is no + * longer needed. + */ + default void close() { Review Comment: Better let the interface extend `AutoCloseable`. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.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 io.smallrye.common.annotation.Identifier; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Duration; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.polaris.core.auth.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; + +/** Factory for creating OPA-based Polaris authorizer implementations. */ +@ApplicationScoped +@Identifier("opa") +public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { + + private final OpaAuthorizationConfig opaConfig; + private CloseableHttpClient httpClient; + private BearerTokenProvider bearerTokenProvider; Review Comment: Need to close this one in a @PreDestroy ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); + + Optional<String> trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); + + /** Bearer token authentication configuration */ + Optional<BearerTokenConfig> bearer(); + + default void validate() { + switch (type()) { + case "bearer": + checkArgument( + bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); + bearer().get().validate(); + break; + case "none": + // No authentication - nothing to validate + break; + default: + throw new IllegalArgumentException( + "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'"); + } + } + } + + interface BearerTokenConfig { + /** Type of bearer token configuration */ + @WithDefault("static-token") + String type(); + + /** Static bearer token configuration */ + Optional<StaticTokenConfig> staticToken(); + + /** File-based bearer token configuration */ + Optional<FileBasedConfig> fileBased(); + + default void validate() { + switch (type()) { + case "static-token": + checkArgument( + staticToken().isPresent(), + "Static token configuration is required when type is 'static-token'"); + staticToken().get().validate(); + break; + case "file-based": + checkArgument( + fileBased().isPresent(), + "File-based configuration is required when type is 'file-based'"); + fileBased().get().validate(); + break; + default: + throw new IllegalArgumentException( + "Invalid bearer token type: " + type() + ". Must be 'static-token' or 'file-based'"); + } + } + + /** Configuration for static bearer tokens */ + interface StaticTokenConfig { + /** Static bearer token value */ + Optional<String> value(); + + default void validate() { + checkArgument( + value().isPresent() && !value().get().isBlank(), + "Static bearer token value cannot be null or empty"); + } + } + + /** Configuration for file-based bearer tokens */ + interface FileBasedConfig { + /** Path to file containing bearer token */ + Optional<String> path(); + + /** How often to refresh file-based bearer tokens (in seconds) */ + @WithDefault("300") + int refreshInterval(); + + /** + * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If + * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on + * the expiration time minus the buffer, rather than the fixed refresh interval. + */ + @WithDefault("true") + boolean jwtExpirationRefresh(); + + /** + * Buffer time in seconds before JWT expiration to refresh the token. Only used when + * jwtExpirationRefresh is true and the token is a valid JWT. Default is 60 seconds. + */ + @WithDefault("60") + int jwtExpirationBuffer(); Review Comment: Can use `Duration` here to remove ambiguity. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.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 io.smallrye.common.annotation.Identifier; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Duration; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.polaris.core.auth.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; + +/** Factory for creating OPA-based Polaris authorizer implementations. */ +@ApplicationScoped +@Identifier("opa") +public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { + + private final OpaAuthorizationConfig opaConfig; + private CloseableHttpClient httpClient; Review Comment: Need to close this one in a `@PreDestroy` ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); + + Optional<String> trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); + + /** Bearer token authentication configuration */ + Optional<BearerTokenConfig> bearer(); + + default void validate() { + switch (type()) { + case "bearer": + checkArgument( + bearer().isPresent(), "Bearer configuration is required when type is 'bearer'"); + bearer().get().validate(); + break; + case "none": + // No authentication - nothing to validate + break; + default: + throw new IllegalArgumentException( + "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'"); + } + } + } + + interface BearerTokenConfig { + /** Type of bearer token configuration */ + @WithDefault("static-token") + String type(); + + /** Static bearer token configuration */ + Optional<StaticTokenConfig> staticToken(); + + /** File-based bearer token configuration */ + Optional<FileBasedConfig> fileBased(); + + default void validate() { + switch (type()) { + case "static-token": + checkArgument( + staticToken().isPresent(), + "Static token configuration is required when type is 'static-token'"); + staticToken().get().validate(); + break; + case "file-based": + checkArgument( + fileBased().isPresent(), + "File-based configuration is required when type is 'file-based'"); + fileBased().get().validate(); + break; + default: + throw new IllegalArgumentException( + "Invalid bearer token type: " + type() + ". Must be 'static-token' or 'file-based'"); + } + } + + /** Configuration for static bearer tokens */ + interface StaticTokenConfig { + /** Static bearer token value */ + Optional<String> value(); + + default void validate() { + checkArgument( + value().isPresent() && !value().get().isBlank(), + "Static bearer token value cannot be null or empty"); + } + } + + /** Configuration for file-based bearer tokens */ + interface FileBasedConfig { + /** Path to file containing bearer token */ + Optional<String> path(); Review Comment: ```suggestion String path(); ``` This property has to be present when using the file-based token provider. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); Review Comment: ```suggestion Optional<Path> trustStorePath(); ``` ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") Review Comment: Blindly trusting all presented server certificates is a security risk. Could you add a _severe_ production-readiness (see `ProductionReadinessChecks`) check that fails startup if this is set to `false`? ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java: ########## @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static com.google.common.base.Preconditions.checkArgument; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import java.util.Optional; + +/** + * Configuration for OPA (Open Policy Agent) authorization. + * + * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable + * release. It may undergo breaking changes in future versions. Use with caution in production + * environments. + */ +@ConfigMapping(prefix = "polaris.authorization.opa") +public interface OpaAuthorizationConfig { + Optional<String> url(); + + Optional<String> policyPath(); + + Optional<AuthenticationConfig> auth(); + + Optional<HttpConfig> http(); + + /** Validates the complete OPA configuration */ + default void validate() { + checkArgument(url().isPresent() && !url().get().isBlank(), "OPA URL cannot be null or empty"); + checkArgument( + policyPath().isPresent() && !policyPath().get().isBlank(), + "OPA policy path cannot be null or empty"); + checkArgument(auth().isPresent(), "Authentication configuration is required"); + + auth().get().validate(); + } + + /** HTTP client configuration for OPA communication. */ + interface HttpConfig { + @WithDefault("2000") + int timeoutMs(); + + @WithDefault("true") + boolean verifySsl(); + + Optional<String> trustStorePath(); + + Optional<String> trustStorePassword(); + } + + /** Authentication configuration for OPA communication. */ + interface AuthenticationConfig { + /** Type of authentication */ + @WithDefault("none") + String type(); Review Comment: Mind using an `enum` instead of `String`? ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java: ########## @@ -0,0 +1,248 @@ +/* + * 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 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.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +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. + * + * <p>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.). + * + * <p>The token file is expected to contain the bearer token as plain text. Leading and trailing + * whitespace will be trimmed. + * + * <p>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 ReadWriteLock lock = new ReentrantReadWriteLock(); + + private volatile String cachedToken; + private volatile Instant lastRefresh; + private volatile Instant nextRefresh; + private volatile boolean closed = false; + + /** + * Create a new file-based token provider with basic refresh interval. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes + */ + public FileBearerTokenProvider(String tokenFilePath, Duration refreshInterval) { + this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); + } + + /** + * Create a new file-based token provider with JWT expiration support. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) + * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing + * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + */ + public FileBearerTokenProvider( + String tokenFilePath, + Duration refreshInterval, + boolean jwtExpirationRefresh, + Duration jwtExpirationBuffer) { + this.tokenFilePath = Paths.get(tokenFilePath); + this.refreshInterval = refreshInterval; + this.jwtExpirationRefresh = jwtExpirationRefresh; + this.jwtExpirationBuffer = jwtExpirationBuffer; + this.lastRefresh = Instant.MIN; // Force initial load + this.nextRefresh = Instant.MIN; // Force initial calculation + + logger.info( + "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", + tokenFilePath, + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer); + } + + @Override + @Nullable + public String getToken() { + if (closed) { + logger.warn("Token provider is closed, returning null"); + return null; + } + + // Check if we need to refresh + if (shouldRefresh()) { + refreshToken(); + } + + lock.readLock().lock(); + try { + return cachedToken; + } finally { + lock.readLock().unlock(); + } + } + + @Override + public void close() { + closed = true; + lock.writeLock().lock(); + try { + cachedToken = null; + logger.info("File token provider closed"); + } finally { + lock.writeLock().unlock(); + } + } + + private boolean shouldRefresh() { + return Instant.now().isAfter(nextRefresh); + } + + private void refreshToken() { + lock.writeLock().lock(); + try { + // Double-check pattern - another thread might have refreshed while we waited for the lock + if (!shouldRefresh()) { + return; + } + + String newToken = loadTokenFromFile(); + cachedToken = newToken; + lastRefresh = Instant.now(); + + // Calculate next refresh time based on JWT expiration or fixed interval + nextRefresh = calculateNextRefresh(newToken); + + logger.debug( + "Token refreshed from file: {} (token present: {}), next refresh: {}", + tokenFilePath, + newToken != null && !newToken.isEmpty(), + nextRefresh); + + } finally { + lock.writeLock().unlock(); + } + } + + /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */ + private Instant calculateNextRefresh(@Nullable String token) { + if (token == null || !jwtExpirationRefresh) { + // Use fixed interval + return lastRefresh.plus(refreshInterval); + } + + // Attempt to parse as JWT and extract expiration + Optional<Instant> 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 = Instant.now().plus(Duration.ofSeconds(1)); + if (refreshTime.isBefore(minRefreshTime)) { + logger.warn( + "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get()); + return lastRefresh.plus(refreshInterval); + } + + logger.debug( + "Using JWT expiration-based refresh: token expires at {}, refreshing at {}", + expiration.get(), + refreshTime); + return refreshTime; + } + + // Fall back to fixed interval (token is not a valid JWT or has no expiration) + logger.debug("Token is not a valid JWT or has no expiration, using fixed refresh interval"); + return lastRefresh.plus(refreshInterval); + } + + @Nullable + private String loadTokenFromFile() { + try { + if (!Files.exists(tokenFilePath)) { + logger.warn("Token file does not exist: {}", tokenFilePath); + return null; + } + + if (!Files.isReadable(tokenFilePath)) { + logger.warn("Token file is not readable: {}", tokenFilePath); + return null; + } + + String content = Files.readString(tokenFilePath, StandardCharsets.UTF_8); + String token = content.trim(); + + if (token.isEmpty()) { + logger.warn("Token file is empty: {}", tokenFilePath); + return null; Review Comment: This read can race with the write of a new token file. For example (similarly when k8s uses a temp-file+rename): 1. New token file gets created (it's empty) 2. This function reads the empty file This leads to authorization failures for the whole `refreshInterval` duration. A better way is to retry in such cases and continue to use the already known token. IMO `getToken()` should never return `null`, either there is a token or it fails hard with an exception. But this one shouldn't cause explicit or implicit failures. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java: ########## @@ -0,0 +1,248 @@ +/* + * 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 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.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +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. + * + * <p>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.). + * + * <p>The token file is expected to contain the bearer token as plain text. Leading and trailing + * whitespace will be trimmed. + * + * <p>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 ReadWriteLock lock = new ReentrantReadWriteLock(); + + private volatile String cachedToken; + private volatile Instant lastRefresh; + private volatile Instant nextRefresh; + private volatile boolean closed = false; + + /** + * Create a new file-based token provider with basic refresh interval. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes + */ + public FileBearerTokenProvider(String tokenFilePath, Duration refreshInterval) { + this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); + } + + /** + * Create a new file-based token provider with JWT expiration support. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) + * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing + * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + */ + public FileBearerTokenProvider( + String tokenFilePath, + Duration refreshInterval, + boolean jwtExpirationRefresh, + Duration jwtExpirationBuffer) { + this.tokenFilePath = Paths.get(tokenFilePath); + this.refreshInterval = refreshInterval; + this.jwtExpirationRefresh = jwtExpirationRefresh; + this.jwtExpirationBuffer = jwtExpirationBuffer; + this.lastRefresh = Instant.MIN; // Force initial load + this.nextRefresh = Instant.MIN; // Force initial calculation + + logger.info( + "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", + tokenFilePath, + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer); + } + + @Override + @Nullable + public String getToken() { + if (closed) { + logger.warn("Token provider is closed, returning null"); + return null; + } + + // Check if we need to refresh + if (shouldRefresh()) { + refreshToken(); + } + + lock.readLock().lock(); + try { + return cachedToken; + } finally { + lock.readLock().unlock(); + } + } + + @Override + public void close() { + closed = true; + lock.writeLock().lock(); + try { + cachedToken = null; + logger.info("File token provider closed"); + } finally { + lock.writeLock().unlock(); + } + } + + private boolean shouldRefresh() { + return Instant.now().isAfter(nextRefresh); + } + + private void refreshToken() { + lock.writeLock().lock(); + try { + // Double-check pattern - another thread might have refreshed while we waited for the lock + if (!shouldRefresh()) { + return; + } + + String newToken = loadTokenFromFile(); + cachedToken = newToken; + lastRefresh = Instant.now(); + + // Calculate next refresh time based on JWT expiration or fixed interval + nextRefresh = calculateNextRefresh(newToken); + + logger.debug( + "Token refreshed from file: {} (token present: {}), next refresh: {}", + tokenFilePath, + newToken != null && !newToken.isEmpty(), + nextRefresh); + + } finally { + lock.writeLock().unlock(); + } + } + + /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */ + private Instant calculateNextRefresh(@Nullable String token) { + if (token == null || !jwtExpirationRefresh) { + // Use fixed interval + return lastRefresh.plus(refreshInterval); + } + + // Attempt to parse as JWT and extract expiration + Optional<Instant> 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 = Instant.now().plus(Duration.ofSeconds(1)); + if (refreshTime.isBefore(minRefreshTime)) { + logger.warn( + "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get()); + return lastRefresh.plus(refreshInterval); + } + + logger.debug( + "Using JWT expiration-based refresh: token expires at {}, refreshing at {}", + expiration.get(), + refreshTime); + return refreshTime; + } + + // Fall back to fixed interval (token is not a valid JWT or has no expiration) + logger.debug("Token is not a valid JWT or has no expiration, using fixed refresh interval"); + return lastRefresh.plus(refreshInterval); + } + + @Nullable + private String loadTokenFromFile() { + try { + if (!Files.exists(tokenFilePath)) { Review Comment: `.exists()` is superfluous, implicit via `.isReadable()`. ########## gradle/libs.versions.toml: ########## @@ -18,6 +18,7 @@ # [versions] +apache-httpclient5 = "5.5" Review Comment: Only one usage, can be inlined. BTW: Any reason why this doesn't use 5.5.1? ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java: ########## @@ -0,0 +1,248 @@ +/* + * 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 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.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +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. + * + * <p>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.). + * + * <p>The token file is expected to contain the bearer token as plain text. Leading and trailing + * whitespace will be trimmed. + * + * <p>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 ReadWriteLock lock = new ReentrantReadWriteLock(); + + private volatile String cachedToken; + private volatile Instant lastRefresh; + private volatile Instant nextRefresh; + private volatile boolean closed = false; + + /** + * Create a new file-based token provider with basic refresh interval. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes + */ + public FileBearerTokenProvider(String tokenFilePath, Duration refreshInterval) { + this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); + } + + /** + * Create a new file-based token provider with JWT expiration support. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) + * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing + * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + */ + public FileBearerTokenProvider( + String tokenFilePath, + Duration refreshInterval, + boolean jwtExpirationRefresh, + Duration jwtExpirationBuffer) { + this.tokenFilePath = Paths.get(tokenFilePath); + this.refreshInterval = refreshInterval; + this.jwtExpirationRefresh = jwtExpirationRefresh; + this.jwtExpirationBuffer = jwtExpirationBuffer; + this.lastRefresh = Instant.MIN; // Force initial load + this.nextRefresh = Instant.MIN; // Force initial calculation + + logger.info( + "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", + tokenFilePath, + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer); + } + + @Override + @Nullable + public String getToken() { + if (closed) { + logger.warn("Token provider is closed, returning null"); + return null; + } + + // Check if we need to refresh + if (shouldRefresh()) { + refreshToken(); + } + + lock.readLock().lock(); + try { + return cachedToken; + } finally { + lock.readLock().unlock(); + } + } + + @Override + public void close() { + closed = true; + lock.writeLock().lock(); + try { + cachedToken = null; + logger.info("File token provider closed"); + } finally { + lock.writeLock().unlock(); + } + } + + private boolean shouldRefresh() { + return Instant.now().isAfter(nextRefresh); + } + + private void refreshToken() { + lock.writeLock().lock(); + try { + // Double-check pattern - another thread might have refreshed while we waited for the lock + if (!shouldRefresh()) { + return; + } + + String newToken = loadTokenFromFile(); + cachedToken = newToken; + lastRefresh = Instant.now(); + + // Calculate next refresh time based on JWT expiration or fixed interval + nextRefresh = calculateNextRefresh(newToken); + + logger.debug( + "Token refreshed from file: {} (token present: {}), next refresh: {}", + tokenFilePath, + newToken != null && !newToken.isEmpty(), + nextRefresh); + + } finally { + lock.writeLock().unlock(); + } + } + + /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */ + private Instant calculateNextRefresh(@Nullable String token) { + if (token == null || !jwtExpirationRefresh) { + // Use fixed interval + return lastRefresh.plus(refreshInterval); + } + + // Attempt to parse as JWT and extract expiration + Optional<Instant> 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 = Instant.now().plus(Duration.ofSeconds(1)); + if (refreshTime.isBefore(minRefreshTime)) { + logger.warn( + "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get()); + return lastRefresh.plus(refreshInterval); + } + + logger.debug( + "Using JWT expiration-based refresh: token expires at {}, refreshing at {}", + expiration.get(), + refreshTime); + return refreshTime; + } + + // Fall back to fixed interval (token is not a valid JWT or has no expiration) + logger.debug("Token is not a valid JWT or has no expiration, using fixed refresh interval"); + return lastRefresh.plus(refreshInterval); + } + + @Nullable + private String loadTokenFromFile() { + try { + if (!Files.exists(tokenFilePath)) { + logger.warn("Token file does not exist: {}", tokenFilePath); + return null; + } + + if (!Files.isReadable(tokenFilePath)) { + logger.warn("Token file is not readable: {}", tokenFilePath); + return null; + } + + String content = Files.readString(tokenFilePath, StandardCharsets.UTF_8); Review Comment: This will fail if the token-file-path is a directory, but I think that's fine. ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java: ########## @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import com.google.common.base.Strings; +import java.io.FileInputStream; +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. + * + * <p>This factory handles the creation of Apache HttpClient instances with proper SSL + * configuration, timeout settings, and connection pooling for communicating with Open Policy Agent + * (OPA) servers. + */ +public class OpaHttpClientFactory { Review Comment: ```suggestion class OpaHttpClientFactory { ``` ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.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 io.smallrye.common.annotation.Identifier; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Duration; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.polaris.core.auth.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; + +/** Factory for creating OPA-based Polaris authorizer implementations. */ +@ApplicationScoped +@Identifier("opa") +public class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { Review Comment: Looks like this class should only be used via CDI. ```suggestion class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory { ``` ########## 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 Review Comment: Nit: this is the implicit default, no need to explicitly set it. ```suggestion #polaris.authorization.type=internal ``` ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java: ########## @@ -0,0 +1,355 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class FileBearerTokenProviderTest { + + @TempDir Path tempDir; + + @Test + public void testLoadTokenFromFile() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String expectedToken = "test-bearer-token-123"; + Files.writeString(tokenFile, expectedToken); + + // Create file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); Review Comment: `.close()` here and elsewhere should happen in a try-finally (or better try-with-resources) ########## extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java: ########## @@ -0,0 +1,248 @@ +/* + * 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 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.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +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. + * + * <p>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.). + * + * <p>The token file is expected to contain the bearer token as plain text. Leading and trailing + * whitespace will be trimmed. + * + * <p>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 ReadWriteLock lock = new ReentrantReadWriteLock(); + + private volatile String cachedToken; + private volatile Instant lastRefresh; + private volatile Instant nextRefresh; + private volatile boolean closed = false; + + /** + * Create a new file-based token provider with basic refresh interval. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes + */ + public FileBearerTokenProvider(String tokenFilePath, Duration refreshInterval) { + this(tokenFilePath, refreshInterval, true, Duration.ofSeconds(60)); + } + + /** + * Create a new file-based token provider with JWT expiration support. + * + * @param tokenFilePath path to the file containing the bearer token + * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens) + * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing + * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token + */ + public FileBearerTokenProvider( + String tokenFilePath, + Duration refreshInterval, + boolean jwtExpirationRefresh, + Duration jwtExpirationBuffer) { + this.tokenFilePath = Paths.get(tokenFilePath); + this.refreshInterval = refreshInterval; + this.jwtExpirationRefresh = jwtExpirationRefresh; + this.jwtExpirationBuffer = jwtExpirationBuffer; + this.lastRefresh = Instant.MIN; // Force initial load + this.nextRefresh = Instant.MIN; // Force initial calculation + + logger.info( + "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}", + tokenFilePath, + refreshInterval, + jwtExpirationRefresh, + jwtExpirationBuffer); + } + + @Override + @Nullable + public String getToken() { + if (closed) { Review Comment: The function should rather fail hard (Guava's `checkState(!closed, "...")`) as this should never be called once the provider's closed. ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java: ########## @@ -0,0 +1,43 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class StaticBearerTokenProviderTest { + + @Test + public void testStaticBearerTokenProvider() { + String expectedToken = "static-bearer-token"; + StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken); + + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + } + + @Test + public void testStaticBearerTokenProviderWithEmptyString() { + StaticBearerTokenProvider provider = new StaticBearerTokenProvider(""); Review Comment: IMO an empty string or null should never be allowed and the class should fail hard early. ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java: ########## @@ -0,0 +1,355 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class FileBearerTokenProviderTest { + + @TempDir Path tempDir; + + @Test + public void testLoadTokenFromFile() throws IOException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String expectedToken = "test-bearer-token-123"; + Files.writeString(tokenFile, expectedToken); + + // Create file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); + } + + @Test + public void testLoadTokenFromFileWithWhitespace() throws IOException { + // Create a temporary token file with whitespace + Path tokenFile = tempDir.resolve("token.txt"); + String tokenWithWhitespace = " test-bearer-token-456 \n\t"; + String expectedToken = "test-bearer-token-456"; + Files.writeString(tokenFile, tokenWithWhitespace); + + // Create file token provider + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMinutes(5)); + + // Test token retrieval (should trim whitespace) + String actualToken = provider.getToken(); + assertEquals(expectedToken, actualToken); + + provider.close(); + } + + @Test + public void testTokenRefresh() throws IOException, InterruptedException { + // Create a temporary token file + Path tokenFile = tempDir.resolve("token.txt"); + String initialToken = "initial-token"; + Files.writeString(tokenFile, initialToken); + + // Create file token provider with short refresh interval + FileBearerTokenProvider provider = + new FileBearerTokenProvider(tokenFile.toString(), Duration.ofMillis(100)); + + // Test initial token + String token1 = provider.getToken(); + assertEquals(initialToken, token1); + + // Wait for refresh interval to pass + Thread.sleep(200); Review Comment: Hm, I'm not a fan of sleeps in tests at all. It's effectively a recipe for flaky tests (beside that those tests run longer than necessary). Instead let's use a Clock where it's needed and use MutableClock from tests. ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java: ########## @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.junit.jupiter.api.Test; + +/** Unit tests for OpaHttpClientFactory. */ +public class OpaHttpClientFactoryTest { + + @Test + void testCreateHttpClientWithHttpUrl() throws Exception { + OpaAuthorizationConfig.HttpConfig httpConfig = createMockHttpConfig(5000, true, null, null); + + CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig); + + assertNotNull(client); + client.close(); Review Comment: Similar to the provider tests, should use `AutoCloseable` + try-with-resources. ########## runtime/defaults/src/main/resources/application-it.properties: ########## @@ -56,3 +56,5 @@ polaris.realm-context.realms=POLARIS,OTHER polaris.storage.gcp.token=token polaris.storage.gcp.lifespan=PT1H + Review Comment: Nit: noop change ########## extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java: ########## @@ -0,0 +1,740 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.iceberg.exceptions.ForbiddenException; +import org.apache.polaris.core.auth.PolarisAuthorizableOperation; +import org.apache.polaris.core.auth.PolarisPrincipal; +import org.apache.polaris.core.entity.PolarisBaseEntity; +import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntityType; +import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.apache.polaris.core.persistence.ResolvedPolarisEntity; +import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider; +import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication + */ +public class OpaPolarisAuthorizerTest { + + @Test + void testOpaInputJsonFormat() throws Exception { + // Capture the request body for verification + final String[] capturedRequestBody = new String[1]; + + HttpServer server = createServerWithRequestCapture(capturedRequestBody); + + String url = "http://localhost:" + server.getAddress().getPort(); + + OpaPolarisAuthorizer authorizer = + createWithStringToken( + url, "/v1/data/polaris/authz/allow", (String) null, HttpClients.createDefault()); + + PolarisPrincipal principal = + PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor")); + + Set<PolarisBaseEntity> entities = Set.of(); + PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of()); + PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of()); + + assertDoesNotThrow( + () -> + authorizer.authorizeOrThrow( + principal, entities, PolarisAuthorizableOperation.LOAD_VIEW, target, secondary)); + + // Parse and verify JSON structure from captured request + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(capturedRequestBody[0]); + assertTrue(root.has("input"), "Root should have 'input' field"); + var input = root.get("input"); + assertTrue(input.has("actor"), "Input should have 'actor' field"); + assertTrue(input.has("action"), "Input should have 'action' field"); + assertTrue(input.has("resource"), "Input should have 'resource' field"); + assertTrue(input.has("context"), "Input should have 'context' field"); + + server.stop(0); Review Comment: Please use try-finally or try-with-resources, also in the other tests. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
