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 ad91ad0240..545efa6c86 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 @@ -271,6 +271,15 @@ public static void enforceFeatureEnabledOrThrow( .defaultValue(false) .buildFeatureConfiguration(); + public static final FeatureConfiguration ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS = + PolarisConfiguration.builder() + .key("ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS") + .description( + "When enabled, allows RBAC operations to create synthetic entities for" + + " entities in federated catalogs that don't exist in the local metastore.") + .defaultValue(false) + .buildFeatureConfiguration(); + public static final FeatureConfiguration ENABLE_POLICY_STORE = PolarisConfiguration.builder() .key("ENABLE_POLICY_STORE") diff --git a/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java b/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java index 469bab0896..d39ebfae6b 100644 --- a/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java +++ b/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java @@ -102,6 +102,7 @@ import org.apache.polaris.core.exceptions.CommitConflictException; import org.apache.polaris.core.persistence.PolarisMetaStoreManager; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; +import org.apache.polaris.core.persistence.dao.entity.BaseResult; import org.apache.polaris.core.persistence.dao.entity.CreateCatalogResult; import org.apache.polaris.core.persistence.dao.entity.CreatePrincipalResult; import org.apache.polaris.core.persistence.dao.entity.DropEntityResult; @@ -446,7 +447,7 @@ private void authorizeGrantOnNamespaceOperationOrThrow( resolutionManifest = resolutionManifestFactory.createResolutionManifest( callContext, securityContext, catalogName); - resolutionManifest.addPath( + resolutionManifest.addPassthroughPath( new ResolverPath(Arrays.asList(namespace.levels()), PolarisEntityType.NAMESPACE), namespace); resolutionManifest.addPath( @@ -487,7 +488,11 @@ private void authorizeGrantOnTableLikeOperationOrThrow( resolutionManifest = resolutionManifestFactory.createResolutionManifest( callContext, securityContext, catalogName); - resolutionManifest.addPath( + resolutionManifest.addPassthroughPath( + new ResolverPath( + Arrays.asList(identifier.namespace().levels()), PolarisEntityType.NAMESPACE), + identifier.namespace()); + resolutionManifest.addPassthroughPath( new ResolverPath( PolarisCatalogHelpers.tableIdentifierToList(identifier), PolarisEntityType.TABLE_LIKE), identifier); @@ -509,7 +514,12 @@ private void authorizeGrantOnTableLikeOperationOrThrow( PolarisResolvedPathWrapper tableLikeWrapper = resolutionManifest.getResolvedPath( identifier, PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ANY_SUBTYPE, true); - if (!subTypes.contains(tableLikeWrapper.getRawLeafEntity().getSubType())) { + boolean rbacForFederatedCatalogsEnabled = + getCurrentPolarisContext() + .getRealmConfig() + .getConfig(FeatureConfiguration.ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS); + if (!(resolutionManifest.getIsPassthroughFacade() && rbacForFederatedCatalogsEnabled) + && !subTypes.contains(tableLikeWrapper.getRawLeafEntity().getSubType())) { CatalogHandler.throwNotFoundExceptionForTableLikeEntity(identifier, subTypes); } @@ -1687,6 +1697,9 @@ public PrivilegeResult grantPrivilegeOnNamespaceToRole( PolarisAuthorizableOperation.ADD_NAMESPACE_GRANT_TO_CATALOG_ROLE; authorizeGrantOnNamespaceOperationOrThrow(op, catalogName, namespace, catalogRoleName); + CatalogEntity catalogEntity = + findCatalogByName(catalogName) + .orElseThrow(() -> new NotFoundException("Parent catalog %s not found", catalogName)); PolarisEntity catalogRoleEntity = findCatalogRoleByName(catalogName, catalogRoleName) .orElseThrow(() -> new NotFoundException("CatalogRole %s not found", catalogRoleName)); @@ -1694,7 +1707,24 @@ public PrivilegeResult grantPrivilegeOnNamespaceToRole( PolarisResolvedPathWrapper resolvedPathWrapper = resolutionManifest.getResolvedPath(namespace); if (resolvedPathWrapper == null || !resolvedPathWrapper.isFullyResolvedNamespace(catalogName, namespace)) { - throw new NotFoundException("Namespace %s not found", namespace); + boolean rbacForFederatedCatalogsEnabled = + getCurrentPolarisContext() + .getRealmConfig() + .getConfig(FeatureConfiguration.ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS); + if (resolutionManifest.getIsPassthroughFacade() && rbacForFederatedCatalogsEnabled) { + resolvedPathWrapper = + createSyntheticNamespaceEntities(catalogEntity, namespace, resolvedPathWrapper); + if (resolvedPathWrapper == null + || !resolvedPathWrapper.isFullyResolvedNamespace(catalogName, namespace)) { + // TODO: update the exception thrown as we refine the possible retry scenarios + throw new RuntimeException( + String.format( + "Failed to create synthetic namespace entities for namespace %s in catalog %s", + namespace, catalogName)); + } + } else { + throw new NotFoundException("Namespace %s not found", namespace); + } } List catalogPath = resolvedPathWrapper.getRawParentPath(); PolarisEntity namespaceEntity = resolvedPathWrapper.getRawLeafEntity(); @@ -1734,6 +1764,86 @@ public PrivilegeResult revokePrivilegeOnNamespaceFromRole( privilege); } + /** + * Creates and persists the missing synthetic namespace entities for external catalogs. + * + * @param catalogEntity the external passthrough facade catalog entity. + * @param namespace the expected fully resolved namespace to be created. + * @param existingPath the partially resolved path currently stored in the metastore. + * @return the fully resolved path wrapper. + */ + private PolarisResolvedPathWrapper createSyntheticNamespaceEntities( + CatalogEntity catalogEntity, Namespace namespace, PolarisResolvedPathWrapper existingPath) { + + if (existingPath == null) { + throw new IllegalStateException( + String.format("Catalog entity %s does not exist.", catalogEntity.getName())); + } + + List completePath = new ArrayList<>(existingPath.getRawFullPath()); + PolarisEntity currentParent = existingPath.getRawLeafEntity(); + + String[] allNamespaceLevels = namespace.levels(); + int numMatchingLevels = 0; + // Find parts of the complete path that match the namespace levels. + // We skip index 0 because it is the CatalogEntity. + for (PolarisEntity entity : completePath.subList(1, completePath.size())) { + if (!entity.getName().equals(allNamespaceLevels[numMatchingLevels])) { + break; + } + numMatchingLevels++; + } + + for (int i = numMatchingLevels; i < allNamespaceLevels.length; i++) { + String[] namespacePart = Arrays.copyOfRange(allNamespaceLevels, 0, i + 1); + String leafNamespace = namespacePart[namespacePart.length - 1]; + Namespace currentNamespace = Namespace.of(namespacePart); + + // TODO: Instead of creating synthetic entitties, rely on external catalog mediated backfill. + PolarisEntity syntheticNamespace = + new NamespaceEntity.Builder(currentNamespace) + .setId(metaStoreManager.generateNewEntityId(getCurrentPolarisContext()).getId()) + .setCatalogId(catalogEntity.getId()) + .setParentId(currentParent.getId()) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + EntityResult result = + metaStoreManager.createEntityIfNotExists( + getCurrentPolarisContext(), + PolarisEntity.toCoreList(completePath), + syntheticNamespace); + + if (result.isSuccess()) { + syntheticNamespace = PolarisEntity.of(result.getEntity()); + } else if (result.getReturnStatus() == BaseResult.ReturnStatus.ENTITY_ALREADY_EXISTS) { + PolarisResolvedPathWrapper partialPath = + resolutionManifest.getPassthroughResolvedPath(namespace); + PolarisEntity partialLeafEntity = + partialPath != null ? partialPath.getRawLeafEntity() : null; + if (partialLeafEntity == null + || !(partialLeafEntity.getName().equals(leafNamespace) + && partialLeafEntity.getType() == PolarisEntityType.NAMESPACE)) { + throw new RuntimeException( + String.format( + "Failed to create or find namespace entity '%s' in federated catalog '%s'", + leafNamespace, catalogEntity.getName())); + } + syntheticNamespace = partialLeafEntity; + } else { + throw new RuntimeException( + String.format( + "Failed to create or find namespace entity '%s' in federated catalog '%s'", + leafNamespace, catalogEntity.getName())); + } + completePath.add(syntheticNamespace); + currentParent = syntheticNamespace; + } + PolarisResolvedPathWrapper resolvedPathWrapper = + resolutionManifest.getPassthroughResolvedPath(namespace); + return resolvedPathWrapper; + } + public PrivilegeResult grantPrivilegeOnTableToRole( String catalogName, String catalogRoleName, @@ -2011,9 +2121,9 @@ private PrivilegeResult grantPrivilegeOnTableLikeToRole( TableIdentifier identifier, List subTypes, PolarisPrivilege privilege) { - if (findCatalogByName(catalogName).isEmpty()) { - throw new NotFoundException("Parent catalog %s not found", catalogName); - } + CatalogEntity catalogEntity = + findCatalogByName(catalogName) + .orElseThrow(() -> new NotFoundException("Parent catalog %s not found", catalogName)); PolarisEntity catalogRoleEntity = findCatalogRoleByName(catalogName, catalogRoleName) .orElseThrow(() -> new NotFoundException("CatalogRole %s not found", catalogRoleName)); @@ -2023,7 +2133,25 @@ private PrivilegeResult grantPrivilegeOnTableLikeToRole( identifier, PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ANY_SUBTYPE); if (resolvedPathWrapper == null || !subTypes.contains(resolvedPathWrapper.getRawLeafEntity().getSubType())) { - CatalogHandler.throwNotFoundExceptionForTableLikeEntity(identifier, subTypes); + boolean rbacForFederatedCatalogsEnabled = + getCurrentPolarisContext() + .getRealmConfig() + .getConfig(FeatureConfiguration.ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS); + if (resolutionManifest.getIsPassthroughFacade() && rbacForFederatedCatalogsEnabled) { + resolvedPathWrapper = + createSyntheticTableLikeEntities( + catalogEntity, identifier, subTypes, resolvedPathWrapper); + if (resolvedPathWrapper == null + || !subTypes.contains(resolvedPathWrapper.getRawLeafEntity().getSubType())) { + // TODO: update the exception thrown as we refine the possible retry scenarios + throw new RuntimeException( + String.format( + "Failed to create synthetic table-like entity for table %s in catalog %s", + identifier.name(), catalogEntity.getName())); + } + } else { + CatalogHandler.throwNotFoundExceptionForTableLikeEntity(identifier, subTypes); + } } List catalogPath = resolvedPathWrapper.getRawParentPath(); PolarisEntity tableLikeEntity = resolvedPathWrapper.getRawLeafEntity(); @@ -2036,6 +2164,77 @@ private PrivilegeResult grantPrivilegeOnTableLikeToRole( privilege); } + /** + * Creates and persists the missing synthetic table-like entity and its parent namespace entities + * for external catalogs. + * + * @param catalogEntity the external passthrough facade catalog entity. + * @param identifier the path of the table-like entity(including the namespace). + * @param subTypes the expected subtypes of the table-like entity + * @param existingPathWrapper the partially resolved path currently stored in the metastore. + * @return the resolved path wrapper + */ + private PolarisResolvedPathWrapper createSyntheticTableLikeEntities( + CatalogEntity catalogEntity, + TableIdentifier identifier, + List subTypes, + PolarisResolvedPathWrapper existingPathWrapper) { + + Namespace namespace = identifier.namespace(); + PolarisResolvedPathWrapper resolvedNamespacePathWrapper = + !namespace.isEmpty() + ? createSyntheticNamespaceEntities(catalogEntity, namespace, existingPathWrapper) + : existingPathWrapper; + + if (resolvedNamespacePathWrapper == null + || (!namespace.isEmpty() + && !resolvedNamespacePathWrapper.isFullyResolvedNamespace( + catalogEntity.getName(), namespace))) { + throw new RuntimeException( + String.format( + "Failed to create synthetic namespace entities for namespace %s in catalog %s", + namespace.toString(), catalogEntity.getName())); + } + + PolarisEntity parentNamespaceEntity = resolvedNamespacePathWrapper.getRawLeafEntity(); + + // TODO: Once we support GENERIC_TABLE federation, select the intended type depending on the + // callsite; if it is instantiated via an Iceberg RESTCatalog factory or a different factory + // for GenericCatalogs. + PolarisEntitySubType syntheticEntitySubType = selectEntitySubType(subTypes); + + // TODO: Instead of creating a synthetic table-like entity, rely on external catalog mediated + // backfill and use the metadata location from the external catalog. + PolarisEntity syntheticTableEntity = + new IcebergTableLikeEntity.Builder(syntheticEntitySubType, identifier, "") + .setParentId(parentNamespaceEntity.getId()) + .setId(metaStoreManager.generateNewEntityId(getCurrentPolarisContext()).getId()) + .setCatalogId(parentNamespaceEntity.getCatalogId()) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + // We will re-resolve later anyway, so + metaStoreManager.createEntityIfNotExists( + getCurrentPolarisContext(), + PolarisEntity.toCoreList(resolvedNamespacePathWrapper.getRawFullPath()), + syntheticTableEntity); + + PolarisResolvedPathWrapper completePathWrapper = + resolutionManifest.getPassthroughResolvedPath(identifier); + PolarisEntity leafEntity = + completePathWrapper != null ? completePathWrapper.getRawLeafEntity() : null; + if (completePathWrapper == null + || leafEntity == null + || !(leafEntity.getType() == PolarisEntityType.TABLE_LIKE + && leafEntity.getSubType() == PolarisEntitySubType.ICEBERG_TABLE + && Objects.equals(leafEntity.getName(), identifier.name()))) { + throw new RuntimeException( + String.format( + "Failed to create or find table entity '%s' in federated catalog '%s'", + identifier.name(), catalogEntity.getName())); + } + return completePathWrapper; + } + /** * Removes a table-level or view-level grant on {@code identifier} from {@code catalogRoleName}. */ @@ -2125,4 +2324,25 @@ private PrivilegeResult revokePrivilegeOnPolicyEntityFromRole( policyEntity, privilege); } + + /** + * Selects the appropriate entity subtype for synthetic entities in external catalogs. + * + * @param subTypes list of candidate subtypes + * @return the selected subtype for the synthetic entity + * @throws IllegalStateException if no supported subtype is found + */ + private static PolarisEntitySubType selectEntitySubType(List subTypes) { + if (subTypes.contains(PolarisEntitySubType.ICEBERG_TABLE)) { + return PolarisEntitySubType.ICEBERG_TABLE; + } else if (subTypes.contains(PolarisEntitySubType.ICEBERG_VIEW)) { + return PolarisEntitySubType.ICEBERG_VIEW; + } else { + throw new IllegalStateException( + String.format( + "No supported subtype found in %s. Only ICEBERG_TABLE and ICEBERG_VIEW are" + + " supported for synthetic entities in external catalogs.", + subTypes)); + } + } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java index ad5fa0ce6a..b60bcb0a92 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceAuthzTest.java @@ -37,6 +37,8 @@ import org.apache.polaris.core.entity.PrincipalRoleEntity; import org.apache.polaris.core.persistence.dao.entity.PrivilegeResult; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; @QuarkusTest @TestProfile(PolarisAuthzTestBase.Profile.class) @@ -1498,8 +1500,9 @@ public void testRevokePrivilegeOnCatalogFromRoleInsufficientPrivileges() { adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); } - @Test - public void testGrantPrivilegeOnNamespaceToRoleSufficientPrivileges() { + @ParameterizedTest(name = "{displayName}({0})") + @ValueSource(strings = {CATALOG_NAME, FEDERATED_CATALOG_NAME}) + public void testGrantPrivilegeOnNamespaceToRoleSufficientPrivileges(String catalogName) { doTestSufficientPrivileges( List.of( PolarisPrivilege.CATALOG_MANAGE_ACCESS, @@ -1507,16 +1510,39 @@ public void testGrantPrivilegeOnNamespaceToRoleSufficientPrivileges() { () -> newTestAdminService(Set.of(PRINCIPAL_ROLE1)) .grantPrivilegeOnNamespaceToRole( - CATALOG_NAME, CATALOG_ROLE2, NS1, PolarisPrivilege.CATALOG_MANAGE_ACCESS), + catalogName, CATALOG_ROLE2, NS1, PolarisPrivilege.CATALOG_MANAGE_ACCESS), null, // cleanupAction (privilege) -> - adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + adminService.grantPrivilegeOnCatalogToRole(catalogName, CATALOG_ROLE1, privilege), (privilege) -> - adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + adminService.revokePrivilegeOnCatalogFromRole(catalogName, CATALOG_ROLE1, privilege)); } @Test - public void testGrantPrivilegeOnNamespaceToRoleInsufficientPrivileges() { + public void testGrantPrivilegeOnNamespaceToRoleSufficientPrivileges_FederationNestedNamespace() { + doTestSufficientPrivileges( + List.of( + PolarisPrivilege.CATALOG_MANAGE_ACCESS, + PolarisPrivilege.NAMESPACE_MANAGE_GRANTS_ON_SECURABLE), + () -> + newTestAdminService(Set.of(PRINCIPAL_ROLE1)) + .grantPrivilegeOnNamespaceToRole( + FEDERATED_CATALOG_NAME, + CATALOG_ROLE2, + NS1AA, + PolarisPrivilege.CATALOG_MANAGE_ACCESS), + null, // cleanupAction + (privilege) -> + adminService.grantPrivilegeOnCatalogToRole( + FEDERATED_CATALOG_NAME, CATALOG_ROLE1, privilege), + (privilege) -> + adminService.revokePrivilegeOnCatalogFromRole( + FEDERATED_CATALOG_NAME, CATALOG_ROLE1, privilege)); + } + + @ParameterizedTest(name = "{displayName}({0})") + @ValueSource(strings = {CATALOG_NAME, FEDERATED_CATALOG_NAME}) + public void testGrantPrivilegeOnNamespaceToRoleInsufficientPrivileges(String catalogName) { doTestInsufficientPrivileges( List.of( PolarisPrivilege.PRINCIPAL_MANAGE_GRANTS_FOR_GRANTEE, @@ -1543,11 +1569,11 @@ public void testGrantPrivilegeOnNamespaceToRoleInsufficientPrivileges() { () -> newTestAdminService(Set.of(PRINCIPAL_ROLE1)) .grantPrivilegeOnNamespaceToRole( - CATALOG_NAME, CATALOG_ROLE2, NS1, PolarisPrivilege.CATALOG_MANAGE_ACCESS), + catalogName, CATALOG_ROLE2, NS1, PolarisPrivilege.CATALOG_MANAGE_ACCESS), (privilege) -> - adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + adminService.grantPrivilegeOnCatalogToRole(catalogName, CATALOG_ROLE1, privilege), (privilege) -> - adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + adminService.revokePrivilegeOnCatalogFromRole(catalogName, CATALOG_ROLE1, privilege)); } @Test @@ -1606,8 +1632,9 @@ public void testRevokePrivilegeOnNamespaceFromRoleInsufficientPrivileges() { adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); } - @Test - public void testGrantPrivilegeOnTableToRoleSufficientPrivileges() { + @ParameterizedTest(name = "{displayName}({0})") + @ValueSource(strings = {CATALOG_NAME, FEDERATED_CATALOG_NAME}) + public void testGrantPrivilegeOnTableToRoleSufficientPrivileges(String catalogName) { doTestSufficientPrivileges( List.of( PolarisPrivilege.CATALOG_MANAGE_ACCESS, @@ -1615,15 +1642,15 @@ public void testGrantPrivilegeOnTableToRoleSufficientPrivileges() { () -> newTestAdminService(Set.of(PRINCIPAL_ROLE1)) .grantPrivilegeOnTableToRole( - CATALOG_NAME, + catalogName, CATALOG_ROLE2, TABLE_NS1_1, PolarisPrivilege.CATALOG_MANAGE_ACCESS), null, // cleanupAction (privilege) -> - adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege), + adminService.grantPrivilegeOnCatalogToRole(catalogName, CATALOG_ROLE1, privilege), (privilege) -> - adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege)); + adminService.revokePrivilegeOnCatalogFromRole(catalogName, CATALOG_ROLE1, privilege)); } @Test diff --git a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceTest.java b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceTest.java index 60e0559426..d44ec0cbff 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceTest.java @@ -18,6 +18,8 @@ */ package org.apache.polaris.service.admin; +import static org.apache.polaris.core.entity.PolarisEntitySubType.ICEBERG_TABLE; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -27,19 +29,28 @@ import jakarta.ws.rs.core.SecurityContext; import java.util.List; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.exceptions.NotFoundException; import org.apache.polaris.core.PolarisCallContext; import org.apache.polaris.core.PolarisDefaultDiagServiceImpl; import org.apache.polaris.core.PolarisDiagnostics; import org.apache.polaris.core.auth.PolarisAuthorizer; import org.apache.polaris.core.auth.PolarisPrincipal; +import org.apache.polaris.core.config.FeatureConfiguration; +import org.apache.polaris.core.config.RealmConfig; import org.apache.polaris.core.context.CallContext; +import org.apache.polaris.core.entity.NamespaceEntity; import org.apache.polaris.core.entity.PolarisEntity; +import org.apache.polaris.core.entity.PolarisEntitySubType; import org.apache.polaris.core.entity.PolarisEntityType; import org.apache.polaris.core.entity.PolarisPrivilege; +import org.apache.polaris.core.entity.table.IcebergTableLikeEntity; import org.apache.polaris.core.persistence.PolarisMetaStoreManager; import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; import org.apache.polaris.core.persistence.dao.entity.BaseResult; +import org.apache.polaris.core.persistence.dao.entity.EntityResult; +import org.apache.polaris.core.persistence.dao.entity.GenerateEntityIdResult; import org.apache.polaris.core.persistence.dao.entity.PrivilegeResult; import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifest; import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory; @@ -65,6 +76,7 @@ public class PolarisAdminServiceTest { @Mock private PolarisPrincipal authenticatedPrincipal; @Mock private PolarisResolutionManifest resolutionManifest; @Mock private PolarisResolvedPathWrapper resolvedPathWrapper; + @Mock private RealmConfig realmConfig; private PolarisAdminService adminService; @@ -73,6 +85,16 @@ void setUp() throws Exception { MockitoAnnotations.openMocks(this); when(securityContext.getUserPrincipal()).thenReturn(authenticatedPrincipal); when(callContext.getPolarisCallContext()).thenReturn(polarisCallContext); + when(polarisCallContext.getRealmConfig()).thenReturn(realmConfig); + + // Default feature configuration - enabled by default + when(realmConfig.getConfig(FeatureConfiguration.ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS)) + .thenReturn(true); + + when(resolutionManifestFactory.createResolutionManifest(any(), any(), any())) + .thenReturn(resolutionManifest); + when(resolutionManifest.resolveAll()).thenReturn(createSuccessfulResolverStatus()); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(false); adminService = new PolarisAdminService( @@ -122,8 +144,14 @@ void testGrantPrivilegeOnNamespaceToRole_ThrowsNamespaceNotFoundException() { .thenReturn(resolutionManifest); when(resolutionManifest.resolveAll()).thenReturn(createSuccessfulResolverStatus()); + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); - PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); @@ -149,8 +177,14 @@ void testGrantPrivilegeOnNamespaceToRole_IncompleteNamespaceThrowsNamespaceNotFo .thenReturn(resolutionManifest); when(resolutionManifest.resolveAll()).thenReturn(createSuccessfulResolverStatus()); + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG, 1L); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); - PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); @@ -159,7 +193,7 @@ void testGrantPrivilegeOnNamespaceToRole_IncompleteNamespaceThrowsNamespaceNotFo .thenReturn( List.of( createEntity("test-catalog", PolarisEntityType.CATALOG), - createEntity("complete-ns", PolarisEntityType.NAMESPACE))); + createNamespaceEntity(Namespace.of("complete-ns"), 3L, 1L))); when(resolvedPathWrapper.isFullyResolvedNamespace(eq(catalogName), eq(namespace))) .thenReturn(false); @@ -204,7 +238,8 @@ void testRevokePrivilegeOnNamespaceFromRole_ThrowsNamespaceNotFoundException() { when(resolutionManifest.resolveAll()).thenReturn(createSuccessfulResolverStatus()); PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); - PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); @@ -231,7 +266,8 @@ void testRevokePrivilegeOnNamespaceFromRole_IncompletelNamespaceThrowsNamespaceN when(resolutionManifest.resolveAll()).thenReturn(createSuccessfulResolverStatus()); PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); - PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); @@ -249,13 +285,389 @@ void testRevokePrivilegeOnNamespaceFromRole_IncompletelNamespaceThrowsNamespaceN .hasMessageContaining("Namespace " + namespace + " not found"); } + @Test + void testGrantPrivilegeOnNamespaceToRole_PassthroughFacade() throws Exception { + String catalogName = "test-catalog"; + String catalogRoleName = "test-role"; + Namespace namespace = Namespace.of("org-ns", "team-ns", "project-ns"); + PolarisPrivilege privilege = PolarisPrivilege.NAMESPACE_FULL_METADATA; + + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(true); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + + PolarisEntity orgNsEntity = createNamespaceEntity(Namespace.of("org-ns"), 3L, 1L); + when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(resolvedPathWrapper); + when(resolvedPathWrapper.getRawFullPath()).thenReturn(List.of(catalogEntity, orgNsEntity)); + when(resolvedPathWrapper.getRawLeafEntity()).thenReturn(orgNsEntity); + + // Mock creation of team-ns. + GenerateEntityIdResult idResult = mock(GenerateEntityIdResult.class); + when(idResult.getId()).thenReturn(4L); + when(metaStoreManager.generateNewEntityId(any())).thenReturn(idResult); + EntityResult teamNsCreateResult = mock(EntityResult.class); + EntityResult projectNsCreateResult = mock(EntityResult.class); + when(teamNsCreateResult.isSuccess()).thenReturn(true); + when(projectNsCreateResult.isSuccess()).thenReturn(true); + + PolarisEntity teamNsEntity = createNamespaceEntity(Namespace.of("org-ns", "team-ns"), 4L, 3L); + when(teamNsCreateResult.getEntity()).thenReturn(teamNsEntity); + + // Mock creation of project-ns. + when(idResult.getId()).thenReturn(5L); + when(metaStoreManager.generateNewEntityId(any())).thenReturn(idResult); + PolarisEntity projectNsEntity = + createNamespaceEntity(Namespace.of("org-ns", "team-ns", "project-ns"), 5L, 4L); + when(projectNsCreateResult.getEntity()).thenReturn(projectNsEntity); + + when(metaStoreManager.createEntityIfNotExists(any(), any(), any())) + .thenReturn(teamNsCreateResult, projectNsCreateResult); + + // Mock successful synthetic namespace resolution. + PolarisResolvedPathWrapper syntheticPathWrapper = mock(PolarisResolvedPathWrapper.class); + when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(syntheticPathWrapper); + when(syntheticPathWrapper.isFullyResolvedNamespace(eq(catalogName), eq(namespace))) + .thenReturn(true); + + PrivilegeResult successResult = mock(PrivilegeResult.class); + when(successResult.isSuccess()).thenReturn(true); + when(metaStoreManager.grantPrivilegeOnSecurableToRole(any(), any(), any(), any(), any())) + .thenReturn(successResult); + + PrivilegeResult result = + adminService.grantPrivilegeOnNamespaceToRole( + catalogName, catalogRoleName, namespace, privilege); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void testGrantPrivilegeOnNamespaceToRole_PassthroughFacade_FeatureDisabled() throws Exception { + String catalogName = "test-catalog"; + String catalogRoleName = "test-role"; + Namespace namespace = Namespace.of("org-ns", "team-ns", "project-ns"); + PolarisPrivilege privilege = PolarisPrivilege.NAMESPACE_FULL_METADATA; + + // Disable the feature configuration + when(realmConfig.getConfig(FeatureConfiguration.ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS)) + .thenReturn(false); + + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(true); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + + // Create a mock resolved path that returns null initially and is not fully resolved + PolarisResolvedPathWrapper unresolvedWrapper = mock(PolarisResolvedPathWrapper.class); + when(unresolvedWrapper.isFullyResolvedNamespace(eq(catalogName), eq(namespace))) + .thenReturn(false); + when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(unresolvedWrapper); + + // Should throw NotFoundException because feature is disabled and it's passthrough facade + assertThatThrownBy( + () -> + adminService.grantPrivilegeOnNamespaceToRole( + catalogName, catalogRoleName, namespace, privilege)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Namespace " + namespace + " not found"); + } + + @Test + void testGrantPrivilegeOnNamespaceToRole_SyntheticEntityCreationFails() throws Exception { + String catalogName = "test-catalog"; + String catalogRoleName = "test-role"; + Namespace namespace = Namespace.of("org-ns", "team-ns", "project-ns"); + PolarisPrivilege privilege = PolarisPrivilege.NAMESPACE_FULL_METADATA; + + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(true); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + + PolarisEntity orgNsEntity = createNamespaceEntity(Namespace.of("org-ns"), 3L, 1L); + when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(resolvedPathWrapper); + when(resolvedPathWrapper.getRawFullPath()).thenReturn(List.of(catalogEntity, orgNsEntity)); + when(resolvedPathWrapper.getRawLeafEntity()).thenReturn(orgNsEntity); + + // Mock generateNewEntityId for team-ns + GenerateEntityIdResult idResult = mock(GenerateEntityIdResult.class); + when(idResult.getId()).thenReturn(4L); + when(metaStoreManager.generateNewEntityId(any())).thenReturn(idResult); + + // Mock createEntityIfNotExists to fail + EntityResult failedResult = mock(EntityResult.class); + when(failedResult.isSuccess()).thenReturn(false); + when(metaStoreManager.createEntityIfNotExists(any(), any(), any())).thenReturn(failedResult); + + // Mock getResolvedPath to return null for partial namespace + PolarisResolvedPathWrapper partialPathWrapper = mock(PolarisResolvedPathWrapper.class); + when(partialPathWrapper.getRawLeafEntity()).thenReturn(orgNsEntity); + when(resolutionManifest.getResolvedPath(eq(Namespace.of("org-ns", "team-ns")))) + .thenReturn(partialPathWrapper); + + assertThatThrownBy( + () -> + adminService.grantPrivilegeOnNamespaceToRole( + catalogName, catalogRoleName, namespace, privilege)) + .isInstanceOf(RuntimeException.class) + .hasMessage( + "Failed to create or find namespace entity 'team-ns' in federated catalog 'test-catalog'"); + } + + @Test + void testGrantPrivilegeOnTableLikeToRole_PassthroughFacade() throws Exception { + String catalogName = "test-catalog"; + String catalogRoleName = "test-role"; + Namespace namespace = Namespace.of("org-ns", "team-ns", "project-ns"); + TableIdentifier identifier = TableIdentifier.of(namespace, "test-table"); + PolarisPrivilege privilege = PolarisPrivilege.TABLE_WRITE_DATA; + + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(true); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + + PolarisEntity orgNsEntity = createNamespaceEntity(Namespace.of("org-ns"), 3L, 1L); + PolarisEntity teamNsEntity = createNamespaceEntity(Namespace.of("org-ns", "team-ns"), 4L, 3L); + + PolarisResolvedPathWrapper existingPathWrapper = mock(PolarisResolvedPathWrapper.class); + when(existingPathWrapper.getRawFullPath()) + .thenReturn(List.of(catalogEntity, orgNsEntity, teamNsEntity)); + when(existingPathWrapper.getRawLeafEntity()).thenReturn(teamNsEntity); + when(resolutionManifest.getResolvedPath( + identifier, PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ANY_SUBTYPE)) + .thenReturn(existingPathWrapper); + when(existingPathWrapper.getRawLeafEntity()).thenReturn(teamNsEntity); + + GenerateEntityIdResult idResult = mock(GenerateEntityIdResult.class); + when(idResult.getId()).thenReturn(5L); + when(metaStoreManager.generateNewEntityId(any())).thenReturn(idResult); + PolarisEntity projectNsEntity = + createNamespaceEntity(Namespace.of("org-ns", "team-ns", "project-ns"), 5L, 4L); + EntityResult projectNsCreateResult = mock(EntityResult.class); + when(projectNsCreateResult.isSuccess()).thenReturn(true); + when(projectNsCreateResult.getEntity()).thenReturn(projectNsEntity); + when(metaStoreManager.createEntityIfNotExists(any(), any(), any())) + .thenReturn(projectNsCreateResult); + + PolarisResolvedPathWrapper syntheticPathWrapper = mock(PolarisResolvedPathWrapper.class); + when(syntheticPathWrapper.getRawFullPath()) + .thenReturn(List.of(catalogEntity, orgNsEntity, teamNsEntity, projectNsEntity)); + when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(syntheticPathWrapper); + when(syntheticPathWrapper.isFullyResolvedNamespace(eq(catalogName), eq(namespace))) + .thenReturn(true); + when(syntheticPathWrapper.getRawLeafEntity()).thenReturn(projectNsEntity); + + when(idResult.getId()).thenReturn(6L); + when(metaStoreManager.generateNewEntityId(any())).thenReturn(idResult); + PolarisEntity tableEntity = createTableEntity(identifier, ICEBERG_TABLE, 6L, 5L); + EntityResult tableCreateResult = mock(EntityResult.class); + when(tableCreateResult.isSuccess()).thenReturn(true); + when(tableCreateResult.getEntity()).thenReturn(tableEntity); + when(metaStoreManager.createEntityIfNotExists(any(), any(), any())) + .thenReturn(tableCreateResult); + + PolarisResolvedPathWrapper tablePathWrapper = mock(PolarisResolvedPathWrapper.class); + when(tablePathWrapper.getRawLeafEntity()).thenReturn(tableEntity); + when(resolutionManifest.getResolvedPath( + identifier, PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ANY_SUBTYPE)) + .thenReturn(tablePathWrapper); + + PrivilegeResult successResult = mock(PrivilegeResult.class); + when(successResult.isSuccess()).thenReturn(true); + when(metaStoreManager.grantPrivilegeOnSecurableToRole(any(), any(), any(), any(), any())) + .thenReturn(successResult); + + PrivilegeResult result = + adminService.grantPrivilegeOnTableToRole( + catalogName, catalogRoleName, identifier, privilege); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void testGrantPrivilegeOnTableLikeToRole_PassthroughFacade_FeatureDisabled() throws Exception { + String catalogName = "test-catalog"; + String catalogRoleName = "test-role"; + Namespace namespace = Namespace.of("org-ns", "team-ns", "project-ns"); + TableIdentifier identifier = TableIdentifier.of(namespace, "test-table"); + PolarisPrivilege privilege = PolarisPrivilege.TABLE_WRITE_DATA; + + // Disable the feature configuration + when(realmConfig.getConfig(FeatureConfiguration.ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS)) + .thenReturn(false); + + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(true); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + + // Create a table entity for authorization but later it should not be found + PolarisEntity tableEntity = + createEntity("test-table", PolarisEntityType.TABLE_LIKE, ICEBERG_TABLE, 5L, 4L); + PolarisResolvedPathWrapper tableWrapper = mock(PolarisResolvedPathWrapper.class); + when(tableWrapper.getRawLeafEntity()).thenReturn(tableEntity); + + // Mock authorization path with table + when(resolutionManifest.getResolvedPath( + eq(identifier), + eq(PolarisEntityType.TABLE_LIKE), + eq(PolarisEntitySubType.ANY_SUBTYPE), + eq(true))) + .thenReturn(tableWrapper); + + // Mock the main resolution to return null (table not found in main logic) + when(resolutionManifest.getResolvedPath( + eq(identifier), eq(PolarisEntityType.TABLE_LIKE), eq(PolarisEntitySubType.ANY_SUBTYPE))) + .thenReturn(null); + + // Should throw NoSuchTableException because feature is disabled + assertThatThrownBy( + () -> + adminService.grantPrivilegeOnTableToRole( + catalogName, catalogRoleName, identifier, privilege)) + .isInstanceOf(NoSuchTableException.class) + .hasMessageContaining("Table does not exist"); + } + + @Test + void testGrantPrivilegeOnTableLikeToRole_SyntheticEntityCreationFails() throws Exception { + String catalogName = "test-catalog"; + String catalogRoleName = "test-role"; + TableIdentifier identifier = TableIdentifier.of(Namespace.empty(), "test-table"); + PolarisPrivilege privilege = PolarisPrivilege.TABLE_WRITE_DATA; + + PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + when(resolutionManifest.getIsPassthroughFacade()).thenReturn(true); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + + PolarisResolvedPathWrapper existingPathWrapper = mock(PolarisResolvedPathWrapper.class); + when(existingPathWrapper.getRawFullPath()).thenReturn(List.of(catalogEntity)); + when(resolutionManifest.getResolvedPath( + identifier, PolarisEntityType.TABLE_LIKE, PolarisEntitySubType.ANY_SUBTYPE)) + .thenReturn(existingPathWrapper); + when(existingPathWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + + GenerateEntityIdResult idResult = mock(GenerateEntityIdResult.class); + when(idResult.getId()).thenReturn(3L); + when(metaStoreManager.generateNewEntityId(any())).thenReturn(idResult); + EntityResult tableCreateResult = mock(EntityResult.class); + when(metaStoreManager.createEntityIfNotExists(any(), any(), any())) + .thenReturn(tableCreateResult); + when(tableCreateResult.isSuccess()).thenReturn(false); + + when(resolutionManifest.getResolvedPath(identifier)).thenReturn(existingPathWrapper); + when(existingPathWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + + assertThatThrownBy( + () -> + adminService.grantPrivilegeOnTableToRole( + catalogName, catalogRoleName, identifier, privilege)) + .isInstanceOf(RuntimeException.class) + .hasMessage( + "Failed to create or find table entity 'test-table' in federated catalog 'test-catalog'"); + } + private PolarisEntity createEntity(String name, PolarisEntityType type) { return new PolarisEntity.Builder() .setName(name) .setType(type) .setId(1L) .setCatalogId(1L) - .setParentId(1L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + } + + private PolarisEntity createEntity(String name, PolarisEntityType type, long id) { + return new PolarisEntity.Builder() + .setName(name) + .setType(type) + .setId(id) + .setCatalogId(1L) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + } + + // private PolarisEntity createEntity(String name, PolarisEntityType type, long id, long + // parentId) { + // return new PolarisEntity.Builder() + // .setName(name) + // .setType(type) + // .setId(id) + // .setCatalogId(1L) + // .setParentId(parentId) + // .setCreateTimestamp(System.currentTimeMillis()) + // .build(); + // } + + private PolarisEntity createEntity( + String name, PolarisEntityType type, PolarisEntitySubType subType, long id, long parentId) { + return new PolarisEntity.Builder() + .setName(name) + .setType(type) + .setSubType(subType) + .setId(id) + .setCatalogId(1L) + .setParentId(parentId) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + } + + private PolarisEntity createNamespaceEntity(Namespace namespace, long id, long parentId) { + return new NamespaceEntity.Builder(namespace) + .setId(id) + .setCatalogId(1L) + .setParentId(parentId) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + } + + private PolarisEntity createTableEntity( + TableIdentifier identifier, PolarisEntitySubType subType, long id, long parentId) { + return new IcebergTableLikeEntity.Builder(subType, identifier, "") + .setId(id) + .setCatalogId(1L) + .setParentId(parentId) .setCreateTimestamp(System.currentTimeMillis()) .build(); } @@ -273,18 +685,24 @@ private void setupSuccessfulNamespaceResolution( when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(resolvedPathWrapper); PolarisEntity catalogEntity = createEntity(catalogName, PolarisEntityType.CATALOG); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + when(catalogWrapper.getRawLeafEntity()).thenReturn(catalogEntity); + when(resolutionManifest.getResolvedReferenceCatalogEntity()).thenReturn(catalogWrapper); + + PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisEntity catalogRoleEntity = + createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE, 2L); + when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); + when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + PolarisEntity namespaceEntity = - createEntity(namespace.levels()[0], PolarisEntityType.NAMESPACE); + createNamespaceEntity(Namespace.of(namespace.levels()[0]), 3L, 1L); List fullPath = List.of(catalogEntity, namespaceEntity); when(resolvedPathWrapper.getRawFullPath()).thenReturn(fullPath); when(resolvedPathWrapper.getRawParentPath()).thenReturn(List.of(catalogEntity)); when(resolvedPathWrapper.getRawLeafEntity()).thenReturn(namespaceEntity); when(resolvedPathWrapper.isFullyResolvedNamespace(eq(catalogName), eq(namespace))) .thenReturn(true); - - PolarisResolvedPathWrapper catalogRoleWrapper = mock(PolarisResolvedPathWrapper.class); - PolarisEntity catalogRoleEntity = createEntity(catalogRoleName, PolarisEntityType.CATALOG_ROLE); - when(catalogRoleWrapper.getRawLeafEntity()).thenReturn(catalogRoleEntity); - when(resolutionManifest.getResolvedPath(eq(catalogRoleName))).thenReturn(catalogRoleWrapper); + when(resolutionManifest.getResolvedPath(eq(namespace))).thenReturn(resolvedPathWrapper); } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAuthzTestBase.java b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAuthzTestBase.java index 1140e3bf66..0f0ceb3dbc 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAuthzTestBase.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAuthzTestBase.java @@ -19,6 +19,7 @@ package org.apache.polaris.service.admin; import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.polaris.core.entity.CatalogEntity.DEFAULT_BASE_LOCATION_KEY; import com.google.auth.oauth2.AccessToken; import com.google.auth.oauth2.GoogleCredentials; @@ -47,8 +48,13 @@ import org.apache.iceberg.types.Types; import org.apache.polaris.core.PolarisCallContext; import org.apache.polaris.core.PolarisDiagnostics; +import org.apache.polaris.core.admin.model.AuthenticationParameters; +import org.apache.polaris.core.admin.model.ConnectionConfigInfo; import org.apache.polaris.core.admin.model.CreateCatalogRequest; +import org.apache.polaris.core.admin.model.ExternalCatalog; import org.apache.polaris.core.admin.model.FileStorageConfigInfo; +import org.apache.polaris.core.admin.model.IcebergRestConnectionConfigInfo; +import org.apache.polaris.core.admin.model.OAuthClientCredentialsParameters; import org.apache.polaris.core.admin.model.PrincipalWithCredentials; import org.apache.polaris.core.admin.model.PrincipalWithCredentialsCredentials; import org.apache.polaris.core.admin.model.StorageConfigInfo; @@ -121,11 +127,14 @@ public Map getConfigOverrides() { "true") .put("polaris.features.\"DROP_WITH_PURGE_ENABLED\"", "true") .put("polaris.behavior-changes.\"ALLOW_NAMESPACE_CUSTOM_LOCATION\"", "true") + .put("polaris.features.\"ENABLE_CATALOG_FEDERATION\"", "true") + .put("polaris.features.\"ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS\"", "true") .build(); } } protected static final String CATALOG_NAME = "polaris-catalog"; + protected static final String FEDERATED_CATALOG_NAME = "federated-polaris-catalog"; protected static final String PRINCIPAL_NAME = "snowman"; // catalog_role1 will be assigned only to principal_role1 and @@ -202,6 +211,7 @@ public Map getConfigOverrides() { protected PolarisMetaStoreManager metaStoreManager; protected UserSecretsManager userSecretsManager; protected PolarisBaseEntity catalogEntity; + protected PolarisBaseEntity federatedCatalogEntity; protected PrincipalEntity principalEntity; protected CallContext callContext; protected RealmConfig realmConfig; @@ -259,11 +269,57 @@ public void before(TestInfo testInfo) { reservedProperties); String storageLocation = "file:///tmp/authz"; + String storageLocationForFederatedCatalog = "file:///tmp/authz_federated"; FileStorageConfigInfo storageConfigModel = FileStorageConfigInfo.builder() .setStorageType(StorageConfigInfo.StorageTypeEnum.FILE) .setAllowedLocations(List.of(storageLocation, "file:///tmp/authz")) .build(); + FileStorageConfigInfo storageConfigModelForFederatedCatalog = + FileStorageConfigInfo.builder() + .setStorageType(StorageConfigInfo.StorageTypeEnum.FILE) + .setAllowedLocations( + List.of(storageLocationForFederatedCatalog, "file:///tmp/authz_federated")) + .build(); + ConnectionConfigInfo connectionConfigInfo = + IcebergRestConnectionConfigInfo.builder( + ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST) + .setUri("https://myorg-my_account.snowflakecomputing.com/polaris/api/catalog") + .setRemoteCatalogName("my-remote-catalog") + .setAuthenticationParameters( + OAuthClientCredentialsParameters.builder( + AuthenticationParameters.AuthenticationTypeEnum.OAUTH) + .setClientId("my-client-id") + .setClientSecret("my-client-secret") + .setScopes(List.of("PRINCIPAL_ROLE:ALL")) + .build()) + .build(); + CatalogEntity externalCatalogEntity = + new CatalogEntity.Builder() + .setName(FEDERATED_CATALOG_NAME) + .setCatalogType("EXTERNAL") + .setDefaultBaseLocation(storageLocationForFederatedCatalog) + .setStorageConfigurationInfo( + realmConfig, + storageConfigModelForFederatedCatalog, + storageLocationForFederatedCatalog) + .build(); + ExternalCatalog externalCatalog = + ExternalCatalog.builder() + .setName(externalCatalogEntity.getName()) + .setType(ExternalCatalog.TypeEnum.EXTERNAL) + .setProperties( + org.apache.polaris.core.admin.model.CatalogProperties.builder( + externalCatalogEntity.getPropertiesAsMap().get(DEFAULT_BASE_LOCATION_KEY)) + .putAll(externalCatalogEntity.getPropertiesAsMap()) + .build()) + .setCreateTimestamp(externalCatalogEntity.getCreateTimestamp()) + .setLastUpdateTimestamp(externalCatalogEntity.getLastUpdateTimestamp()) + .setEntityVersion(externalCatalogEntity.getEntityVersion()) + .setStorageConfigInfo(storageConfigModelForFederatedCatalog) + .setConnectionConfigInfo(connectionConfigInfo) + .build(); + catalogEntity = adminService.createCatalog( new CreateCatalogRequest( @@ -274,6 +330,7 @@ public void before(TestInfo testInfo) { .setStorageConfigurationInfo(realmConfig, storageConfigModel, storageLocation) .build() .asCatalog())); + federatedCatalogEntity = adminService.createCatalog(new CreateCatalogRequest(externalCatalog)); initBaseCatalog(); @@ -296,6 +353,10 @@ public void before(TestInfo testInfo) { CATALOG_NAME, new CatalogRoleEntity.Builder().setName(CATALOG_ROLE2).build()); adminService.createCatalogRole( CATALOG_NAME, new CatalogRoleEntity.Builder().setName(CATALOG_ROLE_SHARED).build()); + adminService.createCatalogRole( + FEDERATED_CATALOG_NAME, new CatalogRoleEntity.Builder().setName(CATALOG_ROLE1).build()); + adminService.createCatalogRole( + FEDERATED_CATALOG_NAME, new CatalogRoleEntity.Builder().setName(CATALOG_ROLE2).build()); assertSuccess(adminService.assignPrincipalRole(PRINCIPAL_NAME, PRINCIPAL_ROLE1)); assertSuccess(adminService.assignPrincipalRole(PRINCIPAL_NAME, PRINCIPAL_ROLE2)); @@ -312,6 +373,12 @@ public void before(TestInfo testInfo) { assertSuccess( adminService.assignCatalogRoleToPrincipalRole( PRINCIPAL_ROLE2, CATALOG_NAME, CATALOG_ROLE_SHARED)); + assertSuccess( + adminService.assignCatalogRoleToPrincipalRole( + PRINCIPAL_ROLE1, FEDERATED_CATALOG_NAME, CATALOG_ROLE1)); + assertSuccess( + adminService.assignCatalogRoleToPrincipalRole( + PRINCIPAL_ROLE2, FEDERATED_CATALOG_NAME, CATALOG_ROLE2)); // Do some shared setup with non-authz-aware baseCatalog. baseCatalog.createNamespace(NS1);