Skip to content

Commit 327697c

Browse files
Feature: Expose resetCredentials via a new reset api to allow root user to reset credentials for an existing principal with custom values (apache#2197)
1 parent 6b4f8e1 commit 327697c

File tree

17 files changed

+479
-5
lines changed

17 files changed

+479
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ automatic storage credential refresh per table on the client side. Java client v
8888
The endpoint path is always returned when using vended credentials, but clients must enable the
8989
refresh-credentials flag for the desired storage provider.
9090

91+
- Added a Management API endpoint to reset principal credentials, controlled by the `ENABLE_CREDENTIAL_RESET` (default: true) feature flag.
92+
9193
### Changes
9294

9395
- Polaris Management API clients must be prepared to deal with new attributes in `AwsStorageConfigInfo` objects.

integration-tests/src/main/java/org/apache/polaris/service/it/env/ManagementApi.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ public PrincipalWithCredentials createPrincipal(CreatePrincipalRequest request)
8080
}
8181
}
8282

83+
/**
84+
* Retrieves a Principal by name via the management API.
85+
*
86+
* @param principalName the name of the principal to fetch
87+
* @return the Principal object
88+
*/
89+
public Principal getPrincipal(String principalName) {
90+
try (Response response =
91+
request("v1/principals/{principalName}", Map.of("principalName", principalName)).get()) {
92+
assertThat(response).returns(Response.Status.OK.getStatusCode(), Response::getStatus);
93+
return response.readEntity(Principal.class);
94+
}
95+
}
96+
8397
public void createPrincipalRole(String name) {
8498
createPrincipalRole(new PrincipalRole(name));
8599
}

integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,57 @@ public void testCreatePrincipalAndRotateCredentials() {
880880
// rotation that makes the old secret fall off retention.
881881
}
882882

883+
@Test
884+
public void testCreatePrincipalAndResetCredentialsWithCustomValues() {
885+
// Create a new principal using root user
886+
Principal principal =
887+
Principal.builder()
888+
.setName(client.newEntityName("myprincipal-reset"))
889+
.setProperties(Map.of("custom-tag", "bar"))
890+
.build();
891+
892+
PrincipalWithCredentials creds =
893+
managementApi.createPrincipal(new CreatePrincipalRequest(principal, true));
894+
895+
Map<String, String> customBody =
896+
Map.of(
897+
"clientId", "f174b76a7e1a99e2",
898+
"clientSecret", "27029d236abc08e204922b0a07031bc2");
899+
900+
PrincipalWithCredentials resetCreds;
901+
try (Response response =
902+
managementApi
903+
.request("v1/principals/{p}/reset", Map.of("p", principal.getName()))
904+
.post(Entity.json(customBody))) {
905+
906+
assertThat(response).returns(Response.Status.OK.getStatusCode(), Response::getStatus);
907+
resetCreds = response.readEntity(PrincipalWithCredentials.class);
908+
}
909+
910+
assertThat(resetCreds.getCredentials().getClientId()).isEqualTo("f174b76a7e1a99e2");
911+
assertThat(resetCreds.getCredentials().getClientSecret())
912+
.isEqualTo("27029d236abc08e204922b0a07031bc2");
913+
914+
// Validate that the principal entity itself is updated in sync with credentials
915+
Principal updatedPrincipal = managementApi.getPrincipal(principal.getName());
916+
assertThat(updatedPrincipal.getClientId()).isEqualTo("f174b76a7e1a99e2");
917+
918+
// Principal itself tries to reset with custom creds → should fail (403 Forbidden)
919+
String principalToken = client.obtainToken(resetCreds);
920+
customBody =
921+
Map.of(
922+
"clientId", "a174b76a7e1a99e3",
923+
"clientSecret", "37029d236abc08e204922b0a07031bc3");
924+
try (Response response =
925+
client
926+
.managementApi(principalToken)
927+
.request("v1/principals/{p}/reset", Map.of("p", principal.getName()))
928+
.post(Entity.json(customBody))) {
929+
930+
assertThat(response).returns(Response.Status.FORBIDDEN.getStatusCode(), Response::getStatus);
931+
}
932+
}
933+
883934
@Test
884935
public void testCreateFederatedPrincipalRoleSucceeds() {
885936
// Create a federated Principal Role

persistence/eclipselink/src/main/java/org/apache/polaris/extension/persistence/impl/eclipselink/PolarisEclipseLinkMetaStoreSessionImpl.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,4 +747,15 @@ Optional<Optional<String>> hasOverlappingSiblings(
747747
@Nonnull PolarisCallContext callContext, T entity) {
748748
return Optional.empty();
749749
}
750+
751+
@Nullable
752+
@Override
753+
public PolarisPrincipalSecrets storePrincipalSecrets(
754+
@Nonnull PolarisCallContext callCtx,
755+
long principalId,
756+
@Nonnull String resolvedClientId,
757+
String customClientSecret) {
758+
throw new UnsupportedOperationException(
759+
"This method is not supported for EclipseLink as metastore");
760+
}
750761
}

persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,42 @@ public PolarisPrincipalSecrets generateNewPrincipalSecrets(
777777
return principalSecrets;
778778
}
779779

780+
@Nullable
781+
@Override
782+
public PolarisPrincipalSecrets storePrincipalSecrets(
783+
@Nonnull PolarisCallContext callCtx,
784+
long principalId,
785+
@Nonnull String resolvedClientId,
786+
String customClientSecret) {
787+
PolarisPrincipalSecrets principalSecrets =
788+
new PolarisPrincipalSecrets(principalId, resolvedClientId, customClientSecret);
789+
try {
790+
ModelPrincipalAuthenticationData modelPrincipalAuthenticationData =
791+
ModelPrincipalAuthenticationData.fromPrincipalAuthenticationData(principalSecrets);
792+
datasourceOperations.executeUpdate(
793+
QueryGenerator.generateInsertQuery(
794+
ModelPrincipalAuthenticationData.ALL_COLUMNS,
795+
ModelPrincipalAuthenticationData.TABLE_NAME,
796+
modelPrincipalAuthenticationData
797+
.toMap(datasourceOperations.getDatabaseType())
798+
.values()
799+
.stream()
800+
.toList(),
801+
realmId));
802+
} catch (SQLException e) {
803+
LOGGER.error(
804+
"Failed to reset PrincipalSecrets for clientId: {}, due to {}",
805+
resolvedClientId,
806+
e.getMessage(),
807+
e);
808+
throw new RuntimeException(
809+
String.format("Failed to reset PrincipalSecrets for clientId: %s", resolvedClientId), e);
810+
}
811+
812+
// return those
813+
return principalSecrets;
814+
}
815+
780816
@Nullable
781817
@Override
782818
public PolarisPrincipalSecrets rotatePrincipalSecrets(

polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package org.apache.polaris.core.auth;
2020

21+
import static org.apache.polaris.core.entity.PolarisEntityConstants.getRootPrincipalName;
2122
import static org.apache.polaris.core.entity.PolarisPrivilege.CATALOG_ATTACH_POLICY;
2223
import static org.apache.polaris.core.entity.PolarisPrivilege.CATALOG_CREATE;
2324
import static org.apache.polaris.core.entity.PolarisPrivilege.CATALOG_DETACH_POLICY;
@@ -581,6 +582,7 @@ public void authorizeOrThrow(
581582
boolean enforceCredentialRotationRequiredState =
582583
realmConfig.getConfig(
583584
FeatureConfiguration.ENFORCE_PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_CHECKING);
585+
boolean isRoot = getRootPrincipalName().equals(polarisPrincipal.getName());
584586
if (enforceCredentialRotationRequiredState
585587
&& polarisPrincipal
586588
.getProperties()
@@ -589,6 +591,14 @@ public void authorizeOrThrow(
589591
throw new ForbiddenException(
590592
"Principal '%s' is not authorized for op %s due to PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_STATE",
591593
polarisPrincipal.getName(), authzOp);
594+
} else if (authzOp == PolarisAuthorizableOperation.RESET_CREDENTIALS) {
595+
if (!isRoot) {
596+
throw new ForbiddenException("Only Root principal(service-admin) can perform %s", authzOp);
597+
}
598+
LOGGER
599+
.atDebug()
600+
.addKeyValue("principalName", polarisPrincipal.getName())
601+
.log("Root principal allowed to reset credentials");
592602
} else if (!isAuthorized(polarisPrincipal, activatedEntities, authzOp, targets, secondaries)) {
593603
throw new ForbiddenException(
594604
"Principal '%s' with activated PrincipalRoles '%s' and activated grants via '%s' is not authorized for op %s",

polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisSecretsManager.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import jakarta.annotation.Nonnull;
2222
import org.apache.polaris.core.PolarisCallContext;
2323
import org.apache.polaris.core.persistence.dao.entity.PrincipalSecretsResult;
24+
import software.amazon.awssdk.annotations.NotNull;
2425

2526
/** Manages secrets for Polaris principals. */
2627
public interface PolarisSecretsManager {
@@ -54,4 +55,38 @@ PrincipalSecretsResult rotatePrincipalSecrets(
5455
long principalId,
5556
boolean reset,
5657
@Nonnull String oldSecretHash);
58+
59+
/**
60+
* Reset the secrets of a principal entity.
61+
*
62+
* <p>This operation makes the specified secrets (either provided by the caller or newly
63+
* generated) the active credentials for the principal. It effectively overwrites any previous
64+
* secrets and sets the provided values as the new client id/secret for the principal.
65+
*
66+
* @param callCtx call context
67+
* @param principalId id of the principal
68+
* @param resolvedClientId current principal client id
69+
* @param customClientSecret optional new client secret to assign (may be {@code null} if
70+
* system-generated)
71+
* @return the secrets associated with the principal, including the updated client id and secret
72+
*/
73+
@Nonnull
74+
PrincipalSecretsResult resetPrincipalSecrets(
75+
@Nonnull PolarisCallContext callCtx,
76+
long principalId,
77+
@NotNull String resolvedClientId,
78+
String customClientSecret);
79+
80+
/**
81+
* Permanently delete the secrets of a principal.
82+
*
83+
* <p>This operation removes all stored secrets associated with the given principal
84+
*
85+
* @param callCtx call context
86+
* @param clientId principal client id
87+
* @param principalId id of the principal whose secrets should be deleted
88+
*/
89+
@Nonnull
90+
void deletePrincipalSecrets(
91+
@Nonnull PolarisCallContext callCtx, @Nonnull String clientId, long principalId);
5792
}

polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,4 +367,13 @@ public static void enforceFeatureEnabledOrThrow(
367367
+ "it is still possible to enforce the uniqueness of table locations within a catalog.")
368368
.defaultValue(false)
369369
.buildFeatureConfiguration();
370+
371+
public static final FeatureConfiguration<Boolean> ENABLE_CREDENTIAL_RESET =
372+
PolarisConfiguration.<Boolean>builder()
373+
.key("ENABLE_CREDENTIAL_RESET")
374+
.description(
375+
"Flag to enable or disable the API to reset principal credentials. "
376+
+ "Defaults to enabled, but service providers may want to disable it.")
377+
.defaultValue(true)
378+
.buildFeatureConfiguration();
370379
}

polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisPrincipalSecrets.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import com.fasterxml.jackson.annotation.JsonCreator;
2222
import com.fasterxml.jackson.annotation.JsonProperty;
23+
import jakarta.annotation.Nullable;
2324
import java.security.SecureRandom;
2425
import org.apache.commons.codec.digest.DigestUtils;
2526

@@ -138,6 +139,17 @@ public PolarisPrincipalSecrets(long principalId) {
138139
this.secondarySecretHash = hashSecret(secondarySecret);
139140
}
140141

142+
public PolarisPrincipalSecrets(long principalId, String newClientId, @Nullable String newSecret) {
143+
this.principalId = principalId;
144+
this.principalClientId = newClientId;
145+
this.mainSecret = (newSecret != null) ? newSecret : this.generateRandomHexString(32);
146+
this.secondarySecret = this.generateRandomHexString(32);
147+
148+
this.secretSalt = this.generateRandomHexString(16);
149+
this.mainSecretHash = hashSecret(mainSecret);
150+
this.secondarySecretHash = hashSecret(secondarySecret);
151+
}
152+
141153
/** Rotate the main secrets */
142154
public void rotateSecrets(String newSecondaryHash) {
143155
this.secondarySecret = null;

polaris-core/src/main/java/org/apache/polaris/core/persistence/AtomicOperationMetaStoreManager.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,15 @@ private void revokeGrantRecord(
857857
: new PrincipalSecretsResult(secrets);
858858
}
859859

860+
/** {@inheritDoc} */
861+
@Override
862+
public @Nonnull void deletePrincipalSecrets(
863+
@Nonnull PolarisCallContext callCtx, @Nonnull String clientId, long principalId) {
864+
// get metastore we should be using
865+
BasePersistence ms = callCtx.getMetaStore();
866+
((IntegrationPersistence) ms).deletePrincipalSecrets(callCtx, clientId, principalId);
867+
}
868+
860869
/** {@inheritDoc} */
861870
@Override
862871
public @Nonnull PrincipalSecretsResult rotatePrincipalSecrets(
@@ -912,6 +921,30 @@ private void revokeGrantRecord(
912921
: new PrincipalSecretsResult(secrets);
913922
}
914923

924+
@Override
925+
public @Nonnull PrincipalSecretsResult resetPrincipalSecrets(
926+
@Nonnull PolarisCallContext callCtx,
927+
long principalId,
928+
@Nonnull String resolvedClientId,
929+
String customClientSecret) {
930+
// get metastore we should be using
931+
BasePersistence ms = callCtx.getMetaStore();
932+
// if not found, the principal must have been dropped
933+
EntityResult loadEntityResult =
934+
loadEntity(
935+
callCtx, PolarisEntityConstants.getNullId(), principalId, PolarisEntityType.PRINCIPAL);
936+
if (loadEntityResult.getReturnStatus() != BaseResult.ReturnStatus.SUCCESS) {
937+
return new PrincipalSecretsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null);
938+
}
939+
940+
PolarisPrincipalSecrets secrets =
941+
((IntegrationPersistence) ms)
942+
.storePrincipalSecrets(callCtx, principalId, resolvedClientId, customClientSecret);
943+
return (secrets == null)
944+
? new PrincipalSecretsResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, null)
945+
: new PrincipalSecretsResult(secrets);
946+
}
947+
915948
/** {@inheritDoc} */
916949
@Override
917950
public @Nonnull EntityResult createEntityIfNotExists(

0 commit comments

Comments
 (0)