This is an automated email from the ASF dual-hosted git repository.
dhuo 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 793a1188f SigV4 Auth Support for Catalog Federation - Part 3: Service
Identity Info Injection (#2523)
793a1188f is described below
commit 793a1188f53658d17e032f2548a38088a8045650
Author: Rulin Xing <[email protected]>
AuthorDate: Fri Oct 3 19:17:41 2025 -0700
SigV4 Auth Support for Catalog Federation - Part 3: Service Identity Info
Injection (#2523)
This PR introduces service identity management for SigV4 Auth Support for
Catalog Federation. Unlike user-supplied parameters, the service identity
represents the identity of the Polaris service itself and should be managed by
Polaris.
* Service Identity Injection
* Return injected service identity info in response
* Use AwsCredentialsProvider to retrieve the credentials
* Move some logic to ServiceIdentityConfiguration
* Rename ServiceIdentityRegistry to ServiceIdentityProvider
* Rename ResolvedServiceIdentity to ServiceIdentityCredential
* Simplify the logic and add more tests
* Use SecretReference and fix some small issues
* Disable Catalog Federation
---
.../polaris/core/config/FeatureConfiguration.java | 3 +-
.../core/connection/ConnectionConfigInfoDpo.java | 6 +-
.../hadoop/HadoopConnectionConfigInfoDpo.java | 8 +-
.../hive/HiveConnectionConfigInfoDpo.java | 11 +-
.../IcebergRestConnectionConfigInfoDpo.java | 8 +-
.../apache/polaris/core/entity/CatalogEntity.java | 25 +-
.../AwsIamServiceIdentityCredential.java | 100 ++++++
.../credential/ServiceIdentityCredential.java | 91 +++++
.../identity/dpo/AwsIamServiceIdentityInfoDpo.java | 29 +-
.../core/identity/dpo/ServiceIdentityInfoDpo.java | 30 +-
.../identity/provider/ServiceIdentityProvider.java | 103 ++++++
.../connection/ConnectionConfigInfoDpoTest.java | 37 ++-
.../AwsIamServiceIdentityCredentialTest.java | 164 +++++++++
.../polaris/service/admin/PolarisAdminService.java | 30 +-
.../polaris/service/admin/PolarisServiceImpl.java | 18 +-
.../polaris/service/config/ServiceProducers.java | 4 +-
.../AwsIamServiceIdentityConfiguration.java | 141 ++++++++
.../RealmServiceIdentityConfiguration.java | 51 +++
.../ResolvableServiceIdentityConfiguration.java | 75 +++++
.../identity/ServiceIdentityConfiguration.java | 114 +++++++
.../provider/DefaultServiceIdentityProvider.java | 155 +++++++++
.../service/admin/ManagementServiceTest.java | 2 +
.../admin/PolarisAdminServiceAuthzTest.java | 13 +-
.../service/admin/PolarisAdminServiceTest.java | 3 +
.../service/admin/PolarisAuthzTestBase.java | 5 +-
.../service/admin/PolarisServiceImplTest.java | 10 +-
.../AbstractPolarisGenericTableCatalogTest.java | 5 +-
.../iceberg/AbstractIcebergCatalogTest.java | 15 +-
.../iceberg/AbstractIcebergCatalogViewTest.java | 5 +-
.../iceberg/IcebergCatalogHandlerAuthzTest.java | 2 +-
.../catalog/policy/AbstractPolicyCatalogTest.java | 5 +-
.../polaris/service/entity/CatalogEntityTest.java | 92 ++++-
.../DefaultServiceIdentityProviderTest.java | 369 +++++++++++++++++++++
.../org/apache/polaris/service/TestServices.java | 7 +-
34 files changed, 1663 insertions(+), 73 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 6eda66a23..5bd9f7257 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
@@ -303,7 +303,8 @@ public class FeatureConfiguration<T> extends
PolarisConfiguration<T> {
.defaultValue(
List.of(
AuthenticationParameters.AuthenticationTypeEnum.OAUTH.name(),
-
AuthenticationParameters.AuthenticationTypeEnum.BEARER.name()))
+
AuthenticationParameters.AuthenticationTypeEnum.BEARER.name(),
+
AuthenticationParameters.AuthenticationTypeEnum.SIGV4.name()))
.buildFeatureConfiguration();
public static final FeatureConfiguration<Integer> ICEBERG_COMMIT_MAX_RETRIES
=
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpo.java
b/polaris-core/src/main/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpo.java
index bb95dc442..555a4008d 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpo.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpo.java
@@ -41,6 +41,7 @@ import
org.apache.polaris.core.connection.hive.HiveConnectionConfigInfoDpo;
import
org.apache.polaris.core.connection.iceberg.IcebergCatalogPropertiesProvider;
import
org.apache.polaris.core.connection.iceberg.IcebergRestConnectionConfigInfoDpo;
import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.SecretReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -225,9 +226,10 @@ public abstract class ConnectionConfigInfoDpo implements
IcebergCatalogPropertie
@Nonnull ServiceIdentityInfoDpo serviceIdentityInfo);
/**
- * Produces the correponding API-model ConnectionConfigInfo for this
persistence object; many
+ * Produces the corresponding API-model ConnectionConfigInfo for this
persistence object; many
* fields are one-to-one direct mappings, but some fields, such as
secretReferences, might only be
* applicable/present in the persistence object, but not the API model
object.
*/
- public abstract ConnectionConfigInfo asConnectionConfigInfoModel();
+ public abstract ConnectionConfigInfo asConnectionConfigInfoModel(
+ ServiceIdentityProvider serviceIdentityProvider);
}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/connection/hadoop/HadoopConnectionConfigInfoDpo.java
b/polaris-core/src/main/java/org/apache/polaris/core/connection/hadoop/HadoopConnectionConfigInfoDpo.java
index 05104dd20..66fe8bb70 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/connection/hadoop/HadoopConnectionConfigInfoDpo.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/connection/hadoop/HadoopConnectionConfigInfoDpo.java
@@ -32,6 +32,7 @@ import
org.apache.polaris.core.connection.AuthenticationParametersDpo;
import org.apache.polaris.core.connection.ConnectionConfigInfoDpo;
import org.apache.polaris.core.connection.ConnectionType;
import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.UserSecretsManager;
/**
@@ -88,7 +89,8 @@ public class HadoopConnectionConfigInfoDpo extends
ConnectionConfigInfoDpo {
}
@Override
- public ConnectionConfigInfo asConnectionConfigInfoModel() {
+ public ConnectionConfigInfo asConnectionConfigInfoModel(
+ ServiceIdentityProvider serviceIdentityProvider) {
return HadoopConnectionConfigInfo.builder()
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.HADOOP)
.setUri(getUri())
@@ -97,7 +99,9 @@ public class HadoopConnectionConfigInfoDpo extends
ConnectionConfigInfoDpo {
getAuthenticationParameters().asAuthenticationParametersModel())
.setServiceIdentity(
Optional.ofNullable(getServiceIdentity())
- .map(ServiceIdentityInfoDpo::asServiceIdentityInfoModel)
+ .map(
+ serviceIdentityInfoDpo ->
+
serviceIdentityInfoDpo.asServiceIdentityInfoModel(serviceIdentityProvider))
.orElse(null))
.build();
}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/connection/hive/HiveConnectionConfigInfoDpo.java
b/polaris-core/src/main/java/org/apache/polaris/core/connection/hive/HiveConnectionConfigInfoDpo.java
index 1f9027f2b..22b067a6c 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/connection/hive/HiveConnectionConfigInfoDpo.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/connection/hive/HiveConnectionConfigInfoDpo.java
@@ -24,6 +24,7 @@ import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
import org.apache.iceberg.CatalogProperties;
import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
import org.apache.polaris.core.admin.model.HiveConnectionConfigInfo;
@@ -31,6 +32,7 @@ import
org.apache.polaris.core.connection.AuthenticationParametersDpo;
import org.apache.polaris.core.connection.ConnectionConfigInfoDpo;
import org.apache.polaris.core.connection.ConnectionType;
import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.UserSecretsManager;
/**
@@ -88,13 +90,20 @@ public class HiveConnectionConfigInfoDpo extends
ConnectionConfigInfoDpo {
}
@Override
- public ConnectionConfigInfo asConnectionConfigInfoModel() {
+ public ConnectionConfigInfo asConnectionConfigInfoModel(
+ ServiceIdentityProvider serviceIdentityProvider) {
return HiveConnectionConfigInfo.builder()
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.HIVE)
.setUri(getUri())
.setWarehouse(getWarehouse())
.setAuthenticationParameters(
getAuthenticationParameters().asAuthenticationParametersModel())
+ .setServiceIdentity(
+ Optional.ofNullable(getServiceIdentity())
+ .map(
+ serviceIdentityInfoDpo ->
+
serviceIdentityInfoDpo.asServiceIdentityInfoModel(serviceIdentityProvider))
+ .orElse(null))
.build();
}
}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/connection/iceberg/IcebergRestConnectionConfigInfoDpo.java
b/polaris-core/src/main/java/org/apache/polaris/core/connection/iceberg/IcebergRestConnectionConfigInfoDpo.java
index 0a6e870b7..ee01691b9 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/connection/iceberg/IcebergRestConnectionConfigInfoDpo.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/connection/iceberg/IcebergRestConnectionConfigInfoDpo.java
@@ -32,6 +32,7 @@ import
org.apache.polaris.core.connection.AuthenticationParametersDpo;
import org.apache.polaris.core.connection.ConnectionConfigInfoDpo;
import org.apache.polaris.core.connection.ConnectionType;
import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.UserSecretsManager;
/**
@@ -80,7 +81,8 @@ public class IcebergRestConnectionConfigInfoDpo extends
ConnectionConfigInfoDpo
}
@Override
- public ConnectionConfigInfo asConnectionConfigInfoModel() {
+ public ConnectionConfigInfo asConnectionConfigInfoModel(
+ ServiceIdentityProvider serviceIdentityProvider) {
return IcebergRestConnectionConfigInfo.builder()
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST)
.setUri(getUri())
@@ -89,7 +91,9 @@ public class IcebergRestConnectionConfigInfoDpo extends
ConnectionConfigInfoDpo
getAuthenticationParameters().asAuthenticationParametersModel())
.setServiceIdentity(
Optional.ofNullable(getServiceIdentity())
- .map(ServiceIdentityInfoDpo::asServiceIdentityInfoModel)
+ .map(
+ serviceIdentityInfoDpo ->
+
serviceIdentityInfoDpo.asServiceIdentityInfoModel(serviceIdentityProvider))
.orElse(null))
.build();
}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/entity/CatalogEntity.java
b/polaris-core/src/main/java/org/apache/polaris/core/entity/CatalogEntity.java
index a31299504..055ccd895 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/entity/CatalogEntity.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/entity/CatalogEntity.java
@@ -43,6 +43,8 @@ import org.apache.polaris.core.admin.model.StorageConfigInfo;
import org.apache.polaris.core.config.BehaviorChangeConfiguration;
import org.apache.polaris.core.config.RealmConfig;
import org.apache.polaris.core.connection.ConnectionConfigInfoDpo;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.SecretReference;
import org.apache.polaris.core.storage.FileStorageConfigurationInfo;
import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo;
@@ -108,6 +110,10 @@ public class CatalogEntity extends PolarisEntity
implements LocationBasedEntity
}
public Catalog asCatalog() {
+ return this.asCatalog(null);
+ }
+
+ public Catalog asCatalog(ServiceIdentityProvider serviceIdentityProvider) {
Map<String, String> internalProperties = getInternalPropertiesAsMap();
Catalog.TypeEnum catalogType =
Optional.ofNullable(internalProperties.get(CATALOG_TYPE_PROPERTY))
@@ -118,6 +124,12 @@ public class CatalogEntity extends PolarisEntity
implements LocationBasedEntity
CatalogProperties.builder(propertiesMap.get(DEFAULT_BASE_LOCATION_KEY))
.putAll(propertiesMap)
.build();
+
+ // Right now, only external catalog may use ServiceIdentityProvider to
resolve identity
+ Preconditions.checkState(
+ catalogType != Catalog.TypeEnum.EXTERNAL || serviceIdentityProvider !=
null,
+ "%s catalog needs ServiceIdentityProvider to resolve service
identities",
+ Catalog.TypeEnum.EXTERNAL);
return catalogType == Catalog.TypeEnum.EXTERNAL
? ExternalCatalog.builder()
.setType(Catalog.TypeEnum.EXTERNAL)
@@ -127,7 +139,7 @@ public class CatalogEntity extends PolarisEntity implements
LocationBasedEntity
.setLastUpdateTimestamp(getLastUpdateTimestamp())
.setEntityVersion(getEntityVersion())
.setStorageConfigInfo(getStorageInfo(internalProperties))
- .setConnectionConfigInfo(getConnectionInfo(internalProperties))
+ .setConnectionConfigInfo(getConnectionInfo(internalProperties,
serviceIdentityProvider))
.build()
: PolarisCatalog.builder()
.setType(Catalog.TypeEnum.INTERNAL)
@@ -187,11 +199,12 @@ public class CatalogEntity extends PolarisEntity
implements LocationBasedEntity
return null;
}
- private ConnectionConfigInfo getConnectionInfo(Map<String, String>
internalProperties) {
+ private ConnectionConfigInfo getConnectionInfo(
+ Map<String, String> internalProperties, ServiceIdentityProvider
serviceIdentityProvider) {
if (internalProperties.containsKey(
PolarisEntityConstants.getConnectionConfigInfoPropertyName())) {
ConnectionConfigInfoDpo configInfo = getConnectionConfigInfoDpo();
- return configInfo.asConnectionConfigInfoModel();
+ return configInfo.asConnectionConfigInfoModel(serviceIdentityProvider);
}
return null;
}
@@ -352,11 +365,13 @@ public class CatalogEntity extends PolarisEntity
implements LocationBasedEntity
public Builder setConnectionConfigInfoDpoWithSecrets(
ConnectionConfigInfo connectionConfigurationModel,
- Map<String, SecretReference> secretReferences) {
+ Map<String, SecretReference> secretReferences,
+ ServiceIdentityInfoDpo serviceIdentityInfoDpo) {
if (connectionConfigurationModel != null) {
ConnectionConfigInfoDpo config =
ConnectionConfigInfoDpo.fromConnectionConfigInfoModelWithSecrets(
- connectionConfigurationModel, secretReferences);
+ connectionConfigurationModel, secretReferences)
+ .withServiceIdentity(serviceIdentityInfoDpo);
internalProperties.put(
PolarisEntityConstants.getConnectionConfigInfoPropertyName(),
config.serialize());
}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/identity/credential/AwsIamServiceIdentityCredential.java
b/polaris-core/src/main/java/org/apache/polaris/core/identity/credential/AwsIamServiceIdentityCredential.java
new file mode 100644
index 000000000..ae85cbbe1
--- /dev/null
+++
b/polaris-core/src/main/java/org/apache/polaris/core/identity/credential/AwsIamServiceIdentityCredential.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.core.identity.credential;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import org.apache.polaris.core.identity.dpo.AwsIamServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.secrets.SecretReference;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+
+/**
+ * Represents an AWS IAM service identity credential used by Polaris to
authenticate to AWS
+ * services.
+ *
+ * <p>This credential encapsulates:
+ *
+ * <ul>
+ * <li>The IAM ARN (role or user) representing the Polaris service identity
+ * <li>An {@link AwsCredentialsProvider} that supplies AWS credentials
(access key, secret key,
+ * and optional session token)
+ * </ul>
+ *
+ * <p>Polaris uses this identity to assume customer-provided IAM roles when
accessing remote
+ * catalogs with SigV4 authentication. The {@link AwsCredentialsProvider} can
be configured to use
+ * either:
+ *
+ * <ul>
+ * <li>Static credentials (for testing or single-tenant deployments)
+ * <li>DefaultCredentialsProvider (which chains through various AWS
credential sources)
+ * <li>Custom credential providers (for vendor-specific secret management)
+ * </ul>
+ */
+public class AwsIamServiceIdentityCredential extends ServiceIdentityCredential
{
+
+ /** IAM role or user ARN representing the Polaris service identity. */
+ private final String iamArn;
+
+ /** AWS credentials provider for accessing AWS services. */
+ private final AwsCredentialsProvider awsCredentialsProvider;
+
+ public AwsIamServiceIdentityCredential(@Nullable String iamArn) {
+ this(null, iamArn, DefaultCredentialsProvider.builder().build());
+ }
+
+ public AwsIamServiceIdentityCredential(
+ @Nullable String iamArn, @Nonnull AwsCredentialsProvider
awsCredentialsProvider) {
+ this(null, iamArn, awsCredentialsProvider);
+ }
+
+ public AwsIamServiceIdentityCredential(
+ @Nullable SecretReference secretReference,
+ @Nullable String iamArn,
+ @Nonnull AwsCredentialsProvider awsCredentialsProvider) {
+ super(ServiceIdentityType.AWS_IAM, secretReference);
+ this.iamArn = iamArn;
+ this.awsCredentialsProvider = awsCredentialsProvider;
+ }
+
+ public @Nullable String getIamArn() {
+ return iamArn;
+ }
+
+ public @Nonnull AwsCredentialsProvider getAwsCredentialsProvider() {
+ return awsCredentialsProvider;
+ }
+
+ @Override
+ public @Nonnull ServiceIdentityInfoDpo asServiceIdentityInfoDpo() {
+ return new AwsIamServiceIdentityInfoDpo(getIdentityInfoReference());
+ }
+
+ @Override
+ public @Nonnull ServiceIdentityInfo asServiceIdentityInfoModel() {
+ return AwsIamServiceIdentityInfo.builder()
+ .setIdentityType(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM)
+ .setIamArn(getIamArn())
+ .build();
+ }
+}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/identity/credential/ServiceIdentityCredential.java
b/polaris-core/src/main/java/org/apache/polaris/core/identity/credential/ServiceIdentityCredential.java
new file mode 100644
index 000000000..635c6fcd6
--- /dev/null
+++
b/polaris-core/src/main/java/org/apache/polaris/core/identity/credential/ServiceIdentityCredential.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.core.identity.credential;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.secrets.SecretReference;
+import software.amazon.awssdk.annotations.NotNull;
+
+/**
+ * Represents a service identity credential used by Polaris to authenticate to
external systems.
+ *
+ * <p>This class encapsulates both the service identity metadata (e.g., AWS
IAM ARN) and the
+ * associated credentials (e.g., AWS access keys) needed to authenticate as
the Polaris service when
+ * accessing external catalog services.
+ *
+ * <p>The credential contains:
+ *
+ * <ul>
+ * <li>Identity type (e.g., AWS_IAM)
+ * <li>A {@link SecretReference} that serves as a unique identifier for this
service identity
+ * instance (used for lookups and persistence)
+ * <li>The actual authentication credentials (implementation-specific, e.g.,
+ * AwsCredentialsProvider)
+ * </ul>
+ */
+public abstract class ServiceIdentityCredential {
+ private final ServiceIdentityType identityType;
+ private SecretReference identityInfoReference;
+
+ public ServiceIdentityCredential(@Nonnull ServiceIdentityType identityType) {
+ this(identityType, null);
+ }
+
+ public ServiceIdentityCredential(
+ @Nonnull ServiceIdentityType identityType, @Nullable SecretReference
identityInfoReference) {
+ this.identityType = identityType;
+ this.identityInfoReference = identityInfoReference;
+ }
+
+ public @NotNull ServiceIdentityType getIdentityType() {
+ return identityType;
+ }
+
+ public @Nonnull SecretReference getIdentityInfoReference() {
+ return identityInfoReference;
+ }
+
+ public void setIdentityInfoReference(@NotNull SecretReference
identityInfoReference) {
+ this.identityInfoReference = identityInfoReference;
+ }
+
+ /**
+ * Converts this service identity credential into its corresponding
persisted form (DPO).
+ *
+ * <p>The DPO contains only a reference to the credential, not the
credential itself, as the
+ * actual secrets are managed externally.
+ *
+ * @return The persistence object representation
+ */
+ public abstract @Nonnull ServiceIdentityInfoDpo asServiceIdentityInfoDpo();
+
+ /**
+ * Converts this service identity credential into its API model
representation.
+ *
+ * <p>The model contains identity information (e.g., IAM ARN) but excludes
sensitive credentials
+ * such as access keys or session tokens.
+ *
+ * @return The API model representation for client responses
+ */
+ public abstract @Nonnull ServiceIdentityInfo asServiceIdentityInfoModel();
+}
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/AwsIamServiceIdentityInfoDpo.java
b/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/AwsIamServiceIdentityInfoDpo.java
index cddad3a42..1e8d77c60 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/AwsIamServiceIdentityInfoDpo.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/AwsIamServiceIdentityInfoDpo.java
@@ -21,26 +21,26 @@ package org.apache.polaris.core.identity.dpo;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.base.MoreObjects;
-import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
-import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
import org.apache.polaris.core.identity.ServiceIdentityType;
+import
org.apache.polaris.core.identity.credential.AwsIamServiceIdentityCredential;
import org.apache.polaris.core.secrets.SecretReference;
/**
* Persistence-layer representation of an AWS IAM service identity used by
Polaris.
*
- * <p>This class models an AWS IAM identity (either a user or role) and
extends {@link
- * ServiceIdentityInfoDpo}. It is typically used internally to store a
reference to the actual
- * credential (e.g., via {@link SecretReference}).
+ * <p>This class stores only the identity type and a {@link SecretReference}
that serves as a unique
+ * identifier for this service identity instance. The reference is used to
look up the identity's
+ * configuration at runtime. The actual credentials (AWS access keys) and
metadata (IAM ARN) are not
+ * persisted in this object.
*
- * <p>During the runtime, it will be resolved to an actual
ResolvedAwsIamServiceIdentityInfo object
- * which contains the actual service identity info (e.g., the IAM user arn)
and the corresponding
- * credential.
+ * <p>At runtime, a ServiceIdentityProvider uses the reference to look up the
configuration and
+ * retrieve the full {@link AwsIamServiceIdentityCredential} which contains
both the identity
+ * metadata (e.g., IAM ARN) and the actual AWS credentials needed for
authentication.
*
- * <p>Instances of this class are convertible to the public API model {@link
- * AwsIamServiceIdentityInfo}.
+ * <p>Instances of this class can be converted to the public API model {@link
+ * AwsIamServiceIdentityInfo} via a ServiceIdentityProvider.
*/
public class AwsIamServiceIdentityInfoDpo extends ServiceIdentityInfoDpo {
@@ -51,15 +51,6 @@ public class AwsIamServiceIdentityInfoDpo extends
ServiceIdentityInfoDpo {
super(ServiceIdentityType.AWS_IAM.getCode(), identityInfoReference);
}
- @Override
- public @Nonnull ServiceIdentityInfo asServiceIdentityInfoModel() {
- return AwsIamServiceIdentityInfo.builder()
- .setIdentityType(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM)
- // TODO: inject service identity info
- .setIamArn("")
- .build();
- }
-
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/ServiceIdentityInfoDpo.java
b/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/ServiceIdentityInfoDpo.java
index 22f25dd70..de30bed30 100644
---
a/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/ServiceIdentityInfoDpo.java
+++
b/polaris-core/src/main/java/org/apache/polaris/core/identity/dpo/ServiceIdentityInfoDpo.java
@@ -23,18 +23,22 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.google.common.base.MoreObjects;
-import jakarta.annotation.Nonnull;
+import com.google.common.base.Preconditions;
import jakarta.annotation.Nullable;
import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
import org.apache.polaris.core.identity.ServiceIdentityType;
+import org.apache.polaris.core.identity.credential.ServiceIdentityCredential;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.SecretReference;
/**
* The internal persistence-object counterpart to ServiceIdentityInfo defined
in the API model.
* Important: JsonSubTypes must be kept in sync with {@link
ServiceIdentityType}.
*
- * <p>During the runtime, it will be resolved to an actual
ResolvedServiceIdentityInfo object which
- * contains the actual service identity info and the corresponding credential.
+ * <p>This DPO stores only the identity type and a {@link SecretReference}
that serves as a unique
+ * identifier for the service identity instance. The reference is used at
runtime by a {@link
+ * ServiceIdentityProvider} to look up the configuration and retrieve the full
{@link
+ * ServiceIdentityCredential} with credentials and metadata.
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@@ -72,10 +76,24 @@ public abstract class ServiceIdentityInfoDpo {
}
/**
- * Converts this persistence object to the corresponding API model. During
the conversion, some
- * fields will be dropped, e.g. the reference to the service identity's
credential
+ * Converts this persistence object to the corresponding API model.
+ *
+ * <p>The conversion uses the provided {@link ServiceIdentityProvider} to
retrieve the user-facing
+ * identity information (e.g., AWS IAM ARN) without exposing sensitive
credentials. The credential
+ * reference stored in this DPO is not included in the API model.
+ *
+ * @param serviceIdentityProvider the service identity provider used to
retrieve display
+ * information
+ * @return the API model representation, or null if the provider is null or
cannot resolve the
+ * identity
*/
- public abstract @Nonnull ServiceIdentityInfo asServiceIdentityInfoModel();
+ public @Nullable ServiceIdentityInfo asServiceIdentityInfoModel(
+ ServiceIdentityProvider serviceIdentityProvider) {
+ Preconditions.checkNotNull(
+ serviceIdentityProvider,
+ "Need ServiceIdentityProvider to inject service identity info, should
not be null");
+ return serviceIdentityProvider.getServiceIdentityInfo(this).orElse(null);
+ }
@Override
public String toString() {
diff --git
a/polaris-core/src/main/java/org/apache/polaris/core/identity/provider/ServiceIdentityProvider.java
b/polaris-core/src/main/java/org/apache/polaris/core/identity/provider/ServiceIdentityProvider.java
new file mode 100644
index 000000000..c652ddbe1
--- /dev/null
+++
b/polaris-core/src/main/java/org/apache/polaris/core/identity/provider/ServiceIdentityProvider.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.core.identity.provider;
+
+import jakarta.annotation.Nonnull;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.identity.credential.ServiceIdentityCredential;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+
+/**
+ * A provider interface for managing and resolving service identities in
Polaris.
+ *
+ * <p>In a multi-tenant Polaris deployment, each catalog or tenant may be
associated with a distinct
+ * service identity that represents the Polaris service itself when accessing
external systems
+ * (e.g., cloud services like AWS or GCP). This provider offers a central
mechanism to allocate
+ * service identities to catalog entities and resolve them at runtime.
+ *
+ * <p>The provider helps abstract the configuration and retrieval of
service-managed credentials
+ * from the logic that uses them. It ensures a consistent and secure way to
handle identity
+ * resolution across different deployment models, including SaaS and
self-managed environments.
+ *
+ * <p>Key responsibilities:
+ *
+ * <ul>
+ * <li><b>Allocation</b>: Assign a service identity to a catalog entity
during creation. The
+ * actual credentials are not stored in the entity; only a reference is
persisted.
+ * <li><b>Resolution (with credentials)</b>: Retrieve the full identity
including credentials for
+ * authentication purposes (e.g., signing requests with SigV4).
+ * <li><b>Resolution (without credentials)</b>: Retrieve the identity
information for display in
+ * API responses without exposing sensitive credentials.
+ * </ul>
+ */
+public interface ServiceIdentityProvider {
+ /**
+ * Allocates a {@link ServiceIdentityInfoDpo} for the given connection
configuration. This method
+ * is typically invoked during catalog entity creation to associate a
service identity with the
+ * catalog for accessing external services.
+ *
+ * <p>The allocation strategy is implementation-specific:
+ *
+ * <ul>
+ * <li>A vendor may choose to use the same service identity across all
entities in an account.
+ * <li>Alternatively, different service identities can be assigned per
catalog entity.
+ * <li>The associated DPO stores only a reference to the service identity,
not the credentials
+ * themselves.
+ * </ul>
+ *
+ * @param connectionConfig The connection configuration for which a service
identity should be
+ * allocated.
+ * @return An {@link Optional} containing the allocated {@link
ServiceIdentityInfoDpo}, or empty
+ * if no service identity is available or applicable for this connection.
+ */
+ Optional<ServiceIdentityInfoDpo> allocateServiceIdentity(
+ @Nonnull ConnectionConfigInfo connectionConfig);
+
+ /**
+ * Retrieves the user-facing {@link ServiceIdentityInfo} model for the given
service identity
+ * reference, without exposing sensitive credentials.
+ *
+ * <p>This method is used when generating API responses (e.g., {@code
getCatalog}) to return
+ * identity details such as the AWS IAM user ARN, but not the actual
credentials.
+ *
+ * @param serviceIdentityInfo The service identity metadata to resolve.
+ * @return An {@link Optional} containing the {@link ServiceIdentityInfo}
model for API responses,
+ * or empty if the identity cannot be resolved.
+ */
+ Optional<ServiceIdentityInfo> getServiceIdentityInfo(
+ @Nonnull ServiceIdentityInfoDpo serviceIdentityInfo);
+
+ /**
+ * Retrieves the service identity credential by resolving the actual
credential or secret
+ * referenced by the given service identity info, typically from a secret
manager or internal
+ * credential store.
+ *
+ * <p>This method is used when Polaris needs to authenticate to external
systems using the service
+ * identity, such as when signing requests with SigV4 authentication.
+ *
+ * @param serviceIdentityInfo The service identity metadata to resolve.
+ * @return An {@link Optional} containing a {@link
ServiceIdentityCredential} with credentials, or
+ * empty if the identity cannot be resolved.
+ */
+ Optional<ServiceIdentityCredential> getServiceIdentityCredential(
+ @Nonnull ServiceIdentityInfoDpo serviceIdentityInfo);
+}
diff --git
a/polaris-core/src/test/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpoTest.java
b/polaris-core/src/test/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpoTest.java
index 727fac0d0..5d8dbc7d4 100644
---
a/polaris-core/src/test/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpoTest.java
+++
b/polaris-core/src/test/java/org/apache/polaris/core/connection/ConnectionConfigInfoDpoTest.java
@@ -22,9 +22,16 @@ import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import
org.apache.polaris.core.identity.credential.AwsIamServiceIdentityCredential;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
public class ConnectionConfigInfoDpoTest {
private static final ObjectMapper objectMapper = new ObjectMapper();
@@ -33,6 +40,24 @@ public class ConnectionConfigInfoDpoTest {
objectMapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
}
+ private ServiceIdentityProvider serviceIdentityProvider;
+
+ @BeforeEach
+ void setUp() {
+ serviceIdentityProvider = Mockito.mock(ServiceIdentityProvider.class);
+ Mockito.when(serviceIdentityProvider.getServiceIdentityInfo(Mockito.any()))
+ .thenReturn(
+ Optional.of(
+ AwsIamServiceIdentityInfo.builder()
+
.setIdentityType(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM)
+ .setIamArn("arn:aws:iam::123456789012:user/test-user")
+ .build()));
+
Mockito.when(serviceIdentityProvider.getServiceIdentityCredential(Mockito.any()))
+ .thenReturn(
+ Optional.of(
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:user/test-user")));
+ }
+
@Test
void testOAuthClientCredentialsParameters() throws JsonProcessingException {
// Test deserialization and reserialization of the persistence JSON.
@@ -64,7 +89,7 @@ public class ConnectionConfigInfoDpoTest {
// Test conversion into API model JSON.
ConnectionConfigInfo connectionConfigInfoApiModel =
- connectionConfigInfoDpo.asConnectionConfigInfoModel();
+
connectionConfigInfoDpo.asConnectionConfigInfoModel(serviceIdentityProvider);
String expectedApiModelJson =
""
+ "{"
@@ -111,7 +136,7 @@ public class ConnectionConfigInfoDpoTest {
// Test conversion into API model JSON.
ConnectionConfigInfo connectionConfigInfoApiModel =
- connectionConfigInfoDpo.asConnectionConfigInfoModel();
+
connectionConfigInfoDpo.asConnectionConfigInfoModel(serviceIdentityProvider);
String expectedApiModelJson =
""
+ "{"
@@ -148,7 +173,7 @@ public class ConnectionConfigInfoDpoTest {
// Test conversion into API model JSON.
ConnectionConfigInfo connectionConfigInfoApiModel =
- connectionConfigInfoDpo.asConnectionConfigInfoModel();
+
connectionConfigInfoDpo.asConnectionConfigInfoModel(serviceIdentityProvider);
String expectedApiModelJson =
""
+ "{"
@@ -184,7 +209,7 @@ public class ConnectionConfigInfoDpoTest {
+ " \"serviceIdentity\": {"
+ " \"identityTypeCode\": 1,"
+ " \"identityInfoReference\": {"
- + " \"urn\":
\"urn:polaris-secret:default-identity-registry:my-realm:AWS_IAM\","
+ + " \"urn\":
\"urn:polaris-secret:default-identity-provider:my-realm:AWS_IAM\","
+ " \"referencePayload\": {"
+ " \"key\": \"value\""
+ " }"
@@ -200,7 +225,7 @@ public class ConnectionConfigInfoDpoTest {
// Test conversion into API model JSON.
ConnectionConfigInfo connectionConfigInfoApiModel =
- connectionConfigInfoDpo.asConnectionConfigInfoModel();
+
connectionConfigInfoDpo.asConnectionConfigInfoModel(serviceIdentityProvider);
String expectedApiModelJson =
""
+ "{"
@@ -217,7 +242,7 @@ public class ConnectionConfigInfoDpoTest {
+ " },"
+ " \"serviceIdentity\": {"
+ " \"identityType\": \"AWS_IAM\","
- + " \"iamArn\": \"\""
+ + " \"iamArn\": \"arn:aws:iam::123456789012:user/test-user\""
+ " }"
+ "}";
Assertions.assertEquals(
diff --git
a/polaris-core/src/test/java/org/apache/polaris/core/identity/credential/AwsIamServiceIdentityCredentialTest.java
b/polaris-core/src/test/java/org/apache/polaris/core/identity/credential/AwsIamServiceIdentityCredentialTest.java
new file mode 100644
index 000000000..ce69a4f08
--- /dev/null
+++
b/polaris-core/src/test/java/org/apache/polaris/core/identity/credential/AwsIamServiceIdentityCredentialTest.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.core.identity.credential;
+
+import java.util.Map;
+import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import org.apache.polaris.core.identity.dpo.AwsIamServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.secrets.SecretReference;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+
+public class AwsIamServiceIdentityCredentialTest {
+
+ @Test
+ void testConstructorWithIamArnOnly() {
+ AwsIamServiceIdentityCredential credential =
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:user/test-user");
+
+ Assertions.assertThat(credential.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/test-user");
+
Assertions.assertThat(credential.getIdentityType()).isEqualTo(ServiceIdentityType.AWS_IAM);
+ Assertions.assertThat(credential.getAwsCredentialsProvider())
+ .isInstanceOf(DefaultCredentialsProvider.class);
+ }
+
+ @Test
+ void testConstructorWithIamArnAndCredentialsProvider() {
+ StaticCredentialsProvider credProvider =
+
StaticCredentialsProvider.create(AwsBasicCredentials.create("access-key",
"secret-key"));
+
+ AwsIamServiceIdentityCredential credential =
+ new AwsIamServiceIdentityCredential(
+ "arn:aws:iam::123456789012:role/test-role", credProvider);
+
+ Assertions.assertThat(credential.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:role/test-role");
+
Assertions.assertThat(credential.getAwsCredentialsProvider()).isEqualTo(credProvider);
+ }
+
+ @Test
+ void testConstructorWithAllParameters() {
+ SecretReference ref = new SecretReference("urn:polaris-secret:test:ref",
Map.of());
+ StaticCredentialsProvider credProvider =
+ StaticCredentialsProvider.create(
+ AwsSessionCredentials.create("access-key", "secret-key",
"session-token"));
+
+ AwsIamServiceIdentityCredential credential =
+ new AwsIamServiceIdentityCredential(
+ ref, "arn:aws:iam::123456789012:user/test-user", credProvider);
+
+ Assertions.assertThat(credential.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/test-user");
+
Assertions.assertThat(credential.getIdentityInfoReference()).isEqualTo(ref);
+
Assertions.assertThat(credential.getAwsCredentialsProvider()).isEqualTo(credProvider);
+ }
+
+ @Test
+ void testConversionToDpo() {
+ SecretReference ref = new
SecretReference("urn:polaris-secret:test:reference", Map.of());
+ AwsIamServiceIdentityCredential credential =
+ new AwsIamServiceIdentityCredential(
+ ref,
+ "arn:aws:iam::123456789012:user/test-user",
+ DefaultCredentialsProvider.builder().build());
+
+ ServiceIdentityInfoDpo dpo = credential.asServiceIdentityInfoDpo();
+
+ Assertions.assertThat(dpo).isNotNull();
+
Assertions.assertThat(dpo).isInstanceOf(AwsIamServiceIdentityInfoDpo.class);
+
Assertions.assertThat(dpo.getIdentityType()).isEqualTo(ServiceIdentityType.AWS_IAM);
+ Assertions.assertThat(dpo.getIdentityInfoReference()).isEqualTo(ref);
+ }
+
+ @Test
+ void testConversionToModel() {
+ AwsIamServiceIdentityCredential credential =
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:user/polaris-service");
+
+ ServiceIdentityInfo model = credential.asServiceIdentityInfoModel();
+
+ Assertions.assertThat(model).isNotNull();
+ Assertions.assertThat(model).isInstanceOf(AwsIamServiceIdentityInfo.class);
+ Assertions.assertThat(model.getIdentityType())
+ .isEqualTo(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM);
+
+ AwsIamServiceIdentityInfo awsModel = (AwsIamServiceIdentityInfo) model;
+ Assertions.assertThat(awsModel.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-service");
+ }
+
+ @Test
+ void testCredentialsProviderWithStaticBasicCredentials() {
+ StaticCredentialsProvider credProvider =
+
StaticCredentialsProvider.create(AwsBasicCredentials.create("my-key-id",
"my-secret"));
+
+ AwsIamServiceIdentityCredential credential =
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:user/test",
credProvider);
+
+
Assertions.assertThat(credential.getAwsCredentialsProvider()).isEqualTo(credProvider);
+ AwsBasicCredentials creds = (AwsBasicCredentials)
credProvider.resolveCredentials();
+ Assertions.assertThat(creds.accessKeyId()).isEqualTo("my-key-id");
+ Assertions.assertThat(creds.secretAccessKey()).isEqualTo("my-secret");
+ }
+
+ @Test
+ void testCredentialsProviderWithSessionCredentials() {
+ StaticCredentialsProvider credProvider =
+ StaticCredentialsProvider.create(
+ AwsSessionCredentials.create("access-key", "secret-key",
"session-token"));
+
+ AwsIamServiceIdentityCredential credential =
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:role/test",
credProvider);
+
+
Assertions.assertThat(credential.getAwsCredentialsProvider()).isEqualTo(credProvider);
+ AwsSessionCredentials creds = (AwsSessionCredentials)
credProvider.resolveCredentials();
+ Assertions.assertThat(creds.accessKeyId()).isEqualTo("access-key");
+ Assertions.assertThat(creds.secretAccessKey()).isEqualTo("secret-key");
+ Assertions.assertThat(creds.sessionToken()).isEqualTo("session-token");
+ }
+
+ @Test
+ void testModelDoesNotExposeCredentials() {
+ // Verify that the API model contains identity info but not credentials
+ StaticCredentialsProvider credProvider =
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create("secret-access-key-id",
"secret-access-key"));
+
+ AwsIamServiceIdentityCredential credential =
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:user/service",
credProvider);
+
+ ServiceIdentityInfo model = credential.asServiceIdentityInfoModel();
+ AwsIamServiceIdentityInfo awsModel = (AwsIamServiceIdentityInfo) model;
+
+ // Model should have the ARN
+
Assertions.assertThat(awsModel.getIamArn()).isEqualTo("arn:aws:iam::123456789012:user/service");
+
+ // Model should NOT have access keys or secrets (they're not in the
AwsIamServiceIdentityInfo
+ // class)
+ // This is by design - credentials are never exposed in API responses
+ }
+}
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 ad7995925..9adf88fd0 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
@@ -100,6 +100,8 @@ import org.apache.polaris.core.entity.PrincipalRoleEntity;
import org.apache.polaris.core.entity.table.IcebergTableLikeEntity;
import org.apache.polaris.core.entity.table.federated.FederatedEntities;
import org.apache.polaris.core.exceptions.CommitConflictException;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
import org.apache.polaris.core.persistence.dao.entity.BaseResult;
@@ -149,6 +151,7 @@ public class PolarisAdminService {
private final PolarisAuthorizer authorizer;
private final PolarisMetaStoreManager metaStoreManager;
private final UserSecretsManager userSecretsManager;
+ private final ServiceIdentityProvider serviceIdentityProvider;
private final ReservedProperties reservedProperties;
// Initialized in the authorize methods.
@@ -161,6 +164,7 @@ public class PolarisAdminService {
@Nonnull ResolutionManifestFactory resolutionManifestFactory,
@Nonnull PolarisMetaStoreManager metaStoreManager,
@Nonnull UserSecretsManager userSecretsManager,
+ @Nonnull ServiceIdentityProvider serviceIdentityProvider,
@Nonnull SecurityContext securityContext,
@Nonnull PolarisAuthorizer authorizer,
@Nonnull ReservedProperties reservedProperties) {
@@ -179,6 +183,7 @@ public class PolarisAdminService {
this.polarisPrincipal = (PolarisPrincipal)
securityContext.getUserPrincipal();
this.authorizer = authorizer;
this.userSecretsManager = userSecretsManager;
+ this.serviceIdentityProvider = serviceIdentityProvider;
this.reservedProperties = reservedProperties;
}
@@ -190,6 +195,10 @@ public class PolarisAdminService {
return userSecretsManager;
}
+ private ServiceIdentityProvider getServiceIdentityProvider() {
+ return serviceIdentityProvider;
+ }
+
private PolarisResolutionManifest newResolutionManifest(@Nullable String
catalogName) {
return resolutionManifestFactory.createResolutionManifest(securityContext,
catalogName);
}
@@ -671,6 +680,12 @@ public class PolarisAdminService {
AuthenticationParametersDpo.INLINE_BEARER_TOKEN_REFERENCE_KEY, secretReference);
break;
}
+ case SIGV4:
+ {
+ // SigV4 authentication is not based on users provided secrets
but based on the
+ // service identity managed by Polaris. Nothing to do here.
+ break;
+ }
default:
throw new IllegalStateException(
"Unsupported authentication type: "
@@ -750,10 +765,19 @@ public class PolarisAdminService {
AuthenticationParameters.AuthenticationTypeEnum.IMPLICIT.name()),
"Implicit authentication based catalog federation is not
supported.");
}
+
+ // Allocate service identity if needed for the authentication type.
+ // The provider will determine if a service identity is required based
on the connection
+ // config.
+ Optional<ServiceIdentityInfoDpo> serviceIdentityInfoDpoOptional =
+
serviceIdentityProvider.allocateServiceIdentity(connectionConfigInfo);
+
entity =
new CatalogEntity.Builder(entity)
.setConnectionConfigInfoDpoWithSecrets(
- connectionConfigInfo, processedSecretReferences)
+ connectionConfigInfo,
+ processedSecretReferences,
+ serviceIdentityInfoDpoOptional.orElse(null))
.build();
}
}
@@ -929,7 +953,9 @@ public class PolarisAdminService {
/** List all catalogs after checking for permission. */
public List<Catalog> listCatalogs() {
authorizeBasicRootOperationOrThrow(PolarisAuthorizableOperation.LIST_CATALOGS);
- return listCatalogsUnsafe().map(CatalogEntity::asCatalog).toList();
+ return listCatalogsUnsafe()
+ .map(catalogEntity ->
catalogEntity.asCatalog(getServiceIdentityProvider()))
+ .toList();
}
/** List all catalogs without checking for permission. */
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java
b/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java
index 03a8001a8..eebd3aa16 100644
---
a/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java
+++
b/runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java
@@ -71,6 +71,7 @@ import org.apache.polaris.core.entity.CatalogRoleEntity;
import org.apache.polaris.core.entity.PolarisPrivilege;
import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.entity.PrincipalRoleEntity;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.dao.entity.BaseResult;
import org.apache.polaris.core.persistence.dao.entity.PrivilegeResult;
import org.apache.polaris.service.admin.api.PolarisCatalogsApiService;
@@ -91,15 +92,18 @@ public class PolarisServiceImpl
private final RealmConfig realmConfig;
private final ReservedProperties reservedProperties;
private final PolarisAdminService adminService;
+ private final ServiceIdentityProvider serviceIdentityProvider;
@Inject
public PolarisServiceImpl(
RealmConfig realmConfig,
ReservedProperties reservedProperties,
- PolarisAdminService adminService) {
+ PolarisAdminService adminService,
+ ServiceIdentityProvider serviceIdentityProvider) {
this.realmConfig = realmConfig;
this.reservedProperties = reservedProperties;
this.adminService = adminService;
+ this.serviceIdentityProvider = serviceIdentityProvider;
}
private static Response toResponse(BaseResult result, Response.Status
successStatus) {
@@ -126,7 +130,8 @@ public class PolarisServiceImpl
validateStorageConfig(catalog.getStorageConfigInfo());
validateExternalCatalog(catalog);
validateCatalogProperties(catalog.getProperties());
- Catalog newCatalog =
CatalogEntity.of(adminService.createCatalog(request)).asCatalog();
+ Catalog newCatalog =
+
CatalogEntity.of(adminService.createCatalog(request)).asCatalog(serviceIdentityProvider);
LOGGER.info("Created new catalog {}", newCatalog);
return Response.status(Response.Status.CREATED).entity(newCatalog).build();
}
@@ -237,7 +242,8 @@ public class PolarisServiceImpl
@Override
public Response getCatalog(
String catalogName, RealmContext realmContext, SecurityContext
securityContext) {
- return
Response.ok(adminService.getCatalog(catalogName).asCatalog()).build();
+ return
Response.ok(adminService.getCatalog(catalogName).asCatalog(serviceIdentityProvider))
+ .build();
}
/** From PolarisCatalogsApiService */
@@ -251,7 +257,11 @@ public class PolarisServiceImpl
validateStorageConfig(updateRequest.getStorageConfigInfo());
}
validateCatalogProperties(updateRequest.getProperties());
- return Response.ok(adminService.updateCatalog(catalogName,
updateRequest).asCatalog()).build();
+ return Response.ok(
+ adminService
+ .updateCatalog(catalogName, updateRequest)
+ .asCatalog(serviceIdentityProvider))
+ .build();
}
/** From PolarisCatalogsApiService */
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
index 6a70d2960..214ba9ad8 100644
---
a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
+++
b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
@@ -60,6 +60,7 @@ import
org.apache.polaris.service.auth.AuthenticationConfiguration;
import org.apache.polaris.service.auth.AuthenticationRealmConfiguration;
import org.apache.polaris.service.auth.AuthenticationType;
import org.apache.polaris.service.auth.Authenticator;
+import org.apache.polaris.service.auth.external.OidcConfiguration;
import org.apache.polaris.service.auth.external.tenant.OidcTenantResolver;
import org.apache.polaris.service.auth.internal.broker.TokenBroker;
import org.apache.polaris.service.auth.internal.broker.TokenBrokerFactory;
@@ -387,8 +388,7 @@ public class ServiceProducers {
@Produces
public OidcTenantResolver oidcTenantResolver(
- org.apache.polaris.service.auth.external.OidcConfiguration config,
- @Any Instance<OidcTenantResolver> resolvers) {
+ OidcConfiguration config, @Any Instance<OidcTenantResolver> resolvers) {
return
resolvers.select(Identifier.Literal.of(config.tenantResolver())).get();
}
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/identity/AwsIamServiceIdentityConfiguration.java
b/runtime/service/src/main/java/org/apache/polaris/service/identity/AwsIamServiceIdentityConfiguration.java
new file mode 100644
index 000000000..e92b28d88
--- /dev/null
+++
b/runtime/service/src/main/java/org/apache/polaris/service/identity/AwsIamServiceIdentityConfiguration.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.service.identity;
+
+import jakarta.annotation.Nonnull;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import
org.apache.polaris.core.identity.credential.AwsIamServiceIdentityCredential;
+import org.apache.polaris.core.secrets.SecretReference;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+
+/**
+ * Configuration for an AWS IAM service identity used by Polaris to access AWS
services.
+ *
+ * <p>This includes the IAM ARN and optionally, static credentials (access
key, secret key, and
+ * session token). If credentials are provided, they will be used to construct
a {@link
+ * AwsIamServiceIdentityCredential}; otherwise, the AWS default credential
provider chain is used.
+ */
+public interface AwsIamServiceIdentityConfiguration extends
ResolvableServiceIdentityConfiguration {
+
+ /** The IAM role or user ARN representing the service identity. */
+ String iamArn();
+
+ /**
+ * Optional AWS access key ID associated with the IAM identity. If not
provided, the AWS default
+ * credential chain will be used.
+ */
+ Optional<String> accessKeyId();
+
+ /**
+ * Optional AWS secret access key associated with the IAM identity. If not
provided, the AWS
+ * default credential chain will be used.
+ */
+ Optional<String> secretAccessKey();
+
+ /**
+ * Optional AWS session token associated with the IAM identity. If not
provided, the AWS default
+ * credential chain will be used.
+ */
+ Optional<String> sessionToken();
+
+ /**
+ * Returns the type of service identity represented by this configuration,
which is always {@link
+ * ServiceIdentityType#AWS_IAM}.
+ *
+ * @return the AWS IAM service identity type
+ */
+ @Override
+ default ServiceIdentityType getType() {
+ return ServiceIdentityType.AWS_IAM;
+ }
+
+ /**
+ * Returns the {@link AwsIamServiceIdentityInfo} model containing only the
IAM ARN.
+ *
+ * <p>This method is lightweight and does not construct AWS credential
providers. It should be
+ * used for API responses where only identity metadata is needed.
+ *
+ * @return the service identity info model, or empty if the IAM ARN is not
configured
+ */
+ @Override
+ default Optional<AwsIamServiceIdentityInfo> asServiceIdentityInfoModel() {
+ if (iamArn() == null) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ AwsIamServiceIdentityInfo.builder()
+ .setIdentityType(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM)
+ .setIamArn(iamArn())
+ .build());
+ }
+
+ /**
+ * Converts this configuration into a {@link
AwsIamServiceIdentityCredential} with actual AWS
+ * credentials.
+ *
+ * <p>Creates a credential object containing the configured IAM ARN and AWS
credentials provider.
+ * The credentials provider is constructed based on whether static
credentials (access key, secret
+ * key, session token) are configured or whether to use the default AWS
credential chain.
+ *
+ * <p>This method should only be called when credentials are actually needed
for authentication.
+ *
+ * @param secretReference the secret reference to associate with this
credential
+ * @return the service identity credential, or empty if the IAM ARN is not
configured
+ */
+ @Override
+ default Optional<AwsIamServiceIdentityCredential>
asServiceIdentityCredential(
+ @Nonnull SecretReference secretReference) {
+ if (iamArn() == null) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new AwsIamServiceIdentityCredential(secretReference, iamArn(),
awsCredentialsProvider()));
+ }
+
+ /**
+ * Constructs an {@link AwsCredentialsProvider} based on the configured
access key, secret key,
+ * and session token. If the access key and secret key are provided, a
static credentials provider
+ * is created; otherwise, the default credentials provider chain is used.
+ *
+ * @return the constructed AWS credentials provider
+ */
+ @Nonnull
+ default AwsCredentialsProvider awsCredentialsProvider() {
+ if (accessKeyId().isPresent() && secretAccessKey().isPresent()) {
+ if (sessionToken().isPresent()) {
+ return StaticCredentialsProvider.create(
+ AwsSessionCredentials.create(
+ accessKeyId().get(), secretAccessKey().get(),
sessionToken().get()));
+ } else {
+ return StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKeyId().get(),
secretAccessKey().get()));
+ }
+ } else {
+ return DefaultCredentialsProvider.builder().build();
+ }
+ }
+}
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/identity/RealmServiceIdentityConfiguration.java
b/runtime/service/src/main/java/org/apache/polaris/service/identity/RealmServiceIdentityConfiguration.java
new file mode 100644
index 000000000..a58fa25a9
--- /dev/null
+++
b/runtime/service/src/main/java/org/apache/polaris/service/identity/RealmServiceIdentityConfiguration.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.service.identity;
+
+import io.smallrye.config.WithName;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Represents service identity configuration for a specific realm.
+ *
+ * <p>Supports multiple identity types, such as AWS IAM. This interface allows
each realm to define
+ * the credentials and metadata needed to resolve service-managed identities.
+ */
+public interface RealmServiceIdentityConfiguration {
+ /**
+ * Returns the AWS IAM service identity configuration for this realm, if
present.
+ *
+ * @return an optional AWS IAM configuration
+ */
+ @WithName("aws-iam")
+ Optional<AwsIamServiceIdentityConfiguration> awsIamServiceIdentity();
+
+ /**
+ * Aggregates all configured service identity types into a list. This
includes AWS IAM and
+ * potentially other types in the future.
+ *
+ * @return a list of configured service identity definitions
+ */
+ default List<? extends ResolvableServiceIdentityConfiguration>
serviceIdentityConfigurations() {
+ return
Stream.of(awsIamServiceIdentity()).flatMap(Optional::stream).toList();
+ }
+}
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/identity/ResolvableServiceIdentityConfiguration.java
b/runtime/service/src/main/java/org/apache/polaris/service/identity/ResolvableServiceIdentityConfiguration.java
new file mode 100644
index 000000000..dc03aa0c5
--- /dev/null
+++
b/runtime/service/src/main/java/org/apache/polaris/service/identity/ResolvableServiceIdentityConfiguration.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.service.identity;
+
+import jakarta.annotation.Nonnull;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import org.apache.polaris.core.identity.credential.ServiceIdentityCredential;
+import org.apache.polaris.core.secrets.SecretReference;
+
+/**
+ * Represents a service identity configuration that can be converted into a
fully initialized {@link
+ * ServiceIdentityCredential}.
+ *
+ * <p>This interface allows identity configurations (e.g., AWS IAM) to
encapsulate the logic
+ * required to construct runtime credentials and metadata needed to
authenticate as a
+ * Polaris-managed service identity.
+ */
+public interface ResolvableServiceIdentityConfiguration {
+ /**
+ * Returns the type of service identity represented by this configuration.
+ *
+ * @return the service identity type, or {@link
ServiceIdentityType#NULL_TYPE} if not specified
+ */
+ default ServiceIdentityType getType() {
+ return ServiceIdentityType.NULL_TYPE;
+ }
+
+ /**
+ * Converts this configuration into a {@link ServiceIdentityInfo} model
containing identity
+ * metadata without credentials.
+ *
+ * <p>This method is used when only identity information (e.g., IAM ARN) is
needed for API
+ * responses, without exposing sensitive credentials.
+ *
+ * @return an optional service identity info model, or empty if required
configuration is missing
+ */
+ default Optional<? extends ServiceIdentityInfo> asServiceIdentityInfoModel()
{
+ return Optional.empty();
+ }
+
+ /**
+ * Converts this configuration into a {@link ServiceIdentityCredential} with
actual credentials.
+ *
+ * <p>This method should only be called when credentials are actually needed
for authentication.
+ * Implementations should construct the appropriate credential object (e.g.,
{@link
+ *
org.apache.polaris.core.identity.credential.AwsIamServiceIdentityCredential})
using the
+ * configured values and the provided secret reference.
+ *
+ * @param secretReference the secret reference to associate with this
credential for persistence
+ * @return an optional service identity credential, or empty if required
configuration is missing
+ */
+ default Optional<? extends ServiceIdentityCredential>
asServiceIdentityCredential(
+ @Nonnull SecretReference secretReference) {
+ return Optional.empty();
+ }
+}
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/identity/ServiceIdentityConfiguration.java
b/runtime/service/src/main/java/org/apache/polaris/service/identity/ServiceIdentityConfiguration.java
new file mode 100644
index 000000000..fc6a2ce42
--- /dev/null
+++
b/runtime/service/src/main/java/org/apache/polaris/service/identity/ServiceIdentityConfiguration.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.service.identity;
+
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefaults;
+import io.smallrye.config.WithParentName;
+import io.smallrye.config.WithUnnamedKey;
+import java.util.Map;
+import org.apache.polaris.core.context.RealmContext;
+
+/**
+ * Configuration interface for managing service identities across multiple
realms in Polaris.
+ *
+ * <p>A service identity represents the Polaris service itself when it needs
to authenticate to
+ * external systems (e.g., AWS services for SigV4 authentication). Each realm
can configure its own
+ * set of service identities for different cloud providers.
+ *
+ * <p>This interface supports multi-tenant deployments where each realm
(tenant) can have distinct
+ * service identities, as well as single-tenant deployments with a default
configuration shared
+ * across all catalogs.
+ *
+ * <p>Configuration is loaded from {@code polaris.service-identity.*}
properties at startup and
+ * includes credentials that Polaris uses to assume customer-provided roles
when accessing federated
+ * catalogs.
+ *
+ * <p><b>Example Configuration:</b>
+ *
+ * <pre>{@code
+ * # Default service identity (used when no realm-specific configuration
exists)
+ *
polaris.service-identity.aws-iam.iam-arn=arn:aws:iam::123456789012:user/polaris-default-user
+ * # Optional: provide static credentials, or omit to use AWS default
credential chain
+ * polaris.service-identity.aws-iam.access-key-id=<access-key-id>
+ * polaris.service-identity.aws-iam.secret-access-key=<secret-access-key>
+ * polaris.service-identity.aws-iam.session-token=<optional-session-token>
+ *
+ * # Realm-specific service identity for multi-tenant deployments
+ *
polaris.service-identity.my-realm.aws-iam.iam-arn=arn:aws:iam::123456789012:user/my-realm-user
+ * polaris.service-identity.my-realm.aws-iam.access-key-id=<access-key-id>
+ *
polaris.service-identity.my-realm.aws-iam.secret-access-key=<secret-access-key>
+ * }</pre>
+ */
+@ConfigMapping(prefix = "polaris.service-identity")
+public interface ServiceIdentityConfiguration {
+ /**
+ * The key used to identify the default realm configuration.
+ *
+ * <p>This default is especially useful in testing scenarios and
single-tenant deployments where
+ * only one realm is expected and explicitly configuring realms is
unnecessary.
+ */
+ String DEFAULT_REALM_KEY = "<default>";
+
+ /**
+ * Returns a map of realm identifiers to their corresponding service
identity configurations.
+ *
+ * @return the map of realm-specific configurations
+ */
+ @WithParentName
+ @WithUnnamedKey(DEFAULT_REALM_KEY)
+ @WithDefaults
+ Map<String, RealmServiceIdentityConfiguration> realms();
+
+ /**
+ * Retrieves the configuration entry for the given realm context.
+ *
+ * <p>If the realm has no specific configuration, falls back to the default
realm configuration.
+ *
+ * @param realmContext the realm context
+ * @return the configuration entry containing the realm identifier and its
configuration
+ */
+ default RealmConfigEntry forRealm(RealmContext realmContext) {
+ return forRealm(realmContext.getRealmIdentifier());
+ }
+
+ /**
+ * Retrieves the configuration entry for the given realm identifier.
+ *
+ * <p>If the realm has no specific configuration, falls back to the default
realm configuration.
+ *
+ * @param realmIdentifier the realm identifier
+ * @return the configuration entry containing the realm identifier and its
configuration
+ */
+ default RealmConfigEntry forRealm(String realmIdentifier) {
+ String resolvedRealmIdentifier =
+ realms().containsKey(realmIdentifier) ? realmIdentifier :
DEFAULT_REALM_KEY;
+ return new RealmConfigEntry(resolvedRealmIdentifier,
realms().get(resolvedRealmIdentifier));
+ }
+
+ /**
+ * A pairing of a realm identifier and its associated service identity
configuration.
+ *
+ * @param realm the realm identifier (may be the default if the requested
realm was not
+ * configured)
+ * @param config the service identity configuration for this realm
+ */
+ record RealmConfigEntry(String realm, RealmServiceIdentityConfiguration
config) {}
+}
diff --git
a/runtime/service/src/main/java/org/apache/polaris/service/identity/provider/DefaultServiceIdentityProvider.java
b/runtime/service/src/main/java/org/apache/polaris/service/identity/provider/DefaultServiceIdentityProvider.java
new file mode 100644
index 000000000..bd11150b1
--- /dev/null
+++
b/runtime/service/src/main/java/org/apache/polaris/service/identity/provider/DefaultServiceIdentityProvider.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.service.identity.provider;
+
+import com.google.common.annotations.VisibleForTesting;
+import jakarta.annotation.Nonnull;
+import jakarta.enterprise.context.RequestScoped;
+import jakarta.inject.Inject;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.AuthenticationParameters;
+import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.context.RealmContext;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import org.apache.polaris.core.identity.credential.ServiceIdentityCredential;
+import org.apache.polaris.core.identity.dpo.AwsIamServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
+import org.apache.polaris.core.secrets.SecretReference;
+import org.apache.polaris.service.identity.RealmServiceIdentityConfiguration;
+import
org.apache.polaris.service.identity.ResolvableServiceIdentityConfiguration;
+import org.apache.polaris.service.identity.ServiceIdentityConfiguration;
+
+/**
+ * Default implementation of {@link ServiceIdentityProvider} that provides
service identity
+ * credentials from statically configured values.
+ *
+ * <p>This implementation loads service identity configurations at startup and
uses them to provide
+ * identity information and credentials on demand. All resolution is done
lazily - credentials are
+ * only created when actually needed for authentication.
+ */
+@RequestScoped
+public class DefaultServiceIdentityProvider implements ServiceIdentityProvider
{
+ public static final String DEFAULT_REALM_KEY =
ServiceIdentityConfiguration.DEFAULT_REALM_KEY;
+ public static final String DEFAULT_REALM_NSS = "system:default";
+ private static final String IDENTITY_INFO_REFERENCE_URN_FORMAT =
+ "urn:polaris-secret:default-identity-provider:%s:%s";
+
+ private final String realm;
+ private final RealmServiceIdentityConfiguration config;
+
+ public DefaultServiceIdentityProvider() {
+ this.realm = DEFAULT_REALM_KEY;
+ this.config = null;
+ }
+
+ @Inject
+ public DefaultServiceIdentityProvider(
+ RealmContext realmContext, ServiceIdentityConfiguration
serviceIdentityConfiguration) {
+ ServiceIdentityConfiguration.RealmConfigEntry entry =
+ serviceIdentityConfiguration.forRealm(realmContext);
+ this.realm = entry.realm();
+ this.config = entry.config();
+ }
+
+ @Override
+ public Optional<ServiceIdentityInfoDpo> allocateServiceIdentity(
+ @Nonnull ConnectionConfigInfo connectionConfig) {
+ if (config == null || connectionConfig.getAuthenticationParameters() ==
null) {
+ return Optional.empty();
+ }
+
+ AuthenticationParameters.AuthenticationTypeEnum authType =
+ connectionConfig.getAuthenticationParameters().getAuthenticationType();
+
+ // Map authentication type to service identity type and check if configured
+ return switch (authType) {
+ case SIGV4 ->
+ config.awsIamServiceIdentity().isPresent()
+ ? Optional.of(
+ new AwsIamServiceIdentityInfoDpo(
+ buildIdentityInfoReference(realm,
ServiceIdentityType.AWS_IAM)))
+ : Optional.empty();
+ default -> Optional.empty();
+ };
+ }
+
+ @Override
+ public Optional<ServiceIdentityInfo> getServiceIdentityInfo(
+ @Nonnull ServiceIdentityInfoDpo serviceIdentityInfo) {
+ if (config == null) {
+ return Optional.empty();
+ }
+
+ // Find the configuration matching the reference and return metadata only
+ SecretReference actualRef = serviceIdentityInfo.getIdentityInfoReference();
+
+ return config.serviceIdentityConfigurations().stream()
+ .filter(
+ identityConfig ->
+ buildIdentityInfoReference(realm,
identityConfig.getType()).equals(actualRef))
+ .findFirst()
+
.flatMap(ResolvableServiceIdentityConfiguration::asServiceIdentityInfoModel);
+ }
+
+ @Override
+ public Optional<ServiceIdentityCredential> getServiceIdentityCredential(
+ @Nonnull ServiceIdentityInfoDpo serviceIdentityInfo) {
+ if (config == null) {
+ return Optional.empty();
+ }
+
+ // Find the configuration matching the reference and resolve credential
lazily
+ SecretReference ref = serviceIdentityInfo.getIdentityInfoReference();
+
+ return config.serviceIdentityConfigurations().stream()
+ .filter(
+ identityConfig ->
+ buildIdentityInfoReference(realm,
identityConfig.getType()).equals(ref))
+ .findFirst()
+ .flatMap(identityConfig ->
identityConfig.asServiceIdentityCredential(ref));
+ }
+
+ @VisibleForTesting
+ public RealmServiceIdentityConfiguration getRealmConfig() {
+ return config;
+ }
+
+ /**
+ * Builds a {@link SecretReference} for the given realm and service identity
type.
+ *
+ * <p>The URN format is:
urn:polaris-secret:default-identity-provider:<realm>:<type>
+ *
+ * <p>If the realm is the default realm key, it is replaced with
"system:default" in the URN.
+ *
+ * @param realm the realm identifier
+ * @param type the service identity type
+ * @return the constructed secret reference for this service identity
+ */
+ public static SecretReference buildIdentityInfoReference(String realm,
ServiceIdentityType type) {
+ // urn:polaris-secret:default-identity-provider:<realm>:<type>
+ return new SecretReference(
+ IDENTITY_INFO_REFERENCE_URN_FORMAT.formatted(
+ realm.equals(DEFAULT_REALM_KEY) ? DEFAULT_REALM_NSS : realm,
type.name()),
+ Map.of());
+ }
+}
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/admin/ManagementServiceTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/admin/ManagementServiceTest.java
index 87f98be0e..e13d6fe08 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/admin/ManagementServiceTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/admin/ManagementServiceTest.java
@@ -59,6 +59,7 @@ import
org.apache.polaris.core.persistence.dao.entity.EntityResult;
import org.apache.polaris.core.secrets.UnsafeInMemorySecretsManager;
import org.apache.polaris.service.TestServices;
import org.apache.polaris.service.config.ReservedProperties;
+import
org.apache.polaris.service.identity.provider.DefaultServiceIdentityProvider;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -376,6 +377,7 @@ public class ManagementServiceTest {
services.resolutionManifestFactory(),
metaStoreManager,
new UnsafeInMemorySecretsManager(),
+ new DefaultServiceIdentityProvider(),
new SecurityContext() {
@Override
public Principal getUserPrincipal() {
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 b60bcb0a9..066aebf20 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
@@ -56,6 +56,7 @@ public class PolarisAdminServiceAuthzTest extends
PolarisAuthzTestBase {
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext(authenticatedPrincipal),
polarisAuthorizer,
reservedProperties);
@@ -134,7 +135,8 @@ public class PolarisAdminServiceAuthzTest extends
PolarisAuthzTestBase {
adminService.grantPrivilegeOnRootContainerToPrincipalRole(
PRINCIPAL_ROLE2, PolarisPrivilege.CATALOG_DROP));
final CatalogEntity newCatalog = new
CatalogEntity.Builder().setName("new_catalog").build();
- final CreateCatalogRequest createRequest = new
CreateCatalogRequest(newCatalog.asCatalog());
+ final CreateCatalogRequest createRequest =
+ new
CreateCatalogRequest(newCatalog.asCatalog(serviceIdentityProvider));
doTestSufficientPrivileges(
List.of(
@@ -153,7 +155,8 @@ public class PolarisAdminServiceAuthzTest extends
PolarisAuthzTestBase {
@Test
public void testCreateCatalogInsufficientPrivileges() {
final CatalogEntity newCatalog = new
CatalogEntity.Builder().setName("new_catalog").build();
- final CreateCatalogRequest createRequest = new
CreateCatalogRequest(newCatalog.asCatalog());
+ final CreateCatalogRequest createRequest =
+ new
CreateCatalogRequest(newCatalog.asCatalog(serviceIdentityProvider));
doTestInsufficientPrivileges(
List.of(
@@ -288,7 +291,8 @@ public class PolarisAdminServiceAuthzTest extends
PolarisAuthzTestBase {
adminService.grantPrivilegeOnRootContainerToPrincipalRole(
PRINCIPAL_ROLE2, PolarisPrivilege.CATALOG_CREATE));
final CatalogEntity newCatalog = new
CatalogEntity.Builder().setName("new_catalog").build();
- final CreateCatalogRequest createRequest = new
CreateCatalogRequest(newCatalog.asCatalog());
+ final CreateCatalogRequest createRequest =
+ new
CreateCatalogRequest(newCatalog.asCatalog(serviceIdentityProvider));
adminService.createCatalog(createRequest);
doTestSufficientPrivileges(
@@ -308,7 +312,8 @@ public class PolarisAdminServiceAuthzTest extends
PolarisAuthzTestBase {
@Test
public void testDeleteCatalogInsufficientPrivileges() {
final CatalogEntity newCatalog = new
CatalogEntity.Builder().setName("new_catalog").build();
- final CreateCatalogRequest createRequest = new
CreateCatalogRequest(newCatalog.asCatalog());
+ final CreateCatalogRequest createRequest =
+ new
CreateCatalogRequest(newCatalog.asCatalog(serviceIdentityProvider));
adminService.createCatalog(createRequest);
doTestInsufficientPrivileges(
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 3181a1ef5..fb58dc344 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
@@ -47,6 +47,7 @@ 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.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
import org.apache.polaris.core.persistence.dao.entity.BaseResult;
@@ -72,6 +73,7 @@ public class PolarisAdminServiceTest {
@Mock private ResolutionManifestFactory resolutionManifestFactory;
@Mock private PolarisMetaStoreManager metaStoreManager;
@Mock private UserSecretsManager userSecretsManager;
+ @Mock private ServiceIdentityProvider identityProvider;
@Mock private SecurityContext securityContext;
@Mock private PolarisAuthorizer authorizer;
@Mock private ReservedProperties reservedProperties;
@@ -108,6 +110,7 @@ public class PolarisAdminServiceTest {
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ identityProvider,
securityContext,
authorizer,
reservedProperties);
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 d0239a83d..4eb9b0f46 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
@@ -72,6 +72,7 @@ import org.apache.polaris.core.entity.PolarisEntityType;
import org.apache.polaris.core.entity.PolarisPrivilege;
import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.entity.PrincipalRoleEntity;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.dao.entity.BaseResult;
@@ -195,6 +196,7 @@ public abstract class PolarisAuthzTestBase {
@Inject protected ResolutionManifestFactory resolutionManifestFactory;
@Inject protected CallContextCatalogFactory callContextCatalogFactory;
@Inject protected UserSecretsManagerFactory userSecretsManagerFactory;
+ @Inject protected ServiceIdentityProvider serviceIdentityProvider;
@Inject protected PolarisDiagnostics diagServices;
@Inject protected FileIOFactory fileIOFactory;
@Inject protected PolarisEventListener polarisEventListener;
@@ -263,6 +265,7 @@ public abstract class PolarisAuthzTestBase {
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext(authenticatedRoot),
polarisAuthorizer,
reservedProperties);
@@ -329,7 +332,7 @@ public abstract class PolarisAuthzTestBase {
.setDefaultBaseLocation(storageLocation)
.setStorageConfigurationInfo(realmConfig,
storageConfigModel, storageLocation)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
federatedCatalogEntity = adminService.createCatalog(new
CreateCatalogRequest(externalCatalog));
initBaseCatalog();
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisServiceImplTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisServiceImplTest.java
index 926c6c89c..0a8996e5f 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisServiceImplTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisServiceImplTest.java
@@ -40,9 +40,11 @@ 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.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory;
import org.apache.polaris.core.secrets.UserSecretsManager;
+import org.apache.polaris.core.secrets.UserSecretsManagerFactory;
import org.apache.polaris.service.config.ReservedProperties;
import org.apache.polaris.service.events.listeners.NoOpPolarisEventListener;
import org.apache.polaris.service.events.listeners.PolarisEventListener;
@@ -54,8 +56,10 @@ public class PolarisServiceImplTest {
private final PolarisDiagnostics diagnostics = new
PolarisDefaultDiagServiceImpl();
private ResolutionManifestFactory resolutionManifestFactory;
+ private UserSecretsManagerFactory userSecretsManagerFactory;
private PolarisMetaStoreManager metaStoreManager;
private UserSecretsManager userSecretsManager;
+ private ServiceIdentityProvider serviceIdentityProvider;
private PolarisAuthorizer polarisAuthorizer;
private CallContext callContext;
private ReservedProperties reservedProperties;
@@ -70,6 +74,7 @@ public class PolarisServiceImplTest {
resolutionManifestFactory = Mockito.mock(ResolutionManifestFactory.class);
metaStoreManager = Mockito.mock(PolarisMetaStoreManager.class);
userSecretsManager = Mockito.mock(UserSecretsManager.class);
+ serviceIdentityProvider = Mockito.mock(ServiceIdentityProvider.class);
polarisAuthorizer = Mockito.mock(PolarisAuthorizer.class);
callContext = Mockito.mock(CallContext.class);
reservedProperties = Mockito.mock(ReservedProperties.class);
@@ -93,10 +98,13 @@ public class PolarisServiceImplTest {
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext,
polarisAuthorizer,
reservedProperties);
- polarisService = new PolarisServiceImpl(realmConfig, reservedProperties,
adminService);
+ polarisService =
+ new PolarisServiceImpl(
+ realmConfig, reservedProperties, adminService,
serviceIdentityProvider);
}
@Test
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/AbstractPolarisGenericTableCatalogTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/AbstractPolarisGenericTableCatalogTest.java
index 8e930dd51..1d1071dfe 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/AbstractPolarisGenericTableCatalogTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/AbstractPolarisGenericTableCatalogTest.java
@@ -52,6 +52,7 @@ import org.apache.polaris.core.entity.CatalogEntity;
import org.apache.polaris.core.entity.PolarisEntity;
import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.entity.table.GenericTableEntity;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory;
@@ -98,6 +99,7 @@ public abstract class AbstractPolarisGenericTableCatalogTest {
@Inject MetaStoreManagerFactory metaStoreManagerFactory;
@Inject UserSecretsManagerFactory userSecretsManagerFactory;
+ @Inject ServiceIdentityProvider serviceIdentityProvider;
@Inject PolarisConfigurationStore configurationStore;
@Inject StorageCredentialCache storageCredentialCache;
@Inject PolarisStorageIntegrationProvider storageIntegrationProvider;
@@ -173,6 +175,7 @@ public abstract class
AbstractPolarisGenericTableCatalogTest {
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext,
authorizer,
reservedProperties);
@@ -202,7 +205,7 @@ public abstract class
AbstractPolarisGenericTableCatalogTest {
FeatureConfiguration.DROP_WITH_PURGE_ENABLED.catalogConfig(), "true")
.setStorageConfigurationInfo(realmConfig,
storageConfigModel, storageLocation)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
PolarisPassthroughResolutionView passthroughView =
new PolarisPassthroughResolutionView(
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
index 64654aeb8..2418bcfcc 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
@@ -110,6 +110,7 @@ import org.apache.polaris.core.entity.PolarisEntityType;
import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.entity.TaskEntity;
import org.apache.polaris.core.exceptions.CommitConflictException;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
@@ -229,6 +230,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
@Inject StorageCredentialCache storageCredentialCache;
@Inject PolarisStorageIntegrationProvider storageIntegrationProvider;
@Inject UserSecretsManagerFactory userSecretsManagerFactory;
+ @Inject ServiceIdentityProvider serviceIdentityProvider;
@Inject PolarisDiagnostics diagServices;
@Inject PolarisEventListener polarisEventListener;
@@ -318,6 +320,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext,
authorizer,
reservedProperties);
@@ -347,7 +350,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
FeatureConfiguration.DROP_WITH_PURGE_ENABLED.catalogConfig(), "true")
.setStorageConfigurationInfo(realmConfig,
storageConfigModel, storageLocation)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
this.fileIOFactory = new DefaultFileIOFactory(storageCredentialCache,
metaStoreManagerFactory);
@@ -1378,7 +1381,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
.setDefaultBaseLocation("file://")
.setName(catalogWithoutStorage)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
IcebergCatalog catalog = newIcebergCatalog(catalogWithoutStorage);
catalog.initialize(
@@ -1428,7 +1431,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
.setDefaultBaseLocation("http://maliciousdomain.com")
.setName(catalogName)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
IcebergCatalog catalog = newIcebergCatalog(catalogName);
catalog.initialize(
@@ -1946,7 +1949,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
.setStorageConfigurationInfo(
realmConfig, noPurgeStorageConfigModel, storageLocation)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
IcebergCatalog noPurgeCatalog =
newIcebergCatalog(noPurgeCatalogName, metaStoreManager, fileIOFactory);
noPurgeCatalog.initialize(
@@ -2204,7 +2207,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
.setName("createCatalogWithReservedProperty")
.setProperties(ImmutableMap.of("polaris.reserved",
"true"))
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("reserved prefix");
@@ -2219,7 +2222,7 @@ public abstract class AbstractIcebergCatalogTest extends
CatalogTests<IcebergCat
.setName("updateCatalogWithReservedProperty")
.setProperties(ImmutableMap.of("a", "b"))
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
Assertions.assertThatCode(
() -> {
adminService.updateCatalog(
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogViewTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogViewTest.java
index 8c34f74f4..c4b68658a 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogViewTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogViewTest.java
@@ -49,6 +49,7 @@ import org.apache.polaris.core.config.RealmConfig;
import org.apache.polaris.core.context.RealmContext;
import org.apache.polaris.core.entity.CatalogEntity;
import org.apache.polaris.core.entity.PrincipalEntity;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory;
@@ -107,6 +108,7 @@ public abstract class AbstractIcebergCatalogViewTest
extends ViewCatalogTests<Ic
@Inject MetaStoreManagerFactory metaStoreManagerFactory;
@Inject UserSecretsManagerFactory userSecretsManagerFactory;
+ @Inject ServiceIdentityProvider serviceIdentityProvider;
@Inject PolarisConfigurationStore configurationStore;
@Inject StorageCredentialCache storageCredentialCache;
@Inject PolarisDiagnostics diagServices;
@@ -180,6 +182,7 @@ public abstract class AbstractIcebergCatalogViewTest
extends ViewCatalogTests<Ic
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext,
authorizer,
reservedProperties);
@@ -199,7 +202,7 @@ public abstract class AbstractIcebergCatalogViewTest
extends ViewCatalogTests<Ic
StorageConfigInfo.StorageTypeEnum.FILE,
List.of("file://", "/", "*")),
"file://tmp")
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
PolarisPassthroughResolutionView passthroughView =
new PolarisPassthroughResolutionView(
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java
index 2c6493c1b..f9827fddf 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerAuthzTest.java
@@ -1707,7 +1707,7 @@ public class IcebergCatalogHandlerAuthzTest extends
PolarisAuthzTestBase {
.setStorageConfigurationInfo(realmConfig, storageConfigModel,
storageLocation)
.setCatalogType("EXTERNAL")
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
adminService.createCatalogRole(
externalCatalog, new
CatalogRoleEntity.Builder().setName(CATALOG_ROLE1).build());
adminService.createCatalogRole(
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/AbstractPolicyCatalogTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/AbstractPolicyCatalogTest.java
index 8473198f7..9da8260eb 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/AbstractPolicyCatalogTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/catalog/policy/AbstractPolicyCatalogTest.java
@@ -58,6 +58,7 @@ import org.apache.polaris.core.context.RealmContext;
import org.apache.polaris.core.entity.CatalogEntity;
import org.apache.polaris.core.entity.PolarisEntity;
import org.apache.polaris.core.entity.PrincipalEntity;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.PolicyMappingAlreadyExistsException;
@@ -124,6 +125,7 @@ public abstract class AbstractPolicyCatalogTest {
@Inject MetaStoreManagerFactory metaStoreManagerFactory;
@Inject UserSecretsManagerFactory userSecretsManagerFactory;
+ @Inject ServiceIdentityProvider serviceIdentityProvider;
@Inject PolarisConfigurationStore configurationStore;
@Inject StorageCredentialCache storageCredentialCache;
@Inject PolarisStorageIntegrationProvider storageIntegrationProvider;
@@ -194,6 +196,7 @@ public abstract class AbstractPolicyCatalogTest {
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext,
authorizer,
reservedProperties);
@@ -221,7 +224,7 @@ public abstract class AbstractPolicyCatalogTest {
"true")
.setStorageConfigurationInfo(realmConfig,
storageConfigModel, storageLocation)
.build()
- .asCatalog()));
+ .asCatalog(serviceIdentityProvider)));
PolarisPassthroughResolutionView passthroughView =
new PolarisPassthroughResolutionView(
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/entity/CatalogEntityTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/entity/CatalogEntityTest.java
index fceeaa54e..47e61f573 100644
---
a/runtime/service/src/test/java/org/apache/polaris/service/entity/CatalogEntityTest.java
+++
b/runtime/service/src/test/java/org/apache/polaris/service/entity/CatalogEntityTest.java
@@ -23,19 +23,30 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Stream;
+import org.apache.polaris.core.admin.model.AuthenticationParameters;
+import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
import org.apache.polaris.core.admin.model.AwsStorageConfigInfo;
import org.apache.polaris.core.admin.model.AzureStorageConfigInfo;
import org.apache.polaris.core.admin.model.Catalog;
import org.apache.polaris.core.admin.model.CatalogProperties;
+import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
+import org.apache.polaris.core.admin.model.ExternalCatalog;
import org.apache.polaris.core.admin.model.GcpStorageConfigInfo;
+import org.apache.polaris.core.admin.model.IcebergRestConnectionConfigInfo;
import org.apache.polaris.core.admin.model.PolarisCatalog;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.admin.model.SigV4AuthenticationParameters;
import org.apache.polaris.core.admin.model.StorageConfigInfo;
import org.apache.polaris.core.config.PolarisConfigurationStore;
import org.apache.polaris.core.config.RealmConfig;
import org.apache.polaris.core.config.RealmConfigImpl;
import org.apache.polaris.core.context.RealmContext;
import org.apache.polaris.core.entity.CatalogEntity;
+import
org.apache.polaris.core.identity.credential.AwsIamServiceIdentityCredential;
+import org.apache.polaris.core.identity.dpo.AwsIamServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -43,16 +54,30 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
public class CatalogEntityTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private RealmConfig realmConfig;
+ private ServiceIdentityProvider serviceIdentityProvider;
@BeforeEach
public void setup() {
RealmContext realmContext = () -> "realm";
this.realmConfig = new RealmConfigImpl(new PolarisConfigurationStore() {},
realmContext);
+ this.serviceIdentityProvider = Mockito.mock(ServiceIdentityProvider.class);
+ Mockito.when(serviceIdentityProvider.getServiceIdentityInfo(Mockito.any()))
+ .thenReturn(
+ Optional.of(
+ AwsIamServiceIdentityInfo.builder()
+
.setIdentityType(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM)
+ .setIamArn("arn:aws:iam::123456789012:user/test-user")
+ .build()));
+
Mockito.when(serviceIdentityProvider.getServiceIdentityCredential(Mockito.any()))
+ .thenReturn(
+ Optional.of(
+ new
AwsIamServiceIdentityCredential("arn:aws:iam::123456789012:user/test-user")));
}
@ParameterizedTest
@@ -278,7 +303,7 @@ public class CatalogEntityTest {
.setStorageConfigurationInfo(realmConfig, storageConfigModel,
baseLocation)
.build();
- Catalog catalog = catalogEntity.asCatalog();
+ Catalog catalog = catalogEntity.asCatalog(serviceIdentityProvider);
assertThat(catalog.getType()).isEqualTo(Catalog.TypeEnum.INTERNAL);
}
@@ -301,7 +326,7 @@ public class CatalogEntityTest {
.setStorageConfigurationInfo(realmConfig, storageConfigModel,
baseLocation)
.build();
- Catalog catalog = catalogEntity.asCatalog();
+ Catalog catalog = catalogEntity.asCatalog(serviceIdentityProvider);
assertThat(catalog.getType()).isEqualTo(Catalog.TypeEnum.EXTERNAL);
}
@@ -324,7 +349,7 @@ public class CatalogEntityTest {
.setStorageConfigurationInfo(realmConfig, storageConfigModel,
baseLocation)
.build();
- Catalog catalog = catalogEntity.asCatalog();
+ Catalog catalog = catalogEntity.asCatalog(serviceIdentityProvider);
assertThat(catalog.getType()).isEqualTo(Catalog.TypeEnum.INTERNAL);
}
@@ -362,11 +387,70 @@ public class CatalogEntityTest {
config.getAllowedLocations().getFirst())
.build();
- Catalog catalog = catalogEntity.asCatalog();
+ Catalog catalog = catalogEntity.asCatalog(serviceIdentityProvider);
assertThat(catalog.getStorageConfigInfo()).isEqualTo(config);
assertThat(MAPPER.writeValueAsString(catalog.getStorageConfigInfo())).isEqualTo(configStr);
}
+ @Test
+ public void testServiceIdentityInjection() {
+ String baseLocation = "s3://test-bucket/path";
+ AwsStorageConfigInfo storageConfigModel =
+ AwsStorageConfigInfo.builder()
+ .setRoleArn("arn:aws:iam::012345678901:role/test-role")
+ .setExternalId("externalId")
+ .setUserArn("aws::a:user:arn")
+ .setStorageType(StorageConfigInfo.StorageTypeEnum.S3)
+ .setAllowedLocations(List.of(baseLocation))
+ .build();
+ IcebergRestConnectionConfigInfo icebergRestConnectionConfigInfoModel =
+ IcebergRestConnectionConfigInfo.builder()
+
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST)
+ .setUri("https://glue.us-west-2.amazonaws.com")
+ .setAuthenticationParameters(
+ SigV4AuthenticationParameters.builder()
+
.setAuthenticationType(AuthenticationParameters.AuthenticationTypeEnum.SIGV4)
+ .setRoleArn("arn:aws:iam::123456789012:role/test-role")
+ .setSigningName("glue")
+ .setSigningRegion("us-west-2")
+ .build())
+ .build();
+ CatalogEntity catalogEntity =
+ new CatalogEntity.Builder()
+ .setName("test-catalog")
+ .setCatalogType(Catalog.TypeEnum.EXTERNAL.name())
+ .setDefaultBaseLocation(baseLocation)
+ .setStorageConfigurationInfo(realmConfig, storageConfigModel,
baseLocation)
+ .setConnectionConfigInfoDpoWithSecrets(
+ icebergRestConnectionConfigInfoModel, null, new
AwsIamServiceIdentityInfoDpo(null))
+ .build();
+
+ Catalog catalog = catalogEntity.asCatalog(serviceIdentityProvider);
+ assertThat(catalog.getType()).isEqualTo(Catalog.TypeEnum.EXTERNAL);
+ ExternalCatalog externalCatalog = (ExternalCatalog) catalog;
+ assertThat(externalCatalog.getConnectionConfigInfo().getConnectionType())
+ .isEqualTo(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST);
+ assertThat(externalCatalog.getConnectionConfigInfo().getUri())
+ .isEqualTo("https://glue.us-west-2.amazonaws.com");
+
+ AuthenticationParameters authParams =
+
externalCatalog.getConnectionConfigInfo().getAuthenticationParameters();
+ assertThat(authParams.getAuthenticationType())
+ .isEqualTo(AuthenticationParameters.AuthenticationTypeEnum.SIGV4);
+ SigV4AuthenticationParameters sigV4AuthParams =
(SigV4AuthenticationParameters) authParams;
+ assertThat(sigV4AuthParams.getSigningName()).isEqualTo("glue");
+ assertThat(sigV4AuthParams.getSigningRegion()).isEqualTo("us-west-2");
+
assertThat(sigV4AuthParams.getRoleArn()).isEqualTo("arn:aws:iam::123456789012:role/test-role");
+
+ ServiceIdentityInfo serviceIdentity =
+ externalCatalog.getConnectionConfigInfo().getServiceIdentity();
+ assertThat(serviceIdentity.getIdentityType())
+ .isEqualTo(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM);
+ AwsIamServiceIdentityInfo awsIamServiceIdentity =
(AwsIamServiceIdentityInfo) serviceIdentity;
+ assertThat(awsIamServiceIdentity.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/test-user");
+ }
+
public static Stream<Arguments> testAwsConfigRoundTrip() {
AwsStorageConfigInfo.Builder b =
AwsStorageConfigInfo.builder()
diff --git
a/runtime/service/src/test/java/org/apache/polaris/service/identity/provider/DefaultServiceIdentityProviderTest.java
b/runtime/service/src/test/java/org/apache/polaris/service/identity/provider/DefaultServiceIdentityProviderTest.java
new file mode 100644
index 000000000..9342994e0
--- /dev/null
+++
b/runtime/service/src/test/java/org/apache/polaris/service/identity/provider/DefaultServiceIdentityProviderTest.java
@@ -0,0 +1,369 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.service.identity.provider;
+
+import io.quarkus.test.InjectMock;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import jakarta.inject.Inject;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.AuthenticationParameters;
+import org.apache.polaris.core.admin.model.AwsIamServiceIdentityInfo;
+import org.apache.polaris.core.admin.model.BearerAuthenticationParameters;
+import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
+import org.apache.polaris.core.admin.model.IcebergRestConnectionConfigInfo;
+import org.apache.polaris.core.admin.model.ServiceIdentityInfo;
+import org.apache.polaris.core.admin.model.SigV4AuthenticationParameters;
+import org.apache.polaris.core.context.RealmContext;
+import org.apache.polaris.core.identity.ServiceIdentityType;
+import
org.apache.polaris.core.identity.credential.AwsIamServiceIdentityCredential;
+import org.apache.polaris.core.identity.credential.ServiceIdentityCredential;
+import org.apache.polaris.core.identity.dpo.AwsIamServiceIdentityInfoDpo;
+import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
+import org.apache.polaris.core.secrets.SecretReference;
+import org.apache.polaris.service.identity.AwsIamServiceIdentityConfiguration;
+import org.apache.polaris.service.identity.RealmServiceIdentityConfiguration;
+import org.apache.polaris.service.identity.ServiceIdentityConfiguration;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+
+@QuarkusTest
+@TestProfile(DefaultServiceIdentityProviderTest.Profile.class)
+public class DefaultServiceIdentityProviderTest {
+ private static final String DEFAULT_REALM_KEY =
ServiceIdentityConfiguration.DEFAULT_REALM_KEY;
+ private static final String MY_REALM_KEY = "my-realm";
+
+ @InjectMock RealmContext realmContext;
+ @Inject ServiceIdentityConfiguration serviceIdentityConfiguration;
+
+ public static class Profile implements QuarkusTestProfile {
+ @Override
+ public Map<String, String> getConfigOverrides() {
+ return Map.of(
+ "quarkus.identity-provider.type",
+ "default",
+ "polaris.service-identity.aws-iam.iam-arn",
+ "arn:aws:iam::123456789012:user/polaris-default-iam-user",
+ "polaris.service-identity.my-realm.aws-iam.iam-arn",
+ "arn:aws:iam::123456789012:user/polaris-iam-user",
+ "polaris.service-identity.my-realm.aws-iam.access-key-id",
+ "access-key-id",
+ "polaris.service-identity.my-realm.aws-iam.secret-access-key",
+ "secret-access-key",
+ "polaris.service-identity.my-realm.aws-iam.session-token",
+ "session-token");
+ }
+ }
+
+ @Test
+ void testServiceIdentityConfiguration() {
+ // Ensure that the service identity configuration is loaded correctly
+ Assertions.assertThat(serviceIdentityConfiguration.realms()).isNotNull();
+ Assertions.assertThat(serviceIdentityConfiguration.realms())
+ .containsKey(ServiceIdentityConfiguration.DEFAULT_REALM_KEY)
+ .containsKey(MY_REALM_KEY)
+ .size()
+ .isEqualTo(2);
+
+ // Check the default realm configuration
+ ServiceIdentityConfiguration.RealmConfigEntry defaultConfigEntry =
+ serviceIdentityConfiguration.forRealm(DEFAULT_REALM_KEY);
+
Assertions.assertThat(defaultConfigEntry.realm()).isEqualTo(DEFAULT_REALM_KEY);
+ RealmServiceIdentityConfiguration defaultConfig =
defaultConfigEntry.config();
+
Assertions.assertThat(defaultConfig.awsIamServiceIdentity().isPresent()).isTrue();
+ Assertions.assertThat(defaultConfig.awsIamServiceIdentity().get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-default-iam-user");
+
Assertions.assertThat(defaultConfig.awsIamServiceIdentity().get().accessKeyId()).isEmpty();
+
Assertions.assertThat(defaultConfig.awsIamServiceIdentity().get().secretAccessKey()).isEmpty();
+
Assertions.assertThat(defaultConfig.awsIamServiceIdentity().get().sessionToken()).isEmpty();
+
+ // Check the my-realm configuration
+ ServiceIdentityConfiguration.RealmConfigEntry myRealmConfigEntry =
+ serviceIdentityConfiguration.forRealm(MY_REALM_KEY);
+ Assertions.assertThat(myRealmConfigEntry.realm()).isEqualTo(MY_REALM_KEY);
+ RealmServiceIdentityConfiguration myRealmConfig =
myRealmConfigEntry.config();
+
Assertions.assertThat(myRealmConfig.awsIamServiceIdentity().isPresent()).isTrue();
+ Assertions.assertThat(myRealmConfig.awsIamServiceIdentity().get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-iam-user");
+
Assertions.assertThat(myRealmConfig.awsIamServiceIdentity().get().accessKeyId())
+ .isEqualTo(Optional.of("access-key-id"));
+
Assertions.assertThat(myRealmConfig.awsIamServiceIdentity().get().secretAccessKey())
+ .isEqualTo(Optional.of("secret-access-key"));
+
Assertions.assertThat(myRealmConfig.awsIamServiceIdentity().get().sessionToken())
+ .isEqualTo(Optional.of("session-token"));
+
+ // Check the unexisting realm configuration
+ ServiceIdentityConfiguration.RealmConfigEntry otherConfigEntry =
+ serviceIdentityConfiguration.forRealm("other-realm");
+
Assertions.assertThat(otherConfigEntry.realm()).isEqualTo(DEFAULT_REALM_KEY);
+ RealmServiceIdentityConfiguration otherConfig = otherConfigEntry.config();
+
Assertions.assertThat(otherConfig.awsIamServiceIdentity().isPresent()).isTrue();
+ Assertions.assertThat(otherConfig.awsIamServiceIdentity().get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-default-iam-user");
+
Assertions.assertThat(otherConfig.awsIamServiceIdentity().get().accessKeyId()).isEmpty();
+
Assertions.assertThat(otherConfig.awsIamServiceIdentity().get().secretAccessKey()).isEmpty();
+
Assertions.assertThat(otherConfig.awsIamServiceIdentity().get().sessionToken()).isEmpty();
+ }
+
+ @Test
+ void testAwsIamConfigurationLoading() {
+ // Check the default realm
+
Mockito.when(realmContext.getRealmIdentifier()).thenReturn(DEFAULT_REALM_KEY);
+ DefaultServiceIdentityProvider defaultProvider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ Optional<AwsIamServiceIdentityConfiguration> awsConfig =
+ defaultProvider.getRealmConfig().awsIamServiceIdentity();
+ Assertions.assertThat(awsConfig).isPresent();
+ Assertions.assertThat(awsConfig.get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-default-iam-user");
+ Assertions.assertThat(awsConfig.get().accessKeyId()).isEmpty();
+
+ // Check the my-realm with static credentials
+ Mockito.when(realmContext.getRealmIdentifier()).thenReturn(MY_REALM_KEY);
+ DefaultServiceIdentityProvider myRealmProvider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ awsConfig = myRealmProvider.getRealmConfig().awsIamServiceIdentity();
+ Assertions.assertThat(awsConfig).isPresent();
+ Assertions.assertThat(awsConfig.get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-iam-user");
+
Assertions.assertThat(awsConfig.get().accessKeyId()).isEqualTo(Optional.of("access-key-id"));
+ Assertions.assertThat(awsConfig.get().secretAccessKey())
+ .isEqualTo(Optional.of("secret-access-key"));
+
Assertions.assertThat(awsConfig.get().sessionToken()).isEqualTo(Optional.of("session-token"));
+
+ // Check the other realm (should fallback to default)
+ Mockito.when(realmContext.getRealmIdentifier()).thenReturn("other-realm");
+ DefaultServiceIdentityProvider otherProvider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ awsConfig = otherProvider.getRealmConfig().awsIamServiceIdentity();
+ Assertions.assertThat(awsConfig).isPresent();
+ Assertions.assertThat(awsConfig.get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-default-iam-user");
+ }
+
+ @Test
+ void testAllocateServiceIdentityWithSigV4Authentication() {
+ // Test allocateServiceIdentity with SigV4 authentication
+
Mockito.when(realmContext.getRealmIdentifier()).thenReturn(DEFAULT_REALM_KEY);
+ DefaultServiceIdentityProvider provider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ ConnectionConfigInfo connectionConfig =
+ IcebergRestConnectionConfigInfo.builder()
+
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST)
+ .setUri("https://example.com/catalog")
+ .setAuthenticationParameters(
+ SigV4AuthenticationParameters.builder()
+
.setAuthenticationType(AuthenticationParameters.AuthenticationTypeEnum.SIGV4)
+ .setRoleArn("arn:aws:iam::123456789012:role/customer-role")
+ .build())
+ .build();
+
+ Optional<ServiceIdentityInfoDpo> result =
provider.allocateServiceIdentity(connectionConfig);
+
+ Assertions.assertThat(result).isPresent();
+ ServiceIdentityInfoDpo serviceIdentityDpo = result.get();
+
Assertions.assertThat(serviceIdentityDpo).isInstanceOf(AwsIamServiceIdentityInfoDpo.class);
+ Assertions.assertThat(serviceIdentityDpo.getIdentityType())
+ .isEqualTo(ServiceIdentityType.AWS_IAM);
+
Assertions.assertThat(serviceIdentityDpo.getIdentityInfoReference().getUrn())
+ .contains("default-identity-provider");
+ }
+
+ @Test
+ void testAllocateServiceIdentityWithBearerAuthenticationReturnsEmpty() {
+ // Test allocateServiceIdentity with non-SigV4 authentication returns empty
+
Mockito.when(realmContext.getRealmIdentifier()).thenReturn(DEFAULT_REALM_KEY);
+ DefaultServiceIdentityProvider provider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ ConnectionConfigInfo connectionConfig =
+ IcebergRestConnectionConfigInfo.builder()
+
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST)
+ .setUri("https://example.com/catalog")
+ .setAuthenticationParameters(
+ BearerAuthenticationParameters.builder()
+
.setAuthenticationType(AuthenticationParameters.AuthenticationTypeEnum.BEARER)
+ .setBearerToken("some-token")
+ .build())
+ .build();
+
+ Optional<ServiceIdentityInfoDpo> result =
provider.allocateServiceIdentity(connectionConfig);
+
+ Assertions.assertThat(result).isEmpty();
+ }
+
+ @Test
+ void testAllocateServiceIdentityWithNullAuthParametersReturnsEmpty() {
+ // Test allocateServiceIdentity with null authentication parameters
+
Mockito.when(realmContext.getRealmIdentifier()).thenReturn(DEFAULT_REALM_KEY);
+ DefaultServiceIdentityProvider provider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ ConnectionConfigInfo connectionConfig =
+ IcebergRestConnectionConfigInfo.builder()
+
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST)
+ .setUri("https://example.com/catalog")
+ .build();
+
+ Optional<ServiceIdentityInfoDpo> result =
provider.allocateServiceIdentity(connectionConfig);
+
+ Assertions.assertThat(result).isEmpty();
+ }
+
+ @Test
+ void testGetServiceIdentityInfoReturnsInfoWithoutCredentials() {
+ // Test getServiceIdentityInfo returns user-facing info without credentials
+
Mockito.when(realmContext.getRealmIdentifier()).thenReturn(DEFAULT_REALM_KEY);
+ DefaultServiceIdentityProvider provider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ ServiceIdentityInfoDpo serviceIdentityDpo =
+ new AwsIamServiceIdentityInfoDpo(
+ new SecretReference(
+
"urn:polaris-secret:default-identity-provider:system:default:AWS_IAM",
Map.of()));
+
+ Optional<ServiceIdentityInfo> result =
provider.getServiceIdentityInfo(serviceIdentityDpo);
+
+ Assertions.assertThat(result).isPresent();
+ ServiceIdentityInfo info = result.get();
+ Assertions.assertThat(info.getIdentityType())
+ .isEqualTo(ServiceIdentityInfo.IdentityTypeEnum.AWS_IAM);
+ Assertions.assertThat(info).isInstanceOf(AwsIamServiceIdentityInfo.class);
+ AwsIamServiceIdentityInfo awsInfo = (AwsIamServiceIdentityInfo) info;
+ Assertions.assertThat(awsInfo.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-default-iam-user");
+ }
+
+ @Test
+ void testGetServiceIdentityCredentialReturnsCredentialWithSecrets() {
+ // Test getServiceIdentityCredential returns full credential with secrets
+ Mockito.when(realmContext.getRealmIdentifier()).thenReturn(MY_REALM_KEY);
+ DefaultServiceIdentityProvider provider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ ServiceIdentityInfoDpo serviceIdentityDpo =
+ new AwsIamServiceIdentityInfoDpo(
+ new SecretReference(
+
"urn:polaris-secret:default-identity-provider:my-realm:AWS_IAM", Map.of()));
+
+ Optional<ServiceIdentityCredential> result =
+ provider.getServiceIdentityCredential(serviceIdentityDpo);
+
+ Assertions.assertThat(result).isPresent();
+ ServiceIdentityCredential credential = result.get();
+
Assertions.assertThat(credential).isInstanceOf(AwsIamServiceIdentityCredential.class);
+
+ AwsIamServiceIdentityCredential awsCredential =
(AwsIamServiceIdentityCredential) credential;
+ Assertions.assertThat(awsCredential.getIamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-iam-user");
+ Assertions.assertThat(awsCredential.getAwsCredentialsProvider())
+ .isInstanceOf(StaticCredentialsProvider.class);
+
+ // Verify credentials are accessible
+ StaticCredentialsProvider credProvider =
+ (StaticCredentialsProvider) awsCredential.getAwsCredentialsProvider();
+ AwsSessionCredentials creds = (AwsSessionCredentials)
credProvider.resolveCredentials();
+ Assertions.assertThat(creds.accessKeyId()).isEqualTo("access-key-id");
+
Assertions.assertThat(creds.secretAccessKey()).isEqualTo("secret-access-key");
+ Assertions.assertThat(creds.sessionToken()).isEqualTo("session-token");
+ }
+
+ @Test
+ void testEmptyProviderAllocateServiceIdentityReturnsEmpty() {
+ // Test that an empty provider returns empty when allocating
+ DefaultServiceIdentityProvider emptyProvider = new
DefaultServiceIdentityProvider();
+
+ ConnectionConfigInfo connectionConfig =
+ IcebergRestConnectionConfigInfo.builder()
+
.setConnectionType(ConnectionConfigInfo.ConnectionTypeEnum.ICEBERG_REST)
+ .setUri("https://example.com/catalog")
+ .setAuthenticationParameters(
+ SigV4AuthenticationParameters.builder()
+
.setAuthenticationType(AuthenticationParameters.AuthenticationTypeEnum.SIGV4)
+ .setRoleArn("arn:aws:iam::123456789012:role/customer-role")
+ .build())
+ .build();
+
+ Optional<ServiceIdentityInfoDpo> result =
+ emptyProvider.allocateServiceIdentity(connectionConfig);
+
+ Assertions.assertThat(result).isEmpty();
+ }
+
+ @Test
+ void testMultiTenantScenarioDifferentRealmsGetDifferentIdentities() {
+ // Test that different realms have different configurations
+
Mockito.when(realmContext.getRealmIdentifier()).thenReturn(DEFAULT_REALM_KEY);
+ DefaultServiceIdentityProvider defaultProvider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ Mockito.when(realmContext.getRealmIdentifier()).thenReturn(MY_REALM_KEY);
+ DefaultServiceIdentityProvider myRealmProvider =
+ new DefaultServiceIdentityProvider(realmContext,
serviceIdentityConfiguration);
+
+ // Verify different IAM ARNs from configuration
+
Assertions.assertThat(defaultProvider.getRealmConfig().awsIamServiceIdentity()).isPresent();
+
Assertions.assertThat(defaultProvider.getRealmConfig().awsIamServiceIdentity().get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-default-iam-user");
+
+
Assertions.assertThat(myRealmProvider.getRealmConfig().awsIamServiceIdentity()).isPresent();
+
Assertions.assertThat(myRealmProvider.getRealmConfig().awsIamServiceIdentity().get().iamArn())
+ .isEqualTo("arn:aws:iam::123456789012:user/polaris-iam-user");
+
+ // Verify different credential configurations
+ Assertions.assertThat(
+
defaultProvider.getRealmConfig().awsIamServiceIdentity().get().accessKeyId())
+ .isEmpty();
+ Assertions.assertThat(
+
myRealmProvider.getRealmConfig().awsIamServiceIdentity().get().accessKeyId())
+ .isEqualTo(Optional.of("access-key-id"));
+ }
+
+ @Test
+ void testBuildIdentityInfoReferenceForDefaultRealm() {
+ // Test URN generation for default realm
+ SecretReference ref =
+ DefaultServiceIdentityProvider.buildIdentityInfoReference(
+ DEFAULT_REALM_KEY, ServiceIdentityType.AWS_IAM);
+
+ Assertions.assertThat(ref.getUrn())
+
.isEqualTo("urn:polaris-secret:default-identity-provider:system:default:AWS_IAM");
+ }
+
+ @Test
+ void testBuildIdentityInfoReferenceForCustomRealm() {
+ // Test URN generation for custom realm
+ SecretReference ref =
+ DefaultServiceIdentityProvider.buildIdentityInfoReference(
+ "custom-realm", ServiceIdentityType.AWS_IAM);
+
+ Assertions.assertThat(ref.getUrn())
+
.isEqualTo("urn:polaris-secret:default-identity-provider:custom-realm:AWS_IAM");
+ }
+}
diff --git
a/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
b/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
index 044398daf..d118e52e9 100644
---
a/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
+++
b/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
@@ -46,6 +46,7 @@ import org.apache.polaris.core.config.RealmConfig;
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.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.persistence.BasePersistence;
import org.apache.polaris.core.persistence.MetaStoreManagerFactory;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
@@ -74,6 +75,7 @@ import
org.apache.polaris.service.context.catalog.CallContextCatalogFactory;
import
org.apache.polaris.service.context.catalog.PolarisCallContextCatalogFactory;
import org.apache.polaris.service.events.listeners.PolarisEventListener;
import org.apache.polaris.service.events.listeners.TestPolarisEventListener;
+import
org.apache.polaris.service.identity.provider.DefaultServiceIdentityProvider;
import
org.apache.polaris.service.persistence.InMemoryPolarisMetaStoreManagerFactory;
import org.apache.polaris.service.secrets.UnsafeInMemorySecretsManagerFactory;
import
org.apache.polaris.service.storage.PolarisStorageIntegrationProviderImpl;
@@ -217,6 +219,7 @@ public record TestServices(
UserSecretsManager userSecretsManager =
userSecretsManagerFactory.getOrCreateUserSecretsManager(realmContext);
+ ServiceIdentityProvider serviceIdentityProvider = new
DefaultServiceIdentityProvider();
FileIOFactory fileIOFactory =
fileIOFactorySupplier.apply(storageCredentialCache,
metaStoreManagerFactory);
@@ -304,12 +307,14 @@ public record TestServices(
resolutionManifestFactory,
metaStoreManager,
userSecretsManager,
+ serviceIdentityProvider,
securityContext,
authorizer,
reservedProperties);
PolarisCatalogsApi catalogsApi =
new PolarisCatalogsApi(
- new PolarisServiceImpl(realmConfig, reservedProperties,
adminService));
+ new PolarisServiceImpl(
+ realmConfig, reservedProperties, adminService,
serviceIdentityProvider));
return new TestServices(
clock,