This is an automated email from the ASF dual-hosted git repository.

honahx pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new f1d71a9c8 (Based on PR#2223)Support Namespace/Table level RBAC for 
external passthrough catalogs (#2673)
f1d71a9c8 is described below

commit f1d71a9c8e15f07eeea86d6c23f737efa50e75ad
Author: Honah (Jonas) J. <[email protected]>
AuthorDate: Thu Sep 25 10:32:23 2025 -0500

    (Based on PR#2223)Support Namespace/Table level RBAC for external 
passthrough catalogs (#2673)
    
    Creates missing synthetic entities for securables in external passthrough 
catalogs.
    Based on Option 1 discussed in the RBAC section of catalog federation 
design doc.
    
    In the future, we could remove calls to PolarisEntity.Builder() and replace 
them with entities fetched from the remote catalog. (enabling Option 2).
    
    ---------
    
    Co-authored-by: Pooja Nilangekar <[email protected]>
---
 .../polaris/core/config/FeatureConfiguration.java  |   9 +
 .../polaris/service/admin/PolarisAdminService.java | 236 ++++++++++-
 .../admin/PolarisAdminServiceAuthzTest.java        |  55 ++-
 .../service/admin/PolarisAdminServiceTest.java     | 442 ++++++++++++++++++++-
 .../service/admin/PolarisAuthzTestBase.java        |  67 ++++
 5 files changed, 775 insertions(+), 34 deletions(-)

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 ad91ad024..545efa6c8 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 class FeatureConfiguration<T> extends 
PolarisConfiguration<T> {
           .defaultValue(false)
           .buildFeatureConfiguration();
 
+  public static final FeatureConfiguration<Boolean> 
ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS =
+      PolarisConfiguration.<Boolean>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<Boolean> ENABLE_POLICY_STORE =
       PolarisConfiguration.<Boolean>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 469bab089..d39ebfae6 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.entity.table.federated.FederatedEntities;
 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 @@ public class PolarisAdminService {
     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 @@ public class PolarisAdminService {
     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 @@ public class PolarisAdminService {
     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 class PolarisAdminService {
         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 class PolarisAdminService {
     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<PolarisEntity> catalogPath = resolvedPathWrapper.getRawParentPath();
     PolarisEntity namespaceEntity = resolvedPathWrapper.getRawLeafEntity();
@@ -1734,6 +1764,86 @@ public class PolarisAdminService {
         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<PolarisEntity> 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 @@ public class PolarisAdminService {
       TableIdentifier identifier,
       List<PolarisEntitySubType> 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 @@ public class PolarisAdminService {
             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<PolarisEntity> catalogPath = resolvedPathWrapper.getRawParentPath();
     PolarisEntity tableLikeEntity = resolvedPathWrapper.getRawLeafEntity();
@@ -2036,6 +2164,77 @@ public class PolarisAdminService {
         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<PolarisEntitySubType> 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 @@ public class PolarisAdminService {
         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<PolarisEntitySubType> 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 ad5fa0ce6..b60bcb0a9 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.PrincipalEntity;
 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 class PolarisAdminServiceAuthzTest extends 
PolarisAuthzTestBase {
             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 class PolarisAdminServiceAuthzTest extends 
PolarisAuthzTestBase {
         () ->
             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 class PolarisAdminServiceAuthzTest extends 
PolarisAuthzTestBase {
         () ->
             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 class PolarisAdminServiceAuthzTest extends 
PolarisAuthzTestBase {
             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 class PolarisAdminServiceAuthzTest extends 
PolarisAuthzTestBase {
         () ->
             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 60e055942..d44ec0cbf 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 static org.mockito.Mockito.when;
 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 @@ public class PolarisAdminServiceTest {
     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 @@ public class PolarisAdminServiceTest {
         .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 @@ public class PolarisAdminServiceTest {
         .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 @@ public class PolarisAdminServiceTest {
         .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 @@ public class PolarisAdminServiceTest {
     
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 @@ public class PolarisAdminServiceTest {
     
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 @@ public class PolarisAdminServiceTest {
         .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 @@ public class PolarisAdminServiceTest {
     
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<PolarisEntity> 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 1140e3bf6..0f0ceb3db 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.exceptions.ForbiddenException;
 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 abstract class PolarisAuthzTestBase {
               "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 abstract class PolarisAuthzTestBase {
   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 abstract class PolarisAuthzTestBase {
             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 abstract class PolarisAuthzTestBase {
                     .setStorageConfigurationInfo(realmConfig, 
storageConfigModel, storageLocation)
                     .build()
                     .asCatalog()));
+    federatedCatalogEntity = adminService.createCatalog(new 
CreateCatalogRequest(externalCatalog));
 
     initBaseCatalog();
 
@@ -296,6 +353,10 @@ public abstract class PolarisAuthzTestBase {
         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 abstract class PolarisAuthzTestBase {
     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);

Reply via email to