diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts index e752d808bc..3a68a81e62 100644 --- a/extensions/auth/opa/tests/build.gradle.kts +++ b/extensions/auth/opa/tests/build.gradle.kts @@ -40,6 +40,10 @@ dependencies { // Test dependencies intTestImplementation("io.quarkus:quarkus-junit5") intTestImplementation("io.rest-assured:rest-assured") + intTestImplementation(project(":polaris-api-management-model")) + intTestImplementation(platform(libs.iceberg.bom)) + intTestImplementation("org.apache.iceberg:iceberg-api") + intTestImplementation("org.apache.iceberg:iceberg-core") // Test container dependencies intTestImplementation(platform(libs.testcontainers.bom)) @@ -54,6 +58,8 @@ tasks.withType { environment("AWS_REGION", "us-west-2") } environment("POLARIS_BOOTSTRAP_CREDENTIALS", "POLARIS,test-admin,test-secret") + val apiVersion = System.getenv("DOCKER_API_VERSION") ?: "1.44" + systemProperty("api.version", apiVersion) jvmArgs("--add-exports", "java.base/sun.nio.ch=ALL-UNNAMED") systemProperty("java.security.manager", "allow") maxParallelForks = 1 diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaAdminServiceIT.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaAdminServiceIT.java new file mode 100644 index 0000000000..9d84030a89 --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaAdminServiceIT.java @@ -0,0 +1,418 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa.test; + +import static io.restassured.RestAssured.given; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableMetadataParser; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * OPA authorization coverage for management endpoints: + * + * + */ +@QuarkusTest +@TestProfile(OpaTestProfiles.StaticToken.class) +public class OpaAdminServiceIT extends OpaIntegrationTestBase { + + private String baseCatalogName; + private String baseCatalogLocation; + private String baseRootToken; + + @BeforeEach + void setupBaseCatalog() throws Exception { + baseRootToken = getRootToken(); + baseCatalogName = "opa-base-cat-" + UUID.randomUUID().toString().replace("-", ""); + baseCatalogLocation = Files.createTempDirectory("opa-base-cat").toUri().toString(); + createFileCatalog( + baseRootToken, baseCatalogName, baseCatalogLocation, List.of(baseCatalogLocation)); + } + + @Test + void assignCatalogRoleToPrincipalRole() { + String rootToken = baseRootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + + String catalogRole = "opa-cat-role-" + UUID.randomUUID().toString().replace("-", ""); + String principalRole = "opa-pr-role-" + UUID.randomUUID().toString().replace("-", ""); + + // create catalog role + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(Map.of("name", catalogRole, "properties", Map.of()))) + .post("/api/management/v1/catalogs/{cat}/catalog-roles", baseCatalogName) + .then() + .statusCode(201); + + // create principal role + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(Map.of("name", principalRole, "properties", Map.of()))) + .post("/api/management/v1/principal-roles") + .then() + .statusCode(201); + + Map grantRequest = + Map.of("catalogRole", Map.of("name", catalogRole, "properties", Map.of())); + + // stranger cannot bind + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + strangerToken) + .body(toJson(grantRequest)) + .put( + "/api/management/v1/principal-roles/{pr}/catalog-roles/{cat}", + principalRole, + baseCatalogName) + .then() + .statusCode(403); + + // root binds successfully + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(grantRequest)) + .put( + "/api/management/v1/principal-roles/{pr}/catalog-roles/{cat}", + principalRole, + baseCatalogName) + .then() + .statusCode(201); + } + + @Test + void listCatalogsAuthorization() { + String rootToken = baseRootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + + // stranger cannot list catalogs + given() + .header("Authorization", "Bearer " + strangerToken) + .get("/api/management/v1/catalogs") + .then() + .statusCode(403); + + // root lists catalogs successfully + given() + .header("Authorization", "Bearer " + rootToken) + .get("/api/management/v1/catalogs") + .then() + .statusCode(200); + } + + @Test + void createCatalogAuthorization() throws Exception { + String rootToken = getRootToken(); + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + + String catalogName = "opa-cat-create-" + UUID.randomUUID().toString().replace("-", ""); + String baseLocation = + java.nio.file.Files.createTempDirectory("opa-cat-create").toUri().toString(); + Map createCatalogRequest = + Map.of( + "type", + "INTERNAL", + "name", + catalogName, + "properties", + Map.of("default-base-location", baseLocation), + "storageConfigInfo", + Map.of("storageType", "FILE", "allowedLocations", List.of(baseLocation))); + + // Stranger cannot create catalog + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + strangerToken) + .body(toJson(createCatalogRequest)) + .post("/api/management/v1/catalogs") + .then() + .statusCode(403); + + // Root creates catalog + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(createCatalogRequest)) + .post("/api/management/v1/catalogs") + .then() + .statusCode(201); + + given() + .header("Authorization", "Bearer " + rootToken) + .delete("/api/management/v1/catalogs/{cat}", catalogName) + .then() + .statusCode(204); + } + + @Test + void grantTablePrivilegesAuthorization() throws Exception { + String rootToken = baseRootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + String catalogName = "opa-grant-cat-" + UUID.randomUUID().toString().replace("-", ""); + String namespace = "ns_" + UUID.randomUUID().toString().replace("-", ""); + String tableName = "tbl_" + UUID.randomUUID().toString().replace("-", ""); + String catalogRole = "role_" + UUID.randomUUID().toString().replace("-", ""); + + Path tempDir = Files.createTempDirectory("opa-grant"); + String baseLocation = tempDir.toUri().toString(); + String allowedPrefix = baseLocation + (baseLocation.endsWith("/") ? "" : "/") + namespace; + createFileCatalog( + rootToken, catalogName, baseLocation, List.of(allowedPrefix, allowedPrefix + "/")); + + createNamespace(rootToken, catalogName, namespace); + + Map registerPayload = + buildRegisterTableRequest(tableName, baseLocation, namespace); + Map grantRequest = + Map.of( + "grant", + Map.of( + "type", + "table", + "namespace", + List.of(namespace), + "tableName", + tableName, + "privilege", + "TABLE_READ_DATA")); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(registerPayload)) + .post("/api/catalog/v1/{cat}/namespaces/{ns}/register", catalogName, namespace) + .then() + .statusCode(200); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(Map.of("name", catalogRole, "properties", Map.of()))) + .post("/api/management/v1/catalogs/{cat}/catalog-roles", catalogName) + .then() + .statusCode(201); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + strangerToken) + .body(toJson(grantRequest)) + .put( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/grants", + catalogName, + catalogRole) + .then() + .statusCode(403); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(grantRequest)) + .put( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/grants", + catalogName, + catalogRole) + .then() + .statusCode(201); + } + + @Test + void listAssigneePrincipalRolesForCatalogRole() { + String rootToken = baseRootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + + String catalogRole = "opa-cat-role-" + UUID.randomUUID().toString().replace("-", ""); + String principalRole = "opa-pr-role-" + UUID.randomUUID().toString().replace("-", ""); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(Map.of("name", catalogRole, "properties", Map.of()))) + .post("/api/management/v1/catalogs/{cat}/catalog-roles", baseCatalogName) + .then() + .statusCode(201); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(Map.of("name", principalRole, "properties", Map.of()))) + .post("/api/management/v1/principal-roles") + .then() + .statusCode(201); + + Map grantRequest = + Map.of("catalogRole", Map.of("name", catalogRole, "properties", Map.of())); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(grantRequest)) + .put( + "/api/management/v1/principal-roles/{pr}/catalog-roles/{cat}", + principalRole, + baseCatalogName) + .then() + .statusCode(201); + + given() + .header("Authorization", "Bearer " + strangerToken) + .get( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/principal-roles", + baseCatalogName, + catalogRole) + .then() + .statusCode(403); + + given() + .header("Authorization", "Bearer " + rootToken) + .get( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/principal-roles", + baseCatalogName, + catalogRole) + .then() + .statusCode(200); + } + + @Test + void listGrantsForCatalogRole() throws Exception { + String rootToken = baseRootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + String catalogName = "opa-grant-list-cat-" + UUID.randomUUID().toString().replace("-", ""); + String namespace = "ns_" + UUID.randomUUID().toString().replace("-", ""); + String tableName = "tbl_" + UUID.randomUUID().toString().replace("-", ""); + String catalogRole = "role_" + UUID.randomUUID().toString().replace("-", ""); + + Path tempDir = Files.createTempDirectory("opa-grant-list"); + String baseLocation = tempDir.toUri().toString(); + String allowedPrefix = baseLocation + (baseLocation.endsWith("/") ? "" : "/") + namespace; + createFileCatalog( + rootToken, catalogName, baseLocation, List.of(allowedPrefix, allowedPrefix + "/")); + createNamespace(rootToken, catalogName, namespace); + + Map registerPayload = + buildRegisterTableRequest(tableName, baseLocation, namespace); + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(registerPayload)) + .post("/api/catalog/v1/{cat}/namespaces/{ns}/register", catalogName, namespace) + .then() + .statusCode(200); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(Map.of("name", catalogRole, "properties", Map.of()))) + .post("/api/management/v1/catalogs/{cat}/catalog-roles", catalogName) + .then() + .statusCode(201); + + Map grantRequest = + Map.of( + "grant", + Map.of( + "type", + "table", + "namespace", + List.of(namespace), + "tableName", + tableName, + "privilege", + "TABLE_READ_DATA")); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(grantRequest)) + .put( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/grants", + catalogName, + catalogRole) + .then() + .statusCode(201); + + given() + .header("Authorization", "Bearer " + strangerToken) + .get( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/grants", + catalogName, + catalogRole) + .then() + .statusCode(403); + + given() + .header("Authorization", "Bearer " + rootToken) + .get( + "/api/management/v1/catalogs/{cat}/catalog-roles/{role}/grants", + catalogName, + catalogRole) + .then() + .statusCode(200); + } + + private Map buildRegisterTableRequest( + String tableName, String baseLocation, String namespace) throws Exception { + String tableLocation = + baseLocation + (baseLocation.endsWith("/") ? "" : "/") + namespace + "/" + tableName; + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.unpartitioned(); + TableMetadata metadata = TableMetadata.newTableMetadata(schema, spec, tableLocation, Map.of()); + Path metadataPath = + Path.of( + URI.create( + tableLocation + + (tableLocation.endsWith("/") ? "" : "/") + + "metadata/v1.metadata.json")); + Files.createDirectories(metadataPath.getParent()); + Files.writeString(metadataPath, TableMetadataParser.toJson(metadata)); + + return Map.of( + "name", + tableName, + "metadata-location", + metadataPath.toUri().toString(), + "stage-create", + false); + } +} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java index da4ba92720..81c2a04364 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java @@ -22,15 +22,7 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; import io.quarkus.test.junit.TestProfile; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; /** @@ -40,45 +32,9 @@ * uses them to authenticate with OPA. */ @QuarkusTest -@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class) +@TestProfile(OpaTestProfiles.FileToken.class) public class OpaFileTokenIntegrationTest extends OpaIntegrationTestBase { - /** - * Test profile for OPA integration with file-based bearer token authentication. The OPA container - * runs with HTTP for simplicity in CI environments. - */ - public static class FileTokenOpaProfile implements QuarkusTestProfile { - // Static field to hold token file path for test access - public static Path tokenFilePath; - - @Override - public Map getConfigOverrides() { - try { - // Create token file early so SmallRye Config validation sees the property - tokenFilePath = Files.createTempFile("opa-test-token", ".txt"); - Files.writeString(tokenFilePath, "test-opa-bearer-token-from-file"); - - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - - // Configure file-based bearer token authentication - config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put( - "polaris.authorization.opa.auth.bearer.file-based.path", tokenFilePath.toString()); - config.put("polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "PT1S"); - - return config; - } catch (IOException e) { - throw new RuntimeException("Failed to create test token file", e); - } - } - - @Override - public List testResources() { - return List.of(new TestResourceEntry(OpaTestResource.class)); - } - } - @Test void testOpaAllowsRootUser() { String rootToken = getRootToken(); diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaGenericTableHandlerIT.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaGenericTableHandlerIT.java new file mode 100644 index 0000000000..54a8d41a45 --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaGenericTableHandlerIT.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa.test; + +import static io.restassured.RestAssured.given; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * OPA authorization coverage for generic table endpoints: + * + *
    + *
  • List generic tables + *
  • Create generic table + *
  • Drop generic table + *
+ */ +@QuarkusTest +@TestProfile(OpaTestProfiles.StaticToken.class) +public class OpaGenericTableHandlerIT extends OpaIntegrationTestBase { + + private String catalogName; + private String namespace; + private String baseLocation; + private String rootToken; + + @BeforeEach + void setupBaseCatalog(@TempDir Path tempDir) throws Exception { + rootToken = getRootToken(); + catalogName = "opa-gt-" + UUID.randomUUID().toString().replace("-", ""); + namespace = "ns_" + UUID.randomUUID().toString().replace("-", ""); + Path warehouse = tempDir.resolve("warehouse"); + Files.createDirectory(warehouse); + baseLocation = warehouse.toUri().toString(); + createFileCatalog(rootToken, catalogName, baseLocation, List.of(baseLocation)); + createNamespace(rootToken, catalogName, namespace); + } + + @Test + void genericTableCreateAndDropAuthorization() throws Exception { + String rootToken = this.rootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + String tableName = "gt_" + UUID.randomUUID().toString().replace("-", ""); + + Map tablePayload = + Map.of("name", tableName, "format", "ICEBERG", "doc", "doc", "properties", Map.of()); + + // Stranger cannot list generic tables + given() + .header("Authorization", "Bearer " + strangerToken) + .get("/api/catalog/polaris/v1/{cat}/namespaces/{ns}/generic-tables", catalogName, namespace) + .then() + .statusCode(403); + + // Root lists generic tables (initially empty) + given() + .header("Authorization", "Bearer " + rootToken) + .get("/api/catalog/polaris/v1/{cat}/namespaces/{ns}/generic-tables", catalogName, namespace) + .then() + .statusCode(200); + + // Stranger cannot create generic table + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + strangerToken) + .body(toJson(tablePayload)) + .post( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/generic-tables", catalogName, namespace) + .then() + .statusCode(403); + + // Root creates generic table + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(tablePayload)) + .post( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/generic-tables", catalogName, namespace) + .then() + .statusCode(200); + + // Root drops generic table + given() + .header("Authorization", "Bearer " + rootToken) + .delete( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/generic-tables/{table}", + catalogName, + namespace, + tableName) + .then() + .statusCode(204); + } +} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIcebergCatalogHandlerIT.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIcebergCatalogHandlerIT.java new file mode 100644 index 0000000000..882de5adfc --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIcebergCatalogHandlerIT.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa.test; + +import static io.restassured.RestAssured.given; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableMetadataParser; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * OPA authorization coverage for Iceberg catalog endpoints: + * + *
    + *
  • List namespaces + *
  • Register/create table + *
  • Drop table + *
+ */ +@QuarkusTest +@TestProfile(OpaTestProfiles.StaticToken.class) +public class OpaIcebergCatalogHandlerIT extends OpaIntegrationTestBase { + + private String catalogName; + private String namespace; + private String baseLocation; + private String rootToken; + + @BeforeEach + void setupBaseCatalog(@TempDir Path tempDir) throws Exception { + rootToken = getRootToken(); + catalogName = "opa-iceberg-" + UUID.randomUUID().toString().replace("-", ""); + namespace = "ns_" + UUID.randomUUID().toString().replace("-", ""); + Path warehouse = tempDir.resolve("warehouse"); + Files.createDirectory(warehouse); + baseLocation = warehouse.toUri().toString(); + String allowedNamespacePath = + baseLocation + (baseLocation.endsWith("/") ? "" : "/") + namespace; + createFileCatalog( + rootToken, + catalogName, + baseLocation, + List.of(allowedNamespacePath, allowedNamespacePath + "/")); + // base namespace for list assertions + createNamespace(rootToken, catalogName, namespace); + } + + @Test + void tableCreateAndDropAuthorization() throws Exception { + String rootToken = this.rootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + String tableName = "tbl_" + UUID.randomUUID().toString().replace("-", ""); + + // Stranger cannot list namespaces for the catalog + given() + .header("Authorization", "Bearer " + strangerToken) + .get("/api/catalog/v1/{cat}/namespaces", catalogName) + .then() + .statusCode(403); + + // Root can list namespaces + given() + .header("Authorization", "Bearer " + rootToken) + .get("/api/catalog/v1/{cat}/namespaces", catalogName) + .then() + .statusCode(200); + + Map createTableRequest = + buildCreateTableRequest(tableName, baseLocation, namespace); + + // Stranger cannot register table + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + strangerToken) + .body(toJson(createTableRequest)) + .post("/api/catalog/v1/{cat}/namespaces/{ns}/register", catalogName, namespace) + .then() + .statusCode(403); + + // Root registers table + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(createTableRequest)) + .post("/api/catalog/v1/{cat}/namespaces/{ns}/register", catalogName, namespace) + .then() + .statusCode(200); + + // Root drops table + given() + .header("Authorization", "Bearer " + rootToken) + .delete( + "/api/catalog/v1/{cat}/namespaces/{ns}/tables/{tbl}", catalogName, namespace, tableName) + .then() + .statusCode(204); + } + + private Map buildCreateTableRequest( + String tableName, String baseLocation, String namespace) throws Exception { + String tableLocation = + baseLocation + (baseLocation.endsWith("/") ? "" : "/") + namespace + "/" + tableName; + Path metadataPath = + Path.of( + URI.create( + tableLocation + + (tableLocation.endsWith("/") ? "" : "/") + + "metadata/v1.metadata.json")); + Files.createDirectories(metadataPath.getParent()); + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.unpartitioned(); + TableMetadata tableMetadata = + TableMetadata.newTableMetadata(schema, spec, tableLocation, Map.of()); + String metadataJson = TableMetadataParser.toJson(tableMetadata); + Files.writeString(metadataPath, metadataJson); + + return Map.of( + "name", + tableName, + "metadata-location", + metadataPath.toUri().toString(), + "stage-create", + false); + } +} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java index 2156f696ee..1cf4cb6deb 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java @@ -22,44 +22,13 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; import io.quarkus.test.junit.TestProfile; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; @QuarkusTest -@TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class) +@TestProfile(OpaTestProfiles.StaticToken.class) public class OpaIntegrationTest extends OpaIntegrationTestBase { - /** - * Test demonstrates OPA integration with bearer token authentication. The OPA container runs with - * HTTP for simplicity in CI environments. The OpaPolarisAuthorizer is configured to disable SSL - * verification for test purposes. - */ - public static class StaticTokenOpaProfile implements QuarkusTestProfile { - @Override - public Map getConfigOverrides() { - Map config = new HashMap<>(); - config.put("polaris.authorization.type", "opa"); - - // Configure static token authentication - config.put("polaris.authorization.opa.auth.type", "bearer"); - config.put( - "polaris.authorization.opa.auth.bearer.static-token.value", - "test-opa-bearer-token-12345"); - - return config; - } - - @Override - public List testResources() { - return List.of(new TestResourceEntry(OpaTestResource.class)); - } - } - @Test void testOpaAllowsRootUser() { String rootToken = getRootToken(); diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java index 9bbd326d9e..aa121c521f 100644 --- a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java @@ -21,12 +21,33 @@ import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.fail; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; +import io.restassured.http.ContentType; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; + /** * Base class for OPA integration tests providing common helper methods for authentication and * principal management. */ public abstract class OpaIntegrationTestBase { + private static final JsonMapper mapper = JsonMapper.builder().build(); + private final List catalogsToCleanup = new ArrayList<>(); + + protected String toJson(Object value) { + try { + return mapper.writeValueAsString(value); + } catch (java.io.IOException e) { + throw new UncheckedIOException("Failed to serialize to JSON", e); + } + } + /** * Helper method to get root access token using the default test admin credentials. * @@ -66,11 +87,13 @@ protected String createPrincipalAndGetToken(String principalName) { String rootToken = getRootToken(); // Create the principal using the root token + Map createPrincipalBody = + Map.of("principal", Map.of("name", principalName, "properties", Map.of())); String createResponse = given() .contentType("application/json") .header("Authorization", "Bearer " + rootToken) - .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}") + .body(toJson(createPrincipalBody)) .when() .post("/api/management/v1/principals") .then() @@ -119,13 +142,79 @@ protected String createPrincipalAndGetToken(String principalName) { * @return the extracted value, or null if not found */ protected String extractJsonValue(String json, String key) { - String searchKey = "\"" + key + "\""; - if (json.contains(searchKey)) { - String value = json.substring(json.indexOf(searchKey) + searchKey.length()); - value = value.substring(value.indexOf("\"") + 1); - value = value.substring(0, value.indexOf("\"")); - return value; + try { + JsonNode valueNode = mapper.readTree(json).findValue(key); + if (valueNode == null || valueNode.isMissingNode() || valueNode.isNull()) { + return null; + } + return valueNode.asText(); + } catch (java.io.IOException e) { + throw new UncheckedIOException("Failed to parse JSON response", e); } - return null; + } + + @AfterEach + void cleanupCatalogs() { + // Use root token for cleanup to avoid cascading auth failures + String rootToken; + try { + rootToken = getRootToken(); + } catch (Exception e) { + return; + } + List reversed = new ArrayList<>(catalogsToCleanup); + Collections.reverse(reversed); + for (String catalog : reversed) { + try { + given() + .header("Authorization", "Bearer " + rootToken) + .delete("/api/management/v1/catalogs/{cat}", catalog) + .then() + .statusCode(org.hamcrest.Matchers.anything()); + } catch (Exception ignored) { + // best effort + } + } + catalogsToCleanup.clear(); + } + + protected String createFileCatalog( + String token, String catalogName, String baseLocation, List allowedLocations) { + // Create a File Catalog to use for testing + Map body = + Map.of( + "type", + "INTERNAL", + "name", + catalogName, + "properties", + Map.of("default-base-location", baseLocation), + "storageConfigInfo", + Map.of("storageType", "FILE", "allowedLocations", allowedLocations)); + + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(toJson(body)) + .post("/api/management/v1/catalogs") + .then() + .statusCode(201); + catalogsToCleanup.add(catalogName); + return baseLocation; + } + + protected void registerCatalogForCleanup(String catalogName) { + catalogsToCleanup.add(catalogName); + } + + protected void createNamespace(String token, String catalogName, String namespace) { + Map namespaceBody = Map.of("namespace", List.of(namespace)); + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + token) + .body(toJson(namespaceBody)) + .post("/api/catalog/v1/{cat}/namespaces", catalogName) + .then() + .statusCode(200); } } diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaPolicyCatalogHandlerIT.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaPolicyCatalogHandlerIT.java new file mode 100644 index 0000000000..6400a47c04 --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaPolicyCatalogHandlerIT.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa.test; + +import static io.restassured.RestAssured.given; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * OPA authorization coverage for catalog policy endpoints: + * + *
    + *
  • List/create policies + *
  • Attach/detach policy mappings + *
  • Delete policies + *
+ */ +@QuarkusTest +@TestProfile(OpaTestProfiles.StaticToken.class) +public class OpaPolicyCatalogHandlerIT extends OpaIntegrationTestBase { + + private String catalogName; + private String namespace; + private String baseLocation; + private String rootToken; + + @BeforeEach + void setupBaseCatalog(@TempDir Path tempDir) throws Exception { + rootToken = getRootToken(); + catalogName = "opa-policy-" + UUID.randomUUID().toString().replace("-", ""); + namespace = "ns_" + UUID.randomUUID().toString().replace("-", ""); + Path warehouse = tempDir.resolve("warehouse"); + Files.createDirectory(warehouse); + baseLocation = warehouse.toUri().toString(); + createFileCatalog(rootToken, catalogName, baseLocation, List.of(baseLocation)); + createNamespace(rootToken, catalogName, namespace); + } + + @Test + void policyListAndAttachAuthorization() throws Exception { + String rootToken = this.rootToken; + String strangerToken = createPrincipalAndGetToken("stranger-" + UUID.randomUUID()); + String policyName = "pol_" + UUID.randomUUID().toString().replace("-", ""); + + Map createPolicyRequest = + Map.of( + "name", policyName, + "type", "system.data-compaction", + "description", "opa policy test", + "content", + """ + { + "version":"2025-02-03", + "enable":true, + "config":{"target_file_size_bytes":134217728} + } + """); + + // Root creates policy + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(createPolicyRequest)) + .post("/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies", catalogName, namespace) + .then() + .statusCode(200); + + // Stranger cannot list policies + given() + .header("Authorization", "Bearer " + strangerToken) + .get("/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies", catalogName, namespace) + .then() + .statusCode(403); + + // Root lists policies + given() + .header("Authorization", "Bearer " + rootToken) + .get("/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies", catalogName, namespace) + .then() + .statusCode(200); + + Map attachRequest = + Map.of("target", Map.of("type", "catalog", "path", List.of()), "parameters", Map.of()); + + // Stranger cannot attach policy + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + strangerToken) + .body(toJson(attachRequest)) + .put( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies/{policy}/mappings", + catalogName, + namespace, + policyName) + .then() + .statusCode(403); + + // Root attaches policy to catalog + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(attachRequest)) + .put( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies/{policy}/mappings", + catalogName, + namespace, + policyName) + .then() + .statusCode(204); + + // Detach policy for cleanup + given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer " + rootToken) + .body(toJson(attachRequest)) + .post( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies/{policy}/mappings", + catalogName, + namespace, + policyName) + .then() + .statusCode(204); + + // Delete policy + given() + .header("Authorization", "Bearer " + rootToken) + .delete( + "/api/catalog/polaris/v1/{cat}/namespaces/{ns}/policies/{policy}", + catalogName, + namespace, + policyName) + .then() + .statusCode(204); + } +} diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestProfiles.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestProfiles.java new file mode 100644 index 0000000000..31276cce5a --- /dev/null +++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestProfiles.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.extension.auth.opa.test; + +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry; +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; + +/** Shared Quarkus test profiles for OPA integration tests. */ +public final class OpaTestProfiles { + + private OpaTestProfiles() {} + + /** OPA profile using a static bearer token. */ + public static class StaticToken implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put( + "polaris.authorization.opa.auth.bearer.static-token.value", + "test-opa-bearer-token-12345"); + config.put("polaris.features.\"SUPPORTED_CATALOG_STORAGE_TYPES\"", "[\"FILE\"]"); + config.put("polaris.features.\"ALLOW_INSECURE_STORAGE_TYPES\"", "true"); + config.put("polaris.readiness.ignore-severe-issues", "true"); + return config; + } + + @Override + public List testResources() { + return List.of(new TestResourceEntry(OpaTestResource.class)); + } + } + + /** OPA profile using a bearer token read from a file. */ + public static class FileToken implements QuarkusTestProfile { + // Exposed for tests that may need to inspect the created token file. + public static Path tokenFilePath; + + @Override + public Map getConfigOverrides() { + try { + tokenFilePath = Files.createTempFile("opa-test-token", ".txt"); + Files.writeString(tokenFilePath, "test-opa-bearer-token-from-file"); + + Map config = new HashMap<>(); + config.put("polaris.authorization.type", "opa"); + config.put("polaris.authorization.opa.auth.type", "bearer"); + config.put( + "polaris.authorization.opa.auth.bearer.file-based.path", tokenFilePath.toString()); + config.put("polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "PT1S"); + config.put("polaris.features.\"SUPPORTED_CATALOG_STORAGE_TYPES\"", "[\"FILE\"]"); + config.put("polaris.features.\"ALLOW_INSECURE_STORAGE_TYPES\"", "true"); + config.put("polaris.readiness.ignore-severe-issues", "true"); + return config; + } catch (IOException e) { + throw new RuntimeException("Failed to create test token file", e); + } + } + + @Override + public List testResources() { + return List.of(new TestResourceEntry(OpaTestResource.class)); + } + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java index cc3c9aeeb2..727fbd67f5 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java @@ -1290,6 +1290,90 @@ public void testRevokeCatalogRoleFromPrincipalRoleInsufficientPrivileges() { PRINCIPAL_ROLE1, privilege)); } + @Test + public void testListAssigneePrincipalRolesForCatalogRoleSufficientPrivileges() { + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.CATALOG_ROLE_LIST_GRANTS, + PolarisPrivilege.CATALOG_ROLE_MANAGE_GRANTS_ON_SECURABLE, + PolarisPrivilege.CATALOG_ROLE_MANAGE_GRANTS_FOR_GRANTEE, + PolarisPrivilege.CATALOG_MANAGE_ACCESS), + () -> + newTestAdminService(Set.of(PRINCIPAL_ROLE1)) + .listAssigneePrincipalRolesForCatalogRole(CATALOG_NAME, CATALOG_ROLE2), + null, // cleanupAction + privilege -> + adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + privilege -> + adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + } + + @Test + public void testListAssigneePrincipalRolesForCatalogRoleInsufficientPrivileges() { + doTestInsufficientPrivileges( + List.of( + PolarisPrivilege.SERVICE_MANAGE_ACCESS, + PolarisPrivilege.CATALOG_ROLE_LIST, + PolarisPrivilege.CATALOG_ROLE_READ_PROPERTIES, + PolarisPrivilege.CATALOG_ROLE_WRITE_PROPERTIES, + PolarisPrivilege.CATALOG_ROLE_CREATE, + PolarisPrivilege.CATALOG_ROLE_DROP, + PolarisPrivilege.CATALOG_LIST_GRANTS, + PolarisPrivilege.PRINCIPAL_FULL_METADATA, + PolarisPrivilege.PRINCIPAL_ROLE_FULL_METADATA, + PolarisPrivilege.CATALOG_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> + newTestAdminService(Set.of(PRINCIPAL_ROLE1)) + .listAssigneePrincipalRolesForCatalogRole(CATALOG_NAME, CATALOG_ROLE2), + privilege -> + adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + privilege -> + adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + } + + @Test + public void testListGrantsForCatalogRoleSufficientPrivileges() { + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.CATALOG_ROLE_LIST_GRANTS, + PolarisPrivilege.CATALOG_ROLE_MANAGE_GRANTS_ON_SECURABLE, + PolarisPrivilege.CATALOG_ROLE_MANAGE_GRANTS_FOR_GRANTEE, + PolarisPrivilege.CATALOG_MANAGE_ACCESS), + () -> + newTestAdminService(Set.of(PRINCIPAL_ROLE1)) + .listGrantsForCatalogRole(CATALOG_NAME, CATALOG_ROLE2), + null, // cleanupAction + privilege -> + adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + privilege -> + adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + } + + @Test + public void testListGrantsForCatalogRoleInsufficientPrivileges() { + doTestInsufficientPrivileges( + List.of( + PolarisPrivilege.SERVICE_MANAGE_ACCESS, + PolarisPrivilege.CATALOG_ROLE_LIST, + PolarisPrivilege.CATALOG_ROLE_READ_PROPERTIES, + PolarisPrivilege.CATALOG_ROLE_WRITE_PROPERTIES, + PolarisPrivilege.CATALOG_ROLE_CREATE, + PolarisPrivilege.CATALOG_ROLE_DROP, + PolarisPrivilege.CATALOG_LIST_GRANTS, + PolarisPrivilege.PRINCIPAL_FULL_METADATA, + PolarisPrivilege.PRINCIPAL_ROLE_FULL_METADATA, + PolarisPrivilege.CATALOG_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> + newTestAdminService(Set.of(PRINCIPAL_ROLE1)) + .listGrantsForCatalogRole(CATALOG_NAME, CATALOG_ROLE2), + privilege -> + adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + privilege -> + adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + } + @Test public void testGrantPrivilegeOnRootContainerToPrincipalRoleSufficientPrivileges() { doTestSufficientPrivileges(