Skip to content

Commit 47d29e7

Browse files
Introduce operation CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION (#27)
This PR introduces a new operation, CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION, and requires it for createTableDirectWithWriteDelegation. --------- Co-authored-by: Eric Maynard <[email protected]>
1 parent 627dc60 commit 47d29e7

File tree

4 files changed

+130
-65
lines changed

4 files changed

+130
-65
lines changed

polaris-core/src/main/java/io/polaris/core/auth/PolarisAuthorizableOperation.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public enum PolarisAuthorizableOperation {
8989
UPDATE_NAMESPACE_PROPERTIES(NAMESPACE_WRITE_PROPERTIES),
9090
LIST_TABLES(TABLE_LIST),
9191
CREATE_TABLE_DIRECT(TABLE_CREATE),
92+
CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION(EnumSet.of(TABLE_CREATE, TABLE_WRITE_DATA)),
9293
CREATE_TABLE_STAGED(TABLE_CREATE),
9394
CREATE_TABLE_STAGED_WITH_WRITE_DELEGATION(EnumSet.of(TABLE_CREATE, TABLE_WRITE_DATA)),
9495
REGISTER_TABLE(TABLE_CREATE),

polaris-service/src/main/java/io/polaris/service/catalog/PolarisCatalogHandlerWrapper.java

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,8 @@ public LoadTableResponse createTableDirect(Namespace namespace, CreateTableReque
551551

552552
public LoadTableResponse createTableDirectWithWriteDelegation(
553553
Namespace namespace, CreateTableRequest request) {
554-
PolarisAuthorizableOperation op = PolarisAuthorizableOperation.CREATE_TABLE_DIRECT;
554+
PolarisAuthorizableOperation op =
555+
PolarisAuthorizableOperation.CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION;
555556
authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
556557
op, TableIdentifier.of(namespace, request.name()));
557558

@@ -591,20 +592,18 @@ public LoadTableResponse createTableDirectWithWriteDelegation(
591592
LoadTableResponse.Builder responseBuilder =
592593
LoadTableResponse.builder().withTableMetadata(tableMetadata);
593594
if (baseCatalog instanceof SupportsCredentialDelegation credentialDelegation) {
594-
try {
595-
Set<PolarisStorageActions> actionsRequested =
596-
getValidTableActionsOrThrow(tableIdentifier);
597-
598-
LOG.atDebug()
599-
.addKeyValue("tableIdentifier", tableIdentifier)
600-
.addKeyValue("tableLocation", tableMetadata.location())
601-
.log("Fetching client credentials for table");
602-
responseBuilder.addAllConfig(
603-
credentialDelegation.getCredentialConfig(
604-
tableIdentifier, tableMetadata, actionsRequested));
605-
} catch (ForbiddenException | NoSuchTableException e) {
606-
// No privileges available
607-
}
595+
LOG.atDebug()
596+
.addKeyValue("tableIdentifier", tableIdentifier)
597+
.addKeyValue("tableLocation", tableMetadata.location())
598+
.log("Fetching client credentials for table");
599+
responseBuilder.addAllConfig(
600+
credentialDelegation.getCredentialConfig(
601+
tableIdentifier,
602+
tableMetadata,
603+
Set.of(
604+
PolarisStorageActions.READ,
605+
PolarisStorageActions.WRITE,
606+
PolarisStorageActions.LIST)));
608607
}
609608
return responseBuilder.build();
610609
} else if (table instanceof BaseMetadataTable) {
@@ -706,18 +705,13 @@ public LoadTableResponse createTableStagedWithWriteDelegation(
706705
LoadTableResponse.builder().withTableMetadata(metadata);
707706

708707
if (baseCatalog instanceof SupportsCredentialDelegation credentialDelegation) {
709-
try {
710-
Set<PolarisStorageActions> actionsRequested = getValidTableActionsOrThrow(ident);
711-
712-
LOG.atDebug()
713-
.addKeyValue("tableIdentifier", ident)
714-
.addKeyValue("tableLocation", metadata.location())
715-
.log("Fetching client credentials for table");
716-
responseBuilder.addAllConfig(
717-
credentialDelegation.getCredentialConfig(ident, metadata, actionsRequested));
718-
} catch (ForbiddenException | NoSuchTableException e) {
719-
// No privileges available
720-
}
708+
LOG.atDebug()
709+
.addKeyValue("tableIdentifier", ident)
710+
.addKeyValue("tableLocation", metadata.location())
711+
.log("Fetching client credentials for table");
712+
responseBuilder.addAllConfig(
713+
credentialDelegation.getCredentialConfig(
714+
ident, metadata, Set.of(PolarisStorageActions.ALL)));
721715
}
722716
return responseBuilder.build();
723717
});
@@ -779,39 +773,31 @@ public LoadTableResponse loadTable(TableIdentifier tableIdentifier, String snaps
779773
return doCatalogOperation(() -> CatalogHandlers.loadTable(baseCatalog, tableIdentifier));
780774
}
781775

782-
private Set<PolarisStorageActions> getValidTableActionsOrThrow(TableIdentifier tableIdentifier) {
776+
public LoadTableResponse loadTableWithAccessDelegation(
777+
TableIdentifier tableIdentifier, String xIcebergAccessDelegation, String snapshots) {
778+
// Here we have a single method that falls through multiple candidate
779+
// PolarisAuthorizableOperations because instead of identifying the desired operation up-front
780+
// and
781+
// failing the authz check if grants aren't found, we find the first most-privileged authz match
782+
// and respond according to that.
783783
PolarisAuthorizableOperation read =
784784
PolarisAuthorizableOperation.LOAD_TABLE_WITH_READ_DELEGATION;
785785
PolarisAuthorizableOperation write =
786786
PolarisAuthorizableOperation.LOAD_TABLE_WITH_WRITE_DELEGATION;
787+
787788
Set<PolarisStorageActions> actionsRequested =
788789
new HashSet<>(Set.of(PolarisStorageActions.READ, PolarisStorageActions.LIST));
789790
try {
790791
// TODO: Refactor to have a boolean-return version of the helpers so we can fallthrough
791792
// easily.
792793
authorizeBasicTableLikeOperationOrThrow(write, PolarisEntitySubType.TABLE, tableIdentifier);
793794
actionsRequested.add(PolarisStorageActions.WRITE);
794-
} catch (ForbiddenException | NoSuchTableException e) {
795-
LOG.atDebug()
796-
.addKeyValue("tableIdentifier", tableIdentifier)
797-
.log("Authz failed for LOAD_TABLE_WITH_WRITE_DELEGATION so attempting READ only");
795+
} catch (ForbiddenException e) {
798796
authorizeBasicTableLikeOperationOrThrow(read, PolarisEntitySubType.TABLE, tableIdentifier);
799797
}
800-
return actionsRequested;
801-
}
802-
803-
public LoadTableResponse loadTableWithAccessDelegation(
804-
TableIdentifier tableIdentifier, String xIcebergAccessDelegation, String snapshots) {
805-
// Here we have a single method that falls through multiple candidate
806-
// PolarisAuthorizableOperations because instead of identifying the desired operation up-front
807-
// and
808-
// failing the authz check if grants aren't found, we find the first most-privileged authz match
809-
// and respond according to that.
810798

811799
// TODO: Find a way for the configuration or caller to better express whether to fail or omit
812800
// when data-access is specified but access delegation grants are not found.
813-
Set<PolarisStorageActions> actionsRequested = getValidTableActionsOrThrow(tableIdentifier);
814-
815801
return doCatalogOperation(
816802
() -> {
817803
Table table = baseCatalog.loadTable(tableIdentifier);

polaris-service/src/test/java/io/polaris/service/catalog/PolarisCatalogHandlerWrapperAuthzTest.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,61 @@ public void testCreateTableDirectInsufficientPermissions() {
575575
});
576576
}
577577

578+
@Test
579+
public void testCreateTableDirectWithWriteDelegationAllSufficientPrivileges() {
580+
Assertions.assertThat(
581+
adminService.grantPrivilegeOnCatalogToRole(
582+
CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP))
583+
.isTrue();
584+
Assertions.assertThat(
585+
adminService.grantPrivilegeOnCatalogToRole(
586+
CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_WRITE_DATA))
587+
.isTrue();
588+
589+
final TableIdentifier newtable = TableIdentifier.of(NS2, "newtable");
590+
final CreateTableRequest createDirectWithWriteDelegationRequest =
591+
CreateTableRequest.builder().withName("newtable").withSchema(SCHEMA).stageCreate().build();
592+
593+
doTestSufficientPrivilegeSets(
594+
List.of(
595+
Set.of(PolarisPrivilege.TABLE_CREATE, PolarisPrivilege.TABLE_WRITE_DATA),
596+
Set.of(PolarisPrivilege.CATALOG_MANAGE_CONTENT)),
597+
() -> {
598+
newWrapper(Set.of(PRINCIPAL_ROLE1))
599+
.createTableDirectWithWriteDelegation(NS2, createDirectWithWriteDelegationRequest);
600+
},
601+
() -> {
602+
newWrapper(Set.of(PRINCIPAL_ROLE2)).dropTableWithPurge(newtable);
603+
},
604+
PRINCIPAL_NAME);
605+
}
606+
607+
@Test
608+
public void testCreateTableDirectWithWriteDelegationInsufficientPermissions() {
609+
final CreateTableRequest createDirectWithWriteDelegationRequest =
610+
CreateTableRequest.builder()
611+
.withName("directtable")
612+
.withSchema(SCHEMA)
613+
.stageCreate()
614+
.build();
615+
616+
doTestInsufficientPrivileges(
617+
List.of(
618+
PolarisPrivilege.NAMESPACE_FULL_METADATA,
619+
PolarisPrivilege.VIEW_FULL_METADATA,
620+
PolarisPrivilege.TABLE_DROP,
621+
PolarisPrivilege.TABLE_CREATE, // TABLE_CREATE itself is insufficient for delegation
622+
PolarisPrivilege.TABLE_READ_PROPERTIES,
623+
PolarisPrivilege.TABLE_WRITE_PROPERTIES,
624+
PolarisPrivilege.TABLE_READ_DATA,
625+
PolarisPrivilege.TABLE_WRITE_DATA,
626+
PolarisPrivilege.TABLE_LIST),
627+
() -> {
628+
newWrapper(Set.of(PRINCIPAL_ROLE1))
629+
.createTableDirectWithWriteDelegation(NS2, createDirectWithWriteDelegationRequest);
630+
});
631+
}
632+
578633
@Test
579634
public void testCreateTableStagedAllSufficientPrivileges() {
580635
Assertions.assertThat(

regtests/t_pyspark/src/test_spark_sql_s3_with_privileges.py

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from polaris.management import PolarisDefaultApi, Principal, PrincipalRole, CatalogRole, \
3636
CatalogGrant, CatalogPrivilege, ApiException, CreateCatalogRoleRequest, CreatePrincipalRoleRequest, \
3737
CreatePrincipalRequest, AddGrantRequest, GrantCatalogRoleRequest, GrantPrincipalRoleRequest
38+
from polaris.management.exceptions import ForbiddenException
3839

3940

4041
@pytest.fixture
@@ -742,8 +743,7 @@ def test_spark_credentials_s3_direct_without_write(root_client, snowflake_catalo
742743
def test_spark_credentials_s3_direct_without_read(
743744
snowflake_catalog, snowman_catalog_client, creator_catalog_client, test_bucket):
744745
"""
745-
Create a table using `creator`, which does not have TABLE_READ_DATA and ensure that credentials to read the table
746-
are not vended.
746+
Create a table using `creator`, which does not have TABLE_READ_DATA and expect a `ForbiddenException`
747747
"""
748748
snowman_catalog_client.create_namespace(
749749
prefix=snowflake_catalog.name,
@@ -752,26 +752,23 @@ def test_spark_credentials_s3_direct_without_read(
752752
)
753753
)
754754

755-
response = creator_catalog_client.create_table(
756-
prefix=snowflake_catalog.name,
757-
namespace="some_schema",
758-
x_iceberg_access_delegation="true",
759-
create_table_request=CreateTableRequest(
760-
name="some_table",
761-
var_schema=ModelSchema(
762-
type = 'struct',
763-
fields = [],
764-
)
765-
)
766-
)
767-
768-
assert not response.config
755+
try:
756+
creator_catalog_client.create_table(
757+
prefix=snowflake_catalog.name,
758+
namespace="some_schema",
759+
x_iceberg_access_delegation="true",
760+
create_table_request=CreateTableRequest(
761+
name="some_table",
762+
var_schema=ModelSchema(
763+
type = 'struct',
764+
fields = [],
765+
)
766+
)
767+
)
768+
pytest.fail("Expected exception when creating a table without TABLE_WRITE")
769+
except Exception as e:
770+
assert 'CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION' in str(e)
769771

770-
snowman_catalog_client.drop_table(
771-
prefix=snowflake_catalog.name,
772-
namespace="some_schema",
773-
table="some_table"
774-
)
775772
snowman_catalog_client.drop_namespace(
776773
prefix=snowflake_catalog.name,
777774
namespace="some_schema"
@@ -883,6 +880,32 @@ def test_spark_credentials_s3_scoped_to_metadata_data_locations(root_client, sno
883880
spark.sql('DROP NAMESPACE db1.schema')
884881
spark.sql('DROP NAMESPACE db1')
885882

883+
@pytest.mark.skipif(os.environ.get('AWS_TEST_ENABLED', 'False').lower() != 'true', reason='AWS_TEST_ENABLED is not set or is false')
884+
def test_spark_ctas(snowflake_catalog, polaris_catalog_url, snowman):
885+
"""
886+
Create a table using CTAS and ensure that credentials are vended
887+
:param root_client:
888+
:param snowflake_catalog:
889+
:return:
890+
"""
891+
with IcebergSparkSession(credentials=f'{snowman.principal.client_id}:{snowman.credentials.client_secret}',
892+
catalog_name=snowflake_catalog.name,
893+
polaris_url=polaris_catalog_url) as spark:
894+
table_name = f'iceberg_test_table_{str(uuid.uuid4())[-10:]}'
895+
spark.sql(f'USE {snowflake_catalog.name}')
896+
spark.sql('CREATE NAMESPACE db1')
897+
spark.sql('CREATE NAMESPACE db1.schema')
898+
spark.sql('USE db1.schema')
899+
spark.sql(f'CREATE TABLE {table_name}_t1 (col1 int)')
900+
spark.sql('SHOW TABLES')
901+
902+
# Insert some data
903+
spark.sql(f"INSERT INTO {table_name}_t1 VALUES (10)")
904+
905+
# Run CTAS
906+
spark.sql(f"CREATE TABLE {table_name}_t2 AS SELECT * FROM {table_name}_t1")
907+
908+
886909
def create_catalog_role(api, catalog, role_name):
887910
catalog_role = CatalogRole(name=role_name)
888911
try:

0 commit comments

Comments
 (0)