diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java index 2013b4f28e..9d12cc148a 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java @@ -71,6 +71,11 @@ import static org.apache.polaris.core.entity.PolarisPrivilege.PRINCIPAL_ROTATE_CREDENTIALS; import static org.apache.polaris.core.entity.PolarisPrivilege.PRINCIPAL_WRITE_PROPERTIES; import static org.apache.polaris.core.entity.PolarisPrivilege.SERVICE_MANAGE_ACCESS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_PARTITION_SPEC; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_SCHEMA; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_SNAPSHOT; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_SORT_ORDER; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ASSIGN_UUID; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ATTACH_POLICY; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_CREATE; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_DETACH_POLICY; @@ -80,6 +85,18 @@ import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_MANAGE_GRANTS_ON_SECURABLE; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_READ_DATA; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_READ_PROPERTIES; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_PARTITION_SPECS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_PROPERTIES; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_SNAPSHOTS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_SNAPSHOT_REF; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_STATISTICS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_CURRENT_SCHEMA; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_DEFAULT_SORT_ORDER; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_LOCATION; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_PROPERTIES; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_SNAPSHOT_REF; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_STATISTICS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_WRITE_DATA; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_WRITE_PROPERTIES; import static org.apache.polaris.core.entity.PolarisPrivilege.VIEW_CREATE; @@ -212,7 +229,24 @@ public enum PolarisAuthorizableOperation { GET_APPLICABLE_POLICIES_ON_TABLE(TABLE_READ_PROPERTIES), ADD_POLICY_GRANT_TO_CATALOG_ROLE(POLICY_MANAGE_GRANTS_ON_SECURABLE), REVOKE_POLICY_GRANT_FROM_CATALOG_ROLE( - POLICY_MANAGE_GRANTS_ON_SECURABLE, CATALOG_ROLE_MANAGE_GRANTS_FOR_GRANTEE); + POLICY_MANAGE_GRANTS_ON_SECURABLE, CATALOG_ROLE_MANAGE_GRANTS_FOR_GRANTEE), + ASSIGN_TABLE_UUID(TABLE_ASSIGN_UUID), + UPGRADE_TABLE_FORMAT_VERSION(TABLE_UPGRADE_FORMAT_VERSION), + ADD_TABLE_SCHEMA(TABLE_ADD_SCHEMA), + SET_TABLE_CURRENT_SCHEMA(TABLE_SET_CURRENT_SCHEMA), + ADD_TABLE_PARTITION_SPEC(TABLE_ADD_PARTITION_SPEC), + ADD_TABLE_SORT_ORDER(TABLE_ADD_SORT_ORDER), + SET_TABLE_DEFAULT_SORT_ORDER(TABLE_SET_DEFAULT_SORT_ORDER), + ADD_TABLE_SNAPSHOT(TABLE_ADD_SNAPSHOT), + SET_TABLE_SNAPSHOT_REF(TABLE_SET_SNAPSHOT_REF), + REMOVE_TABLE_SNAPSHOTS(TABLE_REMOVE_SNAPSHOTS), + REMOVE_TABLE_SNAPSHOT_REF(TABLE_REMOVE_SNAPSHOT_REF), + SET_TABLE_LOCATION(TABLE_SET_LOCATION), + SET_TABLE_PROPERTIES(TABLE_SET_PROPERTIES), + REMOVE_TABLE_PROPERTIES(TABLE_REMOVE_PROPERTIES), + SET_TABLE_STATISTICS(TABLE_SET_STATISTICS), + REMOVE_TABLE_STATISTICS(TABLE_REMOVE_STATISTICS), + REMOVE_TABLE_PARTITION_SPECS(TABLE_REMOVE_PARTITION_SPECS); private final EnumSet privilegesOnTarget; private final EnumSet privilegesOnSecondary; diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java index 5091f4b82d..9d943823a7 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java @@ -83,6 +83,11 @@ import static org.apache.polaris.core.entity.PolarisPrivilege.PRINCIPAL_ROTATE_CREDENTIALS; import static org.apache.polaris.core.entity.PolarisPrivilege.PRINCIPAL_WRITE_PROPERTIES; import static org.apache.polaris.core.entity.PolarisPrivilege.SERVICE_MANAGE_ACCESS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_PARTITION_SPEC; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_SCHEMA; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_SNAPSHOT; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ADD_SORT_ORDER; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ASSIGN_UUID; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_ATTACH_POLICY; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_CREATE; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_DETACH_POLICY; @@ -91,8 +96,21 @@ import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_LIST; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_LIST_GRANTS; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_MANAGE_GRANTS_ON_SECURABLE; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_MANAGE_STRUCTURE; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_READ_DATA; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_READ_PROPERTIES; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_PARTITION_SPECS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_PROPERTIES; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_SNAPSHOTS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_SNAPSHOT_REF; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_REMOVE_STATISTICS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_CURRENT_SCHEMA; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_DEFAULT_SORT_ORDER; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_LOCATION; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_PROPERTIES; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_SNAPSHOT_REF; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_SET_STATISTICS; +import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_WRITE_DATA; import static org.apache.polaris.core.entity.PolarisPrivilege.TABLE_WRITE_PROPERTIES; import static org.apache.polaris.core.entity.PolarisPrivilege.VIEW_CREATE; @@ -248,6 +266,183 @@ public class PolarisAuthorizerImpl implements PolarisAuthorizer { TABLE_FULL_METADATA, TABLE_WRITE_DATA, TABLE_WRITE_PROPERTIES)); + SUPER_PRIVILEGES.putAll( + TABLE_ASSIGN_UUID, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_ASSIGN_UUID)); + SUPER_PRIVILEGES.putAll( + TABLE_UPGRADE_FORMAT_VERSION, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_UPGRADE_FORMAT_VERSION)); + SUPER_PRIVILEGES.putAll( + TABLE_ADD_SCHEMA, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_ADD_SCHEMA)); + SUPER_PRIVILEGES.putAll( + TABLE_SET_CURRENT_SCHEMA, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_SET_CURRENT_SCHEMA)); + SUPER_PRIVILEGES.putAll( + TABLE_ADD_PARTITION_SPEC, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_ADD_PARTITION_SPEC)); + SUPER_PRIVILEGES.putAll( + TABLE_ADD_SORT_ORDER, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_ADD_SORT_ORDER)); + SUPER_PRIVILEGES.putAll( + TABLE_SET_DEFAULT_SORT_ORDER, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_SET_DEFAULT_SORT_ORDER)); + SUPER_PRIVILEGES.putAll( + TABLE_ADD_SNAPSHOT, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_ADD_SNAPSHOT)); + SUPER_PRIVILEGES.putAll( + TABLE_SET_SNAPSHOT_REF, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_SET_SNAPSHOT_REF)); + SUPER_PRIVILEGES.putAll( + TABLE_REMOVE_SNAPSHOTS, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_REMOVE_SNAPSHOTS)); + SUPER_PRIVILEGES.putAll( + TABLE_REMOVE_SNAPSHOT_REF, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_REMOVE_SNAPSHOT_REF)); + SUPER_PRIVILEGES.putAll( + TABLE_SET_LOCATION, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_SET_LOCATION)); + SUPER_PRIVILEGES.putAll( + TABLE_SET_PROPERTIES, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_SET_PROPERTIES)); + SUPER_PRIVILEGES.putAll( + TABLE_REMOVE_PROPERTIES, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_REMOVE_PROPERTIES)); + SUPER_PRIVILEGES.putAll( + TABLE_SET_STATISTICS, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_SET_STATISTICS)); + SUPER_PRIVILEGES.putAll( + TABLE_REMOVE_STATISTICS, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_REMOVE_STATISTICS)); + SUPER_PRIVILEGES.putAll( + TABLE_REMOVE_PARTITION_SPECS, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE, + TABLE_REMOVE_PARTITION_SPECS)); + SUPER_PRIVILEGES.putAll( + TABLE_MANAGE_STRUCTURE, + List.of( + CATALOG_MANAGE_CONTENT, + CATALOG_MANAGE_METADATA, + TABLE_FULL_METADATA, + TABLE_WRITE_DATA, + TABLE_WRITE_PROPERTIES, + TABLE_MANAGE_STRUCTURE)); SUPER_PRIVILEGES.putAll( VIEW_WRITE_PROPERTIES, List.of( diff --git a/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java b/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java index 5bd9f7257a..5d81c79f1e 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java @@ -420,4 +420,13 @@ public static void enforceFeatureEnabledOrThrow( .catalogConfig("polaris.config.allow-dropping-non-empty-passthrough-facade-catalog") .defaultValue(false) .buildFeatureConfiguration(); + + public static final FeatureConfiguration ENABLE_FINE_GRAINED_UPDATE_TABLE_PRIVILEGES = + PolarisConfiguration.builder() + .key("ENABLE_FINE_GRAINED_UPDATE_TABLE_PRIVILEGES") + .catalogConfig("polaris.config.enable-fine-grained-update-table-privileges") + .description( + "When true, enables finer grained update table privileges which are passed to the authorizer for update table operations") + .defaultValue(true) + .buildFeatureConfiguration(); } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisPrivilege.java b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisPrivilege.java index b7a51565b5..d76a6d457a 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisPrivilege.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisPrivilege.java @@ -155,6 +155,96 @@ public enum PolarisPrivilege { PolarisEntityType.POLICY, PolarisEntitySubType.NULL_SUBTYPE, PolarisEntityType.CATALOG_ROLE), + TABLE_ASSIGN_UUID( + 85, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_UPGRADE_FORMAT_VERSION( + 86, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_ADD_SCHEMA( + 87, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_SET_CURRENT_SCHEMA( + 88, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_ADD_PARTITION_SPEC( + 89, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_ADD_SORT_ORDER( + 90, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_SET_DEFAULT_SORT_ORDER( + 91, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_ADD_SNAPSHOT( + 92, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_SET_SNAPSHOT_REF( + 93, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_REMOVE_SNAPSHOTS( + 94, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_REMOVE_SNAPSHOT_REF( + 95, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_SET_LOCATION( + 96, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_SET_PROPERTIES( + 97, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_REMOVE_PROPERTIES( + 98, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_SET_STATISTICS( + 99, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_REMOVE_STATISTICS( + 100, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_REMOVE_PARTITION_SPECS( + 101, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), + TABLE_MANAGE_STRUCTURE( + 102, + PolarisEntityType.TABLE_LIKE, + List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE), + PolarisEntityType.CATALOG_ROLE), ; /** diff --git a/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisPrivilegeTest.java b/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisPrivilegeTest.java index 70c52e911d..14596911fd 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisPrivilegeTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/entity/PolarisPrivilegeTest.java @@ -113,7 +113,25 @@ static Stream polarisPrivileges() { Arguments.of(82, PolarisPrivilege.NAMESPACE_DETACH_POLICY), Arguments.of(83, PolarisPrivilege.TABLE_DETACH_POLICY), Arguments.of(84, PolarisPrivilege.POLICY_MANAGE_GRANTS_ON_SECURABLE), - Arguments.of(85, null)); + Arguments.of(85, PolarisPrivilege.TABLE_ASSIGN_UUID), + Arguments.of(86, PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION), + Arguments.of(87, PolarisPrivilege.TABLE_ADD_SCHEMA), + Arguments.of(88, PolarisPrivilege.TABLE_SET_CURRENT_SCHEMA), + Arguments.of(89, PolarisPrivilege.TABLE_ADD_PARTITION_SPEC), + Arguments.of(90, PolarisPrivilege.TABLE_ADD_SORT_ORDER), + Arguments.of(91, PolarisPrivilege.TABLE_SET_DEFAULT_SORT_ORDER), + Arguments.of(92, PolarisPrivilege.TABLE_ADD_SNAPSHOT), + Arguments.of(93, PolarisPrivilege.TABLE_SET_SNAPSHOT_REF), + Arguments.of(94, PolarisPrivilege.TABLE_REMOVE_SNAPSHOTS), + Arguments.of(95, PolarisPrivilege.TABLE_REMOVE_SNAPSHOT_REF), + Arguments.of(96, PolarisPrivilege.TABLE_SET_LOCATION), + Arguments.of(97, PolarisPrivilege.TABLE_SET_PROPERTIES), + Arguments.of(98, PolarisPrivilege.TABLE_REMOVE_PROPERTIES), + Arguments.of(99, PolarisPrivilege.TABLE_SET_STATISTICS), + Arguments.of(100, PolarisPrivilege.TABLE_REMOVE_STATISTICS), + Arguments.of(101, PolarisPrivilege.TABLE_REMOVE_PARTITION_SPECS), + Arguments.of(102, PolarisPrivilege.TABLE_MANAGE_STRUCTURE), + Arguments.of(103, null)); } @ParameterizedTest diff --git a/regtests/t_pyspark/src/conftest.py b/regtests/t_pyspark/src/conftest.py index a250257af9..d45363f6c5 100644 --- a/regtests/t_pyspark/src/conftest.py +++ b/regtests/t_pyspark/src/conftest.py @@ -76,6 +76,7 @@ def test_bucket(): def aws_role_arn(): return os.getenv('AWS_ROLE_ARN') + @pytest.fixture def aws_bucket_base_location_prefix(): """ @@ -178,3 +179,112 @@ def root_client(polaris_host, polaris_url): host=polaris_url)) api = PolarisDefaultApi(client) return api + +# Helper function to create catalog with specific storage configuration +def _create_catalog_with_storage(root_client, catalog_client, catalog_name, storage_config_info, base_location): + """ + Internal helper to create a catalog with specific storage configuration. + + Args: + root_client: Management API client + catalog_client: Catalog API client + catalog_name: Name for the catalog + storage_config_info: Storage configuration (S3 or FILE) + base_location: Base location for the catalog + """ + from polaris.management import AwsStorageConfigInfo + + # Build properties dict + catalog_properties = { + "default-base-location": base_location, + "polaris.config.drop-with-purge.enabled": "true" + } + + # Add AWS-specific properties if using S3 storage + if isinstance(storage_config_info, AwsStorageConfigInfo): + catalog_properties["client.credentials-provider"] = "software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider" + + catalog = Catalog(name=catalog_name, type='INTERNAL', + properties=CatalogProperties.from_dict(catalog_properties), + storage_config_info=storage_config_info) + + try: + root_client.create_catalog(create_catalog_request=CreateCatalogRequest(catalog=catalog)) + resp = root_client.get_catalog(catalog_name=catalog.name) + + # Set up basic catalog role with admin privileges + root_client.assign_catalog_role_to_principal_role( + principal_role_name='service_admin', + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest( + catalog_role=CatalogRole(name='catalog_admin') + ) + ) + + writer_catalog_role = create_catalog_role(root_client, resp, 'admin_writer') + root_client.add_grant_to_catalog_role( + catalog_name, writer_catalog_role.name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=CatalogPrivilege.CATALOG_MANAGE_CONTENT + )) + ) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name='service_admin', + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=writer_catalog_role) + ) + + yield resp + finally: + # Cleanup + namespaces = catalog_client.list_namespaces(catalog_name) + for n in namespaces.namespaces: + clear_namespace(catalog_name, catalog_client, n) + catalog_roles = root_client.list_catalog_roles(catalog_name) + for r in catalog_roles.roles: + if r.name != 'catalog_admin': + root_client.delete_catalog_role(catalog_name, r.name) + root_client.delete_catalog(catalog_name=catalog_name) + + +@pytest.fixture +def file_catalog(root_client, catalog_client): + """ + Catalog that always uses FILE storage for local testing. + This fixture runs in any environment without external dependencies. + """ + from polaris.management import FileStorageConfigInfo + + catalog_name = f'file_catalog_{str(uuid.uuid4())[-10:]}' + storage_config = FileStorageConfigInfo(storage_type="FILE", allowed_locations=["file:///tmp"]) + base_location = "file:///tmp/polaris" + + yield from _create_catalog_with_storage( + root_client, catalog_client, catalog_name, storage_config, base_location + ) + + +@pytest.fixture +def s3_catalog(root_client, catalog_client, test_bucket, aws_role_arn, aws_bucket_base_location_prefix): + """ + Catalog that always uses S3 storage for AWS testing. + Tests using this fixture should include @pytest.mark.skipif for AWS_TEST_ENABLED. + """ + from polaris.management import AwsStorageConfigInfo + + catalog_name = f's3_catalog_{str(uuid.uuid4())[-10:]}' + storage_config = AwsStorageConfigInfo( + storage_type="S3", + allowed_locations=[f"s3://{test_bucket}/{aws_bucket_base_location_prefix}/"], + role_arn=aws_role_arn + ) + base_location = f"s3://{test_bucket}/{aws_bucket_base_location_prefix}/s3_catalog" + + yield from _create_catalog_with_storage( + root_client, catalog_client, catalog_name, storage_config, base_location + ) + + diff --git a/regtests/t_pyspark/src/iceberg_spark.py b/regtests/t_pyspark/src/iceberg_spark.py index 7d866bde2e..fb430d48a0 100644 --- a/regtests/t_pyspark/src/iceberg_spark.py +++ b/regtests/t_pyspark/src/iceberg_spark.py @@ -46,7 +46,8 @@ def __init__( aws_region: str = None, catalog_name: str = None, polaris_url: str = None, - realm: str = 'POLARIS' + realm: str = 'POLARIS', + use_vended_credentials: bool = True ): """Constructor for Iceberg Spark session. Sets the member variables.""" self.bearer_token = bearer_token @@ -56,6 +57,7 @@ def __init__( self.catalog_name = catalog_name self.polaris_url = polaris_url self.realm = realm + self.use_vended_credentials = use_vended_credentials def get_catalog_name(self): """Get the catalog name of this spark session based on catalog_type.""" @@ -101,7 +103,6 @@ def __enter__(self): .config( f"spark.sql.catalog.{catalog_name}", "org.apache.iceberg.spark.SparkCatalog" ) - .config(f"spark.sql.catalog.{catalog_name}.header.X-Iceberg-Access-Delegation", "vended-credentials") .config(f"spark.sql.catalog.{catalog_name}.type", "rest") .config(f"spark.sql.catalog.{catalog_name}.uri", self.polaris_url) .config(f"spark.sql.catalog.{catalog_name}.warehouse", self.catalog_name) @@ -112,6 +113,17 @@ def __enter__(self): .config("spark.ui.showConsoleProgress", False) ) + # Conditionally add vended credentials header + if self.use_vended_credentials: + spark_session_builder = spark_session_builder.config( + f"spark.sql.catalog.{catalog_name}.header.X-Iceberg-Access-Delegation", "vended-credentials" + ) + else: + # Explicitly remove the header if it was set globally + spark_session_builder = spark_session_builder.config( + f"spark.sql.catalog.{catalog_name}.header.X-Iceberg-Access-Delegation", "" + ) + self.spark_session = spark_session_builder.getOrCreate() self.quiet_logs(self.spark_session.sparkContext) return self diff --git a/regtests/t_pyspark/src/test_spark_sql_fine_grained_authz.py b/regtests/t_pyspark/src/test_spark_sql_fine_grained_authz.py new file mode 100644 index 0000000000..b6b47203c7 --- /dev/null +++ b/regtests/t_pyspark/src/test_spark_sql_fine_grained_authz.py @@ -0,0 +1,572 @@ +# +# 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. +# + +""" +Fine-grained authorization tests for Polaris. + +These tests validate that fine-grained table metadata update privileges work correctly. + +The authorization logic is storage-agnostic, so testing with a catalog using FILE storage +""" + +import os +import pytest +import uuid +from py4j.protocol import Py4JJavaError + +from iceberg_spark import IcebergSparkSession +from polaris.management import PrincipalRole, CatalogRole, CatalogGrant, CatalogPrivilege, \ + AddGrantRequest, GrantCatalogRoleRequest, GrantPrincipalRoleRequest + +# Import existing helper functions instead of copying them +from conftest import create_catalog_role +from test_spark_sql_s3_with_privileges import create_principal, create_principal_role + + +@pytest.fixture +def fine_grained_authz_test_catalog(root_client, catalog_client): + """ + Catalog specifically for fine-grained authorization testing. + Does NOT assign catalog_admin to service_admin to avoid privilege inheritance issues. + """ + from polaris.management import FileStorageConfigInfo, Catalog, CatalogProperties, CreateCatalogRequest + from conftest import create_catalog_role + + catalog_name = f'fine_grained_authz_test_catalog_{str(uuid.uuid4())[-10:]}' + storage_config = FileStorageConfigInfo(storage_type="FILE", allowed_locations=["file:///tmp"]) + base_location = "file:///tmp/polaris" + + # Build properties dict with fine-grained authorization enabled + catalog_properties = { + "default-base-location": base_location, + "polaris.config.drop-with-purge.enabled": "true", + "polaris.config.enable-fine-grained-update-table-privileges": "true" + } + + catalog = Catalog(name=catalog_name, type='INTERNAL', + properties=CatalogProperties.from_dict(catalog_properties), + storage_config_info=storage_config) + + try: + root_client.create_catalog(create_catalog_request=CreateCatalogRequest(catalog=catalog)) + resp = root_client.get_catalog(catalog_name=catalog.name) + + # IMPORTANT: We do NOT assign catalog_admin to service_admin here! + # This ensures fine-grained tests have only the privileges explicitly granted + + # However, we need to grant cleanup privileges to service_admin for fixture teardown + cleanup_catalog_role = create_catalog_role(root_client, resp, 'cleanup_role') + cleanup_privileges = [ + CatalogPrivilege.TABLE_DROP, + CatalogPrivilege.TABLE_WRITE_DATA, # Needed for DROP_TABLE_WITH_PURGE + CatalogPrivilege.NAMESPACE_DROP + ] + + for privilege in cleanup_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, cleanup_catalog_role.name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name='service_admin', + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=cleanup_catalog_role) + ) + + yield resp + finally: + # Cleanup + from conftest import clear_namespace + namespaces = catalog_client.list_namespaces(catalog_name) + for n in namespaces.namespaces: + clear_namespace(catalog_name, catalog_client, n) + catalog_roles = root_client.list_catalog_roles(catalog_name) + for r in catalog_roles.roles: + if r.name not in ['catalog_admin', 'cleanup_role']: + root_client.delete_catalog_role(catalog_name, r.name) + # Delete cleanup_role last + try: + root_client.delete_catalog_role(catalog_name, 'cleanup_role') + except: + pass + root_client.delete_catalog(catalog_name=catalog_name) + + + +def test_coarse_grained_table_write_properties(polaris_url, polaris_catalog_url, root_client, fine_grained_authz_test_catalog): + """Test that coarse-grained TABLE_WRITE_PROPERTIES privilege allows all metadata operations""" + + catalog_name = fine_grained_authz_test_catalog.name + + # Create a single principal with TABLE_WRITE_PROPERTIES (coarse-grained privilege) + principal_name = f"coarse_grained_user_{str(uuid.uuid4())[-10:]}" + principal_role_name = f"coarse_grained_role_{str(uuid.uuid4())[-10:]}" + catalog_role_name = f"coarse_grained_cat_role_{str(uuid.uuid4())[-10:]}" + + try: + # Create principal with coarse-grained privileges + principal = create_principal(polaris_url, polaris_catalog_url, root_client, principal_name) + principal_role = create_principal_role(root_client, principal_role_name) + catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=catalog_role) + ) + + # Grant coarse-grained privileges (including TABLE_WRITE_PROPERTIES super-privilege) + coarse_grained_privileges = [ + CatalogPrivilege.NAMESPACE_FULL_METADATA, + CatalogPrivilege.TABLE_CREATE, + CatalogPrivilege.TABLE_LIST, + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_READ_DATA, + CatalogPrivilege.TABLE_DROP, + CatalogPrivilege.TABLE_WRITE_DATA, + CatalogPrivilege.TABLE_WRITE_PROPERTIES # This should allow both SET and UNSET + ] + + for privilege in coarse_grained_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=principal_role) + ) + + # Test with coarse-grained privilege - should work for both SET and UNSET operations + with IcebergSparkSession( + credentials=f'{principal.principal.client_id}:{principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url + ) as spark: + spark.sql(f'USE {catalog_name}') + spark.sql('CREATE NAMESPACE db1') + spark.sql('CREATE TABLE db1.test_table (col1 int, col2 string)') + + # Both operations should work with TABLE_WRITE_PROPERTIES + spark.sql("ALTER TABLE db1.test_table SET TBLPROPERTIES ('test.property' = 'test.value')") + spark.sql("ALTER TABLE db1.test_table UNSET TBLPROPERTIES ('test.property')") + + finally: + # Cleanup principal and roles + try: + root_client.delete_principal(principal_name) + root_client.delete_principal_role(principal_role_name) + root_client.delete_catalog_role(catalog_name, catalog_role_name) + except: + pass + + +def test_fine_grained_table_set_properties(polaris_url, polaris_catalog_url, root_client, fine_grained_authz_test_catalog): + """Test fine-grained TABLE_SET_PROPERTIES privilege allows SET operations but not UNSET""" + + catalog_name = fine_grained_authz_test_catalog.name + + # Create setup principal (for table creation) + setup_principal_name = f"setup_user_{str(uuid.uuid4())[-10:]}" + setup_principal_role_name = f"setup_role_{str(uuid.uuid4())[-10:]}" + setup_catalog_role_name = f"setup_cat_role_{str(uuid.uuid4())[-10:]}" + + # Create test principal (for fine-grained testing) + test_principal_name = f"test_user_{str(uuid.uuid4())[-10:]}" + test_principal_role_name = f"test_role_{str(uuid.uuid4())[-10:]}" + test_catalog_role_name = f"test_cat_role_{str(uuid.uuid4())[-10:]}" + + try: + # Create setup principal with full privileges + setup_principal = create_principal(polaris_url, polaris_catalog_url, root_client, setup_principal_name) + setup_principal_role = create_principal_role(root_client, setup_principal_role_name) + setup_catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, setup_catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=setup_principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=setup_catalog_role) + ) + + # Grant setup privileges (including super-privileges) + setup_privileges = [ + CatalogPrivilege.NAMESPACE_FULL_METADATA, + CatalogPrivilege.TABLE_CREATE, + CatalogPrivilege.TABLE_LIST, + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_DROP, + CatalogPrivilege.TABLE_WRITE_DATA + ] + + for privilege in setup_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + setup_catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + setup_principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=setup_principal_role) + ) + + # Create test principal with only fine-grained privileges + test_principal = create_principal(polaris_url, polaris_catalog_url, root_client, test_principal_name) + test_principal_role = create_principal_role(root_client, test_principal_role_name) + test_catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, test_catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=test_principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=test_catalog_role) + ) + + # Grant only basic privileges to test role + test_basic_privileges = [ + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_SET_PROPERTIES # The specific privilege we're testing + ] + + for privilege in test_basic_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + test_catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + test_principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=test_principal_role) + ) + + # Create table using the setup principal + with IcebergSparkSession( + credentials=f'{setup_principal.principal.client_id}:{setup_principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url + ) as spark: + spark.sql(f'USE {catalog_name}') + spark.sql('CREATE NAMESPACE db1') + spark.sql('CREATE TABLE db1.test_table (col1 int, col2 string)') + + # Test fine-grained operations using the test principal + with IcebergSparkSession( + credentials=f'{test_principal.principal.client_id}:{test_principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url, + use_vended_credentials=False # Not needed for file storage type + ) as spark: + spark.sql(f'USE {catalog_name}') + + # SET operation should work with TABLE_SET_PROPERTIES + spark.sql("ALTER TABLE db1.test_table SET TBLPROPERTIES ('test.property' = 'test.value')") + + # UNSET operation should fail without TABLE_REMOVE_PROPERTIES + with pytest.raises(Py4JJavaError) as exc_info: + spark.sql("ALTER TABLE db1.test_table UNSET TBLPROPERTIES ('test.property')") + + # Verify the error is related to authorization + error_str = str(exc_info.value).lower() + assert "forbidden" in error_str or "not authorized" in error_str, f"Unexpected error: {exc_info.value}" + finally: + # Cleanup principals and roles + try: + root_client.delete_principal(setup_principal_name) + root_client.delete_principal_role(setup_principal_role_name) + root_client.delete_catalog_role(catalog_name, setup_catalog_role_name) + root_client.delete_principal(test_principal_name) + root_client.delete_principal_role(test_principal_role_name) + root_client.delete_catalog_role(catalog_name, test_catalog_role_name) + except: + pass + + +def test_fine_grained_table_remove_properties(polaris_url, polaris_catalog_url, root_client, fine_grained_authz_test_catalog): + """Test that fine-grained TABLE_REMOVE_PROPERTIES privilege allows UNSET operations but not SET""" + + catalog_name = fine_grained_authz_test_catalog.name + + # Create setup principal (for table creation) + setup_principal_name = f"setup_user_{str(uuid.uuid4())[-10:]}" + setup_principal_role_name = f"setup_role_{str(uuid.uuid4())[-10:]}" + setup_catalog_role_name = f"setup_cat_role_{str(uuid.uuid4())[-10:]}" + + # Create test principal (for fine-grained testing) + test_principal_name = f"test_user_{str(uuid.uuid4())[-10:]}" + test_principal_role_name = f"test_role_{str(uuid.uuid4())[-10:]}" + test_catalog_role_name = f"test_cat_role_{str(uuid.uuid4())[-10:]}" + + try: + # Create setup principal with full privileges + setup_principal = create_principal(polaris_url, polaris_catalog_url, root_client, setup_principal_name) + setup_principal_role = create_principal_role(root_client, setup_principal_role_name) + setup_catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, setup_catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=setup_principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=setup_catalog_role) + ) + + # Grant setup privileges (including super-privileges) + setup_privileges = [ + CatalogPrivilege.NAMESPACE_FULL_METADATA, + CatalogPrivilege.TABLE_CREATE, + CatalogPrivilege.TABLE_LIST, + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_DROP, + CatalogPrivilege.TABLE_WRITE_DATA + ] + + for privilege in setup_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + setup_catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + setup_principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=setup_principal_role) + ) + + # Create test principal with only fine-grained privileges + test_principal = create_principal(polaris_url, polaris_catalog_url, root_client, test_principal_name) + test_principal_role = create_principal_role(root_client, test_principal_role_name) + test_catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, test_catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=test_principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=test_catalog_role) + ) + + # Grant only TABLE_REMOVE_PROPERTIES (not SET_PROPERTIES) + test_basic_privileges = [ + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_REMOVE_PROPERTIES # The specific privilege we're testing + ] + + for privilege in test_basic_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + test_catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + test_principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=test_principal_role) + ) + + # Create table using the setup principal and set a property to remove later + with IcebergSparkSession( + credentials=f'{setup_principal.principal.client_id}:{setup_principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url + ) as spark: + spark.sql(f'USE {catalog_name}') + spark.sql('CREATE NAMESPACE db1') + spark.sql('CREATE TABLE db1.test_table (col1 int, col2 string)') + # Set a property first so we can remove it + spark.sql("ALTER TABLE db1.test_table SET TBLPROPERTIES ('test.property' = 'test.value')") + + # Test fine-grained operations using the test principal + with IcebergSparkSession( + credentials=f'{test_principal.principal.client_id}:{test_principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url, + use_vended_credentials=False # Not needed for file storage type + ) as spark: + spark.sql(f'USE {catalog_name}') + + # UNSET operation should work with TABLE_REMOVE_PROPERTIES + spark.sql("ALTER TABLE db1.test_table UNSET TBLPROPERTIES ('test.property')") + + # SET operation should fail without TABLE_SET_PROPERTIES + with pytest.raises(Py4JJavaError) as exc_info: + spark.sql("ALTER TABLE db1.test_table SET TBLPROPERTIES ('test.property2' = 'test.value2')") + + # Verify the error is related to authorization + error_str = str(exc_info.value).lower() + assert "not authorized" in error_str or "forbidden" in error_str, f"Unexpected error: {exc_info.value}" + + finally: + # Cleanup principals and roles + try: + root_client.delete_principal(setup_principal_name) + root_client.delete_principal_role(setup_principal_role_name) + root_client.delete_catalog_role(catalog_name, setup_catalog_role_name) + root_client.delete_principal(test_principal_name) + root_client.delete_principal_role(test_principal_role_name) + root_client.delete_catalog_role(catalog_name, test_catalog_role_name) + except: + pass + + +def test_multiple_fine_grained_privileges_together(polaris_url, polaris_catalog_url, root_client, fine_grained_authz_test_catalog): + """Test that multiple fine-grained privileges work together correctly""" + + catalog_name = fine_grained_authz_test_catalog.name + + # Create setup principal (for table creation) + setup_principal_name = f"setup_user_{str(uuid.uuid4())[-10:]}" + setup_principal_role_name = f"setup_role_{str(uuid.uuid4())[-10:]}" + setup_catalog_role_name = f"setup_cat_role_{str(uuid.uuid4())[-10:]}" + + # Create test principal (for fine-grained testing) + test_principal_name = f"test_user_{str(uuid.uuid4())[-10:]}" + test_principal_role_name = f"test_role_{str(uuid.uuid4())[-10:]}" + test_catalog_role_name = f"test_cat_role_{str(uuid.uuid4())[-10:]}" + + try: + # Create setup principal with full privileges + setup_principal = create_principal(polaris_url, polaris_catalog_url, root_client, setup_principal_name) + setup_principal_role = create_principal_role(root_client, setup_principal_role_name) + setup_catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, setup_catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=setup_principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=setup_catalog_role) + ) + + # Grant setup privileges (including super-privileges) + setup_privileges = [ + CatalogPrivilege.NAMESPACE_FULL_METADATA, + CatalogPrivilege.TABLE_CREATE, + CatalogPrivilege.TABLE_LIST, + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_DROP, + CatalogPrivilege.TABLE_WRITE_DATA + ] + + for privilege in setup_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + setup_catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + setup_principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=setup_principal_role) + ) + + # Create test principal with multiple fine-grained privileges + test_principal = create_principal(polaris_url, polaris_catalog_url, root_client, test_principal_name) + test_principal_role = create_principal_role(root_client, test_principal_role_name) + test_catalog_role = create_catalog_role(root_client, fine_grained_authz_test_catalog, test_catalog_role_name) + + root_client.assign_catalog_role_to_principal_role( + principal_role_name=test_principal_role.name, + catalog_name=catalog_name, + grant_catalog_role_request=GrantCatalogRoleRequest(catalog_role=test_catalog_role) + ) + + # Grant both SET and REMOVE properties privileges + test_privileges = [ + CatalogPrivilege.TABLE_READ_PROPERTIES, + CatalogPrivilege.TABLE_SET_PROPERTIES, # For SET operations + CatalogPrivilege.TABLE_REMOVE_PROPERTIES # For UNSET operations + ] + + for privilege in test_privileges: + root_client.add_grant_to_catalog_role( + catalog_name, + test_catalog_role_name, + AddGrantRequest(grant=CatalogGrant( + catalog_name=catalog_name, + type='catalog', + privilege=privilege + )) + ) + + root_client.assign_principal_role( + test_principal.principal.name, + grant_principal_role_request=GrantPrincipalRoleRequest(principal_role=test_principal_role) + ) + + # Create table using the setup principal + with IcebergSparkSession( + credentials=f'{setup_principal.principal.client_id}:{setup_principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url + ) as spark: + spark.sql(f'USE {catalog_name}') + spark.sql('CREATE NAMESPACE db1') + spark.sql('CREATE TABLE db1.test_table (col1 int, col2 string)') + + # Test multiple fine-grained operations using the test principal + with IcebergSparkSession( + credentials=f'{test_principal.principal.client_id}:{test_principal.credentials.client_secret.get_secret_value()}', + catalog_name=catalog_name, + polaris_url=polaris_catalog_url, + use_vended_credentials=False # Not needed for file storage type + ) as spark: + spark.sql(f'USE {catalog_name}') + + # Multiple operations in sequence - all should work with both privileges + spark.sql("ALTER TABLE db1.test_table SET TBLPROPERTIES ('prop1' = 'value1', 'prop2' = 'value2')") + spark.sql("ALTER TABLE db1.test_table UNSET TBLPROPERTIES ('prop1')") + spark.sql("ALTER TABLE db1.test_table SET TBLPROPERTIES ('prop3' = 'value3')") + spark.sql("ALTER TABLE db1.test_table UNSET TBLPROPERTIES ('prop2', 'prop3')") + + finally: + # Cleanup principals and roles + try: + root_client.delete_principal(setup_principal_name) + root_client.delete_principal_role(setup_principal_role_name) + root_client.delete_catalog_role(catalog_name, setup_catalog_role_name) + root_client.delete_principal(test_principal_name) + root_client.delete_principal_role(test_principal_role_name) + root_client.delete_catalog_role(catalog_name, test_catalog_role_name) + except: + pass diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java index cdee75213e..8919aeb2ac 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java @@ -23,6 +23,7 @@ import jakarta.enterprise.inject.Instance; import jakarta.ws.rs.core.SecurityContext; import java.util.Arrays; +import java.util.EnumSet; import java.util.List; import java.util.Optional; import org.apache.iceberg.catalog.Namespace; @@ -238,29 +239,50 @@ protected void authorizeCreateTableLikeUnderNamespaceOperationOrThrow( initializeCatalog(); } + /** + * Ensures resolution manifest is initialized for a table identifier. This allows checking + * catalog-level feature flags or other resolved entities before authorization. If already + * initialized, this is a no-op. + */ + protected void ensureResolutionManifestForTable(TableIdentifier identifier) { + if (resolutionManifest == null) { + resolutionManifest = newResolutionManifest(); + + // The underlying Catalog is also allowed to fetch "fresh" versions of the target entity. + resolutionManifest.addPassthroughPath( + new ResolverPath( + PolarisCatalogHelpers.tableIdentifierToList(identifier), + PolarisEntityType.TABLE_LIKE, + true /* optional */), + identifier); + resolutionManifest.resolveAll(); + } + } + protected void authorizeBasicTableLikeOperationOrThrow( PolarisAuthorizableOperation op, PolarisEntitySubType subType, TableIdentifier identifier) { - resolutionManifest = newResolutionManifest(); + authorizeBasicTableLikeOperationsOrThrow(EnumSet.of(op), subType, identifier); + } - // The underlying Catalog is also allowed to fetch "fresh" versions of the target entity. - resolutionManifest.addPassthroughPath( - new ResolverPath( - PolarisCatalogHelpers.tableIdentifierToList(identifier), - PolarisEntityType.TABLE_LIKE, - true /* optional */), - identifier); - resolutionManifest.resolveAll(); + protected void authorizeBasicTableLikeOperationsOrThrow( + EnumSet ops, + PolarisEntitySubType subType, + TableIdentifier identifier) { + ensureResolutionManifestForTable(identifier); PolarisResolvedPathWrapper target = resolutionManifest.getResolvedPath(identifier, PolarisEntityType.TABLE_LIKE, subType, true); if (target == null) { throwNotFoundExceptionForTableLikeEntity(identifier, List.of(subType)); } - authorizer.authorizeOrThrow( - polarisPrincipal, - resolutionManifest.getAllActivatedCatalogRoleAndPrincipalRoles(), - op, - target, - null /* secondary */); + + for (PolarisAuthorizableOperation op : ops) { + authorizer.authorizeOrThrow( + polarisPrincipal, + resolutionManifest.getAllActivatedCatalogRoleAndPrincipalRoles(), + op, + target, + null /* secondary */); + } initializeCatalog(); } diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java index 0154dd3caf..03a5881c8d 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java @@ -852,9 +852,16 @@ private UpdateTableRequest applyUpdateFilters(UpdateTableRequest request) { public LoadTableResponse updateTable( TableIdentifier tableIdentifier, UpdateTableRequest request) { - PolarisAuthorizableOperation op = PolarisAuthorizableOperation.UPDATE_TABLE; - authorizeBasicTableLikeOperationOrThrow( - op, PolarisEntitySubType.ICEBERG_TABLE, tableIdentifier); + + // Ensure resolution manifest is initialized so we can determine whether + // fine grained authz model is enabled at the catalog level + ensureResolutionManifestForTable(tableIdentifier); + + EnumSet authorizableOperations = + getUpdateTableAuthorizableOperations(request); + + authorizeBasicTableLikeOperationsOrThrow( + authorizableOperations, PolarisEntitySubType.ICEBERG_TABLE, tableIdentifier); CatalogEntity catalog = getResolvedCatalogEntity(); if (catalog.isStaticFacade()) { @@ -1122,6 +1129,73 @@ public void renameView(RenameTableRequest request) { } } + private EnumSet getUpdateTableAuthorizableOperations( + UpdateTableRequest request) { + boolean useFineGrainedOperations = + realmConfig.getConfig( + FeatureConfiguration.ENABLE_FINE_GRAINED_UPDATE_TABLE_PRIVILEGES, + getResolvedCatalogEntity()); + + if (useFineGrainedOperations) { + EnumSet actions = + request.updates().stream() + .map( + update -> + switch (update) { + case MetadataUpdate.AssignUUID assignUuid -> + PolarisAuthorizableOperation.ASSIGN_TABLE_UUID; + case MetadataUpdate.UpgradeFormatVersion upgradeFormat -> + PolarisAuthorizableOperation.UPGRADE_TABLE_FORMAT_VERSION; + case MetadataUpdate.AddSchema addSchema -> + PolarisAuthorizableOperation.ADD_TABLE_SCHEMA; + case MetadataUpdate.SetCurrentSchema setCurrentSchema -> + PolarisAuthorizableOperation.SET_TABLE_CURRENT_SCHEMA; + case MetadataUpdate.AddPartitionSpec addPartitionSpec -> + PolarisAuthorizableOperation.ADD_TABLE_PARTITION_SPEC; + case MetadataUpdate.AddSortOrder addSortOrder -> + PolarisAuthorizableOperation.ADD_TABLE_SORT_ORDER; + case MetadataUpdate.SetDefaultSortOrder setDefaultSortOrder -> + PolarisAuthorizableOperation.SET_TABLE_DEFAULT_SORT_ORDER; + case MetadataUpdate.AddSnapshot addSnapshot -> + PolarisAuthorizableOperation.ADD_TABLE_SNAPSHOT; + case MetadataUpdate.SetSnapshotRef setSnapshotRef -> + PolarisAuthorizableOperation.SET_TABLE_SNAPSHOT_REF; + case MetadataUpdate.RemoveSnapshots removeSnapshots -> + PolarisAuthorizableOperation.REMOVE_TABLE_SNAPSHOTS; + case MetadataUpdate.RemoveSnapshotRef removeSnapshotRef -> + PolarisAuthorizableOperation.REMOVE_TABLE_SNAPSHOT_REF; + case MetadataUpdate.SetLocation setLocation -> + PolarisAuthorizableOperation.SET_TABLE_LOCATION; + case MetadataUpdate.SetProperties setProperties -> + PolarisAuthorizableOperation.SET_TABLE_PROPERTIES; + case MetadataUpdate.RemoveProperties removeProperties -> + PolarisAuthorizableOperation.REMOVE_TABLE_PROPERTIES; + case MetadataUpdate.SetStatistics setStatistics -> + PolarisAuthorizableOperation.SET_TABLE_STATISTICS; + case MetadataUpdate.RemoveStatistics removeStatistics -> + PolarisAuthorizableOperation.REMOVE_TABLE_STATISTICS; + case MetadataUpdate.RemovePartitionSpecs removePartitionSpecs -> + PolarisAuthorizableOperation.REMOVE_TABLE_PARTITION_SPECS; + default -> + PolarisAuthorizableOperation + .UPDATE_TABLE; // Fallback for unknown update types + }) + .collect( + () -> EnumSet.noneOf(PolarisAuthorizableOperation.class), + EnumSet::add, + EnumSet::addAll); + + // If there are no MetadataUpdates, then default to the UPDATE_TABLE operation. + if (actions.isEmpty()) { + actions.add(PolarisAuthorizableOperation.UPDATE_TABLE); + } + + return actions; + } else { + return EnumSet.of(PolarisAuthorizableOperation.UPDATE_TABLE); + } + } + @Override public void close() throws Exception { if (baseCatalog instanceof Closeable closeable) { diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java index f9827fddfa..0a5f063e2c 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java @@ -22,6 +22,7 @@ import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; import jakarta.ws.rs.core.SecurityContext; import java.time.Instant; import java.util.List; @@ -31,6 +32,7 @@ import java.util.UUID; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.MetadataUpdate; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.SortOrder; import org.apache.iceberg.TableMetadata; @@ -57,6 +59,9 @@ import org.apache.polaris.core.admin.model.StorageConfigInfo; import org.apache.polaris.core.auth.PolarisPrincipal; import org.apache.polaris.core.catalog.ExternalCatalogFactory; +import org.apache.polaris.core.config.FeatureConfiguration; +import org.apache.polaris.core.config.PolarisConfiguration; +import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.core.context.CallContext; import org.apache.polaris.core.entity.CatalogEntity; import org.apache.polaris.core.entity.CatalogRoleEntity; @@ -76,10 +81,26 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +/** + * Authorization test class for IcebergCatalogHandler. Runs with the default value for + * ENABLE_FINE_GRAINED_UPDATE_TABLE_PRIVILEGES (currently true). + * + *

This class tests: + * + *

    + *
  • Standard authorization behavior for all catalog operations + *
  • Fine-grained authorization for table metadata update operations + *
  • Coarse-grained fallback behavior + *
  • Super-privilege behavior (e.g., TABLE_MANAGE_STRUCTURE) + *
+ */ @QuarkusTest @TestProfile(PolarisAuthzTestBase.Profile.class) public class IcebergCatalogHandlerAuthzTest extends PolarisAuthzTestBase { + @Inject CallContextCatalogFactory callContextCatalogFactory; + @Inject Instance externalCatalogFactories; + @SuppressWarnings("unchecked") private static Instance emptyExternalCatalogFactory() { Instance mock = Mockito.mock(Instance.class); @@ -88,7 +109,7 @@ private static Instance emptyExternalCatalogFactory() { return mock; } - private IcebergCatalogHandler newWrapper() { + protected IcebergCatalogHandler newWrapper() { return newWrapper(Set.of()); } @@ -116,6 +137,27 @@ private IcebergCatalogHandler newWrapper( polarisEventListener); } + protected void doTestInsufficientPrivileges( + List insufficientPrivileges, Runnable action) { + doTestInsufficientPrivileges(insufficientPrivileges, PRINCIPAL_NAME, action); + } + + /** + * Tests each "insufficient" privilege individually using CATALOG_ROLE1 by granting at the + * CATALOG_NAME level, ensuring the action fails, then revoking after each test case. + */ + private void doTestInsufficientPrivileges( + List insufficientPrivileges, String principalName, Runnable action) { + doTestInsufficientPrivileges( + insufficientPrivileges, + principalName, + action, + (privilege) -> + adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + (privilege) -> + adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + } + /** * Tests each "sufficient" privilege individually using CATALOG_ROLE1 by granting at the * CATALOG_NAME level, revoking after each test, and also ensuring that the request fails after @@ -130,7 +172,7 @@ private IcebergCatalogHandler newWrapper( * either the cleanup privileges must be latent, or the cleanup action could be run with * PRINCIPAL_ROLE2 while runnint {@code action} with PRINCIPAL_ROLE1. */ - private void doTestSufficientPrivileges( + protected void doTestSufficientPrivileges( List sufficientPrivileges, Runnable action, Runnable cleanupAction) { doTestSufficientPrivilegeSets( sufficientPrivileges.stream().map(Set::of).toList(), action, cleanupAction, PRINCIPAL_NAME); @@ -160,7 +202,7 @@ private void doTestSufficientPrivilegeSets( * @param principalName * @param catalogName */ - private void doTestSufficientPrivilegeSets( + protected void doTestSufficientPrivilegeSets( List> sufficientPrivileges, Runnable action, Runnable cleanupAction, @@ -177,27 +219,6 @@ private void doTestSufficientPrivilegeSets( adminService.revokePrivilegeOnCatalogFromRole(catalogName, CATALOG_ROLE1, privilege)); } - private void doTestInsufficientPrivileges( - List insufficientPrivileges, Runnable action) { - doTestInsufficientPrivileges(insufficientPrivileges, PRINCIPAL_NAME, action); - } - - /** - * Tests each "insufficient" privilege individually using CATALOG_ROLE1 by granting at the - * CATALOG_NAME level, ensuring the action fails, then revoking after each test case. - */ - private void doTestInsufficientPrivileges( - List insufficientPrivileges, String principalName, Runnable action) { - doTestInsufficientPrivileges( - insufficientPrivileges, - principalName, - action, - (privilege) -> - adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), - (privilege) -> - adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); - } - @Test public void testListNamespacesAllSufficientPrivileges() { doTestSufficientPrivileges( @@ -1069,6 +1090,108 @@ public void testUpdateTableForStagedCreateInsufficientPermissions() { () -> newWrapper().updateTableForStagedCreate(TABLE_NS1A_2, new UpdateTableRequest())); } + @Test + public void testUpdateTableFallbackToCoarseGrainedWhenFeatureDisabled() { + // Test that when fine-grained authorization is disabled, it falls back to + // TABLE_WRITE_PROPERTIES + // This test validates that the feature flag works correctly by testing the negative case + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.AssignUUID(UUID.randomUUID().toString()))); + + // With fine-grained authorization disabled, TABLE_WRITE_PROPERTIES should work + // even for operations that would require specific privileges when enabled + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_WRITE_PROPERTIES, + PolarisPrivilege.TABLE_WRITE_DATA, + PolarisPrivilege.TABLE_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> newWrapperWithFineGrainedAuthzDisabled().updateTable(TABLE_NS1A_2, request), + null /* cleanupAction */); + } + + /** + * Creates a wrapper with fine-grained authorization explicitly disabled for testing the fallback + * behavior to coarse-grained authorization. + */ + private IcebergCatalogHandler newWrapperWithFineGrainedAuthzDisabled() { + // Create a custom CallContextCatalogFactory that mocks the configuration + CallContextCatalogFactory mockFactory = Mockito.mock(CallContextCatalogFactory.class); + + // Mock the catalog factory to return our regular catalog but with mocked config + Mockito.when( + mockFactory.createCallContextCatalog( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(baseCatalog); + + return newWrapperWithFineLevelAuthDisabled(Set.of(), CATALOG_NAME, mockFactory, false); + } + + private IcebergCatalogHandler newWrapperWithFineLevelAuthDisabled( + Set activatedPrincipalRoles, + String catalogName, + CallContextCatalogFactory factory, + boolean fineGrainedAuthzEnabled) { + + PolarisPrincipal authenticatedPrincipal = + PolarisPrincipal.of(principalEntity, activatedPrincipalRoles); + + // Create a custom CallContext that returns a custom RealmConfig + CallContext mockCallContext = Mockito.mock(CallContext.class); + + // Create a simple RealmConfig implementation that overrides just what we need + RealmConfig customRealmConfig = + new RealmConfig() { + @Override + public T getConfig(String configName) { + return realmConfig.getConfig(configName); + } + + @Override + public T getConfig(String configName, T defaultValue) { + return realmConfig.getConfig(configName, defaultValue); + } + + @Override + public T getConfig(PolarisConfiguration config) { + return realmConfig.getConfig(config); + } + + @Override + @SuppressWarnings("unchecked") + public T getConfig(PolarisConfiguration config, CatalogEntity catalogEntity) { + // Override the specific configuration we want to test + if (config.equals(FeatureConfiguration.ENABLE_FINE_GRAINED_UPDATE_TABLE_PRIVILEGES)) { + return (T) Boolean.valueOf(fineGrainedAuthzEnabled); + } + return realmConfig.getConfig(config, catalogEntity); + } + }; + + // Mock the regular CallContext calls + Mockito.when(mockCallContext.getRealmConfig()).thenReturn(customRealmConfig); + Mockito.when(mockCallContext.getPolarisCallContext()) + .thenReturn(callContext.getPolarisCallContext()); + + return new IcebergCatalogHandler( + diagServices, + mockCallContext, + resolutionManifestFactory, + metaStoreManager, + userSecretsManager, + securityContext(authenticatedPrincipal), + factory, + catalogName, + polarisAuthorizer, + reservedProperties, + catalogHandlerUtils, + emptyExternalCatalogFactory(), + polarisEventListener); + } + @Test public void testDropTableWithoutPurgeAllSufficientPrivileges() { assertSuccess( @@ -1906,4 +2029,207 @@ public void testSendNotificationInsufficientPermissions() { newWrapper(Set.of(PRINCIPAL_ROLE1)).sendNotification(table, request); }); } + + @Test + public void testUpdateTableWith_AssignUuid_Privilege() { + // Test that TABLE_ASSIGN_UUID privilege is required for AssignUUID MetadataUpdate + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.AssignUUID(UUID.randomUUID().toString()))); + + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_ASSIGN_UUID, + PolarisPrivilege.TABLE_WRITE_PROPERTIES, // Should also work with broader privilege + PolarisPrivilege.TABLE_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> newWrapper().updateTable(TABLE_NS1A_2, request), + null /* cleanupAction */); + } + + @Test + public void testUpdateTableWith_AssignUuidInsufficientPermissions() { + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.AssignUUID(UUID.randomUUID().toString()))); + + doTestInsufficientPrivileges( + List.of( + PolarisPrivilege.NAMESPACE_FULL_METADATA, + PolarisPrivilege.VIEW_FULL_METADATA, + PolarisPrivilege.TABLE_READ_PROPERTIES, + PolarisPrivilege.TABLE_READ_DATA, + PolarisPrivilege.TABLE_CREATE, + PolarisPrivilege.TABLE_LIST, + PolarisPrivilege.TABLE_DROP, + // Test that other fine-grained privileges don't work + PolarisPrivilege.TABLE_ADD_SCHEMA, + PolarisPrivilege.TABLE_SET_LOCATION), + () -> newWrapper().updateTable(TABLE_NS1A_2, request)); + } + + @Test + public void testUpdateTableWith_UpgradeFormatVersionPrivilege() { + // Test that TABLE_UPGRADE_FORMAT_VERSION privilege is required for UpgradeFormatVersion + // MetadataUpdate + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.UpgradeFormatVersion(2))); + + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION, + PolarisPrivilege.TABLE_WRITE_PROPERTIES, // Should also work with broader privilege + PolarisPrivilege.TABLE_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> newWrapper().updateTable(TABLE_NS1A_2, request), + null /* cleanupAction */); + } + + @Test + public void testUpdateTableWith_SetPropertiesPrivilege() { + // Test that TABLE_SET_PROPERTIES privilege is required for SetProperties MetadataUpdate + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.SetProperties(Map.of("test.property", "test.value")))); + + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_SET_PROPERTIES, + PolarisPrivilege.TABLE_WRITE_PROPERTIES, // Should also work with broader privilege + PolarisPrivilege.TABLE_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> newWrapper().updateTable(TABLE_NS1A_2, request), + null /* cleanupAction */); + } + + @Test + public void testUpdateTableWith_RemoveProperties_Privilege() { + // Test that TABLE_REMOVE_PROPERTIES privilege is required for RemoveProperties MetadataUpdate + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.RemoveProperties(Set.of("property.to.remove")))); + + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_REMOVE_PROPERTIES, + PolarisPrivilege.TABLE_WRITE_PROPERTIES, // Should also work with broader privilege + PolarisPrivilege.TABLE_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> newWrapper().updateTable(TABLE_NS1A_2, request), + null /* cleanupAction */); + } + + @Test + public void testUpdateTableWith_MultipleUpdates_Privilege() { + // Test that multiple MetadataUpdate types require multiple specific privileges + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of( + new MetadataUpdate.UpgradeFormatVersion(2), + new MetadataUpdate.SetProperties(Map.of("test.prop", "test.val")))); + + // Test that having both specific privileges works + doTestSufficientPrivilegeSets( + List.of( + Set.of( + PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION, + PolarisPrivilege.TABLE_SET_PROPERTIES), + Set.of(PolarisPrivilege.TABLE_WRITE_PROPERTIES), // Broader privilege should work + Set.of(PolarisPrivilege.TABLE_FULL_METADATA), + Set.of(PolarisPrivilege.CATALOG_MANAGE_CONTENT)), + () -> newWrapper().updateTable(TABLE_NS1A_2, request), + null /* cleanupAction */, + PRINCIPAL_NAME, + CATALOG_NAME); + } + + @Test + public void testUpdateTableWith_MultipleUpdatesInsufficientPermissions() { + // Test that having only one of the required privileges fails + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of( + new MetadataUpdate.UpgradeFormatVersion(2), + new MetadataUpdate.SetProperties(Map.of("test.prop", "test.val")))); + + // Test that having only one specific privilege fails (need both) + doTestInsufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION, // Only one of the two needed + PolarisPrivilege.TABLE_SET_PROPERTIES, // Only one of the two needed + PolarisPrivilege.TABLE_ASSIGN_UUID, // Wrong privilege + PolarisPrivilege.TABLE_READ_PROPERTIES, + PolarisPrivilege.TABLE_CREATE), + () -> newWrapper().updateTable(TABLE_NS1A_2, request)); + } + + @Test + public void testUpdateTableWith_TableManageStructureSuperPrivilege() { + // Test that TABLE_MANAGE_STRUCTURE works as a super privilege for structural operations + // (but NOT for snapshot operations like TABLE_ADD_SNAPSHOT) + + // Test structural operations that should work with TABLE_MANAGE_STRUCTURE + UpdateTableRequest structuralRequest = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of( + new MetadataUpdate.AssignUUID(UUID.randomUUID().toString()), + new MetadataUpdate.UpgradeFormatVersion(2), + new MetadataUpdate.SetProperties(Map.of("test.property", "test.value")), + new MetadataUpdate.RemoveProperties(Set.of("property.to.remove")))); + + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.TABLE_MANAGE_STRUCTURE, // Should work for all structural operations + PolarisPrivilege.TABLE_WRITE_PROPERTIES, // Should also work with broader privilege + PolarisPrivilege.TABLE_FULL_METADATA, + PolarisPrivilege.CATALOG_MANAGE_CONTENT), + () -> newWrapper().updateTable(TABLE_NS1A_2, structuralRequest), + null /* cleanupAction */); + } + + @Test + public void testUpdateTableWith_TableManageStructureDoesNotIncludeSnapshots() { + // Verify that TABLE_MANAGE_STRUCTURE does NOT grant access to snapshot operations + // This test verifies that TABLE_ADD_SNAPSHOT and TABLE_SET_SNAPSHOT_REF were correctly + // excluded from the TABLE_MANAGE_STRUCTURE super privilege mapping + + // Test that TABLE_MANAGE_STRUCTURE works for non-snapshot structural operations + UpdateTableRequest nonSnapshotRequest = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of( + new MetadataUpdate.AssignUUID(UUID.randomUUID().toString()), + new MetadataUpdate.SetProperties(Map.of("structure.test", "value")))); + + doTestSufficientPrivileges( + List.of(PolarisPrivilege.TABLE_MANAGE_STRUCTURE), + () -> newWrapper().updateTable(TABLE_NS1A_2, nonSnapshotRequest), + null /* cleanupAction */); + + // Test that TABLE_MANAGE_STRUCTURE is insufficient for operations that require + // different privilege categories (like read operations) + doTestInsufficientPrivileges( + List.of(PolarisPrivilege.TABLE_MANAGE_STRUCTURE), + () -> + newWrapper() + .loadTable(TABLE_NS1A_2, "all")); // Load table requires different privileges + } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFineGrainedDisabledTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFineGrainedDisabledTest.java new file mode 100644 index 0000000000..0e97197949 --- /dev/null +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFineGrainedDisabledTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.catalog.iceberg; + +import com.google.common.collect.ImmutableMap; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import jakarta.enterprise.inject.Instance; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.apache.iceberg.MetadataUpdate; +import org.apache.iceberg.rest.requests.UpdateTableRequest; +import org.apache.polaris.core.auth.PolarisPrincipal; +import org.apache.polaris.core.entity.PolarisPrivilege; +import org.apache.polaris.service.admin.PolarisAuthzTestBase; +import org.apache.polaris.service.context.catalog.CallContextCatalogFactory; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Test class specifically for testing fine-grained authorization when the feature is DISABLED. This + * ensures that fine-grained privileges are properly ignored when the feature flag is off. + */ +@QuarkusTest +@TestProfile(IcebergCatalogHandlerFineGrainedDisabledTest.Profile.class) +public class IcebergCatalogHandlerFineGrainedDisabledTest extends PolarisAuthzTestBase { + + @jakarta.inject.Inject CallContextCatalogFactory callContextCatalogFactory; + + @SuppressWarnings("unchecked") + private static Instance + emptyExternalCatalogFactory() { + Instance mock = + Mockito.mock(Instance.class); + Mockito.when(mock.select(Mockito.any())).thenReturn(mock); + Mockito.when(mock.isUnsatisfied()).thenReturn(true); + return mock; + } + + private IcebergCatalogHandler newWrapper() { + PolarisPrincipal authenticatedPrincipal = PolarisPrincipal.of(principalEntity, Set.of()); + return new IcebergCatalogHandler( + diagServices, + callContext, + resolutionManifestFactory, + metaStoreManager, + userSecretsManager, + securityContext(authenticatedPrincipal), + callContextCatalogFactory, + CATALOG_NAME, + polarisAuthorizer, + reservedProperties, + catalogHandlerUtils, + emptyExternalCatalogFactory(), + polarisEventListener); + } + + public static class Profile extends PolarisAuthzTestBase.Profile { + @Override + public Map getConfigOverrides() { + return ImmutableMap.builder() + .putAll(super.getConfigOverrides()) + .put("polaris.features.\"ENABLE_FINE_GRAINED_UPDATE_TABLE_PRIVILEGES\"", "false") + .build(); + } + } + + @Test + public void testUpdateTableFineGrainedPrivilegesIgnoredWhenFeatureDisabled() { + // Test that when fine-grained authorization is disabled, fine-grained privileges alone are + // insufficient + // This ensures the feature flag properly controls behavior and fine-grained privileges don't + // "leak through" + UpdateTableRequest request = + UpdateTableRequest.create( + TABLE_NS1A_2, + List.of(), // no requirements + List.of(new MetadataUpdate.AssignUUID(UUID.randomUUID().toString()))); + + // With fine-grained authorization disabled, even having the specific fine-grained privilege + // should be insufficient - the system should require the broader privileges + doTestInsufficientPrivileges( + List.of( + PolarisPrivilege + .TABLE_ASSIGN_UUID, // This alone should be insufficient when feature disabled + PolarisPrivilege.TABLE_UPGRADE_FORMAT_VERSION, + PolarisPrivilege.TABLE_SET_PROPERTIES, + PolarisPrivilege.TABLE_REMOVE_PROPERTIES, + PolarisPrivilege.TABLE_ADD_SCHEMA, + PolarisPrivilege.TABLE_SET_LOCATION, + PolarisPrivilege.TABLE_READ_PROPERTIES, + PolarisPrivilege.TABLE_READ_DATA, + PolarisPrivilege.TABLE_CREATE, + PolarisPrivilege.TABLE_LIST, + PolarisPrivilege.TABLE_DROP), + PRINCIPAL_NAME, + () -> newWrapper().updateTable(TABLE_NS1A_2, request), + (privilege) -> + adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + (privilege) -> + adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + } +} diff --git a/spec/polaris-management-service.yml b/spec/polaris-management-service.yml index 84546bbf40..59baaf99d8 100644 --- a/spec/polaris-management-service.yml +++ b/spec/polaris-management-service.yml @@ -1468,6 +1468,24 @@ components: - TABLE_FULL_METADATA - TABLE_ATTACH_POLICY - TABLE_DETACH_POLICY + - TABLE_ASSIGN_UUID + - TABLE_UPGRADE_FORMAT_VERSION + - TABLE_ADD_SCHEMA + - TABLE_SET_CURRENT_SCHEMA + - TABLE_ADD_PARTITION_SPEC + - TABLE_ADD_SORT_ORDER + - TABLE_SET_DEFAULT_SORT_ORDER + - TABLE_ADD_SNAPSHOT + - TABLE_SET_SNAPSHOT_REF + - TABLE_REMOVE_SNAPSHOTS + - TABLE_REMOVE_SNAPSHOT_REF + - TABLE_SET_LOCATION + - TABLE_SET_PROPERTIES + - TABLE_REMOVE_PROPERTIES + - TABLE_SET_STATISTICS + - TABLE_REMOVE_STATISTICS + - TABLE_REMOVE_PARTITION_SPECS + - TABLE_MANAGE_STRUCTURE PolicyPrivilege: type: string @@ -1515,6 +1533,24 @@ components: - POLICY_FULL_METADATA - NAMESPACE_ATTACH_POLICY - NAMESPACE_DETACH_POLICY + - TABLE_ASSIGN_UUID + - TABLE_UPGRADE_FORMAT_VERSION + - TABLE_ADD_SCHEMA + - TABLE_SET_CURRENT_SCHEMA + - TABLE_ADD_PARTITION_SPEC + - TABLE_ADD_SORT_ORDER + - TABLE_SET_DEFAULT_SORT_ORDER + - TABLE_ADD_SNAPSHOT + - TABLE_SET_SNAPSHOT_REF + - TABLE_REMOVE_SNAPSHOTS + - TABLE_REMOVE_SNAPSHOT_REF + - TABLE_SET_LOCATION + - TABLE_SET_PROPERTIES + - TABLE_REMOVE_PROPERTIES + - TABLE_SET_STATISTICS + - TABLE_REMOVE_STATISTICS + - TABLE_REMOVE_PARTITION_SPECS + - TABLE_MANAGE_STRUCTURE CatalogPrivilege: type: string @@ -1552,6 +1588,24 @@ components: - POLICY_FULL_METADATA - CATALOG_ATTACH_POLICY - CATALOG_DETACH_POLICY + - TABLE_ASSIGN_UUID + - TABLE_UPGRADE_FORMAT_VERSION + - TABLE_ADD_SCHEMA + - TABLE_SET_CURRENT_SCHEMA + - TABLE_ADD_PARTITION_SPEC + - TABLE_ADD_SORT_ORDER + - TABLE_SET_DEFAULT_SORT_ORDER + - TABLE_ADD_SNAPSHOT + - TABLE_SET_SNAPSHOT_REF + - TABLE_REMOVE_SNAPSHOTS + - TABLE_REMOVE_SNAPSHOT_REF + - TABLE_SET_LOCATION + - TABLE_SET_PROPERTIES + - TABLE_REMOVE_PROPERTIES + - TABLE_SET_STATISTICS + - TABLE_REMOVE_STATISTICS + - TABLE_REMOVE_PARTITION_SPECS + - TABLE_MANAGE_STRUCTURE AddGrantRequest: type: object