diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java index 573623021..6049428d5 100644 --- a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java +++ b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java @@ -18,6 +18,8 @@ */ package org.apache.polaris.service.it.test; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.CREATED; import static javax.ws.rs.core.Response.Status.FORBIDDEN; import static org.apache.polaris.service.it.env.PolarisClient.polarisClient; import static org.apache.polaris.service.it.test.PolarisApplicationIntegrationTest.PRINCIPAL_ROLE_ALL; @@ -871,6 +873,42 @@ public void testCreatePrincipalAndRotateCredentials() { // rotation that makes the old secret fall off retention. } + @Test + public void testCreateFederatedPrincipalFails() { + // Create a federated Principal + Principal federatedPrincipal = + new Principal(client.newEntityName("federatedPrincipal"), "abc", true, Map.of(), 0L, 0L, 1); + + // Attempt to create the federated Principal using the managementApi + try (Response createPResponse = + managementApi + .request("v1/principals") + .post(Entity.json(new CreatePrincipalRequest(federatedPrincipal, false)))) { + assertThat(createPResponse).returns(BAD_REQUEST.getStatusCode(), Response::getStatus); + } + } + + @Test + public void testCreateFederatedPrincipalRoleSucceeds() { + // Create a federated Principal Role + PrincipalRole federatedPrincipalRole = + new PrincipalRole( + client.newEntityName("federatedRole"), + true, + Map.of(), + Instant.now().toEpochMilli(), + Instant.now().toEpochMilli(), + 1); + + // Attempt to create the federated Principal using the managementApi + try (Response createResponse = + managementApi + .request("v1/principal-roles") + .post(Entity.json(new CreatePrincipalRoleRequest(federatedPrincipalRole)))) { + assertThat(createResponse).returns(CREATED.getStatusCode(), Response::getStatus); + } + } + @Test public void testCreateListUpdateAndDeletePrincipal() { Principal principal = @@ -1022,7 +1060,7 @@ public void testGetPrincipalWithInvalidName() { public void testCreateListUpdateAndDeletePrincipalRole() { PrincipalRole principalRole = new PrincipalRole( - client.newEntityName("myprincipalrole"), Map.of("custom-tag", "foo"), 0L, 0L, 1); + client.newEntityName("myprincipalrole"), false, Map.of("custom-tag", "foo"), 0L, 0L, 1); managementApi.createPrincipalRole(principalRole); // Second attempt to create the same entity should fail with CONFLICT. @@ -1114,7 +1152,7 @@ public void testCreateListUpdateAndDeletePrincipalRole() { public void testCreatePrincipalRoleInvalidName() { String goodName = RandomStringUtils.random(MAX_IDENTIFIER_LENGTH, true, true); PrincipalRole principalRole = - new PrincipalRole(goodName, Map.of("custom-tag", "good_principal_role"), 0L, 0L, 1); + new PrincipalRole(goodName, false, Map.of("custom-tag", "good_principal_role"), 0L, 0L, 1); managementApi.createPrincipalRole(principalRole); String longInvalidName = RandomStringUtils.random(MAX_IDENTIFIER_LENGTH + 1, true, true); @@ -1130,7 +1168,12 @@ public void testCreatePrincipalRoleInvalidName() { for (String invalidPrincipalRoleName : invalidPrincipalRoleNames) { principalRole = new PrincipalRole( - invalidPrincipalRoleName, Map.of("custom-tag", "bad_principal_role"), 0L, 0L, 1); + invalidPrincipalRoleName, + false, + Map.of("custom-tag", "bad_principal_role"), + 0L, + 0L, + 1); try (Response response = managementApi diff --git a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntity.java b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntity.java index 08ac29b35..bfc796d3e 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntity.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntity.java @@ -182,6 +182,12 @@ public static List toNameAndIdList(List entit .orElse(null); } + public static boolean isFederated(PolarisBaseEntity entity) { + return Optional.ofNullable(entity.getInternalPropertiesAsMap()) + .map(map -> Boolean.parseBoolean(map.get(PolarisEntityConstants.FEDERATED_ENTITY))) + .orElse(false); + } + public PolarisEntity(@Nonnull PolarisBaseEntity sourceEntity) { super( sourceEntity.getCatalogId(), diff --git a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityConstants.java b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityConstants.java index 26d3c09a7..ab9f9cc06 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityConstants.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisEntityConstants.java @@ -57,6 +57,8 @@ public class PolarisEntityConstants { public static final String PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_STATE = "CREDENTIAL_ROTATION_REQUIRED"; + public static final String FEDERATED_ENTITY = "federated"; + /** * Name format of storage integration for polaris entity: {@code * POLARIS__}. This name format gives us flexibility to switch to use diff --git a/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalEntity.java b/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalEntity.java index ba0cfe3f5..63c7f3b9e 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalEntity.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalEntity.java @@ -36,6 +36,7 @@ public static PrincipalEntity of(PolarisBaseEntity sourceEntity) { public static PrincipalEntity fromPrincipal(Principal principal) { return new Builder() .setName(principal.getName()) + .setFederated(principal.getFederated()) .setProperties(principal.getProperties()) .setClientId(principal.getClientId()) .build(); @@ -45,6 +46,7 @@ public Principal asPrincipal() { return new Principal( getName(), getClientId(), + PolarisEntity.isFederated(this), getPropertiesAsMap(), getCreateTimestamp(), getLastUpdateTimestamp(), @@ -78,6 +80,13 @@ public Builder setCredentialRotationRequiredState() { return this; } + public Builder setFederated(Boolean isFederated) { + if (isFederated != null && isFederated) { + internalProperties.put(PolarisEntityConstants.FEDERATED_ENTITY, "true"); + } + return this; + } + @Override public PrincipalEntity build() { return new PrincipalEntity(buildBase()); diff --git a/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalRoleEntity.java b/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalRoleEntity.java index 26f13cc8f..81d46c268 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalRoleEntity.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/entity/PrincipalRoleEntity.java @@ -38,19 +38,19 @@ public static PrincipalRoleEntity of(PolarisBaseEntity sourceEntity) { public static PrincipalRoleEntity fromPrincipalRole(PrincipalRole principalRole) { return new Builder() .setName(principalRole.getName()) + .setFederated(principalRole.getFederated()) .setProperties(principalRole.getProperties()) .build(); } public PrincipalRole asPrincipalRole() { - PrincipalRole principalRole = - new PrincipalRole( - getName(), - getPropertiesAsMap(), - getCreateTimestamp(), - getLastUpdateTimestamp(), - getEntityVersion()); - return principalRole; + return new PrincipalRole( + getName(), + PolarisEntity.isFederated(this), + getPropertiesAsMap(), + getCreateTimestamp(), + getLastUpdateTimestamp(), + getEntityVersion()); } public static class Builder extends PolarisEntity.BaseBuilder { @@ -65,6 +65,13 @@ public Builder(PrincipalRoleEntity original) { super(original); } + public Builder setFederated(Boolean isFederated) { + if (isFederated != null && isFederated) { + internalProperties.put(PolarisEntityConstants.FEDERATED_ENTITY, "true"); + } + return this; + } + @Override public PrincipalRoleEntity build() { return new PrincipalRoleEntity(buildBase()); diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java index c07c80bd7..6c0562682 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/ManagementServiceTest.java @@ -22,9 +22,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import java.security.Principal; import java.time.Clock; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Set; +import org.apache.iceberg.exceptions.ValidationException; import org.apache.polaris.core.PolarisCallContext; import org.apache.polaris.core.admin.model.AwsStorageConfigInfo; import org.apache.polaris.core.admin.model.Catalog; @@ -34,8 +39,18 @@ import org.apache.polaris.core.admin.model.PolarisCatalog; import org.apache.polaris.core.admin.model.StorageConfigInfo; import org.apache.polaris.core.admin.model.UpdateCatalogRequest; +import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; +import org.apache.polaris.core.auth.PolarisAuthorizerImpl; import org.apache.polaris.core.context.CallContext; +import org.apache.polaris.core.context.RealmContext; +import org.apache.polaris.core.entity.PrincipalEntity; +import org.apache.polaris.core.entity.PrincipalRoleEntity; +import org.apache.polaris.core.persistence.MetaStoreManagerFactory; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.core.persistence.dao.entity.EntityResult; import org.apache.polaris.service.TestServices; +import org.apache.polaris.service.admin.PolarisAdminService; +import org.apache.polaris.service.config.DefaultConfigurationStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -158,4 +173,150 @@ public void testUpdateCatalogWithDisallowedStorageConfig() { .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported storage type: FILE"); } + + private PolarisMetaStoreManager setupMetaStoreManager() { + MetaStoreManagerFactory metaStoreManagerFactory = services.metaStoreManagerFactory(); + RealmContext realmContext = services.realmContext(); + return metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext); + } + + private PolarisCallContext setupCallContext(PolarisMetaStoreManager metaStoreManager) { + MetaStoreManagerFactory metaStoreManagerFactory = services.metaStoreManagerFactory(); + RealmContext realmContext = services.realmContext(); + return new PolarisCallContext( + metaStoreManagerFactory.getOrCreateSessionSupplier(realmContext).get(), + services.polarisDiagnostics()); + } + + private PolarisAdminService setupPolarisAdminService( + PolarisMetaStoreManager metaStoreManager, PolarisCallContext callContext) { + RealmContext realmContext = services.realmContext(); + return new PolarisAdminService( + CallContext.of(realmContext, callContext), + services.entityManagerFactory().getOrCreateEntityManager(realmContext), + metaStoreManager, + new SecurityContext() { + @Override + public Principal getUserPrincipal() { + return new AuthenticatedPolarisPrincipal( + new PrincipalEntity.Builder().setName("root").build(), Set.of("service_admin")); + } + + @Override + public boolean isUserInRole(String role) { + return true; + } + + @Override + public boolean isSecure() { + return false; + } + + @Override + public String getAuthenticationScheme() { + return ""; + } + }, + new PolarisAuthorizerImpl(new DefaultConfigurationStore(Map.of()))); + } + + private PrincipalEntity createPrincipal( + PolarisMetaStoreManager metaStoreManager, + PolarisCallContext callContext, + String name, + boolean isFederated) { + return new PrincipalEntity.Builder() + .setFederated(isFederated) + .setName(name) + .setCreateTimestamp(Instant.now().toEpochMilli()) + .setId(metaStoreManager.generateNewEntityId(callContext).getId()) + .build(); + } + + private PrincipalRoleEntity createRole( + PolarisMetaStoreManager metaStoreManager, + PolarisCallContext callContext, + String name, + boolean isFederated) { + return new PrincipalRoleEntity.Builder() + .setId(metaStoreManager.generateNewEntityId(callContext).getId()) + .setName(name) + .setFederated(isFederated) + .setProperties(Map.of()) + .setCreateTimestamp(Instant.now().toEpochMilli()) + .setLastUpdateTimestamp(Instant.now().toEpochMilli()) + .build(); + } + + @Test + public void testCannotAddFederatedPrincipalToNonFederatedRole() { + PolarisMetaStoreManager metaStoreManager = setupMetaStoreManager(); + PolarisCallContext callContext = setupCallContext(metaStoreManager); + PolarisAdminService polarisAdminService = + setupPolarisAdminService(metaStoreManager, callContext); + + PrincipalEntity federatedPrincipal = + createPrincipal(metaStoreManager, callContext, "federated_id", true); + metaStoreManager.createPrincipal(callContext, federatedPrincipal); + + PrincipalRoleEntity nonFederatedRole = + createRole(metaStoreManager, callContext, "non_federated_role", false); + EntityResult result = + metaStoreManager.createEntityIfNotExists(callContext, null, nonFederatedRole); + assertThat(result.isSuccess()).isTrue(); + + assertThatThrownBy( + () -> + polarisAdminService.assignPrincipalRole( + federatedPrincipal.getName(), nonFederatedRole.getName())) + .isInstanceOf(ValidationException.class); + } + + @Test + public void testCannotAddNonFederatedPrincipalToFederatedRole() { + PolarisMetaStoreManager metaStoreManager = setupMetaStoreManager(); + PolarisCallContext callContext = setupCallContext(metaStoreManager); + PolarisAdminService polarisAdminService = + setupPolarisAdminService(metaStoreManager, callContext); + + PrincipalEntity nonFederatedPrincipal = + createPrincipal(metaStoreManager, callContext, "non_federated_id", false); + metaStoreManager.createPrincipal(callContext, nonFederatedPrincipal); + + PrincipalRoleEntity federatedRole = + createRole(metaStoreManager, callContext, "federated_role", true); + EntityResult result = + metaStoreManager.createEntityIfNotExists(callContext, null, federatedRole); + assertThat(result.isSuccess()).isTrue(); + + assertThatThrownBy( + () -> + polarisAdminService.assignPrincipalRole( + nonFederatedPrincipal.getName(), federatedRole.getName())) + .isInstanceOf(ValidationException.class); + } + + @Test + public void testCannotAddFederatedPrincipalToFederatedRole() { + PolarisMetaStoreManager metaStoreManager = setupMetaStoreManager(); + PolarisCallContext callContext = setupCallContext(metaStoreManager); + PolarisAdminService polarisAdminService = + setupPolarisAdminService(metaStoreManager, callContext); + + PrincipalEntity federatedPrincipal = + createPrincipal(metaStoreManager, callContext, "federated_principal", true); + metaStoreManager.createPrincipal(callContext, federatedPrincipal); + + PrincipalRoleEntity federatedRole = + createRole(metaStoreManager, callContext, "federated_role", true); + EntityResult result = + metaStoreManager.createEntityIfNotExists(callContext, null, federatedRole); + assertThat(result.isSuccess()).isTrue(); + + assertThatThrownBy( + () -> + polarisAdminService.assignPrincipalRole( + federatedPrincipal.getName(), federatedRole.getName())) + .isInstanceOf(ValidationException.class); + } } diff --git a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java index effcededd..b955ffa2e 100644 --- a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java +++ b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java @@ -771,6 +771,9 @@ public PrincipalWithCredentials createPrincipal(PolarisEntity entity) { PolarisAuthorizableOperation op = PolarisAuthorizableOperation.CREATE_PRINCIPAL; authorizeBasicRootOperationOrThrow(op); + if (PolarisEntity.isFederated(entity)) { + throw new ValidationException("Cannot create a federated principal"); + } checkArgument(entity.getId() == -1, "Entity to be created must have no ID assigned"); CreatePrincipalResult principalResult = @@ -833,6 +836,10 @@ public void deletePrincipal(String name) { findPrincipalByName(name) .orElseThrow(() -> new NotFoundException("Principal %s not found", name)); + if (PolarisEntity.isFederated(currentPrincipalEntity)) { + throw new ValidationException( + "Cannot update a federated principal: %s", currentPrincipalEntity.getName()); + } if (currentPrincipalEntity.getEntityVersion() != updateRequest.getCurrentEntityVersion()) { throw new CommitFailedException( "Failed to update Principal; currentEntityVersion '%s', expected '%s'", @@ -863,6 +870,10 @@ public void deletePrincipal(String name) { findPrincipalByName(principalName) .orElseThrow(() -> new NotFoundException("Principal %s not found", principalName)); + if (PolarisEntity.isFederated(currentPrincipalEntity)) { + throw new ValidationException( + "Cannot rotate/reset credentials for a federated principal: %s", principalName); + } PolarisPrincipalSecrets currentSecrets = metaStoreManager .loadPrincipalSecrets(getCurrentPolarisContext(), currentPrincipalEntity.getClientId()) @@ -1180,11 +1191,16 @@ public boolean assignPrincipalRole(String principalName, String principalRoleNam PolarisEntity principalEntity = findPrincipalByName(principalName) .orElseThrow(() -> new NotFoundException("Principal %s not found", principalName)); + if (PolarisEntity.isFederated(principalEntity)) { + throw new ValidationException("Cannot assign a role to a federated principal"); + } PolarisEntity principalRoleEntity = findPrincipalRoleByName(principalRoleName) .orElseThrow( () -> new NotFoundException("PrincipalRole %s not found", principalRoleName)); - + if (PolarisEntity.isFederated(principalRoleEntity)) { + throw new ValidationException("Cannot assign a federated role to a principal"); + } return metaStoreManager .grantUsageOnRoleToGrantee( getCurrentPolarisContext(), null, principalRoleEntity, principalEntity) @@ -1198,10 +1214,16 @@ public boolean revokePrincipalRole(String principalName, String principalRoleNam PolarisEntity principalEntity = findPrincipalByName(principalName) .orElseThrow(() -> new NotFoundException("Principal %s not found", principalName)); + if (PolarisEntity.isFederated(principalEntity)) { + throw new ValidationException("Cannot revoke a role from a federated principal"); + } PolarisEntity principalRoleEntity = findPrincipalRoleByName(principalRoleName) .orElseThrow( () -> new NotFoundException("PrincipalRole %s not found", principalRoleName)); + if (PolarisEntity.isFederated(principalRoleEntity)) { + throw new ValidationException("Cannot revoke a federated role from a principal"); + } return metaStoreManager .revokeUsageOnRoleFromGrantee( getCurrentPolarisContext(), null, principalRoleEntity, principalEntity) diff --git a/spec/polaris-management-service.yml b/spec/polaris-management-service.yml index 318f17a6c..fe0f51a82 100644 --- a/spec/polaris-management-service.yml +++ b/spec/polaris-management-service.yml @@ -1089,6 +1089,10 @@ components: clientId: type: string description: The output-only OAuth clientId associated with this principal if applicable + federated: + type: boolean + description: Whether the principal is a federated identity (that is, managed by an external identity provider) + default: false properties: type: object additionalProperties: @@ -1151,6 +1155,10 @@ components: maxLength: 256 pattern: '^(?!\s*[s|S][y|Y][s|S][t|T][e|E][m|M]\$).*$' description: The name of the role + federated: + type: boolean + description: Whether the principal role is a federated role (that is, managed by an external identity provider) + default: false properties: type: object additionalProperties: