diff --git a/CHANGELOG.md b/CHANGELOG.md
index c217eab6b36..14485b78f74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,13 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
## [Unreleased]
### Highlights
+- Polaris now fully supports "external" principals, that is, principals that are not backed by an
+ entity in Polaris metastore. By enabling external principals, either globally or per-realm,
+ Polaris now skips the principal entity metastore lookup. This means that synchronizing principals
+ between an external IDP and Polaris is not necessary anymore. To enable external principals,
+ set the `polaris.authentication.credential-mode` option to `external`. Note: external principals
+ are not compatible with internal authentication and internal authorization; you must configure an
+ external IDP and an external PDP, such as OPA or Ranger.
### Upgrade notes
diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts
index 380c84651e9..1941312b398 100644
--- a/bom/build.gradle.kts
+++ b/bom/build.gradle.kts
@@ -37,6 +37,7 @@ dependencies {
api(project(":polaris-floci-az-testcontainer"))
api(project(":polaris-floci-gcp-testcontainer"))
api(project(":polaris-keycloak-testcontainer"))
+ api(project(":polaris-opa-testcontainer"))
api(project(":polaris-rustfs-testcontainer"))
api(project(":polaris-immutables"))
api(project(":polaris-misc-types"))
@@ -121,6 +122,7 @@ dependencies {
api(project(":polaris-runtime-defaults"))
api(project(":polaris-server"))
api(project(":polaris-runtime-service"))
+ api(project(":polaris-runtime-service-it"))
api(project(":polaris-runtime-spark-tests"))
api(project(":polaris-tests"))
diff --git a/extensions/auth/opa/build.gradle.kts b/extensions/auth/opa/build.gradle.kts
index 037dabbbecf..b1cc447886d 100644
--- a/extensions/auth/opa/build.gradle.kts
+++ b/extensions/auth/opa/build.gradle.kts
@@ -96,7 +96,7 @@ dependencies {
opaStartupActionCompileOnly("org.apache.polaris.server-test-runner:polaris-server-test-runner")
opaStartupActionImplementation(platform(libs.testcontainers.bom))
opaStartupActionImplementation("org.testcontainers:testcontainers")
- opaStartupActionImplementation(project(":polaris-container-spec-helper"))
+ opaStartupActionImplementation(project(":polaris-opa-testcontainer"))
intTestBase(platform(libs.junit.bom))
intTestBase("org.junit.jupiter:junit-jupiter")
diff --git a/extensions/auth/opa/src/opaStartupAction/java/org/apache/polaris/extension/auth/opa/test/OpaStartupAction.java b/extensions/auth/opa/src/opaStartupAction/java/org/apache/polaris/extension/auth/opa/test/OpaStartupAction.java
index e51f9502409..20283b0b119 100644
--- a/extensions/auth/opa/src/opaStartupAction/java/org/apache/polaris/extension/auth/opa/test/OpaStartupAction.java
+++ b/extensions/auth/opa/src/opaStartupAction/java/org/apache/polaris/extension/auth/opa/test/OpaStartupAction.java
@@ -18,87 +18,18 @@
*/
package org.apache.polaris.extension.auth.opa.test;
-import java.io.OutputStream;
-import java.net.HttpURLConnection;
-import java.net.URI;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.time.Duration;
-import org.apache.polaris.containerspec.ContainerSpecHelper;
import org.apache.polaris.server.test.runner.spi.PolarisServerStartupAction;
import org.apache.polaris.server.test.runner.spi.PolarisServerStartupContext;
-import org.testcontainers.containers.GenericContainer;
-import org.testcontainers.containers.wait.strategy.Wait;
+import org.apache.polaris.test.opa.OpaContainer;
/** Starts an OPA test server before the external Polaris server process starts. */
public class OpaStartupAction implements PolarisServerStartupAction {
- private static final int OPA_PORT = 8181;
+
private static final String POLICY_NAME = "polaris/authz";
private static final String POLICY_PACKAGE = POLICY_NAME.replace('/', '.');
- private GenericContainer> opa;
-
- @Override
- @SuppressWarnings("resource")
- public void start(PolarisServerStartupContext context) {
- opa =
- new GenericContainer<>(
- ContainerSpecHelper.containerSpecHelper("opa", OpaStartupAction.class)
- .dockerImageName(null))
- .withExposedPorts(OPA_PORT)
- .withCommand("run", "--server", "--addr=0.0.0.0:8181")
- .waitingFor(
- Wait.forHttp("/health")
- .forPort(OPA_PORT)
- .forStatusCode(200)
- .withStartupTimeout(Duration.ofSeconds(120)));
-
- opa.start();
-
- String baseUrl = "http://" + opa.getHost() + ":" + opa.getMappedPort(OPA_PORT);
- loadRegoPolicy(baseUrl, POLICY_NAME, polarisRegoPolicy());
- context
- .getSystemProperties()
- .put("polaris.authorization.opa.policy-uri", baseUrl + "/v1/data/" + POLICY_NAME);
- }
-
- @Override
- public void close() {
- if (opa != null) {
- opa.stop();
- opa = null;
- }
- }
-
- private void loadRegoPolicy(String baseUrl, String policyName, String regoPolicy) {
- try {
- URL url = URI.create(baseUrl + "/v1/policies/" + policyName).toURL();
- HttpURLConnection conn = (HttpURLConnection) url.openConnection();
- conn.setRequestMethod("PUT");
- conn.setDoOutput(true);
- conn.setRequestProperty("Content-Type", "text/plain");
-
- try (OutputStream os = conn.getOutputStream()) {
- os.write(regoPolicy.getBytes(StandardCharsets.UTF_8));
- }
-
- int code = conn.getResponseCode();
- if (code < 200 || code >= 300) {
- throw new RuntimeException("OPA policy upload failed, HTTP " + code);
- }
- } catch (Exception e) {
- String logs = "";
- try {
- logs = opa.getLogs();
- } catch (Throwable ignored) {
- // ignore logging failures while reporting the original startup failure
- }
- throw new RuntimeException("Failed to load OPA policy. Container logs:\n" + logs, e);
- }
- }
-
- private String polarisRegoPolicy() {
- return """
+ private static final String POLICY =
+ """
package %s
default allow := false
@@ -113,6 +44,27 @@ private String polarisRegoPolicy() {
input.actor.principal == "admin"
}
"""
- .formatted(POLICY_PACKAGE);
+ .formatted(POLICY_PACKAGE);
+
+ private OpaContainer opa;
+
+ @Override
+ public void start(PolarisServerStartupContext context) {
+ opa = new OpaContainer();
+ opa.start();
+ opa.uploadRegoPolicy(POLICY_NAME, POLICY);
+ context
+ .getSystemProperties()
+ .put(
+ "polaris.authorization.opa.policy-uri",
+ opa.getExternalUrl() + "v1/data/" + POLICY_NAME);
+ }
+
+ @Override
+ public void close() {
+ if (opa != null) {
+ opa.stop();
+ opa = null;
+ }
}
}
diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties
index e1f02fb9be1..b008fae8d6f 100644
--- a/gradle/projects.main.properties
+++ b/gradle/projects.main.properties
@@ -29,6 +29,7 @@ polaris-runtime-defaults=runtime/defaults
polaris-runtime-service=runtime/service
polaris-server=runtime/server
polaris-distribution=runtime/distribution
+polaris-runtime-service-it=runtime/service-it
polaris-runtime-spark-tests=runtime/spark-tests
polaris-admin=runtime/admin
polaris-runtime-common=runtime/common
@@ -42,6 +43,7 @@ polaris-floci-aws-testcontainer=tools/testcontainers/floci-aws
polaris-floci-az-testcontainer=tools/testcontainers/floci-az
polaris-floci-gcp-testcontainer=tools/testcontainers/floci-gcp
polaris-keycloak-testcontainer=tools/testcontainers/keycloak
+polaris-opa-testcontainer=tools/testcontainers/opa
polaris-rustfs-testcontainer=tools/testcontainers/rustfs-testcontainer
polaris-hms-testcontainer=tools/testcontainers/hms-testcontainer
polaris-version=tools/version
diff --git a/helm/polaris/ci/authentication-values.yaml b/helm/polaris/ci/authentication-values.yaml
index 7ee9fe4107a..c2875e63cb5 100644
--- a/helm/polaris/ci/authentication-values.yaml
+++ b/helm/polaris/ci/authentication-values.yaml
@@ -67,3 +67,5 @@ oidc:
secret:
name: polaris-oidc
key: client-secret
+ principalMapper:
+ nameClaimPath: preferred_username
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisPrincipalAttributes.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisPrincipalAttributes.java
index c2cdf263630..a79ec9fbe90 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisPrincipalAttributes.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisPrincipalAttributes.java
@@ -26,6 +26,22 @@ public final class PolarisPrincipalAttributes {
private PolarisPrincipalAttributes() {}
+ /**
+ * Attribute key, of type {@link Boolean}, used to mark a principal as external.
+ *
+ *
When present and true, the principal is external: it has no backing entity in the Polaris
+ * metastore, and its roles are resolved directly from the authentication result rather than from
+ * metastore grants.
+ *
+ *
Authenticators must set this attribute to {@code true} when they deliberately take the
+ * external-principal path. The resolver treats an explicit {@code true} as external and a missing
+ * or false marker as internal.
+ *
+ *
Note: Callers must not assume that this attribute is always present.
+ */
+ public static final AttributeKey EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY =
+ new AttributeKey<>("org.apache.polaris.core.auth.EXTERNAL_PRINCIPAL");
+
/**
* Attribute key for the principal entity attribute, of type {@link PrincipalEntity}.
*
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/Resolver.java b/polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/Resolver.java
index 87476f135e0..733f067ae86 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/Resolver.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/Resolver.java
@@ -30,6 +30,8 @@
import java.util.stream.Collectors;
import org.apache.polaris.core.PolarisCallContext;
import org.apache.polaris.core.PolarisDiagnostics;
+import org.apache.polaris.core.auth.AuthorizationRequest;
+import org.apache.polaris.core.auth.AuthorizationState;
import org.apache.polaris.core.auth.PolarisPrincipal;
import org.apache.polaris.core.auth.PolarisPrincipalAttributes;
import org.apache.polaris.core.entity.CatalogEntity;
@@ -40,6 +42,8 @@
import org.apache.polaris.core.entity.PolarisEntityType;
import org.apache.polaris.core.entity.PolarisGrantRecord;
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.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.ResolvedPolarisEntity;
import org.apache.polaris.core.persistence.cache.EntityCache;
@@ -56,6 +60,11 @@
*/
public class Resolver {
+ // Sentinel ids for synthetic entities created for external principals. Negative so they never
+ // collide with real, positive entity ids. Role ids are derived by decrementing from the base.
+ private static final long EXTERNAL_PRINCIPAL_ID = -1L;
+ private static final long EXTERNAL_PRINCIPAL_ROLE_ID_BASE = -2L;
+
// we stash the Polaris call context here
private final @NonNull PolarisCallContext polarisCallContext;
@@ -781,6 +790,17 @@ private ResolverStatus resolvePaths(
private ResolverStatus resolveCallerPrincipalAndPrincipalRoles(
List toValidate, boolean resolvePrincipalRoles) {
+ // External principals are not backed by the metastore: synthesize the caller principal and its
+ // roles directly from the authenticated principal instead of resolving them from the backend.
+ boolean externalPrincipal =
+ polarisPrincipal
+ .getAttributes()
+ .getOptional(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY)
+ .orElse(false);
+ if (externalPrincipal) {
+ return resolveExternalCallerPrincipalAndPrincipalRoles(resolvePrincipalRoles);
+ }
+
// resolve the principal, by name or id
this.resolvedCallerPrincipal =
this.resolveByName(toValidate, PolarisEntityType.PRINCIPAL, polarisPrincipal.getName());
@@ -811,6 +831,48 @@ private ResolverStatus resolveCallerPrincipalAndPrincipalRoles(
return new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS);
}
+ /**
+ * Synthesize the caller principal and its principal roles for an external principal. The
+ * principal is assumed to exist and to be valid; neither it nor its roles are read from the
+ * metastore. Synthetic entities carry negative sentinel ids (so they never collide with real,
+ * positive entity ids) and empty grant records.
+ *
+ * TODO: synthetic principal and roles are a temporary workaround for external principals,
+ * until external authorizers change their implementation of {@link
+ * org.apache.polaris.core.auth.PolarisAuthorizer#resolveAuthorizationInputs(AuthorizationState,
+ * AuthorizationRequest)} to avoid calling {@link PolarisResolutionManifest#resolveAll()}, and
+ * instead only resolve the securables that are actually needed for authorization, cf. {@link
+ * PolarisResolutionManifest#resolveSelections(Set)}.
+ */
+ private ResolverStatus resolveExternalCallerPrincipalAndPrincipalRoles(
+ boolean resolvePrincipalRoles) {
+ PrincipalEntity syntheticPrincipal =
+ new PrincipalEntity.Builder()
+ .setId(EXTERNAL_PRINCIPAL_ID)
+ .setName(polarisPrincipal.getName())
+ .build();
+ this.resolvedCallerPrincipal =
+ new ResolvedPolarisEntity(syntheticPrincipal, List.of(), List.of());
+ // Register by ID only: synthetic entities must not be indexed by name, or they would shadow
+ // real stored entities with the same name in subsequent resolveByName() lookups.
+ this.resolvedEntriesById.put(EXTERNAL_PRINCIPAL_ID, this.resolvedCallerPrincipal);
+
+ this.resolvedCallerPrincipalRoles = new ArrayList<>();
+ if (resolvePrincipalRoles) {
+ long roleId = EXTERNAL_PRINCIPAL_ROLE_ID_BASE;
+ for (String roleName : polarisPrincipal.getRoles()) {
+ PrincipalRoleEntity syntheticRole =
+ new PrincipalRoleEntity.Builder().setId(roleId).setName(roleName).build();
+ ResolvedPolarisEntity resolvedRole =
+ new ResolvedPolarisEntity(syntheticRole, List.of(), List.of());
+ this.resolvedEntriesById.put(roleId--, resolvedRole);
+ this.resolvedCallerPrincipalRoles.add(resolvedRole);
+ }
+ }
+
+ return new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS);
+ }
+
/**
* Resolve all principal roles that the principal has grants for
*
diff --git a/polaris-core/src/test/java/org/apache/polaris/core/persistence/ResolverTest.java b/polaris-core/src/test/java/org/apache/polaris/core/persistence/ResolverTest.java
index 856b407ed76..4527fde5b8c 100644
--- a/polaris-core/src/test/java/org/apache/polaris/core/persistence/ResolverTest.java
+++ b/polaris-core/src/test/java/org/apache/polaris/core/persistence/ResolverTest.java
@@ -25,8 +25,11 @@
import java.util.Set;
import org.apache.polaris.core.PolarisCallContext;
import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.auth.PolarisPrincipalAttributes;
import org.apache.polaris.core.collection.AttributeMap;
+import org.apache.polaris.core.collection.ImmutableAttributeMap;
import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.entity.PrincipalEntity;
import org.apache.polaris.core.persistence.resolver.Resolvable;
import org.apache.polaris.core.persistence.resolver.Resolver;
import org.apache.polaris.core.persistence.resolver.ResolverPath;
@@ -74,6 +77,94 @@ protected PolarisTestMetaStoreManager tm() {
return tm;
}
+ @Test
+ public void testResolveExternalCallerPrincipalIsSynthetic() {
+ Resolver resolver =
+ new Resolver(
+ diagServices,
+ callCtx(),
+ metaStoreManager(),
+ PolarisPrincipal.of(
+ "ext-user",
+ ImmutableAttributeMap.builder()
+ .put(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY, true)
+ .build(),
+ Set.of("ext-role1", "ext-role2")),
+ null,
+ null);
+
+ // The external principal and its roles do not exist in the metastore, yet resolution succeeds.
+ ResolverStatus status = resolver.resolveAll();
+ Assertions.assertThat(status.getStatus()).isEqualTo(ResolverStatus.StatusEnum.SUCCESS);
+
+ ResolvedPolarisEntity principal = resolver.getResolvedCallerPrincipal();
+ Assertions.assertThat(principal.getEntity().getName()).isEqualTo("ext-user");
+ Assertions.assertThat(principal.getEntity().getType()).isEqualTo(PolarisEntityType.PRINCIPAL);
+ Assertions.assertThat(principal.getGrantRecordsAsGrantee()).isEmpty();
+
+ Assertions.assertThat(resolver.getResolvedCallerPrincipalRoles())
+ .extracting(r -> r.getEntity().getName())
+ .containsExactlyInAnyOrder("ext-role1", "ext-role2");
+ Assertions.assertThat(resolver.getResolvedCallerPrincipalRoles())
+ .allSatisfy(r -> Assertions.assertThat(r.getGrantRecordsAsGrantee()).isEmpty());
+ }
+
+ @Test
+ public void testExternalCallerPrincipalNameDoesNotShadowStoredPrincipal() {
+ // P1 is a stored principal created in setupTest(); use the same name for the external caller.
+ Resolver resolver =
+ new Resolver(
+ diagServices,
+ callCtx(),
+ metaStoreManager(),
+ PolarisPrincipal.of(
+ "P1",
+ ImmutableAttributeMap.builder()
+ .put(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY, true)
+ .build(),
+ Set.of()),
+ null,
+ null);
+ resolver.addEntityByName(PolarisEntityType.PRINCIPAL, "P1");
+
+ ResolverStatus status = resolver.resolveAll();
+ Assertions.assertThat(status.getStatus()).isEqualTo(ResolverStatus.StatusEnum.SUCCESS);
+
+ // Must return the real stored P1, not the synthetic sentinel with id -1.
+ ResolvedPolarisEntity resolved = resolver.getResolvedEntity(PolarisEntityType.PRINCIPAL, "P1");
+ Assertions.assertThat(resolved).isNotNull();
+ Assertions.assertThat(resolved.getEntity().getId()).isEqualTo(P1.getId());
+ Assertions.assertThat(resolved.getEntity().getId()).isPositive();
+ }
+
+ @Test
+ public void testExternalCallerRoleNameDoesNotShadowStoredPrincipalRole() {
+ // PR1 is a stored principal role created in setupTest(); use the same name as an external role.
+ Resolver resolver =
+ new Resolver(
+ diagServices,
+ callCtx(),
+ metaStoreManager(),
+ PolarisPrincipal.of(
+ "ext-user",
+ ImmutableAttributeMap.builder()
+ .put(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY, true)
+ .build(),
+ Set.of("PR1")),
+ null,
+ null);
+ resolver.addOptionalEntityByName(PolarisEntityType.PRINCIPAL_ROLE, "PR1");
+
+ ResolverStatus status = resolver.resolveAll();
+ Assertions.assertThat(status.getStatus()).isEqualTo(ResolverStatus.StatusEnum.SUCCESS);
+
+ // Must return the real stored PR1, not the synthetic sentinel with a negative id.
+ ResolvedPolarisEntity resolved =
+ resolver.getResolvedEntity(PolarisEntityType.PRINCIPAL_ROLE, "PR1");
+ Assertions.assertThat(resolved).isNotNull();
+ Assertions.assertThat(resolved.getEntity().getId()).isPositive();
+ }
+
@Test
public void testResolveSelectionsSkipsCallerPrincipalForReferenceCatalog() {
Resolver resolver =
@@ -126,7 +217,14 @@ public void testResolveSelectionsRequiresCallerPrincipalForCallerCatalogRoles()
diagServices,
callCtx(),
metaStoreManager(),
- PolarisPrincipal.of("missing", AttributeMap.EMPTY, Set.of()),
+ PolarisPrincipal.of(
+ "missing",
+ ImmutableAttributeMap.builder()
+ .put(
+ PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY,
+ new PrincipalEntity.Builder().setName("missing").build())
+ .build(),
+ Set.of()),
null,
"test");
ResolverStatus status = resolver.resolveSelections(Set.of(Resolvable.CALLER_CATALOG_ROLES));
@@ -161,7 +259,14 @@ public void testResolveSelectionsRequiresCallerPrincipalForCallerPrincipal() {
diagServices,
callCtx(),
metaStoreManager(),
- PolarisPrincipal.of("missing", AttributeMap.EMPTY, Set.of()),
+ PolarisPrincipal.of(
+ "missing",
+ ImmutableAttributeMap.builder()
+ .put(
+ PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY,
+ new PrincipalEntity.Builder().setName("missing").build())
+ .build(),
+ Set.of()),
null,
"test");
ResolverStatus status = resolver.resolveSelections(Set.of(Resolvable.CALLER_PRINCIPAL));
@@ -176,7 +281,14 @@ public void testResolveSelectionsRequiresCallerPrincipalForCallerPrincipalRoles(
diagServices,
callCtx(),
metaStoreManager(),
- PolarisPrincipal.of("missing", AttributeMap.EMPTY, Set.of()),
+ PolarisPrincipal.of(
+ "missing",
+ ImmutableAttributeMap.builder()
+ .put(
+ PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY,
+ new PrincipalEntity.Builder().setName("missing").build())
+ .build(),
+ Set.of()),
null,
"test");
ResolverStatus status = resolver.resolveSelections(Set.of(Resolvable.CALLER_PRINCIPAL_ROLES));
diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties
index 2979e52f7f2..587d5053826 100644
--- a/runtime/defaults/src/main/resources/application.properties
+++ b/runtime/defaults/src/main/resources/application.properties
@@ -230,12 +230,14 @@ polaris.rate-limiter.token-bucket.requests-per-second=9999
# Polaris authentication settings
polaris.authentication.type=internal
+polaris.authentication.credential-mode=internal
polaris.authentication.authenticator.type=default
# Per-realm overrides:
# polaris.authentication.realm1.type=external
+# polaris.authentication.realm1.credential-mode=external
# polaris.authentication.realm1.authenticator.type=custom
-# Options effective when using internal auth (can be overridden in per realm):
+# Options effective when using internal auth (can be overridden per realm):
polaris.authentication.token-service.type=default
polaris.authentication.token-broker.type=rsa-key-pair
polaris.authentication.token-broker.max-token-generation=PT1H
diff --git a/runtime/service-it/build.gradle.kts b/runtime/service-it/build.gradle.kts
new file mode 100644
index 00000000000..49d9dec3ef1
--- /dev/null
+++ b/runtime/service-it/build.gradle.kts
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+// This module hosts integration tests that must run against a Polaris server assembled with extra
+// extensions that are NOT part of the base :polaris-runtime-service runtime classpath (e.g. the
+// OPA authorizer).
+
+plugins {
+ alias(libs.plugins.quarkus)
+ id("org.kordamp.gradle.jandex")
+ id("polaris-runtime")
+}
+
+dependencies {
+ // The application under test: the full Polaris service plus the OPA authorizer extension.
+ implementation(enforcedPlatform(libs.quarkus.bom))
+ implementation(project(":polaris-runtime-service"))
+ runtimeOnly(project(":polaris-extensions-auth-opa"))
+
+ // Integration-test dependencies. The `intTest` source set inherits `testImplementation` (see the
+ // polaris-runtime convention plugin).
+ testImplementation(enforcedPlatform(libs.quarkus.bom))
+ testImplementation(project(":polaris-core"))
+ testImplementation(project(":polaris-api-management-model"))
+ testImplementation(project(":polaris-tests"))
+ testImplementation(testFixtures(project(":polaris-runtime-service")))
+
+ testImplementation("io.quarkus:quarkus-junit")
+ testImplementation("io.rest-assured:rest-assured")
+ testImplementation(libs.jakarta.ws.rs.api)
+
+ // The base class org.apache.polaris.service.it.test.PolarisRestCatalogFileIntegrationTest extends
+ // Iceberg's CatalogTests and uses RESTCatalog; :polaris-tests exposes these only as
+ // implementation, so the Iceberg API/core jars (plus their :tests classifier) are needed here.
+ testImplementation(platform(libs.iceberg.bom))
+ testImplementation("org.apache.iceberg:iceberg-api")
+ testImplementation("org.apache.iceberg:iceberg-core")
+ testImplementation("org.apache.iceberg:iceberg-api:${libs.versions.iceberg.get()}:tests")
+ testImplementation("org.apache.iceberg:iceberg-core:${libs.versions.iceberg.get()}:tests")
+
+ // Test containers for the OIDC server and the OPA server used in the integration tests.
+ testImplementation(platform(libs.testcontainers.bom))
+ testImplementation(project(":polaris-keycloak-testcontainer"))
+ testImplementation(project(":polaris-opa-testcontainer"))
+}
+
+tasks.named("javadoc") { dependsOn("jandex") }
+
+tasks.withType(Test::class.java).configureEach {
+ // Note: the test secrets are referenced in org.apache.polaris.service.it.ServerManager
+ environment("POLARIS_BOOTSTRAP_CREDENTIALS", "POLARIS,test-admin,test-secret")
+}
diff --git a/runtime/service-it/src/intTest/java/org/apache/polaris/service/it/ExternalPrincipalKeycloakOpaIT.java b/runtime/service-it/src/intTest/java/org/apache/polaris/service/it/ExternalPrincipalKeycloakOpaIT.java
new file mode 100644
index 00000000000..f21c25c976e
--- /dev/null
+++ b/runtime/service-it/src/intTest/java/org/apache/polaris/service/it/ExternalPrincipalKeycloakOpaIT.java
@@ -0,0 +1,180 @@
+/*
+ * 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.it;
+
+import static jakarta.ws.rs.core.Response.Status.FORBIDDEN;
+import static org.apache.polaris.service.it.env.PolarisClient.polarisClient;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import jakarta.ws.rs.core.Response;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.polaris.core.persistence.bootstrap.RootCredentialsSet;
+import org.apache.polaris.service.it.env.CatalogApi;
+import org.apache.polaris.service.it.env.ClientPrincipal;
+import org.apache.polaris.service.it.env.ManagementApi;
+import org.apache.polaris.service.it.env.PolarisApiEndpoints;
+import org.apache.polaris.service.it.env.PolarisClient;
+import org.apache.polaris.service.it.ext.PolarisIntegrationTestExtension;
+import org.apache.polaris.service.it.test.PolarisRestCatalogFileIntegrationTest;
+import org.apache.polaris.test.keycloak.Keycloak;
+import org.apache.polaris.test.keycloak.KeycloakAccess;
+import org.apache.polaris.test.keycloak.KeycloakTestResource;
+import org.apache.polaris.test.opa.OpaTestResource;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * End-to-end test for external principals: a principal authenticated via Keycloak (OIDC) that does
+ * NOT exist in the Polaris metastore, authorized by an external OPA authorizer.
+ */
+@QuarkusIntegrationTest
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@TestProfile(ExternalPrincipalKeycloakOpaIT.Profile.class)
+@ExtendWith(PolarisIntegrationTestExtension.class)
+public class ExternalPrincipalKeycloakOpaIT extends PolarisRestCatalogFileIntegrationTest {
+
+ private static final String DENIED_USER = "denied_user";
+
+ private static final String OPA_POLICY =
+ """
+ package polaris.authz
+
+ default allow := false
+
+ allow if {
+ input.actor.principal != "%s"
+ }
+ """
+ .formatted(DENIED_USER);
+
+ public static class Profile implements QuarkusTestProfile {
+
+ @Override
+ public Map getConfigOverrides() {
+ return Map.ofEntries(
+ Map.entry("quarkus.oidc.tenant-enabled", "true"),
+ Map.entry("quarkus.oidc.client-id", "polaris"),
+ Map.entry("polaris.authentication.type", "external"),
+ Map.entry("polaris.authentication.credential-mode", "external"),
+ Map.entry(
+ "polaris.oidc.principal-mapper.name-claim-path", KeycloakAccess.PRINCIPAL_NAME_CLAIM),
+ Map.entry("polaris.features.\"SUPPORTED_CATALOG_STORAGE_TYPES\"", "[\"FILE\"]"),
+ Map.entry("polaris.features.\"ALLOW_INSECURE_STORAGE_TYPES\"", "true"),
+ Map.entry("polaris.readiness.ignore-severe-issues", "true"));
+ }
+
+ @Override
+ public List testResources() {
+ Optional clients =
+ RootCredentialsSet.fromEnvironment().credentials().values().stream()
+ .map(creds -> creds.clientId() + "=" + creds.clientSecret())
+ .reduce((a, b) -> a + "," + b);
+ return List.of(
+ new TestResourceEntry(
+ KeycloakTestResource.class,
+ Map.of(
+ KeycloakTestResource.ROLES_ARG, "PRINCIPAL_ROLE:ALL",
+ KeycloakTestResource.USERS_ARG, "root=s3cr3t",
+ KeycloakTestResource.GRANTS_ARG, "root=PRINCIPAL_ROLE:ALL",
+ KeycloakTestResource.CLIENTS_ARG, clients.orElse(""))),
+ new TestResourceEntry(
+ OpaTestResource.class, Map.of(OpaTestResource.REGO_POLICY_ARG, OPA_POLICY)));
+ }
+ }
+
+ @Keycloak KeycloakAccess keycloak;
+
+ @Override
+ protected ClientPrincipal createTestPrincipal(
+ PolarisClient client, String principalName, String principalRole) {
+ keycloak.createRole(principalRole);
+ keycloak.createUser(principalName, "s3cr3t");
+ keycloak.assignRoleToUser(principalRole, principalName);
+ ClientPrincipal principal = super.createTestPrincipal(client, principalName, principalRole);
+ keycloak.createServiceAccount(
+ principal.credentials().clientId(), principal.credentials().clientSecret());
+ return principal;
+ }
+
+ @Override
+ protected void cleanUp(PolarisClient client, String adminToken) {
+ ManagementApi managementApi = client.managementApi(adminToken);
+ managementApi.listPrincipals().stream()
+ .filter(p -> client.ownedName(p.getName()))
+ .forEach(
+ p -> {
+ keycloak.deleteUser(p.getName());
+ keycloak.deleteServiceAccount(p.getClientId());
+ });
+ managementApi.listPrincipalRoles().stream()
+ .filter(r -> client.ownedName(r.getName()))
+ .forEach(role -> keycloak.deleteRole(role.getName()));
+ super.cleanUp(client, adminToken);
+ }
+
+ @Override
+ protected String obtainToken(PolarisClient client, ClientPrincipal principal) {
+ return obtainToken(
+ principal.principalName(),
+ "s3cr3t",
+ principal.credentials().clientId(),
+ principal.credentials().clientSecret());
+ }
+
+ private String obtainToken(
+ String username, String password, String clientId, String clientSecret) {
+ // Use password grant type to obtain a token that is tied to the principal user,
+ // not just to the service account.
+ return keycloak.getToken(
+ Map.of(
+ "grant_type", "password",
+ "client_id", clientId,
+ "client_secret", clientSecret,
+ "username", username,
+ "password", password));
+ }
+
+ @Test
+ void externalPrincipalDeniedByOpaIsForbidden(PolarisApiEndpoints endpoints) throws Exception {
+ String password = "s3cr3t2";
+ String clientId = "denied_user_client";
+ String clientSecret = "S3CR3T2";
+ keycloak.createUser(DENIED_USER, password);
+ keycloak.createServiceAccount(clientId, clientSecret);
+ try (var client = polarisClient(endpoints)) {
+ String token = obtainToken(DENIED_USER, password, clientId, clientSecret);
+ CatalogApi catalogApi = client.catalogApi(token);
+ @SuppressWarnings("resource")
+ String catalogName = catalog().properties().get("warehouse");
+ try (Response response =
+ catalogApi.request("v1/{cat}/namespaces", Map.of("cat", catalogName), Map.of()).get()) {
+ assertThat(response.getStatus()).isEqualTo(FORBIDDEN.getStatusCode());
+ }
+ } finally {
+ keycloak.deleteUser("denied_user");
+ keycloak.deleteServiceAccount(clientId);
+ }
+ }
+}
diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java b/runtime/service-it/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java
similarity index 100%
rename from runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java
rename to runtime/service-it/src/intTest/java/org/apache/polaris/service/it/RestCatalogKeycloakFileIT.java
diff --git a/runtime/service-it/src/intTest/resources/META-INF/services/org.apache.polaris.service.it.ext.PolarisServerManager b/runtime/service-it/src/intTest/resources/META-INF/services/org.apache.polaris.service.it.ext.PolarisServerManager
new file mode 100644
index 00000000000..07ecf4b1fd0
--- /dev/null
+++ b/runtime/service-it/src/intTest/resources/META-INF/services/org.apache.polaris.service.it.ext.PolarisServerManager
@@ -0,0 +1,20 @@
+#
+# 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.
+#
+
+org.apache.polaris.service.it.ServerManager
\ No newline at end of file
diff --git a/runtime/service-it/src/main/java/org/apache/polaris/service/it/quarkus/Readme.java b/runtime/service-it/src/main/java/org/apache/polaris/service/it/quarkus/Readme.java
new file mode 100644
index 00000000000..83950245562
--- /dev/null
+++ b/runtime/service-it/src/main/java/org/apache/polaris/service/it/quarkus/Readme.java
@@ -0,0 +1,29 @@
+/*
+ * 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.it.quarkus;
+
+/**
+ * This file is here as a placeholder to allow the Quarkus build tasks to work, since this module
+ * has no other main source files: the application under test is assembled entirely from
+ * dependencies.
+ */
+@SuppressWarnings("unused")
+public final class Readme {
+ private Readme() {}
+}
diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts
index f9d588399e3..98367edf957 100644
--- a/runtime/service/build.gradle.kts
+++ b/runtime/service/build.gradle.kts
@@ -134,8 +134,6 @@ dependencies {
testImplementation(project(":polaris-api-management-model"))
testImplementation(project(":polaris-relational-jdbc"))
- testImplementation(project(":polaris-rustfs-testcontainer"))
-
testImplementation("org.apache.iceberg:iceberg-api:${libs.versions.iceberg.get()}:tests")
testImplementation("org.apache.iceberg:iceberg-core:${libs.versions.iceberg.get()}:tests")
@@ -146,6 +144,7 @@ dependencies {
testImplementation(enforcedPlatform(libs.quarkus.bom))
testImplementation("io.quarkus:quarkus-junit")
testImplementation("io.quarkus:quarkus-junit-mockito")
+ testImplementation("io.quarkus:quarkus-test-oidc-server")
testImplementation("io.quarkus:quarkus-rest-client")
testImplementation("io.quarkus:quarkus-rest-client-jackson")
testImplementation("io.quarkus:quarkus-jdbc-h2")
@@ -155,10 +154,12 @@ dependencies {
testImplementation("io.rest-assured:rest-assured")
testImplementation(platform(libs.testcontainers.bom))
+ testImplementation("org.testcontainers:testcontainers")
+ testImplementation("org.testcontainers:testcontainers-postgresql")
testImplementation(project(":polaris-floci-aws-testcontainer"))
testImplementation(project(":polaris-floci-az-testcontainer"))
testImplementation(project(":polaris-floci-gcp-testcontainer"))
- testImplementation(project(":polaris-keycloak-testcontainer"))
+ testImplementation(project(":polaris-rustfs-testcontainer"))
testImplementation(project(":polaris-runtime-test-common"))
testImplementation(project(":polaris-container-spec-helper"))
@@ -170,10 +171,6 @@ dependencies {
testImplementation(libs.junit.pioneer)
- testImplementation(platform(libs.testcontainers.bom))
- testImplementation("org.testcontainers:testcontainers")
- testImplementation("org.testcontainers:testcontainers-postgresql")
-
testImplementation(project(":polaris-persistence-nosql-api"))
testImplementation(testFixtures(project(":polaris-persistence-nosql-api")))
testImplementation(project(":polaris-persistence-nosql-impl"))
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 2702988f94c..179ab5a9ee2 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
@@ -82,6 +82,7 @@
import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
import org.apache.polaris.core.auth.PolarisAuthorizer;
import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.auth.PolarisPrincipalAttributes;
import org.apache.polaris.core.auth.PolarisSecurable;
import org.apache.polaris.core.auth.PrivilegeGrantAuthorizationIntent;
import org.apache.polaris.core.auth.RoleAssignmentAuthorizationIntent;
@@ -299,6 +300,17 @@ private PolarisResolutionManifest authorizeBasicTopLevelEntityOperationOrThrow(
* PolarisPrincipal}.
*/
private boolean isSelfEntity(PolarisEntity entity) {
+ // External principals are not backed by the metastore, so they can never be the stored target
+ // entity: a name match would be an accidental collision, not genuine self-service. Denying the
+ // shortcut forces such callers through the authorizer.
+ boolean externalPrincipal =
+ polarisPrincipal
+ .getAttributes()
+ .getOptional(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY)
+ .orElse(false);
+ if (externalPrincipal) {
+ return false;
+ }
// Entity name is unique for (realm_id, catalog_id, parent_id, type_code),
// which is reduced to (realm_id, type_code) for top-level entities;
// so there can be only one principal with a given name inside any realm.
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java
index acad76c54e4..54cc3c76a8b 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java
@@ -31,6 +31,26 @@ public interface AuthenticationRealmConfiguration {
@WithDefault("internal")
AuthenticationType type();
+ /**
+ * The credential mode to use. Valid values are {@code INTERNAL} and {@code EXTERNAL}.
+ *
+ * Internal mode is the default. When internal mode is used, callers must have a corresponding
+ * principal in the Polaris metastore, which serves as the authoritative source of truth for
+ * principals, roles, and grants.
+ *
+ *
When external mode is used, callers are authenticated entirely from the presented
+ * credentials and are not required to have a corresponding entity in the Polaris metastore. Note
+ * that when the presented credentials were issued by Polaris itself, a backing principal entity
+ * is always required, regardless of the credential mode.
+ *
+ *
External credential mode is only meaningful when an external IDP is used, that is, when the
+ * {@linkplain #type() authentication type} is {@link AuthenticationType#EXTERNAL} or {@link
+ * AuthenticationType#MIXED}. Also, this mode is not compatible with the default, built-in Polaris
+ * authorizer; an external authorizer (e.g., OPA or Ranger) must be configured.
+ */
+ @WithDefault("internal")
+ CredentialMode credentialMode();
+
/**
* The configuration for the authenticator. The authenticator is responsible for validating token
* credentials and mapping those credentials to an existing principal and validated principal
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/CredentialMode.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/CredentialMode.java
new file mode 100644
index 00000000000..29ef035573e
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/CredentialMode.java
@@ -0,0 +1,37 @@
+/*
+ * 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.auth;
+
+/** Represents the mode of a credential in the authentication process. */
+public enum CredentialMode {
+
+ /**
+ * The principal represented by this credential is backed by an entity in the Polaris metastore,
+ * which serves as the authoritative source of truth for principals, roles, and grants.
+ */
+ INTERNAL,
+
+ /**
+ * The principal represented by this credential is not backed by an entity in the Polaris
+ * metastore and is authenticated entirely from the presented credentials. This mode is not
+ * compatible with the built-in Polaris authorizer.
+ */
+ EXTERNAL,
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java
index 6fe2f4a727c..d1479b65908 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java
@@ -100,14 +100,15 @@ public PolarisPrincipal authenticate(SecurityIdentity identity) {
PolarisCredential credentials = extractPolarisCredential(identity);
LOGGER.debug("Resolving principal for credentials: {}", credentials);
- PrincipalEntity principalEntity = resolvePrincipalEntity(credentials);
- PrincipalRoleSelection principalRoles = resolvePrincipalRoles(credentials, principalEntity);
- AttributeMap principalAttributes =
- resolvePrincipalAttributes(identity, principalEntity, principalRoles.allRolesRequested());
- PolarisPrincipal polarisPrincipal =
- PolarisPrincipal.of(principalEntity.getName(), principalAttributes, principalRoles.roles());
+ var entity = resolvePrincipalEntity(credentials);
+ var roleSelection = resolvePrincipalRoles(credentials, entity);
+ var attributes =
+ resolvePrincipalAttributes(identity, entity, roleSelection.allRolesRequested());
- LOGGER.debug("Resolved principal: {}", polarisPrincipal);
+ var principalName = entity != null ? entity.getName() : credentials.getPrincipalName();
+ var polarisPrincipal = PolarisPrincipal.of(principalName, attributes, roleSelection.roles());
+
+ LOGGER.debug("Resolved principal: {} - entity available: {}", polarisPrincipal, entity != null);
return polarisPrincipal;
}
@@ -122,73 +123,104 @@ private static PolarisCredential extractPolarisCredential(SecurityIdentity ident
/**
* Resolves the principal entity based on the provided credentials.
*
- *
This method attempts to load the principal entity using either the principal ID or the
- * principal name from the credentials. If neither is available, nor if the principal entity can
- * be found, it throws a {@link AuthenticationFailedException}.
+ *
When {@link PolarisCredential#isExternal()} is {@code true}, the credentials represent an
+ * externally-managed principal: no metastore lookup is performed and {@code null} is returned. It
+ * throws {@link AuthenticationFailedException} if the principal name is not available in the
+ * credentials.
+ *
+ *
Otherwise, the credentials are treated as internal — this includes both credentials created
+ * via {@link PolarisCredential#of} and any plain {@link PolarisCredential} returned by a custom
+ * token broker. This method attempts to load the principal entity using either the principal ID
+ * or the principal name from the credentials. If neither is available, nor if the principal
+ * entity can be found, it throws a {@link AuthenticationFailedException}.
*/
+ @Nullable
protected PrincipalEntity resolvePrincipalEntity(PolarisCredential credentials) {
- PrincipalEntity principal = null;
+ if (credentials.isExternal()) {
+ if (credentials.getPrincipalName() == null) {
+ LOGGER.warn("Failed to resolve external principal, no principal name in credentials");
+ throw new AuthenticationFailedException("Invalid credential");
+ }
+ return null;
+ }
+
+ // Internal principal: the credentials must resolve to a backing entity in the metastore.
+ Long principalId = credentials.getPrincipalId();
+ String principalName = credentials.getPrincipalName();
+
+ PrincipalEntity entity = null;
try {
// If the principal id is present, prefer to use it to load the principal entity,
// otherwise, use the principal name to load the entity.
- if (credentials.getPrincipalId() != null && credentials.getPrincipalId() > 0) {
- principal =
+ if (principalId != null && principalId > 0) {
+ entity =
metaStoreManager
- .findPrincipalById(
- callContext.getPolarisCallContext(), credentials.getPrincipalId())
+ .findPrincipalById(callContext.getPolarisCallContext(), principalId)
.orElse(null);
- } else if (credentials.getPrincipalName() != null) {
- principal =
+ } else if (principalName != null) {
+ entity =
metaStoreManager
- .findPrincipalByName(
- callContext.getPolarisCallContext(), credentials.getPrincipalName())
+ .findPrincipalByName(callContext.getPolarisCallContext(), principalName)
.orElse(null);
}
} catch (Exception e) {
throw metaStoreUnavailable(
e,
"Unable to resolve principal entity from credentials, principalName={} principalId={}",
- credentials.getPrincipalName(),
- credentials.getPrincipalId());
+ principalName,
+ principalId);
}
- if (principal == null || principal.getType() != PolarisEntityType.PRINCIPAL) {
+ if (entity == null || entity.getType() != PolarisEntityType.PRINCIPAL) {
LOGGER.warn("Failed to resolve principal from credentials={}", credentials);
throw new AuthenticationFailedException("Unable to authenticate");
}
- return principal;
+ return entity;
}
protected AttributeMap resolvePrincipalAttributes(
- SecurityIdentity identity, PrincipalEntity principalEntity, boolean allRolesRequested) {
+ SecurityIdentity identity,
+ @Nullable PrincipalEntity principalEntity,
+ boolean allRolesRequested) {
// Do not merge the security identity's attributes into the principal attributes:
// these must stay separate.
- ImmutableAttributeMap.Builder principalAttributes =
- ImmutableAttributeMap.builder()
- .put(PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY, principalEntity)
- .put(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY, allRolesRequested);
+ ImmutableAttributeMap.Builder principalAttributes = ImmutableAttributeMap.builder();
if (identity.getPrincipal() instanceof JsonWebToken jwt) {
principalAttributes.put(PolarisPrincipalAttributes.JWT_ATTRIBUTE_KEY, jwt.getRawToken());
}
+ if (principalEntity != null) {
+ principalAttributes
+ .put(PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY, principalEntity)
+ .put(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY, allRolesRequested);
+ } else {
+ principalAttributes.put(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY, true);
+ }
return principalAttributes.build();
}
/**
- * Resolves the roles for the given principal based on the provided credentials.
+ * Resolves the roles for the given principal based on the provided credentials and entity.
*
- *
This method checks the credentials for requested roles and loads the principal's grants to
- * determine which roles are currently active for the principal.
+ *
When no entity is available, the method assumes that the principal is not backed by an
+ * entity in the Polaris metastore and resolves the roles from the credentials directly. In this
+ * case, no special treatment is applied to role names, and the pseudo-role {@link
+ * #PRINCIPAL_ROLE_ALL} is ignored.
*
- *
The returned set of roles will include only those roles that the principal has been granted
- * and that match the requested roles from the credentials. If the credentials contain the
- * pseudo-role {@link #PRINCIPAL_ROLE_ALL}, it indicates that the principal is requesting all
- * roles they have been granted in the system, and all such roles will be included in the returned
- * set.
+ *
When an entity is available, this method checks the credentials for requested roles and
+ * loads the principal's grants to determine which roles are currently active for the principal.
+ * The returned set of roles will include only those roles that the principal has been granted and
+ * that match the requested roles from the credentials. If the credentials contain the pseudo-role
+ * {@link #PRINCIPAL_ROLE_ALL}, it indicates that the principal is requesting all roles they have
+ * been granted in the system, and all such roles will be included in the returned set.
*/
protected PrincipalRoleSelection resolvePrincipalRoles(
- PolarisCredential credentials, PrincipalEntity principal) {
+ PolarisCredential credentials, @Nullable PrincipalEntity principal) {
+
+ if (principal == null) {
+ return new PrincipalRoleSelection(credentials.getPrincipalRoles(), false);
+ }
PrincipalRoleSelection requestedRoles = extractRequestedRoles(credentials);
LoadGrantsResult loadGrantsResult = loadPrincipalGrants(principal);
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/PolarisCredential.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/PolarisCredential.java
index 4093a9b18e8..abf16134676 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/PolarisCredential.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/PolarisCredential.java
@@ -21,15 +21,26 @@
import io.quarkus.security.credential.Credential;
import java.util.Set;
import org.apache.polaris.immutables.PolarisImmutable;
+import org.immutables.value.Value;
import org.jspecify.annotations.Nullable;
/**
- * A Quarkus Security {@link Credential} exposing Polaris-specific attributes: the principal id,
- * name, and roles.
+ * A Quarkus Security {@link Credential} exposing Polaris-specific attributes.
+ *
+ *
Credentials where {@link #isExternal()} returns {@code true} represent externally-managed
+ * principals: the authenticator takes the principal name and roles as-is without performing a
+ * metastore lookup. All other credentials — including plain {@link PolarisCredential} instances
+ * returned by a custom token broker — are treated as internal and require a backing principal
+ * entity in the Polaris metastore.
*/
@PolarisImmutable
public interface PolarisCredential extends Credential {
+ /**
+ * Creates a new {@link PolarisCredential} with the given principal id, name and roles. The
+ * returned credential is considered internal, and therefore requires a backing principal entity
+ * in the Polaris metastore.
+ */
static PolarisCredential of(
@Nullable Long principalId, @Nullable String principalName, Set principalRoles) {
return ImmutablePolarisCredential.builder()
@@ -39,10 +50,42 @@ static PolarisCredential of(
.build();
}
- /** The principal id, or null if unknown. Used for principal lookups by id. */
+ /**
+ * Creates a new {@link PolarisCredential} with the given principal name and roles. The returned
+ * credential is considered external, and therefore does not require a backing principal entity in
+ * the Polaris metastore.
+ */
+ static PolarisCredential ofExternal(@Nullable String principalName, Set principalRoles) {
+ return ImmutablePolarisCredential.builder()
+ .principalName(principalName)
+ .principalRoles(principalRoles)
+ .external(true)
+ .build();
+ }
+
+ /**
+ * Whether this credential represents an externally-managed principal not backed by the Polaris
+ * metastore. Defaults to {@code false}.
+ */
+ @Value.Default
+ default boolean isExternal() {
+ return false;
+ }
+
+ /**
+ * The principal id, or null if the credential does not carry one.
+ *
+ * Principal IDs are used solely for principal entity lookups by id in the metastore. Such
+ * lookups are only relevant for internal principals; therefore, the value returned by this method
+ * is always ignored when {@link #isExternal()} is true.
+ */
@Nullable Long getPrincipalId();
- /** The principal name, or null if unknown. Used for principal lookups by name. */
+ /**
+ * The principal name, or null if the credential does not carry one. A name is not guaranteed to
+ * be present here; it is the authenticator's responsibility to validate it and reject credentials
+ * that lack a required name.
+ */
@Nullable String getPrincipalName();
/** The principal roles, or empty if the principal has no roles. */
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentor.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentor.java
index 30fe86cccdc..0ddbaa42681 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentor.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentor.java
@@ -32,6 +32,8 @@
import jakarta.inject.Inject;
import java.util.Set;
import org.apache.polaris.service.auth.AuthenticatingAugmentor;
+import org.apache.polaris.service.auth.AuthenticationRealmConfiguration;
+import org.apache.polaris.service.auth.CredentialMode;
import org.apache.polaris.service.auth.PolarisCredential;
import org.apache.polaris.service.auth.external.mapping.PrincipalMapper;
import org.apache.polaris.service.auth.external.mapping.PrincipalRolesMapper;
@@ -48,13 +50,16 @@ public class OidcPolarisCredentialAugmentor implements SecurityIdentityAugmentor
// must run before the authenticating augmentor
public static final int PRIORITY = AuthenticatingAugmentor.PRIORITY + 100;
+ private final AuthenticationRealmConfiguration authConfig;
private final Instance principalMappers;
private final Instance principalRoleMappers;
@Inject
public OidcPolarisCredentialAugmentor(
+ AuthenticationRealmConfiguration authConfig,
@Any Instance principalMappers,
@Any Instance principalRoleMappers) {
+ this.authConfig = authConfig;
this.principalMappers = principalMappers;
this.principalRoleMappers = principalRoleMappers;
}
@@ -86,11 +91,18 @@ protected SecurityIdentity setPolarisCredential(
SecurityIdentity identity,
PrincipalMapper principalMapper,
PrincipalRolesMapper rolesMapper) {
- Long principalId =
- principalMapper.mapPrincipalId(identity).stream().boxed().findFirst().orElse(null);
String principalName = principalMapper.mapPrincipalName(identity).orElse(null);
Set principalRoles = rolesMapper.mapPrincipalRoles(identity);
- PolarisCredential credential = PolarisCredential.of(principalId, principalName, principalRoles);
+ // Note: we build the credential even if it doesn't contain enough data to authenticate;
+ // DefaultAuthenticator will reject it later on.
+ PolarisCredential credential;
+ if (authConfig.credentialMode() == CredentialMode.INTERNAL) {
+ Long principalId =
+ principalMapper.mapPrincipalId(identity).stream().boxed().findFirst().orElse(null);
+ credential = PolarisCredential.of(principalId, principalName, principalRoles);
+ } else {
+ credential = PolarisCredential.ofExternal(principalName, principalRoles);
+ }
// Note: we don't change the identity roles here, this will be done later on
// by the AuthenticatingAugmentor, which will also validate them.
return QuarkusSecurityIdentity.builder(identity).addCredential(credential).build();
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/external/mapping/PrincipalMapper.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/external/mapping/PrincipalMapper.java
index f552ecb4bf4..ae4cf7161a1 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/external/mapping/PrincipalMapper.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/external/mapping/PrincipalMapper.java
@@ -36,10 +36,10 @@
public interface PrincipalMapper {
/**
- * Maps the {@link SecurityIdentity} to a Polaris principal.
+ * Maps the {@link SecurityIdentity} to a Polaris principal ID.
*
* @param identity the {@link SecurityIdentity} of the user
- * @return the Polaris principal, or an empty optional if no mapping is available
+ * @return the Polaris principal ID, or an empty optional if no mapping is available
*/
OptionalLong mapPrincipalId(SecurityIdentity identity);
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/external/tenant/OidcTenantConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/external/tenant/OidcTenantConfiguration.java
index acf5adce2cb..84480d3249c 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/external/tenant/OidcTenantConfiguration.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/external/tenant/OidcTenantConfiguration.java
@@ -55,7 +55,12 @@ interface PrincipalMapper {
* separator, e.g. {@code "resource_access/client1/roles"} would look for the "roles" field
* inside the "client1" object inside the "resource_access" object in the token claims.
*
- * Optional. Either this option or {@link #idClaimPath()} must be provided.
+ *
Optional when using internal credential mode, in which case either this option or {@link
+ * #idClaimPath()} must be provided.
+ *
+ *
Required when using external credential mode ({@code
+ * polaris.authentication.credential-mode=EXTERNAL}), since external principals are identified
+ * exclusively by name.
*/
Optional nameClaimPath();
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/auth/internal/broker/InternalPolarisToken.java b/runtime/service/src/main/java/org/apache/polaris/service/auth/internal/broker/InternalPolarisToken.java
index d944e11c214..a4f9d35e004 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/auth/internal/broker/InternalPolarisToken.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/auth/internal/broker/InternalPolarisToken.java
@@ -27,8 +27,7 @@
import org.jspecify.annotations.NonNull;
/**
- * A specialized {@link PolarisCredential} used for internal authentication, when Polaris is the
- * identity provider.
+ * A specialized {@link PolarisCredential} produced by Polaris default internal token broker.
*
* Such credentials are created by the Polaris service itself, from a JWT token previously issued
* by Polaris itself.
@@ -50,14 +49,17 @@ static InternalPolarisToken of(
.build();
}
+ @Override
+ public final boolean isExternal() {
+ return false;
+ }
+
@NonNull // switch from nullable to non-nullable
@Override
- @SuppressWarnings("NullableProblems")
public abstract String getPrincipalName();
@NonNull // switch from nullable to non-nullable
@Override
- @SuppressWarnings("NullableProblems")
public abstract Long getPrincipalId();
@Value.Lazy
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ProductionReadinessChecks.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ProductionReadinessChecks.java
index c10af1a8e7e..35429f6db99 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/config/ProductionReadinessChecks.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ProductionReadinessChecks.java
@@ -40,6 +40,8 @@
import org.apache.polaris.service.auth.AuthenticationRealmConfiguration.TokenBrokerConfiguration.RSAKeyPairConfiguration;
import org.apache.polaris.service.auth.AuthenticationRealmConfiguration.TokenBrokerConfiguration.SymmetricKeyConfiguration;
import org.apache.polaris.service.auth.AuthenticationType;
+import org.apache.polaris.service.auth.CredentialMode;
+import org.apache.polaris.service.auth.external.OidcConfiguration;
import org.apache.polaris.service.catalog.validation.IcebergPropertiesValidation;
import org.apache.polaris.service.context.DefaultRealmContextResolver;
import org.apache.polaris.service.context.RealmContextResolver;
@@ -198,6 +200,137 @@ public ProductionReadinessCheck checkTokenBrokers(AuthenticationConfiguration co
return ProductionReadinessCheck.of(errors);
}
+ @Produces
+ public ProductionReadinessCheck checkExternalPrincipals(
+ AuthenticationConfiguration configuration) {
+ List errors = new ArrayList<>();
+ configuration
+ .realms()
+ .forEach(
+ (realm, config) -> {
+ if (config.credentialMode() == CredentialMode.EXTERNAL
+ && config.type() == AuthenticationType.INTERNAL) {
+ errors.add(
+ Error.ofSevere(
+ "Setting principal mode to EXTERNAL when the authentication type is INTERNAL is not allowed.",
+ "polaris.authentication.%scredential-mode"
+ .formatted(authRealmSegment(realm))));
+ }
+ });
+ return ProductionReadinessCheck.of(errors);
+ }
+
+ @Produces
+ public ProductionReadinessCheck checkExternalPrincipalsAuthorizer(
+ AuthenticationConfiguration authenticationConfiguration,
+ AuthorizationConfiguration authorizationConfiguration) {
+ List errors = new ArrayList<>();
+ if (authorizationConfiguration.type().equals("internal")) {
+ authenticationConfiguration
+ .realms()
+ .forEach(
+ (realm, config) -> {
+ if (config.credentialMode() == CredentialMode.EXTERNAL) {
+ errors.add(
+ Error.ofSevere(
+ "Setting principal mode to EXTERNAL when the authorizer is the default (internal) is not allowed.",
+ "polaris.authentication.%scredential-mode"
+ .formatted(authRealmSegment(realm))));
+ }
+ });
+ }
+ return ProductionReadinessCheck.of(errors);
+ }
+
+ @Produces
+ public ProductionReadinessCheck checkOidcPrincipalMapping(
+ AuthenticationConfiguration authConfig, OidcConfiguration oidcConfig) {
+ // Only validate OIDC claim mapping when at least one realm actually uses OIDC (i.e. auth type
+ // is EXTERNAL or MIXED). A purely INTERNAL realm never activates the OIDC augmentor.
+ boolean anyOidc =
+ authConfig.realms().values().stream()
+ .anyMatch(
+ r ->
+ r.type() == AuthenticationType.EXTERNAL
+ || r.type() == AuthenticationType.MIXED);
+ if (!anyOidc) {
+ return ProductionReadinessCheck.OK;
+ }
+ boolean anyExternal =
+ authConfig.realms().values().stream()
+ .anyMatch(
+ r ->
+ (r.type() == AuthenticationType.EXTERNAL
+ || r.type() == AuthenticationType.MIXED)
+ && r.credentialMode() == CredentialMode.EXTERNAL);
+ boolean anyInternal =
+ authConfig.realms().values().stream()
+ .anyMatch(
+ r ->
+ (r.type() == AuthenticationType.EXTERNAL
+ || r.type() == AuthenticationType.MIXED)
+ && r.credentialMode() != CredentialMode.EXTERNAL);
+ List errors = new ArrayList<>();
+ oidcConfig
+ .tenants()
+ .forEach(
+ (tenantId, tenant) -> {
+ if (!"default".equals(tenant.principalMapper().type())) {
+ return;
+ }
+ var pm = tenant.principalMapper();
+ String propPrefix = oidcTenantPropPrefix(tenantId);
+ // Named tenants are only activated at runtime by the tenant resolver; a named tenant
+ // that is never resolved is harmless. Downgrade its findings to warnings so that a
+ // partially-configured named tenant does not block startup.
+ boolean isDefault = OidcConfiguration.DEFAULT_TENANT_KEY.equals(tenantId);
+ if (anyExternal) {
+ if (pm.nameClaimPath().isEmpty()) {
+ errors.add(
+ isDefault
+ ? Error.ofSevere(
+ "name-claim-path must be configured when credential-mode is EXTERNAL,"
+ + " since external principals are identified exclusively by name.",
+ propPrefix + ".name-claim-path")
+ : Error.of(
+ "name-claim-path should be configured when credential-mode is EXTERNAL,"
+ + " since external principals are identified exclusively by name."
+ + " Requests resolved to this tenant will be rejected.",
+ propPrefix + ".name-claim-path"));
+ }
+ if (pm.idClaimPath().isPresent()) {
+ errors.add(
+ Error.of(
+ "id-claim-path is ignored when credential-mode is EXTERNAL and should be"
+ + " removed to avoid confusion.",
+ propPrefix + ".id-claim-path"));
+ }
+ }
+ if (anyInternal) {
+ if (pm.nameClaimPath().isEmpty() && pm.idClaimPath().isEmpty()) {
+ errors.add(
+ isDefault
+ ? Error.ofSevere(
+ "Either name-claim-path or id-claim-path must be configured so that"
+ + " internal principals can be resolved.",
+ propPrefix + ".name-claim-path")
+ : Error.of(
+ "Either name-claim-path or id-claim-path should be configured so that"
+ + " internal principals can be resolved."
+ + " Requests resolved to this tenant will be rejected.",
+ propPrefix + ".name-claim-path"));
+ }
+ }
+ });
+ return ProductionReadinessCheck.of(errors);
+ }
+
+ private static String oidcTenantPropPrefix(String tenantId) {
+ return OidcConfiguration.DEFAULT_TENANT_KEY.equals(tenantId)
+ ? "polaris.oidc.principal-mapper"
+ : "polaris.oidc." + tenantId + ".principal-mapper";
+ }
+
@Produces
public ProductionReadinessCheck checkMetastore(MetaStoreManagerFactory factory) {
if (factory instanceof InMemoryPolarisMetaStoreManagerFactory) {
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 abadc5c2542..9cf31287779 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
@@ -18,11 +18,15 @@
*/
package org.apache.polaris.service.admin;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
+import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.polaris.core.admin.model.CreateCatalogRequest;
import org.apache.polaris.core.admin.model.UpdateCatalogRequest;
import org.apache.polaris.core.admin.model.UpdateCatalogRoleRequest;
@@ -38,6 +42,7 @@
import org.apache.polaris.core.entity.PrincipalRoleEntity;
import org.apache.polaris.service.Profiles;
import org.junit.jupiter.api.DynamicNode;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
@QuarkusTest
@@ -63,6 +68,25 @@ private PolarisAdminService newTestAdminService() {
reservedProperties);
}
+ private PolarisAdminService newExternalTestAdminService(String principalName) {
+ final PolarisPrincipal externalPrincipal =
+ PolarisPrincipal.of(
+ principalName,
+ ImmutableAttributeMap.builder()
+ .put(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY, true)
+ .build(),
+ Set.of());
+ return new PolarisAdminService(
+ callContext,
+ resolutionManifestFactory,
+ metaStoreManager,
+ userSecretsManager,
+ serviceIdentityProvider,
+ externalPrincipal,
+ polarisAuthorizer,
+ reservedProperties);
+ }
+
private PolarisAdminService newTestAdminService(Set activatedPrincipalRoles) {
final PolarisPrincipal authenticatedPrincipal =
PolarisPrincipal.of(
@@ -959,4 +983,31 @@ Stream testGrantPrivilegeOnPolicyToRolePrivileges() {
.shouldPassWith(PolarisPrivilege.CATALOG_MANAGE_ACCESS)
.createTests();
}
+
+ /**
+ * An external principal must not benefit from the "rotate own credentials" self-service shortcut
+ * just because it happens to share a name with a stored principal. External principals are not
+ * backed by the metastore, so a name match is an accidental collision, not genuine self-service;
+ * the rotation must be denied by the authorizer and the stored secret must remain unchanged.
+ */
+ @Test
+ void testExternalPrincipalCannotRotateSameNamedStoredPrincipalCredentials() {
+ String clientId = principalEntity.getClientId();
+ String secretHashBefore =
+ metaStoreManager
+ .loadPrincipalSecrets(polarisContext, clientId)
+ .getPrincipalSecrets()
+ .getMainSecretHash();
+
+ assertThatThrownBy(
+ () -> newExternalTestAdminService(PRINCIPAL_NAME).rotateCredentials(PRINCIPAL_NAME))
+ .isInstanceOf(ForbiddenException.class);
+
+ String secretHashAfter =
+ metaStoreManager
+ .loadPrincipalSecrets(polarisContext, clientId)
+ .getPrincipalSecrets()
+ .getMainSecretHash();
+ assertThat(secretHashAfter).isEqualTo(secretHashBefore);
+ }
}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java
index af1d6bb2fc3..e02d908b22d 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java
@@ -102,6 +102,9 @@ public void setup(TestInfo testInfo) {
PolarisPrincipal.of(
PolarisEntityConstants.getRootPrincipalName(),
ImmutableAttributeMap.builder()
+ .put(
+ PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY,
+ new PrincipalEntity.Builder().setName("root").build())
.put(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY, true)
.build(),
Set.of());
@@ -113,7 +116,7 @@ public void setup(TestInfo testInfo) {
@Test
void testNullPrincipalIdAndName() {
- // Given: credentials with both null principal ID and name
+ // Given: internal credentials with both null principal ID and name
PolarisCredential credentials =
PolarisCredential.of(null, null, Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL));
@@ -123,7 +126,7 @@ void testNullPrincipalIdAndName() {
@Test
void testPrincipalNotFoundByName() {
- // Given: credentials with a non-existent principal name
+ // Given: internal credentials with a non-existent principal name
PolarisCredential credentials =
PolarisCredential.of(
null, "non-existent-principal", Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL));
@@ -134,7 +137,7 @@ void testPrincipalNotFoundByName() {
@Test
void testPrincipalNotFoundById() {
- // Given: credentials with a non-existent principal ID
+ // Given: internal credentials with a non-existent principal ID
PolarisCredential credentials =
PolarisCredential.of(999999L, null, Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL));
@@ -145,7 +148,7 @@ void testPrincipalNotFoundById() {
@Test
public void testFetchPrincipalThrowsServiceExceptionOnMetastoreException() {
- // Given: credentials with a non-existent principal ID
+ // Given: internal credentials with a non-existent principal ID
PolarisCredential credentials =
PolarisCredential.of(123L, null, Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL));
@@ -232,7 +235,7 @@ void testAuthenticationByPrincipalId() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with all assigned roles
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
}
@Test
@@ -246,7 +249,7 @@ void testPrincipalFoundByName() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with all assigned roles
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
}
@Test
@@ -259,7 +262,7 @@ void testPrincipalFoundWithAllRolesRequested() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with all assigned roles
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
assertThat(
result.getAttributes().get(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY))
.isTrue();
@@ -278,7 +281,7 @@ void testPrincipalFoundWithSubsetOfRolesRequested() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with only the requested role
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1);
assertThat(
result.getAttributes().get(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY))
.isFalse();
@@ -299,7 +302,7 @@ void testPrincipalFoundWithMultipleSpecificRolesRequested() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with both requested roles
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
}
@Test
@@ -313,7 +316,7 @@ void testPrincipalFoundButHasNoRolesAssigned() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with empty roles set
- assertPrincipal(result, principalEntityNoRoles);
+ assertInternalPrincipal(result, principalEntityNoRoles);
}
@Test
@@ -361,7 +364,7 @@ void testRolesWithoutPrefixAreIgnored() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with only the properly prefixed role
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1);
}
@Test
@@ -376,7 +379,7 @@ void testEmptyRolesRequestedReturnsEmptyRoles() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal with empty roles set
- assertPrincipal(result, principalEntity);
+ assertInternalPrincipal(result, principalEntity);
assertThat(
result.getAttributes().get(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY))
.isFalse();
@@ -398,7 +401,7 @@ void testResolvePrincipalRolesUsesEntitiesFromLoadGrantsResult() {
PolarisPrincipal result =
newStandaloneAuthenticator(metaStoreManagerSpy).authenticate(identityFor(credentials));
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
// The role entities must have been served from LoadGrantsResult.getEntities(),
// not re-fetched via loadEntity(..., PRINCIPAL_ROLE) per grant record.
@@ -431,7 +434,7 @@ void testResolvePrincipalRolesFallsBackToLoadEntityWhenEntitiesNotPreloaded() {
newStandaloneAuthenticator(metaStoreManagerSpy).authenticate(identityFor(credentials));
// Roles should still resolve — the fallback path must produce the same result.
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
verify(metaStoreManagerSpy, Mockito.times(2))
.loadEntity(any(), anyLong(), anyLong(), Mockito.eq(PolarisEntityType.PRINCIPAL_ROLE));
@@ -450,7 +453,7 @@ void testPrincipalIdTakesPrecedenceOverName() {
PolarisPrincipal result = authenticator.authenticate(identityFor(credentials));
// Then: should return principal resolved by ID, not name
- assertPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
+ assertInternalPrincipal(result, principalEntity, PRINCIPAL_ROLE1, PRINCIPAL_ROLE2);
}
@Test
@@ -510,6 +513,65 @@ void testInputIdentityAttributesNotMerged() {
.isFalse();
}
+ @Test
+ void testExternalPrincipal() {
+ // Given: an external credential (isExternal=true) carries name + roles,
+ // so the authenticator takes the external path with no metastore lookup
+ PolarisMetaStoreManager metaStoreManagerSpy = Mockito.spy(metaStoreManager);
+ DefaultAuthenticator sa = newStandaloneAuthenticator(metaStoreManagerSpy);
+ JsonWebToken jwt = Mockito.mock(JsonWebToken.class);
+ Mockito.when(jwt.getName()).thenReturn(PRINCIPAL_NAME);
+ Mockito.when(jwt.getRawToken()).thenReturn("raw.jwt.token");
+
+ PolarisCredential credentials =
+ PolarisCredential.ofExternal("ext-user", Set.of("ext-role1", "ext-role2"));
+ SecurityIdentity jwtIdentity =
+ QuarkusSecurityIdentity.builder()
+ .setAnonymous(false)
+ .setPrincipal(jwt)
+ .addCredential(credentials)
+ .build();
+
+ // When: authenticating
+ PolarisPrincipal result = sa.authenticate(jwtIdentity);
+
+ // Then: an external principal is built from the token with no metastore lookup, and its roles
+ // are taken as-is from the credentials
+ assertThat(result.getName()).isEqualTo("ext-user");
+ assertThat(result.getRoles()).containsExactlyInAnyOrder("ext-role1", "ext-role2");
+ ImmutableAttributeMap attributes = result.getAttributes();
+ assertThat(attributes.containsKey(PolarisPrincipalAttributes.PRINCIPAL_ENTITY_ATTRIBUTE_KEY))
+ .isFalse();
+ assertThat(attributes.containsKey(PolarisPrincipalAttributes.PRINCIPAL_ROLE_ALL_ATTRIBUTE_KEY))
+ .isFalse();
+ assertThat(attributes.get(PolarisPrincipalAttributes.EXTERNAL_PRINCIPAL_ATTRIBUTE_KEY))
+ .isTrue();
+ assertThat(attributes.get(PolarisPrincipalAttributes.JWT_ATTRIBUTE_KEY))
+ .isEqualTo("raw.jwt.token");
+ Mockito.verifyNoInteractions(metaStoreManagerSpy);
+ }
+
+ @Test
+ void testExternalPrincipalWithoutName() {
+ // Given: an external credential with no principal name (e.g. an id-only OIDC mapping)
+ PolarisMetaStoreManager metaStoreManagerSpy = Mockito.spy(metaStoreManager);
+ DefaultAuthenticator sa = newStandaloneAuthenticator(metaStoreManagerSpy);
+ JsonWebToken jwt = Mockito.mock(JsonWebToken.class);
+ PolarisCredential credentials = PolarisCredential.ofExternal(null, Set.of("ext-role1"));
+ SecurityIdentity jwtIdentity =
+ QuarkusSecurityIdentity.builder()
+ .setAnonymous(false)
+ .setPrincipal(jwt)
+ .addCredential(credentials)
+ .build();
+
+ // When / Then: the missing name is rejected as an authentication failure, with no metastore
+ // lookup
+ assertThatThrownBy(() -> sa.authenticate(jwtIdentity))
+ .isInstanceOf(AuthenticationFailedException.class);
+ Mockito.verifyNoInteractions(metaStoreManagerSpy);
+ }
+
private PrincipalEntity createPrincipal(String name, String... roles) {
PrincipalWithCredentialsCredentials credentials =
@@ -553,7 +615,8 @@ private PolarisAdminService newAdminService() {
reservedProperties);
}
- private void assertPrincipal(PolarisPrincipal result, PrincipalEntity entity, String... roles) {
+ private void assertInternalPrincipal(
+ PolarisPrincipal result, PrincipalEntity entity, String... roles) {
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo(entity.getName());
assertThat(result.getRoles()).containsExactlyInAnyOrder(roles);
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentorTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentorTest.java
index 4a583e75112..2e4774a337a 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentorTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentorTest.java
@@ -32,6 +32,8 @@
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
+import org.apache.polaris.service.auth.AuthenticationRealmConfiguration;
+import org.apache.polaris.service.auth.CredentialMode;
import org.apache.polaris.service.auth.PolarisCredential;
import org.apache.polaris.service.auth.external.tenant.OidcTenantConfiguration;
import org.apache.polaris.service.auth.external.tenant.OidcTenantConfiguration.PrincipalMapper;
@@ -43,6 +45,7 @@
class OidcPolarisCredentialAugmentorTest {
private OidcPolarisCredentialAugmentor augmentor;
+ private AuthenticationRealmConfiguration authConfig;
private org.apache.polaris.service.auth.external.mapping.PrincipalMapper principalMapper;
private org.apache.polaris.service.auth.external.mapping.PrincipalRolesMapper
principalRolesMapper;
@@ -69,7 +72,9 @@ public void setup() {
when(principalRoleMappers.select(Identifier.Literal.of("default")))
.thenReturn(principalRoleMappers);
when(principalRoleMappers.get()).thenReturn(principalRolesMapper);
- augmentor = new OidcPolarisCredentialAugmentor(principalMappers, principalRoleMappers);
+ authConfig = mock(AuthenticationRealmConfiguration.class);
+ augmentor =
+ new OidcPolarisCredentialAugmentor(authConfig, principalMappers, principalRoleMappers);
}
@Test
@@ -100,8 +105,9 @@ public void testAugmentNonOidcPrincipal() {
}
@Test
- public void testAugmentOidcPrincipal() {
+ public void testAugmentOidcInternalPrincipal() {
// Given
+ when(authConfig.credentialMode()).thenReturn(CredentialMode.INTERNAL);
JsonWebToken oidcPrincipal = mock(JsonWebToken.class);
SecurityIdentity identity =
QuarkusSecurityIdentity.builder()
@@ -125,4 +131,55 @@ public void testAugmentOidcPrincipal() {
// the identity roles should not change, since this is done by the ActiveRolesAugmentor
assertThat(result.getRoles()).containsExactlyInAnyOrder("ROLE1");
}
+
+ @Test
+ public void testAugmentOidcExternalPrincipal() {
+ // Given
+ when(authConfig.credentialMode()).thenReturn(CredentialMode.EXTERNAL);
+ JsonWebToken oidcPrincipal = mock(JsonWebToken.class);
+ SecurityIdentity identity =
+ QuarkusSecurityIdentity.builder()
+ .setPrincipal(oidcPrincipal)
+ .addRole("ROLE1")
+ .addAttribute(TENANT_CONFIG_ATTRIBUTE, config)
+ .build();
+ when(principalMapper.mapPrincipalName(identity)).thenReturn(Optional.of("alice"));
+ when(principalRolesMapper.mapPrincipalRoles(identity)).thenReturn(Set.of("MAPPED_ROLE1"));
+
+ // When
+ SecurityIdentity result =
+ augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely();
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getPrincipal()).isSameAs(oidcPrincipal);
+ assertThat(result.getCredential(PolarisCredential.class))
+ .isEqualTo(PolarisCredential.ofExternal("alice", Set.of("MAPPED_ROLE1")));
+ // the identity roles should not change, since this is done by the ActiveRolesAugmentor
+ assertThat(result.getRoles()).containsExactlyInAnyOrder("ROLE1");
+ }
+
+ @Test
+ public void testAugmentOidcExternalPrincipalWithoutName() {
+ // Given: external mode but the principal mapper cannot resolve a name
+ when(authConfig.credentialMode()).thenReturn(CredentialMode.EXTERNAL);
+ JsonWebToken oidcPrincipal = mock(JsonWebToken.class);
+ SecurityIdentity identity =
+ QuarkusSecurityIdentity.builder()
+ .setPrincipal(oidcPrincipal)
+ .addRole("ROLE1")
+ .addAttribute(TENANT_CONFIG_ATTRIBUTE, config)
+ .build();
+ when(principalMapper.mapPrincipalName(identity)).thenReturn(Optional.empty());
+ when(principalRolesMapper.mapPrincipalRoles(identity)).thenReturn(Set.of("MAPPED_ROLE1"));
+
+ // When: the augmentor builds a credential with a null name (no NPE from the builder); it is
+ // DefaultAuthenticator that later rejects the missing name.
+ SecurityIdentity result =
+ augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely();
+
+ // Then
+ assertThat(result.getCredential(PolarisCredential.class))
+ .isEqualTo(PolarisCredential.ofExternal(null, Set.of("MAPPED_ROLE1")));
+ }
}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/AbstractOidcAuthTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/AbstractOidcAuthTest.java
new file mode 100644
index 00000000000..7cd80172868
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/AbstractOidcAuthTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.auth.oidc;
+
+import static io.restassured.RestAssured.given;
+
+import com.google.common.collect.ImmutableMap;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.oidc.server.OidcWiremockTestResource;
+import io.restassured.specification.RequestSpecification;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.polaris.service.it.env.PolarisApiEndpoints;
+import org.apache.polaris.service.it.ext.PolarisIntegrationTestExtension;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Shared harness for authorization tests that exercise the external OIDC authentication pipeline.
+ * Identities are minted as real signed bearer tokens by an in-JVM WireMock OIDC server ({@link
+ * OidcWiremockTestResource}).
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@ExtendWith(PolarisIntegrationTestExtension.class)
+public abstract class AbstractOidcAuthTest {
+
+ public abstract static class Profile implements QuarkusTestProfile {
+
+ /**
+ * Config shared by all OIDC auth tests: external authentication backed by the WireMock OIDC
+ * server, mapping the {@code preferred_username} claim to the principal name. {@code
+ * keycloak.url} is published by {@link OidcWiremockTestResource} and already ends with {@code
+ * /auth}; the mock serves the OIDC discovery document under {@code
+ * {keycloak.url}/realms/quarkus}.
+ */
+ @Override
+ public Map getConfigOverrides() {
+ ImmutableMap.Builder config = ImmutableMap.builder();
+ config.put("quarkus.oidc.tenant-enabled", "true");
+ config.put("quarkus.oidc.auth-server-url", "${keycloak.url}/realms/quarkus");
+ config.put("quarkus.oidc.client-id", "quarkus-app");
+ config.put("polaris.authentication.type", "external");
+ config.put("polaris.oidc.principal-mapper.name-claim-path", "preferred_username");
+ config.put("polaris.readiness.ignore-severe-issues", "true");
+ return config.build();
+ }
+
+ @Override
+ public List testResources() {
+ return List.of(new TestResourceEntry(OidcWiremockTestResource.class));
+ }
+ }
+
+ /**
+ * Builds a request bearing a freshly minted OIDC token for {@code user} carrying {@code roles}.
+ */
+ protected RequestSpecification asUser(
+ PolarisApiEndpoints endpoints, String user, Set roles) {
+ String token = OidcWiremockTestResource.getAccessToken(user, roles);
+ return asUser(endpoints, token);
+ }
+
+ /**
+ * Builds a request bearing the given OIDC {@code token}.
+ *
+ * Note that this method does not validate the token; it is assumed to be valid and signed by
+ * the WireMock OIDC server.
+ */
+ protected RequestSpecification asUser(PolarisApiEndpoints endpoints, String token) {
+ RequestSpecification request = given().auth().oauth2(token);
+ for (Map.Entry header : endpoints.extraHeaders().entrySet()) {
+ request = request.header(header.getKey(), header.getValue());
+ }
+ return request;
+ }
+
+ /** The Polaris management "list catalogs" endpoint — a simple authenticated+authorized call. */
+ protected String listCatalogs(PolarisApiEndpoints endpoints) {
+ return endpoints.managementApiEndpoint() + "/v1/catalogs";
+ }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/ExternalPrincipalOidcAuthTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/ExternalPrincipalOidcAuthTest.java
new file mode 100644
index 00000000000..0fa16c10745
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/ExternalPrincipalOidcAuthTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.auth.oidc;
+
+import com.google.common.collect.ImmutableMap;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+import io.smallrye.common.annotation.Identifier;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.util.Map;
+import java.util.Set;
+import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
+import org.apache.polaris.service.it.env.PolarisApiEndpoints;
+import org.junit.jupiter.api.Test;
+
+/**
+ * OIDC tests for external principals: an OIDC-authenticated caller that has no backing
+ * entity in the Polaris metastore ({@code credential-mode=external}), authorized by a non-default
+ * authorizer.
+ */
+@QuarkusTest
+@TestProfile(ExternalPrincipalOidcAuthTest.Profile.class)
+public class ExternalPrincipalOidcAuthTest extends AbstractOidcAuthTest {
+
+ public static class Profile extends AbstractOidcAuthTest.Profile {
+
+ @Override
+ public Map getConfigOverrides() {
+ ImmutableMap.Builder config = ImmutableMap.builder();
+ config.putAll(super.getConfigOverrides());
+ config.put("polaris.authentication.credential-mode", "external");
+ config.put("polaris.authorization.type", "test");
+ return config.build();
+ }
+
+ @ApplicationScoped
+ @Identifier("test")
+ public PolarisAuthorizerFactory getAuthorizerFactory() {
+ return realmConfig -> new TestPolarisAuthorizer();
+ }
+ }
+
+ @Test
+ void allowedExternalPrincipal(PolarisApiEndpoints endpoints) {
+ asUser(endpoints, "alice", Set.of("admin")).get(listCatalogs(endpoints)).then().statusCode(200);
+ }
+
+ @Test
+ void unauthenticatedExternalPrincipal(PolarisApiEndpoints endpoints) {
+ // Invalid OIDC token (not signed by the WireMock OIDC server)
+ asUser(endpoints, "bad-token").get(listCatalogs(endpoints)).then().statusCode(401);
+ }
+
+ @Test
+ void unauthorizedExternalPrincipal(PolarisApiEndpoints endpoints) {
+ // Bad username
+ asUser(endpoints, "denied_bob", Set.of("admin"))
+ .get(listCatalogs(endpoints))
+ .then()
+ .statusCode(403);
+ // Bad roles
+ asUser(endpoints, "bob", Set.of("denied_role"))
+ .get(listCatalogs(endpoints))
+ .then()
+ .statusCode(403);
+ }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/InternalPrincipalOidcAuthTest.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/InternalPrincipalOidcAuthTest.java
new file mode 100644
index 00000000000..5d4639700eb
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/InternalPrincipalOidcAuthTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.auth.oidc;
+
+import com.google.common.collect.ImmutableMap;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+import io.quarkus.test.oidc.server.OidcWiremockTestResource;
+import java.util.Map;
+import java.util.Set;
+import org.apache.polaris.service.auth.DefaultAuthenticator;
+import org.apache.polaris.service.it.env.ManagementApi;
+import org.apache.polaris.service.it.env.PolarisApiEndpoints;
+import org.apache.polaris.service.it.env.PolarisClient;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * OIDC tests for internal principals: an OIDC-authenticated caller mapped to a Polaris
+ * principal that does exist in the metastore ({@code credential-mode=internal}),
+ * authorized by the built-in {@code internal} authorizer via its grants.
+ */
+@QuarkusTest
+@TestProfile(InternalPrincipalOidcAuthTest.Profile.class)
+public class InternalPrincipalOidcAuthTest extends AbstractOidcAuthTest {
+
+ /** An ad-hoc principal created with no principal roles, hence no privileges on any securable. */
+ private static final String UNPRIVILEGED_PRINCIPAL = "unprivileged-user";
+
+ public static class Profile extends AbstractOidcAuthTest.Profile {
+
+ @Override
+ public Map getConfigOverrides() {
+ ImmutableMap.Builder config = ImmutableMap.builder();
+ config.putAll(super.getConfigOverrides());
+ config.put("polaris.authentication.credential-mode", "internal");
+ return config.build();
+ }
+ }
+
+ private PolarisApiEndpoints endpoints;
+ private PolarisClient client;
+
+ @BeforeAll
+ void createUnprivilegedPrincipal(PolarisApiEndpoints endpoints) {
+ this.endpoints = endpoints;
+ client = PolarisClient.polarisClient(endpoints);
+ // Create an unprivileged principal with no principal roles: the principal exists in the
+ // metastore (so it authenticates) but holds no grants (so authorization must deny it).
+ managementApiAsRoot().createPrincipal(UNPRIVILEGED_PRINCIPAL);
+ }
+
+ @AfterAll
+ void deleteUnprivilegedPrincipal() throws Exception {
+ if (client != null) {
+ managementApiAsRoot().deletePrincipal(UNPRIVILEGED_PRINCIPAL);
+ client.close();
+ }
+ }
+
+ private ManagementApi managementApiAsRoot() {
+ String rootToken =
+ OidcWiremockTestResource.getAccessToken(
+ "root", Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL));
+ return client.managementApi(rootToken);
+ }
+
+ @Test
+ void allowedInternalPrincipal() {
+ asUser(endpoints, "root", Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL))
+ .get(listCatalogs(endpoints))
+ .then()
+ .statusCode(200);
+ }
+
+ @Test
+ void unauthenticatedInternalPrincipal() {
+ // Internal-principal mode requires a backing metastore entity; a valid token for a principal
+ // that does not exist must fail authentication (401), not merely authorization (403).
+ asUser(endpoints, "non-existent", Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL))
+ .get(listCatalogs(endpoints))
+ .then()
+ .statusCode(401);
+ // Non-existent role should also fail authentication (401)
+ asUser(endpoints, "root", Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_PREFIX + "non-existent"))
+ .get(listCatalogs(endpoints))
+ .then()
+ .statusCode(401);
+ }
+
+ @Test
+ void unauthorizedInternalPrincipal() {
+ // A known principal with no grants authenticates successfully, but the internal authorizer
+ // denies the operation for lack of privileges (403, not 401).
+ asUser(endpoints, UNPRIVILEGED_PRINCIPAL, Set.of(DefaultAuthenticator.PRINCIPAL_ROLE_ALL))
+ .get(listCatalogs(endpoints))
+ .then()
+ .statusCode(403);
+ }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/TestPolarisAuthorizer.java b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/TestPolarisAuthorizer.java
new file mode 100644
index 00000000000..fa07f2262bc
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/auth/oidc/TestPolarisAuthorizer.java
@@ -0,0 +1,67 @@
+/*
+ * 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.auth.oidc;
+
+import java.util.Set;
+import org.apache.polaris.core.auth.AuthorizationDecision;
+import org.apache.polaris.core.auth.AuthorizationRequest;
+import org.apache.polaris.core.auth.AuthorizationState;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.persistence.resolver.Resolvable;
+import org.jspecify.annotations.NonNull;
+
+/**
+ * A minimal authorizer used only in tests. It mirrors the shape of a real external authorizer: it
+ * resolves the manifest with minimal selections, ignores Polaris grants, and decides purely from
+ * the principal identity.
+ *
+ * By default, a principal whose name starts with {@value #DENY_PREFIX} or who has any role
+ * starting with {@value #DENY_PREFIX} is denied; all others are allowed.
+ */
+public class TestPolarisAuthorizer implements PolarisAuthorizer {
+
+ public static final String DENY_PREFIX = "denied";
+
+ @Override
+ public void resolveAuthorizationInputs(
+ @NonNull AuthorizationState authzState, @NonNull AuthorizationRequest request) {
+ // nothing to resolve for this oidc authorizer, but the manifest must be in resolved state to
+ // avoid errors in the authorization decision phase.
+ authzState
+ .getResolutionManifest()
+ .resolveSelections(Set.of(Resolvable.REQUESTED_TOP_LEVEL_ENTITIES));
+ }
+
+ @Override
+ @NonNull
+ public AuthorizationDecision authorize(
+ @NonNull AuthorizationState authzState, @NonNull AuthorizationRequest request) {
+ return isAllowed(request.principal())
+ ? AuthorizationDecision.allow()
+ : AuthorizationDecision.deny(
+ "Test authorizer denied principal " + request.principal().getName());
+ }
+
+ protected boolean isAllowed(PolarisPrincipal principal) {
+ return principal.getName() != null
+ && !principal.getName().startsWith(DENY_PREFIX)
+ && principal.getRoles().stream().noneMatch(role -> role.startsWith(DENY_PREFIX));
+ }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/config/ProductionReadinessChecksTest.java b/runtime/service/src/test/java/org/apache/polaris/service/config/ProductionReadinessChecksTest.java
index f542e86c8f0..fe0ccde3ea8 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/config/ProductionReadinessChecksTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/config/ProductionReadinessChecksTest.java
@@ -19,15 +19,26 @@
package org.apache.polaris.service.config;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import java.util.Map;
+import java.util.Optional;
import org.apache.polaris.core.config.ProductionReadinessCheck;
+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.CredentialMode;
+import org.apache.polaris.service.auth.external.OidcConfiguration;
+import org.apache.polaris.service.auth.external.tenant.OidcTenantConfiguration;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigValue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
@@ -74,6 +85,294 @@ void reflectionFreeSerializersEnabledReturnsSevereError() {
});
}
+ @Test
+ void externalPrincipalsWithExternalTypeReturnsOk() {
+ ProductionReadinessCheck result =
+ checks.checkExternalPrincipals(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void externalPrincipalsDisabledReturnsOk() {
+ ProductionReadinessCheck result =
+ checks.checkExternalPrincipals(
+ authenticationConfig(AuthenticationType.INTERNAL, CredentialMode.INTERNAL));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void externalPrincipalsWithInternalAuthenticationReturnsSevereError() {
+ ProductionReadinessCheck result =
+ checks.checkExternalPrincipals(
+ authenticationConfig(AuthenticationType.INTERNAL, CredentialMode.EXTERNAL));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.authentication.credential-mode");
+ assertThat(error.severe()).isTrue();
+ });
+ }
+
+ @ParameterizedTest
+ @EnumSource(AuthenticationType.class)
+ void internalPrincipalsWithInternalAuthorizerReturnsOk(AuthenticationType type) {
+ ProductionReadinessCheck result =
+ checks.checkExternalPrincipalsAuthorizer(
+ authenticationConfig(type, CredentialMode.INTERNAL), authorizationConfig("internal"));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void externalPrincipalsWithNonInternalAuthorizerReturnsOk() {
+ ProductionReadinessCheck result =
+ checks.checkExternalPrincipalsAuthorizer(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ authorizationConfig("ranger"));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void externalPrincipalsWithInternalAuthorizerReturnsSevereError() {
+ ProductionReadinessCheck result =
+ checks.checkExternalPrincipalsAuthorizer(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ authorizationConfig("internal"));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.authentication.credential-mode");
+ assertThat(error.severe()).isTrue();
+ });
+ }
+
+ @Test
+ void oidcMappingWithInternalAuthTypeReturnsOk() {
+ // OIDC is not involved; the check should be skipped regardless of claim-path config
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.INTERNAL, CredentialMode.INTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.empty(),
+ Optional.empty()));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void oidcMappingExternalModeWithNameClaimPathReturnsOk() {
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.of("preferred_username"),
+ Optional.empty()));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void oidcMappingExternalModeWithoutNameClaimPathReturnsSevereError() {
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.empty(),
+ Optional.empty()));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.oidc.principal-mapper.name-claim-path");
+ assertThat(error.severe()).isTrue();
+ });
+ }
+
+ @Test
+ void oidcMappingExternalModeWithIdClaimPathPresentReturnsWarning() {
+ // name-claim-path is set (required) but id-claim-path is also set (ignored in external mode)
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.of("preferred_username"),
+ Optional.of("sub")));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.oidc.principal-mapper.id-claim-path");
+ assertThat(error.severe()).isFalse();
+ });
+ }
+
+ @Test
+ void oidcMappingInternalModeWithNameClaimPathReturnsOk() {
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.INTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.of("preferred_username"),
+ Optional.empty()));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void oidcMappingInternalModeWithIdClaimPathReturnsOk() {
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.INTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.empty(),
+ Optional.of("sub")));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void oidcMappingInternalModeWithoutAnyPathReturnsSevereError() {
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.INTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.empty(),
+ Optional.empty()));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.oidc.principal-mapper.name-claim-path");
+ assertThat(error.severe()).isTrue();
+ });
+ }
+
+ @Test
+ void oidcMappingNamedTenantWithoutNameClaimPathReturnsWarningNotSevere() {
+ // Named tenants are only activated at runtime; misconfiguration is a warning, not a blocker
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ oidcConfig("idp1", "default", Optional.empty(), Optional.empty()));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.oidc.idp1.principal-mapper.name-claim-path");
+ assertThat(error.severe()).isFalse();
+ });
+ }
+
+ @Test
+ void oidcMappingWithCustomMapperTypeSkipsValidation() {
+ // Custom mappers handle their own name resolution; no check is applied
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.EXTERNAL, CredentialMode.EXTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "custom",
+ Optional.empty(),
+ Optional.empty()));
+
+ assertThat(result.ready()).isTrue();
+ }
+
+ @Test
+ void oidcMappingMixedAuthTypeExternalModeWithoutNameClaimPathReturnsSevereError() {
+ ProductionReadinessCheck result =
+ checks.checkOidcPrincipalMapping(
+ authenticationConfig(AuthenticationType.MIXED, CredentialMode.EXTERNAL),
+ oidcConfig(
+ OidcConfiguration.DEFAULT_TENANT_KEY,
+ "default",
+ Optional.empty(),
+ Optional.empty()));
+
+ assertThat(result.ready()).isFalse();
+ assertThat(result.getErrors())
+ .singleElement()
+ .satisfies(
+ error -> {
+ assertThat(error.offendingProperty())
+ .isEqualTo("polaris.oidc.principal-mapper.name-claim-path");
+ assertThat(error.severe()).isTrue();
+ });
+ }
+
+ private static OidcConfiguration oidcConfig(
+ String tenantId,
+ String mapperType,
+ Optional nameClaimPath,
+ Optional idClaimPath) {
+ OidcTenantConfiguration.PrincipalMapper pm =
+ mock(OidcTenantConfiguration.PrincipalMapper.class);
+ lenient().when(pm.type()).thenReturn(mapperType);
+ lenient().when(pm.nameClaimPath()).thenReturn(nameClaimPath);
+ lenient().when(pm.idClaimPath()).thenReturn(idClaimPath);
+ OidcTenantConfiguration tenant = mock(OidcTenantConfiguration.class);
+ lenient().when(tenant.principalMapper()).thenReturn(pm);
+ OidcConfiguration config = mock(OidcConfiguration.class);
+ lenient().when(config.tenants()).thenReturn(Map.of(tenantId, tenant));
+ return config;
+ }
+
+ private static AuthorizationConfiguration authorizationConfig(String type) {
+ AuthorizationConfiguration config = mock(AuthorizationConfiguration.class);
+ lenient().when(config.type()).thenReturn(type);
+ return config;
+ }
+
+ private static AuthenticationConfiguration authenticationConfig(
+ AuthenticationType type, CredentialMode mode) {
+ AuthenticationRealmConfiguration realmConfig = mock(AuthenticationRealmConfiguration.class);
+ lenient().when(realmConfig.type()).thenReturn(type);
+ lenient().when(realmConfig.credentialMode()).thenReturn(mode);
+ AuthenticationConfiguration config = mock(AuthenticationConfiguration.class);
+ lenient()
+ .when(config.realms())
+ .thenReturn(Map.of(AuthenticationConfiguration.DEFAULT_REALM_KEY, realmConfig));
+ return config;
+ }
+
private static Config configWithReflectionFreeSerializers(String value) {
Config config = mock(Config.class);
ConfigValue configValue = mock(ConfigValue.class);
diff --git a/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_authentication.md b/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_authentication.md
index 8328c276537..b794fe3dc96 100644
--- a/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_authentication.md
+++ b/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_authentication.md
@@ -26,6 +26,7 @@ build:
| Property | Default Value | Type | Description |
|----------|---------------|------|-------------|
| `polaris.authentication.type` | `internal` | `enum (INTERNAL, EXTERNAL, MIXED)` | The type of authentication to use. |
+| `polaris.authentication.credential-mode` | `internal` | `enum (INTERNAL, EXTERNAL)` | The credential mode to use. Valid values are `INTERNAL` and `EXTERNAL`.
Internal mode is the default. When internal mode is used, callers must have a corresponding principal in the Polaris metastore, which serves as the authoritative source of truth for principals, roles, and grants.
When external mode is used, callers are authenticated entirely from the presented credentials and are not required to have a corresponding entity in the Polaris metastore. Note that when the presented credentials were issued by Polaris itself, a backing principal entity is always required, regardless of the credential mode.
External credential mode is only meaningful when an external IDP is used, that is, when the authentication type (#type()) is (`AuthenticationType#EXTERNAL`) or (`AuthenticationType#MIXED`). Also, this mode is not compatible with the default, built-in Polaris authorizer; an external authorizer (e.g., OPA or Ranger) must be configured. |
| `polaris.authentication.authenticator.type` | `default` | `string` | The type of the identity provider. Must be a registered (`Authenticator`) identifier. |
| `polaris.authentication.token-service.type` | `default` | `string` | The type of the OAuth2 service. Must be a registered (`IcebergRestOAuth2ApiService`) identifier. |
| `polaris.authentication.token-broker.max-token-generation` | `PT1H` | `duration` | The maximum token duration. |
@@ -35,6 +36,7 @@ build:
| `polaris.authentication.token-broker.symmetric-key.secret` | | `string` | The secret to use for both signing and verifying signatures. Either this option of (`#file()`) must be provided. |
| `polaris.authentication.token-broker.symmetric-key.file` | | `path` | The file to read the secret from. Either this option of (`#secret()`) must be provided. |
| `polaris.authentication.`_``_`.type` | `internal` | `enum (INTERNAL, EXTERNAL, MIXED)` | The type of authentication to use. |
+| `polaris.authentication.`_``_`.credential-mode` | `internal` | `enum (INTERNAL, EXTERNAL)` | The credential mode to use. Valid values are `INTERNAL` and `EXTERNAL`.
Internal mode is the default. When internal mode is used, callers must have a corresponding principal in the Polaris metastore, which serves as the authoritative source of truth for principals, roles, and grants.
When external mode is used, callers are authenticated entirely from the presented credentials and are not required to have a corresponding entity in the Polaris metastore. Note that when the presented credentials were issued by Polaris itself, a backing principal entity is always required, regardless of the credential mode.
External credential mode is only meaningful when an external IDP is used, that is, when the authentication type (#type()) is (`AuthenticationType#EXTERNAL`) or (`AuthenticationType#MIXED`). Also, this mode is not compatible with the default, built-in Polaris authorizer; an external authorizer (e.g., OPA or Ranger) must be configured. |
| `polaris.authentication.`_``_`.authenticator.type` | `default` | `string` | The type of the identity provider. Must be a registered (`Authenticator`) identifier. |
| `polaris.authentication.`_``_`.token-service.type` | `default` | `string` | The type of the OAuth2 service. Must be a registered (`IcebergRestOAuth2ApiService`) identifier. |
| `polaris.authentication.`_``_`.token-broker.max-token-generation` | `PT1H` | `duration` | The maximum token duration. |
diff --git a/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_oidc.md b/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_oidc.md
index 05af0f6671a..ade96c7abe4 100644
--- a/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_oidc.md
+++ b/site/content/in-dev/unreleased/configuration/config-sections/smallrye-polaris_oidc.md
@@ -28,7 +28,7 @@ Polaris-specific configuration for OIDC tenants.
| Property | Default Value | Type | Description |
|----------|---------------|------|-------------|
| `polaris.oidc.principal-mapper.id-claim-path` | | `string` | The path to the claim that contains the principal ID. Nested paths can be expressed using "/" as a separator, e.g. `"resource_access/client1/roles"` would look for the "roles" field inside the "client1" object inside the "resource_access" object in the token claims.
Optional. Either this option or (`#nameClaimPath()`) must be provided. |
-| `polaris.oidc.principal-mapper.name-claim-path` | | `string` | The claim that contains the principal name. Nested paths can be expressed using "/" as a separator, e.g. `"resource_access/client1/roles"` would look for the "roles" field inside the "client1" object inside the "resource_access" object in the token claims.
Optional. Either this option or (`#idClaimPath()`) must be provided. |
+| `polaris.oidc.principal-mapper.name-claim-path` | | `string` | The claim that contains the principal name. Nested paths can be expressed using "/" as a separator, e.g. `"resource_access/client1/roles"` would look for the "roles" field inside the "client1" object inside the "resource_access" object in the token claims.
Optional when using internal credential mode, in which case either this option or (`#idClaimPath()`) must be provided.
Required when using external credential mode ( `polaris.authentication.credential-mode=EXTERNAL` ), since external principals are identified exclusively by name. |
| `polaris.oidc.principal-mapper.type` | `default` | `string` | The type of the principal mapper. Must be a registered (`org.apache.polaris.service.auth.external.mapping.PrincipalMapper`) identifier. |
| `polaris.oidc.principal-roles-mapper.type` | `default` | `string` | The type of the principal roles mapper. Must be a registered (`org.apache.polaris.service.auth.external.mapping.PrincipalRolesMapper`) identifier. |
| `polaris.oidc.principal-roles-mapper.filter` | | `string` | A regular expression that matches the role names in the identity. Only roles that match this regex will be included in the Polaris-specific roles. |
@@ -36,7 +36,7 @@ Polaris-specific configuration for OIDC tenants.
| `polaris.oidc.principal-roles-mapper.mappings.regex` | | `string` | A regular expression that will be applied to each role name in the identity. Along with (`#replacement()`), this regex is used to transform the role names in the identity into Polaris-specific roles. |
| `polaris.oidc.principal-roles-mapper.mappings.replacement` | | `string` | The replacement string for the role names in the identity. This is used along with (`#regex()`) to transform the role names in the identity into Polaris-specific roles. |
| `polaris.oidc.`_``_`.principal-mapper.id-claim-path` | | `string` | The path to the claim that contains the principal ID. Nested paths can be expressed using "/" as a separator, e.g. `"resource_access/client1/roles"` would look for the "roles" field inside the "client1" object inside the "resource_access" object in the token claims.
Optional. Either this option or (`#nameClaimPath()`) must be provided. |
-| `polaris.oidc.`_``_`.principal-mapper.name-claim-path` | | `string` | The claim that contains the principal name. Nested paths can be expressed using "/" as a separator, e.g. `"resource_access/client1/roles"` would look for the "roles" field inside the "client1" object inside the "resource_access" object in the token claims.
Optional. Either this option or (`#idClaimPath()`) must be provided. |
+| `polaris.oidc.`_``_`.principal-mapper.name-claim-path` | | `string` | The claim that contains the principal name. Nested paths can be expressed using "/" as a separator, e.g. `"resource_access/client1/roles"` would look for the "roles" field inside the "client1" object inside the "resource_access" object in the token claims.
Optional when using internal credential mode, in which case either this option or (`#idClaimPath()`) must be provided.
Required when using external credential mode ( `polaris.authentication.credential-mode=EXTERNAL` ), since external principals are identified exclusively by name. |
| `polaris.oidc.`_``_`.principal-mapper.type` | `default` | `string` | The type of the principal mapper. Must be a registered (`org.apache.polaris.service.auth.external.mapping.PrincipalMapper`) identifier. |
| `polaris.oidc.`_``_`.principal-roles-mapper.type` | `default` | `string` | The type of the principal roles mapper. Must be a registered (`org.apache.polaris.service.auth.external.mapping.PrincipalRolesMapper`) identifier. |
| `polaris.oidc.`_``_`.principal-roles-mapper.filter` | | `string` | A regular expression that matches the role names in the identity. Only roles that match this regex will be included in the Polaris-specific roles. |
diff --git a/site/content/in-dev/unreleased/managing-security/external-idp/_index.md b/site/content/in-dev/unreleased/managing-security/external-idp/_index.md
index 4ccecbadfd7..85784a06e28 100644
--- a/site/content/in-dev/unreleased/managing-security/external-idp/_index.md
+++ b/site/content/in-dev/unreleased/managing-security/external-idp/_index.md
@@ -197,6 +197,48 @@ polaris.oidc.principal-roles-mapper.mappings[0].replacement=PRINCIPAL_ROLE:$1
See more examples below.
+### External Principals
+
+By default, even when authentication is delegated to an external IdP, the principal named in
+the token must still correspond to a `PrincipalEntity` stored in the Polaris metastore: Polaris
+looks it up (by id or name) and derives its active roles from the grants recorded for that
+entity.
+
+**External principals** relax this requirement. When enabled for a realm, the caller is
+authenticated entirely from the token: no metastore lookup is performed, and the principal is
+never required to exist in Polaris. The principal name and roles are taken directly from the
+token via the Principal Mapping and Role Mapping configuration described above.
+
+External principals are enabled per realm:
+
+```properties
+# Global default
+polaris.authentication.credential-mode=external
+# Per-realm override
+polaris.authentication.realm1.credential-mode=external
+```
+
+{{< alert important >}}
+Unlike internal principals, an external principal's roles are used **verbatim** as they come out
+of the role mapper. The `PRINCIPAL_ROLE:` prefix convention and the `PRINCIPAL_ROLE:ALL`
+pseudo-role (which expand or filter roles against the principal's grants) do **not** apply to
+external principals, since there are no grants in the metastore to expand. **Configure the role
+mapper to emit exactly the role names your authorizer expects.**
+{{< /alert >}}
+
+Because an external principal has no metastore-backed grants, this feature has two hard
+requirements, both enforced at startup (the service refuses to boot otherwise):
+
+1. The realm's authentication `type` must not be the default (`internal`) one. Enabling external
+ principals with `internal` authentication is meaningless, and therefore not allowed.
+2. The authorizer must not be the default (`internal`) one. The default authorizer authorizes
+ requests using metastore-backed grants and therefore cannot authorize external principals.
+ Configure a different authorizer (for example, OPA):
+
+ ```properties
+ polaris.authorization.type=opa
+ ```
+
### Example JWT Mappings
#### Example 1: Custom Claim Paths
diff --git a/site/content/in-dev/unreleased/managing-security/external-idp/idp-dev-notes.md b/site/content/in-dev/unreleased/managing-security/external-idp/idp-dev-notes.md
index 27ab61cc117..3539dec5648 100644
--- a/site/content/in-dev/unreleased/managing-security/external-idp/idp-dev-notes.md
+++ b/site/content/in-dev/unreleased/managing-security/external-idp/idp-dev-notes.md
@@ -60,6 +60,13 @@ See [Token Broker description]({{< relref "../external-idp#token-broker" >}}) fo
3. [`OidcPolarisCredentialAugmentor`](https://github.com/apache/polaris/blob/main/runtime/service/src/main/java/org/apache/polaris/service/auth/external/OidcPolarisCredentialAugmentor.java) extracts JWT claims.
4. `Authenticator.authenticate()` validates the claims, resolves the principal and principal roles, then creates the `PolarisPrincipal`.
+When [external principals]({{< relref "../external-idp#external-principals" >}}) are enabled for
+the realm, step 4 differs: `DefaultAuthenticator` builds the `PolarisPrincipal` directly from the
+mapped credentials, without any metastore lookup, and the principal's roles are taken verbatim from
+the credentials. Downstream, the
+[`Resolver`](https://github.com/apache/polaris/blob/main/polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/Resolver.java)
+synthesizes the caller principal and its roles instead of loading them from the backend.
+
### Mixed Authentication
1. [`InternalAuthenticationMechanism`](https://github.com/apache/polaris/blob/main/runtime/service/src/main/java/org/apache/polaris/service/auth/internal/InternalAuthenticationMechanism.java) tries decoding.
diff --git a/tools/testcontainers/opa/build.gradle.kts b/tools/testcontainers/opa/build.gradle.kts
new file mode 100644
index 00000000000..061984728e3
--- /dev/null
+++ b/tools/testcontainers/opa/build.gradle.kts
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("org.kordamp.gradle.jandex")
+ id("polaris-server")
+}
+
+dependencies {
+ api(platform(libs.testcontainers.bom))
+ api("org.testcontainers:testcontainers")
+
+ // For OpaTestResource
+ compileOnly(platform(libs.quarkus.bom))
+ compileOnly("io.quarkus:quarkus-test-common")
+
+ implementation(project(":polaris-container-spec-helper"))
+ implementation(libs.guava)
+}
diff --git a/tools/testcontainers/opa/src/main/java/org/apache/polaris/test/opa/OpaContainer.java b/tools/testcontainers/opa/src/main/java/org/apache/polaris/test/opa/OpaContainer.java
new file mode 100644
index 00000000000..807bd9785c5
--- /dev/null
+++ b/tools/testcontainers/opa/src/main/java/org/apache/polaris/test/opa/OpaContainer.java
@@ -0,0 +1,94 @@
+/*
+ * 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.test.opa;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+public class OpaContainer extends GenericContainer {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(OpaContainer.class);
+
+ private static final int OPA_PORT = 8181;
+
+ private URI externalUrl;
+
+ @SuppressWarnings("resource")
+ public OpaContainer() {
+ super(
+ ContainerSpecHelper.containerSpecHelper("opa", OpaContainer.class)
+ .dockerImageName(null)
+ .asCanonicalNameString());
+ withExposedPorts(OPA_PORT);
+ withCommand("run", "--server", "--addr=0.0.0.0:" + OPA_PORT);
+ waitingFor(
+ Wait.forHttp("/health")
+ .forPort(OPA_PORT)
+ .forStatusCode(200)
+ .withStartupTimeout(Duration.ofSeconds(120)));
+ withLogConsumer(new Slf4jLogConsumer(LOGGER));
+ }
+
+ @Override
+ public void start() {
+ super.start();
+ externalUrl = URI.create("http://" + getHost() + ":" + getMappedPort(OPA_PORT) + "/");
+ }
+
+ public URI getExternalUrl() {
+ return externalUrl;
+ }
+
+ public void uploadRegoPolicy(String name, String rego) {
+ try {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(getExternalUrl().resolve("v1/policies/" + name))
+ .header("Content-Type", "text/plain")
+ .PUT(HttpRequest.BodyPublishers.ofString(rego, StandardCharsets.UTF_8))
+ .build();
+ HttpResponse response;
+ try (HttpClient client =
+ HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()) {
+ response = client.send(request, HttpResponse.BodyHandlers.discarding());
+ }
+ if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ throw new IllegalStateException("OPA policy upload failed, HTTP " + response.statusCode());
+ }
+ } catch (Exception e) {
+ String logs = "";
+ try {
+ logs = getLogs();
+ } catch (Throwable ignored) {
+ // ignore logging failures while reporting the original startup failure
+ }
+ throw new IllegalStateException("Failed to load OPA policy. Container logs:\n" + logs, e);
+ }
+ }
+}
diff --git a/tools/testcontainers/opa/src/main/java/org/apache/polaris/test/opa/OpaTestResource.java b/tools/testcontainers/opa/src/main/java/org/apache/polaris/test/opa/OpaTestResource.java
new file mode 100644
index 00000000000..817dc8b3537
--- /dev/null
+++ b/tools/testcontainers/opa/src/main/java/org/apache/polaris/test/opa/OpaTestResource.java
@@ -0,0 +1,80 @@
+/*
+ * 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.test.opa;
+
+import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
+import java.util.Map;
+
+/**
+ * A {@link QuarkusTestResourceLifecycleManager} that starts an Open Policy Agent (OPA) container, uploads a Rego
+ * policy, and points Polaris at it by returning {@code polaris.authorization.type=opa} and {@code
+ * polaris.authorization.opa.policy-uri}.
+ *
+ * The Rego policy can be supplied via the {@value #REGO_POLICY_ARG} init argument; otherwise a
+ * default policy that denies everything is used. The policy must declare {@code package
+ * polaris.authz}.
+ */
+public class OpaTestResource implements QuarkusTestResourceLifecycleManager {
+
+ /**
+ * Init argument holding the Rego policy to upload. Must declare {@code package polaris.authz}.
+ */
+ public static final String REGO_POLICY_ARG = "rego-policy";
+
+ private static final String POLICY_NAME = "polaris/authz";
+
+ private static final String DEFAULT_POLICY =
+ """
+ package polaris.authz
+
+ default allow := false
+ """;
+
+ private OpaContainer opa;
+ private String regoPolicy;
+
+ @Override
+ public void init(Map initArgs) {
+ regoPolicy = initArgs.getOrDefault(REGO_POLICY_ARG, DEFAULT_POLICY);
+ }
+
+ @Override
+ public Map start() {
+ opa = new OpaContainer();
+ opa.start();
+ opa.uploadRegoPolicy(POLICY_NAME, regoPolicy);
+ return Map.of(
+ "polaris.authorization.type",
+ "opa",
+ "polaris.authorization.opa.policy-uri",
+ opa.getExternalUrl() + "v1/data/" + POLICY_NAME);
+ }
+
+ @Override
+ public void stop() {
+ if (opa != null) {
+ try {
+ opa.stop();
+ } finally {
+ opa = null;
+ }
+ }
+ }
+}
diff --git a/tools/testcontainers/opa/src/main/resources/org/apache/polaris/test/opa/Dockerfile-opa-version b/tools/testcontainers/opa/src/main/resources/org/apache/polaris/test/opa/Dockerfile-opa-version
new file mode 100644
index 00000000000..b682a06179a
--- /dev/null
+++ b/tools/testcontainers/opa/src/main/resources/org/apache/polaris/test/opa/Dockerfile-opa-version
@@ -0,0 +1,22 @@
+#
+# 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.
+#
+
+# Dockerfile to provide the image name and tag to a test.
+# Version is managed by Renovate - do not edit.
+FROM docker.io/openpolicyagent/opa:1.18.2