From 3beebab387c0c1396fced5bd9055f6fb393d621a Mon Sep 17 00:00:00 2001 From: Prashant Date: Sat, 12 Jul 2025 18:58:45 -0700 Subject: [PATCH 1/2] FGAC policies --- .../core/policy/PredefinedPolicyTypes.java | 3 +- .../content/AccessControlPolicyContent.java | 106 +++++++++ .../IcebergExpressionListDeserializer.java | 49 +++++ .../policy/content/PolicyContentUtil.java | 3 + .../policy/validator/PolicyValidators.java | 4 + .../AccessControlPolicyContentTest.java | 208 ++++++++++++++++++ .../quarkus/admin/PolarisAuthzTestBase.java | 3 +- .../quarkus/catalog/PolicyCatalogTest.java | 3 +- .../service/catalog/policy/PolicyCatalog.java | 28 ++- .../catalog/policy/PolicyCatalogHandler.java | 3 +- 10 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListDeserializer.java create mode 100644 polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/PredefinedPolicyTypes.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/PredefinedPolicyTypes.java index 6cb86eb52f..bc2d397295 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/policy/PredefinedPolicyTypes.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/PredefinedPolicyTypes.java @@ -28,7 +28,8 @@ public enum PredefinedPolicyTypes implements PolicyType { DATA_COMPACTION(0, "system.data-compaction", true), METADATA_COMPACTION(1, "system.metadata-compaction", true), ORPHAN_FILE_REMOVAL(2, "system.orphan-file-removal", true), - SNAPSHOT_EXPIRY(3, "system.snapshot-expiry", true); + SNAPSHOT_EXPIRY(3, "system.snapshot-expiry", true), + ACCESS_CONTROL(4, "system.access-control", false); private final int code; private final String name; diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java new file mode 100644 index 0000000000..1f907a5d2b --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java @@ -0,0 +1,106 @@ +/* + * 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.policy.content; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.base.Strings; +import java.util.List; +import java.util.Set; +import org.apache.iceberg.expressions.Expression; +import org.apache.polaris.core.policy.validator.InvalidPolicyException; + +public class AccessControlPolicyContent implements PolicyContent { + + // Optional, if there means policies is applicable to the role + private String principalRole; + + // TODO: model them as iceberg transforms + private List columnProjections; + + // Iceberg expressions without context functions for now. + // Use a custom deserializer for the list of Iceberg Expressions + @JsonDeserialize(using = IcebergExpressionListDeserializer.class) + private List rowFilters; + + private static final String DEFAULT_POLICY_SCHEMA_VERSION = "2025-02-03"; + private static final Set POLICY_SCHEMA_VERSIONS = Set.of(DEFAULT_POLICY_SCHEMA_VERSION); + + public static AccessControlPolicyContent fromString(String content) { + if (Strings.isNullOrEmpty(content)) { + throw new InvalidPolicyException("Policy is empty"); + } + + AccessControlPolicyContent policy; + try { + policy = PolicyContentUtil.MAPPER.readValue(content, AccessControlPolicyContent.class); + } catch (Exception e) { + throw new InvalidPolicyException(e); + } + + boolean isProjectionsEmpty = + policy.getColumnProjections() == null || policy.getColumnProjections().isEmpty(); + boolean isRowFilterEmpty = policy.getRowFilters() == null || policy.getRowFilters().isEmpty(); + if (isProjectionsEmpty && isRowFilterEmpty) { + throw new InvalidPolicyException("Policy must contain 'columnProjections' or 'rowFilters'."); + } + + return policy; + } + + // Constructors, getters, and setters + public AccessControlPolicyContent() {} + + public String getPrincipalRole() { + return principalRole; + } + + public void setPrincipalRole(String principalRole) { + this.principalRole = principalRole; + } + + public List getColumnProjections() { + return columnProjections; + } + + public void setAllowedColumns(List columnProjections) { + this.columnProjections = columnProjections; + } + + public List getRowFilters() { + return rowFilters; + } + + public void setRowFilters(List rowFilters) { + this.rowFilters = rowFilters; + } + + @Override + public String toString() { + return "AccessControlPolicyContent{" + + "principalRole='" + + principalRole + + '\'' + + ", columnProjections=" + + columnProjections + + ", rowFilters=" + + rowFilters + + '}'; + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListDeserializer.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListDeserializer.java new file mode 100644 index 0000000000..b4eaa1a34d --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListDeserializer.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.polaris.core.policy.content; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionParser; + +public class IcebergExpressionListDeserializer extends JsonDeserializer> { + @Override + public List deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + ObjectMapper mapper = (ObjectMapper) p.getCodec(); + JsonNode node = mapper.readTree(p); + + List expressions = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + // Convert each JSON element back to a string and pass it to ExpressionParser.fromJson + expressions.add(ExpressionParser.fromJson(mapper.writeValueAsString(element))); + } + } + return expressions; + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/PolicyContentUtil.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/PolicyContentUtil.java index 2ba025a663..274bba1c38 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/PolicyContentUtil.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/PolicyContentUtil.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.iceberg.rest.RESTSerializers; public class PolicyContentUtil { public static final ObjectMapper MAPPER = configureMapper(); @@ -30,6 +31,8 @@ private static ObjectMapper configureMapper() { mapper.configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, true); // Fails if a required field is present but explicitly null, e.g., {"enable": null} mapper.configure(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES, true); + // This will make sure all the Iceberg parsers are loaded. + RESTSerializers.registerAll(mapper); return mapper; } } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java index 3be2387833..aa0d7ab09b 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java @@ -22,6 +22,7 @@ import org.apache.polaris.core.entity.PolarisEntity; import org.apache.polaris.core.policy.PolicyEntity; import org.apache.polaris.core.policy.PredefinedPolicyTypes; +import org.apache.polaris.core.policy.content.AccessControlPolicyContent; import org.apache.polaris.core.policy.content.maintenance.DataCompactionPolicyContent; import org.apache.polaris.core.policy.content.maintenance.MetadataCompactionPolicyContent; import org.apache.polaris.core.policy.content.maintenance.OrphanFileRemovalPolicyContent; @@ -66,6 +67,9 @@ public static void validate(PolicyEntity policy) { case ORPHAN_FILE_REMOVAL: OrphanFileRemovalPolicyContent.fromString(policy.getContent()); break; + case ACCESS_CONTROL: + AccessControlPolicyContent.fromString(policy.getContent()); + break; default: throw new IllegalArgumentException("Unsupported policy type: " + type.getName()); } diff --git a/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java b/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java new file mode 100644 index 0000000000..d15f5fa42c --- /dev/null +++ b/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java @@ -0,0 +1,208 @@ +/* + * 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.policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import org.apache.iceberg.expressions.Expression; +import org.apache.polaris.core.policy.content.AccessControlPolicyContent; +import org.apache.polaris.core.policy.validator.InvalidPolicyException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AccessControlPolicyContentTest { + @Test + @DisplayName("Should deserialize a full policy with all fields correctly") + void testFromString_fullPolicy() { + String jsonContent = + "{\n" + + " \"principalRole\": \"ANALYST\",\n" + + " \"columnProjections\": [\"name\", \"location\"],\n" + + " \"rowFilters\": [\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"country\",\n" + + " \"value\": \"USA\"\n" + + " },\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"name\",\n" + + " \"value\": \"PK\"\n" + + " }\n" + + " ]\n" + + "}"; + + AccessControlPolicyContent policy = AccessControlPolicyContent.fromString(jsonContent); + + assertNotNull(policy); + assertEquals("ANALYST", policy.getPrincipalRole()); + assertEquals(Arrays.asList("name", "location"), policy.getColumnProjections()); + + // Validate rowFilters + assertNotNull(policy.getRowFilters()); + assertEquals(2, policy.getRowFilters().size()); + + Expression filter1 = policy.getRowFilters().get(0); + Expression filter2 = policy.getRowFilters().get(1); + assertEquals("ref(name=\"country\") == \"USA\"", filter1.toString()); + assertEquals("ref(name=\"name\") == \"PK\"", filter2.toString()); + } + + @Test + @DisplayName( + "Should fail deserialize policy with only required fields (principalRole empty, lists empty/null)") + void testFromString_minimalPolicy() { + String jsonContent = + "{\n" + + " \"principalRole\": null,\n" + + " \"columnProjections\": [],\n" + + " \"rowFilters\": []\n" + + "}"; + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(jsonContent); + }); + } + + @Test + void testFromString_missingOptionalFields() { + String jsonContent = "{\n" + " \"principalRole\": \"DATA_ENGINEER\"\n" + "}"; + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(jsonContent); + }); + } + + @Test + @DisplayName("Should throw InvalidPolicyException for empty content") + void testFromString_emptyContent() { + String emptyContent = ""; + InvalidPolicyException exception = + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(emptyContent); + }); + assertEquals("Policy is empty", exception.getMessage()); + } + + @Test + @DisplayName("Should throw InvalidPolicyException for null content") + void testFromString_nullContent() { + String nullContent = null; + InvalidPolicyException exception = + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(nullContent); + }); + assertEquals("Policy is empty", exception.getMessage()); + } + + @Test + @DisplayName("Should throw InvalidPolicyException for invalid JSON content") + void testFromString_invalidJson() { + String invalidJson = + "{ \"principalRole\": \"ANALYST\", \"columnProjections\": [\"name\", }"; // Malformed JSON + InvalidPolicyException exception = + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(invalidJson); + }); + assertNotNull( + exception.getCause()); // Check that the cause is set (Jackson's JsonParseException) + assertTrue(exception.getCause() instanceof com.fasterxml.jackson.core.JsonProcessingException); + } + + @Test + @DisplayName( + "Should handle rowFilters with different Iceberg expression types (if supported by parser)") + void testFromString_differentRowFilterTypes() { + // This test assumes your ExpressionParser can handle various types. + // Our dummy only handles 'eq' for now, but in a real scenario, you'd test 'and', 'or', 'gt', + // etc. + String jsonContent = + "{\n" + + " \"principalRole\": \"ADMIN\",\n" + + " \"rowFilters\": [\n" + + " {\n" + + " \"type\": \"gt\",\n" + + // Example of a different type + " \"term\": \"age\",\n" + + " \"value\": \"30\"\n" + + " },\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"status\",\n" + + " \"value\": \"active\"\n" + + " }\n" + + " ]\n" + + "}"; + + AccessControlPolicyContent policy = AccessControlPolicyContent.fromString(jsonContent); + assertNotNull(policy); + assertEquals(2, policy.getRowFilters().size()); + Expression filter1 = policy.getRowFilters().get(0); + Expression filter2 = policy.getRowFilters().get(1); + assertEquals("ref(name=\"age\") > \"30\"", filter1.toString()); + assertEquals("ref(name=\"status\") == \"active\"", filter2.toString()); + } + + @Test + @DisplayName("Should correctly handle empty lists in JSON") + void testFromString_emptyListsInJson() { + String jsonContent = + "{\n" + + " \"principalRole\": \"VIEWER\",\n" + + " \"columnProjections\": [],\n" + + " \"rowFilters\": []\n" + + "}"; + + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(jsonContent); + }); + } + + @Test + @DisplayName( + "Should handle unmapped properties if FAIL_ON_UNKNOWN_PROPERTIES is false (default for this setup)") + void testFromString_unmappedProperties() { + String jsonContent = + "{\n" + + " \"principalRole\": \"ANALYST\",\n" + + " \"extraField\": \"someValue\"\n" + + // Extra field + "}"; + assertThrows( + InvalidPolicyException.class, + () -> { + AccessControlPolicyContent.fromString(jsonContent); + }); + } +} diff --git a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java index 536e994b9d..1a3113188c 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java @@ -487,7 +487,8 @@ private void initBaseCatalog() { this.genericTableCatalog = new PolarisGenericTableCatalog(metaStoreManager, callContext, passthroughView); this.genericTableCatalog.initialize(CATALOG_NAME, ImmutableMap.of()); - this.policyCatalog = new PolicyCatalog(metaStoreManager, callContext, passthroughView); + this.policyCatalog = + new PolicyCatalog(metaStoreManager, securityContext, callContext, passthroughView); } @Alternative diff --git a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java index cadfab7131..39b0836d04 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java @@ -284,7 +284,8 @@ public void before(TestInfo testInfo) { isA(AwsStorageConfigurationInfo.class))) .thenReturn((PolarisStorageIntegration) storageIntegration); - this.policyCatalog = new PolicyCatalog(metaStoreManager, callContext, passthroughView); + this.policyCatalog = + new PolicyCatalog(metaStoreManager, securityContext, callContext, passthroughView); this.icebergCatalog = new IcebergCatalog( entityManager, diff --git a/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java b/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java index e0edebfc64..bf086ca8d6 100644 --- a/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java +++ b/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java @@ -20,10 +20,12 @@ import static org.apache.polaris.core.persistence.dao.entity.BaseResult.ReturnStatus.POLICY_HAS_MAPPINGS; import static org.apache.polaris.core.persistence.dao.entity.BaseResult.ReturnStatus.POLICY_MAPPING_OF_SAME_TYPE_ALREADY_EXISTS; +import static org.apache.polaris.core.policy.PredefinedPolicyTypes.ACCESS_CONTROL; import static org.apache.polaris.service.types.PolicyAttachmentTarget.TypeEnum.CATALOG; import com.google.common.base.Strings; import jakarta.annotation.Nonnull; +import jakarta.ws.rs.core.SecurityContext; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -40,6 +42,7 @@ import org.apache.iceberg.exceptions.BadRequestException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; import org.apache.polaris.core.context.CallContext; import org.apache.polaris.core.entity.CatalogEntity; import org.apache.polaris.core.entity.PolarisEntity; @@ -54,6 +57,7 @@ import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifestCatalogView; import org.apache.polaris.core.policy.PolicyEntity; import org.apache.polaris.core.policy.PolicyType; +import org.apache.polaris.core.policy.content.AccessControlPolicyContent; import org.apache.polaris.core.policy.exceptions.NoSuchPolicyException; import org.apache.polaris.core.policy.exceptions.PolicyAttachException; import org.apache.polaris.core.policy.exceptions.PolicyInUseException; @@ -70,6 +74,7 @@ public class PolicyCatalog { private static final Logger LOGGER = LoggerFactory.getLogger(PolicyCatalog.class); private final CallContext callContext; + private final AuthenticatedPolarisPrincipal authenticatedPrincipal; private final PolarisResolutionManifestCatalogView resolvedEntityView; private final CatalogEntity catalogEntity; private long catalogId = -1; @@ -77,9 +82,12 @@ public class PolicyCatalog { public PolicyCatalog( PolarisMetaStoreManager metaStoreManager, + SecurityContext securityContext, CallContext callContext, PolarisResolutionManifestCatalogView resolvedEntityView) { this.callContext = callContext; + this.authenticatedPrincipal = + (AuthenticatedPolarisPrincipal) securityContext.getUserPrincipal(); this.resolvedEntityView = resolvedEntityView; this.catalogEntity = CatalogEntity.of(resolvedEntityView.getResolvedReferenceCatalogEntity().getRawLeafEntity()); @@ -420,8 +428,11 @@ private List getEffectivePolicies( } return Stream.concat( - nonInheritablePolicies.stream().map(policy -> constructApplicablePolicy(policy, false)), + nonInheritablePolicies.stream() + .filter(policy -> filterApplicablePolicy(policy)) + .map(policy -> constructApplicablePolicy(policy, false)), inheritablePolicies.values().stream() + .filter(policy -> filterApplicablePolicy(policy)) .map( policy -> constructApplicablePolicy( @@ -525,6 +536,21 @@ private static Policy constructPolicy(PolicyEntity policyEntity) { .build(); } + private boolean filterApplicablePolicy(PolicyEntity policyEntity) { + // check the type + if (policyEntity.getPolicyType().equals(ACCESS_CONTROL)) { + AccessControlPolicyContent content = + AccessControlPolicyContent.fromString(policyEntity.getContent()); + String applicablePrincipal = content.getPrincipalRole(); + return applicablePrincipal == null + || authenticatedPrincipal + .getActivatedPrincipalRoleNames() + .contains(content.getPrincipalRole()); + } + + return true; + } + private static ApplicablePolicy constructApplicablePolicy( PolicyEntity policyEntity, boolean inherited) { Namespace parentNamespace = policyEntity.getParentNamespace(); diff --git a/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalogHandler.java b/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalogHandler.java index 5a2cc7a530..82130e2f9f 100644 --- a/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalogHandler.java +++ b/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalogHandler.java @@ -72,7 +72,8 @@ public PolicyCatalogHandler( @Override protected void initializeCatalog() { - this.policyCatalog = new PolicyCatalog(metaStoreManager, callContext, this.resolutionManifest); + this.policyCatalog = + new PolicyCatalog(metaStoreManager, securityContext, callContext, this.resolutionManifest); } public ListPoliciesResponse listPolicies(Namespace parent, PolicyType policyType) { From 85ba5266d4ef2c63598d9d7050cec3a1e11c12b2 Mon Sep 17 00:00:00 2001 From: Prashant Date: Sat, 12 Jul 2025 19:36:28 -0700 Subject: [PATCH 2/2] Replace context --- .../polaris/core/policy/PolicyUtil.java | 108 ++++++++++++++++++ .../content/AccessControlPolicyContent.java | 14 +++ .../IcebergExpressionListSerializer.java | 50 ++++++++ .../policy/validator/PolicyValidators.java | 4 + .../AccessControlPolicyContentTest.java | 99 ++++++++++++++++ .../quarkus/catalog/PolicyCatalogTest.java | 52 ++++++++- .../service/catalog/policy/PolicyCatalog.java | 29 ++--- 7 files changed, 333 insertions(+), 23 deletions(-) create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/policy/PolicyUtil.java create mode 100644 polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListSerializer.java diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/PolicyUtil.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/PolicyUtil.java new file mode 100644 index 0000000000..e65de402ce --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/PolicyUtil.java @@ -0,0 +1,108 @@ +/* + * 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.policy; + +import static org.apache.polaris.core.policy.PredefinedPolicyTypes.ACCESS_CONTROL; + +import com.google.common.collect.Lists; +import java.util.List; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.UnboundPredicate; +import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; +import org.apache.polaris.core.policy.content.AccessControlPolicyContent; + +public class PolicyUtil { + private PolicyUtil() {} + + public static String replaceContextVariable( + String content, PolicyType policyType, AuthenticatedPolarisPrincipal authenticatedPrincipal) { + if (policyType == ACCESS_CONTROL) { + try { + AccessControlPolicyContent policyContent = AccessControlPolicyContent.fromString(content); + List evaluatedRowFilterExpressions = Lists.newArrayList(); + for (Expression rowFilterExpression : policyContent.getRowFilters()) { + // check if the expression refers to context variable current_principal_role + if (rowFilterExpression instanceof UnboundPredicate) { + UnboundPredicate boundPredicate = (UnboundPredicate) rowFilterExpression; + // check if this references to current_principal + if (boundPredicate.ref().name().equals("$current_principal_role")) { + // compare the literal and replace + String val = (String) boundPredicate.literal().value(); + if (authenticatedPrincipal.getActivatedPrincipalRoleNames().contains(val)) { + // TODO: see if we can utilize the expression evaluation of iceberg SDK + Expression result = + boundPredicate.op().equals(Expression.Operation.EQ) + ? Expressions.alwaysTrue() + : Expressions.alwaysFalse(); + evaluatedRowFilterExpressions.add(result); + } else { + Expression result = + boundPredicate.op().equals(Expression.Operation.NOT_EQ) + ? Expressions.alwaysTrue() + : Expressions.alwaysFalse(); + evaluatedRowFilterExpressions.add(result); + } + } else if (boundPredicate.ref().name().equals("$current_principal")) { + String val = (String) boundPredicate.literal().value(); + if (authenticatedPrincipal.getName().equals(val)) { + Expression result = + boundPredicate.op().equals(Expression.Operation.EQ) + ? Expressions.alwaysTrue() + : Expressions.alwaysFalse(); + evaluatedRowFilterExpressions.add(result); + } else { + Expression result = + boundPredicate.op().equals(Expression.Operation.NOT_EQ) + ? Expressions.alwaysTrue() + : Expressions.alwaysFalse(); + evaluatedRowFilterExpressions.add(result); + } + } else { + evaluatedRowFilterExpressions.add(rowFilterExpression); + } + } + } + + policyContent.setRowFilters(evaluatedRowFilterExpressions); + return AccessControlPolicyContent.toString(policyContent); + } catch (Exception e) { + return content; + } + } + return content; + } + + public static boolean filterApplicablePolicy( + PolicyEntity policyEntity, AuthenticatedPolarisPrincipal authenticatedPrincipal) { + if (policyEntity.getPolicyType().equals(ACCESS_CONTROL)) { + AccessControlPolicyContent content = + AccessControlPolicyContent.fromString(policyEntity.getContent()); + String applicablePrincipal = content.getPrincipalRole(); + return applicablePrincipal == null + || authenticatedPrincipal.getActivatedPrincipalRoleNames().isEmpty() + || authenticatedPrincipal + .getActivatedPrincipalRoleNames() + .contains(content.getPrincipalRole()); + } + + return true; + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java index 1f907a5d2b..589082c606 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/AccessControlPolicyContent.java @@ -19,7 +19,9 @@ package org.apache.polaris.core.policy.content; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.base.Strings; import java.util.List; import java.util.Set; @@ -37,6 +39,7 @@ public class AccessControlPolicyContent implements PolicyContent { // Iceberg expressions without context functions for now. // Use a custom deserializer for the list of Iceberg Expressions @JsonDeserialize(using = IcebergExpressionListDeserializer.class) + @JsonSerialize(using = IcebergExpressionListSerializer.class) private List rowFilters; private static final String DEFAULT_POLICY_SCHEMA_VERSION = "2025-02-03"; @@ -64,6 +67,17 @@ public static AccessControlPolicyContent fromString(String content) { return policy; } + public static String toString(AccessControlPolicyContent content) { + if (content == null) { + return null; + } + try { + return PolicyContentUtil.MAPPER.writeValueAsString(content); + } catch (JsonProcessingException e) { + throw new InvalidPolicyException("Failed to convert policy content to JSON string", e); + } + } + // Constructors, getters, and setters public AccessControlPolicyContent() {} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListSerializer.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListSerializer.java new file mode 100644 index 0000000000..749c78fb91 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/content/IcebergExpressionListSerializer.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.polaris.core.policy.content; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionParser; + +/** + * Custom Jackson JsonSerializer for a List of Iceberg Expression objects. This serializer converts + * each Iceberg Expression into its string representation. + */ +public class IcebergExpressionListSerializer extends JsonSerializer> { + + @Override + public void serialize( + List expressions, + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider) + throws IOException { + jsonGenerator.writeStartArray(); + if (expressions != null) { + for (Expression expression : expressions) { + jsonGenerator.writeString(ExpressionParser.toJson(expression)); + } + } + jsonGenerator.writeEndArray(); + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java b/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java index aa0d7ab09b..6851dc7a4f 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/policy/validator/PolicyValidators.java @@ -102,6 +102,10 @@ public static boolean canAttach(PolicyEntity policy, PolarisEntity targetEntity) case ORPHAN_FILE_REMOVAL: return BaseMaintenancePolicyValidator.INSTANCE.canAttach(entityType, entitySubType); + case ACCESS_CONTROL: + // TODO: Add validator for attaching this only to table + return true; + default: LOGGER.warn("Attachment not supported for policy type: {}", policyType.getName()); return false; diff --git a/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java b/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java index d15f5fa42c..c9e3fde3d6 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/policy/AccessControlPolicyContentTest.java @@ -25,9 +25,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; +import java.util.List; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; import org.apache.polaris.core.policy.content.AccessControlPolicyContent; import org.apache.polaris.core.policy.validator.InvalidPolicyException; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -69,6 +72,44 @@ void testFromString_fullPolicy() { assertEquals("ref(name=\"name\") == \"PK\"", filter2.toString()); } + @Test + @DisplayName( + "Should deserialize a full policy with all fields correctly - with context variables") + void testFromString_fullPolicy_withContextVariable() { + String jsonContent = + "{\n" + + " \"principalRole\": \"ANALYST\",\n" + + " \"columnProjections\": [\"name\", \"location\"],\n" + + " \"rowFilters\": [\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"country\",\n" + + " \"value\": \"USA\"\n" + + " },\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"$current_principal_role\",\n" + + " \"value\": \"PK\"\n" + + " }\n" + + " ]\n" + + "}"; + + AccessControlPolicyContent policy = AccessControlPolicyContent.fromString(jsonContent); + + assertNotNull(policy); + assertEquals("ANALYST", policy.getPrincipalRole()); + assertEquals(Arrays.asList("name", "location"), policy.getColumnProjections()); + + // Validate rowFilters + assertNotNull(policy.getRowFilters()); + assertEquals(2, policy.getRowFilters().size()); + + Expression filter1 = policy.getRowFilters().get(0); + Expression filter2 = policy.getRowFilters().get(1); + assertEquals("ref(name=\"country\") == \"USA\"", filter1.toString()); + assertEquals("ref(name=\"$current_principal_role\") == \"PK\"", filter2.toString()); + } + @Test @DisplayName( "Should fail deserialize policy with only required fields (principalRole empty, lists empty/null)") @@ -189,6 +230,64 @@ void testFromString_emptyListsInJson() { }); } + @Test + void testToString_basicPolicy() { + AccessControlPolicyContent policy = new AccessControlPolicyContent(); + policy.setPrincipalRole("analyst"); + policy.setAllowedColumns(Arrays.asList("col1", "col2")); + policy.setRowFilters(null); + + String expectedJson = + "{\"principalRole\":\"analyst\",\"columnProjections\":[\"col1\",\"col2\"],\"rowFilters\":null}"; + String actualJson = AccessControlPolicyContent.toString(policy); + + assertEquals(expectedJson, actualJson); + } + + @Test + void testToString_policyWithRowFilters() { + AccessControlPolicyContent policy = new AccessControlPolicyContent(); + policy.setPrincipalRole("data_engineer"); + policy.setAllowedColumns(List.of()); + + Expression filter1 = Expressions.equal("name", "Alice"); + Expression filter2 = Expressions.greaterThan("age", 30); + policy.setRowFilters(Arrays.asList(filter1, filter2)); + + String expectedJson = + "{\"principalRole\":\"data_engineer\",\"columnProjections\":[],\"rowFilters\":[\"{\\\"type\\\":\\\"eq\\\",\\\"term\\\":\\\"name\\\",\\\"value\\\":\\\"Alice\\\"}\",\"{\\\"type\\\":\\\"gt\\\",\\\"term\\\":\\\"age\\\",\\\"value\\\":30}\"]}"; + + String actualJson = AccessControlPolicyContent.toString(policy); + + assertEquals(expectedJson, actualJson); + } + + @Test + void testToString_emptyPolicy() { + AccessControlPolicyContent policy = new AccessControlPolicyContent(); + policy.setPrincipalRole(null); + policy.setAllowedColumns(List.of()); + policy.setRowFilters(List.of()); + + String expectedJson = "{\"principalRole\":null,\"columnProjections\":[],\"rowFilters\":[]}"; + String actualJson = AccessControlPolicyContent.toString(policy); + + assertEquals(expectedJson, actualJson); + } + + @Test + void testToString_nullInput() { + String actualJson = AccessControlPolicyContent.toString(null); + Assertions.assertNull(actualJson); + } + + @Test + void testToString_exceptionHandling() { + AccessControlPolicyContent policy = new AccessControlPolicyContent(); + policy.setAllowedColumns(Arrays.asList("id")); + Assertions.assertDoesNotThrow(() -> AccessControlPolicyContent.toString(policy)); + } + @Test @DisplayName( "Should handle unmapped properties if FAIL_ON_UNKNOWN_PROPERTIES is false (default for this setup)") diff --git a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java index 39b0836d04..032f5cdb69 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java @@ -19,6 +19,7 @@ package org.apache.polaris.service.quarkus.catalog; import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.polaris.core.policy.PredefinedPolicyTypes.ACCESS_CONTROL; import static org.apache.polaris.core.policy.PredefinedPolicyTypes.DATA_COMPACTION; import static org.apache.polaris.core.policy.PredefinedPolicyTypes.METADATA_COMPACTION; import static org.apache.polaris.core.policy.PredefinedPolicyTypes.ORPHAN_FILE_REMOVAL; @@ -137,6 +138,24 @@ public Map getConfigOverrides() { required(3, "id", Types.IntegerType.get(), "unique ID"), required(4, "data", Types.StringType.get())); + private static final String EXAMPLE_ACCESS_CONTROL_POLICY_CONTENT = + "{\n" + + " \"principalRole\": \"ANALYST\",\n" + + " \"columnProjections\": [\"name\", \"location\"],\n" + + " \"rowFilters\": [\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"country\",\n" + + " \"value\": \"USA\"\n" + + " },\n" + + " {\n" + + " \"type\": \"eq\",\n" + + " \"term\": \"$current_principal\",\n" + + " \"value\": \"PK\"\n" + + " }\n" + + " ]\n" + + "}"; + private static final PolicyIdentifier POLICY1 = new PolicyIdentifier(NS, "p1"); private static final PolicyIdentifier POLICY2 = new PolicyIdentifier(NS, "p2"); private static final PolicyIdentifier POLICY3 = new PolicyIdentifier(NS, "p3"); @@ -630,6 +649,30 @@ public void testGetApplicablePoliciesFilterOnType() { assertThat(applicablePolicies.contains(policyToApplicablePolicy(p2, false, NS))).isTrue(); } + @Test + public void testAttachAccessControlPolicyToTableAndCheckApplicablePolicy() { + icebergCatalog.createNamespace(NS); + icebergCatalog.createTable(TABLE, SCHEMA); + policyCatalog.createPolicy( + POLICY1, METADATA_COMPACTION.getName(), "test", "{\"enable\": false}"); + var p2 = + policyCatalog.createPolicy( + POLICY2, ACCESS_CONTROL.getName(), "FGAC", EXAMPLE_ACCESS_CONTROL_POLICY_CONTENT); + + // attach a policy to table + var target = + new PolicyAttachmentTarget( + PolicyAttachmentTarget.TypeEnum.TABLE_LIKE, List.of(NS.toString(), TABLE.name())); + policyCatalog.attachPolicy(POLICY2, target, null); + + // attach a different type of policy to namespace + policyCatalog.attachPolicy(POLICY1, POLICY_ATTACH_TARGET_NS, null); + var applicablePolicies = policyCatalog.getApplicablePolicies(NS, TABLE.name(), ACCESS_CONTROL); + System.out.println(applicablePolicies); + // only p2 is with the type fetched + assertThat(applicablePolicies.contains(policyToApplicablePolicy(p2, false, NS))).isTrue(); + } + private static ApplicablePolicy policyToApplicablePolicy( Policy policy, boolean inherited, Namespace parent) { return new ApplicablePolicy( @@ -637,9 +680,16 @@ private static ApplicablePolicy policyToApplicablePolicy( policy.getInheritable(), policy.getName(), policy.getDescription(), - policy.getContent(), + replaceContextVariable(policy.getContent(), policy.getPolicyType()), policy.getVersion(), inherited, Arrays.asList(parent.levels())); } + + private static String replaceContextVariable(String content, String policyType) { + if (policyType.equals(ACCESS_CONTROL.getName())) { + return "{\"principalRole\":\"ANALYST\",\"columnProjections\":[\"name\",\"location\"],\"rowFilters\":[\"{\\\"type\\\":\\\"eq\\\",\\\"term\\\":\\\"country\\\",\\\"value\\\":\\\"USA\\\"}\",\"false\"]}"; + } + return content; + } } diff --git a/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java b/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java index bf086ca8d6..7ef132e1b7 100644 --- a/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java +++ b/service/common/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalog.java @@ -20,7 +20,6 @@ import static org.apache.polaris.core.persistence.dao.entity.BaseResult.ReturnStatus.POLICY_HAS_MAPPINGS; import static org.apache.polaris.core.persistence.dao.entity.BaseResult.ReturnStatus.POLICY_MAPPING_OF_SAME_TYPE_ALREADY_EXISTS; -import static org.apache.polaris.core.policy.PredefinedPolicyTypes.ACCESS_CONTROL; import static org.apache.polaris.service.types.PolicyAttachmentTarget.TypeEnum.CATALOG; import com.google.common.base.Strings; @@ -57,7 +56,7 @@ import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifestCatalogView; import org.apache.polaris.core.policy.PolicyEntity; import org.apache.polaris.core.policy.PolicyType; -import org.apache.polaris.core.policy.content.AccessControlPolicyContent; +import org.apache.polaris.core.policy.PolicyUtil; import org.apache.polaris.core.policy.exceptions.NoSuchPolicyException; import org.apache.polaris.core.policy.exceptions.PolicyAttachException; import org.apache.polaris.core.policy.exceptions.PolicyInUseException; @@ -429,10 +428,10 @@ private List getEffectivePolicies( return Stream.concat( nonInheritablePolicies.stream() - .filter(policy -> filterApplicablePolicy(policy)) + .filter(p -> PolicyUtil.filterApplicablePolicy(p, authenticatedPrincipal)) .map(policy -> constructApplicablePolicy(policy, false)), inheritablePolicies.values().stream() - .filter(policy -> filterApplicablePolicy(policy)) + .filter(p -> PolicyUtil.filterApplicablePolicy(p, authenticatedPrincipal)) .map( policy -> constructApplicablePolicy( @@ -536,23 +535,7 @@ private static Policy constructPolicy(PolicyEntity policyEntity) { .build(); } - private boolean filterApplicablePolicy(PolicyEntity policyEntity) { - // check the type - if (policyEntity.getPolicyType().equals(ACCESS_CONTROL)) { - AccessControlPolicyContent content = - AccessControlPolicyContent.fromString(policyEntity.getContent()); - String applicablePrincipal = content.getPrincipalRole(); - return applicablePrincipal == null - || authenticatedPrincipal - .getActivatedPrincipalRoleNames() - .contains(content.getPrincipalRole()); - } - - return true; - } - - private static ApplicablePolicy constructApplicablePolicy( - PolicyEntity policyEntity, boolean inherited) { + private ApplicablePolicy constructApplicablePolicy(PolicyEntity policyEntity, boolean inherited) { Namespace parentNamespace = policyEntity.getParentNamespace(); return ApplicablePolicy.builder() @@ -560,7 +543,9 @@ private static ApplicablePolicy constructApplicablePolicy( .setInheritable(policyEntity.getPolicyType().isInheritable()) .setName(policyEntity.getName()) .setDescription(policyEntity.getDescription()) - .setContent(policyEntity.getContent()) + .setContent( + PolicyUtil.replaceContextVariable( + policyEntity.getContent(), policyEntity.getPolicyType(), authenticatedPrincipal)) .setVersion(policyEntity.getPolicyVersion()) .setInherited(inherited) .setNamespace(Arrays.asList(parentNamespace.levels()))