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 05b7e0dec3..10155ccdd5 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 @@ -253,6 +253,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 816fc67986..66f828e67d 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 @@ -526,7 +526,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); } @@ -1686,6 +1691,9 @@ public boolean 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)); @@ -1693,7 +1701,23 @@ public boolean 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)) { + throw new RuntimeException( + String.format( + "Failed to create synthetic namespace entities for namespace %s in catalog %s", + namespace.toString(), catalogName)); + } + } else { + throw new NotFoundException("Namespace %s not found", namespace); + } } List catalogPath = resolvedPathWrapper.getRawParentPath(); PolarisEntity namespaceEntity = resolvedPathWrapper.getRawLeafEntity(); @@ -1737,6 +1761,80 @@ public boolean revokePrivilegeOnNamespaceFromRole( .isSuccess(); } + /** + * 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 { + Namespace partialNamespace = Namespace.of(Arrays.copyOf(allNamespaceLevels, i + 1)); + PolarisResolvedPathWrapper partialPath = + resolutionManifest.getResolvedPath(partialNamespace); + PolarisEntity partialLeafEntity = partialPath.getRawLeafEntity(); + 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; + } + completePath.add(syntheticNamespace); + currentParent = syntheticNamespace; + } + PolarisResolvedPathWrapper resolvedPathWrapper = resolutionManifest.getResolvedPath(namespace); + return resolvedPathWrapper; + } + public boolean grantPrivilegeOnTableToRole( String catalogName, String catalogRoleName, @@ -2014,9 +2112,9 @@ private boolean 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)); @@ -2026,7 +2124,24 @@ private boolean 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())) { + 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(); @@ -2041,6 +2156,77 @@ private boolean grantPrivilegeOnTableLikeToRole( .isSuccess(); } + /** + * 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(identifier, "") + .setId(metaStoreManager.generateNewEntityId(getCurrentPolarisContext()).getId()) + .setCatalogId(parentNamespaceEntity.getCatalogId()) + .setSubType(syntheticEntitySubType) + .setCreateTimestamp(System.currentTimeMillis()) + .build(); + + EntityResult result = + metaStoreManager.createEntityIfNotExists( + getCurrentPolarisContext(), + PolarisEntity.toCoreList(resolvedNamespacePathWrapper.getRawFullPath()), + syntheticTableEntity); + + if (result.isSuccess()) { + syntheticTableEntity = PolarisEntity.of(result.getEntity()); + } else { + PolarisResolvedPathWrapper tablePathWrapper = resolutionManifest.getResolvedPath(identifier); + PolarisEntity leafEntity = + tablePathWrapper != null ? tablePathWrapper.getRawLeafEntity() : null; + if (leafEntity == null || !subTypes.contains(leafEntity.getSubType())) { + throw new RuntimeException( + String.format( + "Failed to create or find table entity '%s' in federated catalog '%s'", + identifier.name(), catalogEntity.getName())); + } + } + return resolutionManifest.getResolvedPath(identifier); + } + /** * Removes a table-level or view-level grant on {@code identifier} from {@code catalogRoleName}. */ @@ -2136,4 +2322,25 @@ private boolean revokePrivilegeOnPolicyEntityFromRole( privilege) .isSuccess(); } + + /** + * 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/PolarisAdminServiceTest.java b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceTest.java index c5744a8de8..41c425dd30 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 @@ -28,17 +28,26 @@ 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.PolarisDiagnostics; import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; import org.apache.polaris.core.auth.PolarisAuthorizer; +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.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; @@ -64,6 +73,7 @@ public class PolarisAdminServiceTest { @Mock private AuthenticatedPolarisPrincipal authenticatedPrincipal; @Mock private PolarisResolutionManifest resolutionManifest; @Mock private PolarisResolvedPathWrapper resolvedPathWrapper; + @Mock private RealmConfig realmConfig; private PolarisAdminService adminService; @@ -73,6 +83,16 @@ void setUp() throws Exception { when(securityContext.getUserPrincipal()).thenReturn(authenticatedPrincipal); when(callContext.getPolarisCallContext()).thenReturn(polarisCallContext); when(polarisCallContext.getDiagServices()).thenReturn(polarisDiagnostics); + 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( @@ -117,8 +137,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); @@ -144,8 +170,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); @@ -154,7 +186,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); @@ -199,7 +231,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); @@ -226,7 +259,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); @@ -244,13 +278,391 @@ 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); + + boolean result = + adminService.grantPrivilegeOnNamespaceToRole( + catalogName, catalogRoleName, namespace, privilege); + assertThat(result).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, PolarisEntitySubType.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); + + boolean result = + adminService.grantPrivilegeOnTableToRole( + catalogName, catalogRoleName, identifier, privilege); + assertThat(result).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, PolarisEntitySubType.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(identifier, "") + .setId(id) + .setCatalogId(1L) + .setParentId(parentId) + .setSubType(subType) .setCreateTimestamp(System.currentTimeMillis()) .build(); } @@ -268,18 +680,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); } }