Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
Expand Down Expand Up @@ -108,6 +110,50 @@ void testOpaInputJsonFormat() throws Exception {
}
}

@Test
void testExternalPrincipalRolesAndNameAreSentToOpa() throws Exception {
final String[] capturedRequestBody = new String[1];

HttpServer server = createServerWithRequestCapture(capturedRequestBody);
try {
URI policyUri =
URI.create(
"http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
OpaPolarisAuthorizer authorizer =
new OpaPolarisAuthorizer(
policyUri, HttpClients.createDefault(), new ObjectMapper(), null);

PolarisPrincipal principal =
PolarisPrincipal.of(
"external-user",
Map.of("external", "true", "principalId", "42"),
Set.of("ext-role", "common-role"));

PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of());

assertThatNoException()
.isThrownBy(
() ->
authorizer.authorizeOrThrow(
principal,
Set.of(),
PolarisAuthorizableOperation.LIST_NAMESPACES,
target,
null));

JsonNode actorNode =
new ObjectMapper().readTree(capturedRequestBody[0]).path("input").path("actor");
assertThat(actorNode.get("principal").asText()).isEqualTo("external-user");
assertThat(
StreamSupport.stream(actorNode.get("roles").spliterator(), false)
.map(JsonNode::asText)
.collect(Collectors.toSet()))
.containsExactlyInAnyOrder("ext-role", "common-role");
} finally {
server.stop(0);
}
}

@Test
void testOpaRequestJsonWithHierarchicalResource() throws Exception {
// Capture the request body for verification
Expand Down
4 changes: 4 additions & 0 deletions extensions/auth/opa/tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ dependencies {
testImplementation(project(":polaris-runtime-test-common"))

// Test dependencies
intTestImplementation(platform(libs.iceberg.bom))
intTestImplementation("org.apache.iceberg:iceberg-api")
intTestImplementation(project(":polaris-core"))
intTestImplementation("io.quarkus:quarkus-junit5")
intTestImplementation("io.rest-assured:rest-assured")
intTestImplementation("io.quarkus:quarkus-security")

// Test container dependencies
intTestImplementation(platform(libs.testcontainers.bom))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.polaris.extension.auth.opa.test;

import static io.restassured.RestAssured.given;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry;
import io.quarkus.test.junit.TestProfile;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;

/**
* Integration test that exercises OPA authorization when Polaris uses the external authenticator.
*
* <p>Authentication is driven entirely by test headers via {@link
* TestExternalHeaderAuthenticationMechanism}, ensuring no internal principal lookups occur.
*/
@QuarkusTest
@TestProfile(OpaExternalAuthIntegrationTest.ExternalOpaProfile.class)
public class OpaExternalAuthIntegrationTest extends OpaIntegrationTestBase {

public static class ExternalOpaProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
Map<String, String> config = new HashMap<>();
config.put("polaris.authorization.type", "opa");
config.put("polaris.authorization.opa.auth.type", "bearer");
config.put(
"polaris.authorization.opa.auth.bearer.static-token.value",
"test-opa-bearer-token-12345");

// Enable external authentication and skip internal token services
config.put("polaris.authentication.type", "external");
config.put("polaris.authentication.authenticator.type", "external");
config.put("polaris.authentication.token-broker.type", "none");
config.put("polaris.authentication.token-service.type", "disabled");

// OIDC not used in this flow
config.put("quarkus.oidc.enabled", "false");

return config;
}

@Override
public List<TestResourceEntry> testResources() {
return List.of(new TestResourceEntry(OpaTestResource.class));
}
}

@Test
void testExternalPrincipalAllowed() {
given()
.header(TestExternalHeaderAuthenticationMechanism.PRINCIPAL_HEADER, "admin")
.header(TestExternalHeaderAuthenticationMechanism.ROLES_HEADER, "ext-role")
.when()
.get("/api/management/v1/catalogs")
.then()
.statusCode(200);
}

@Test
void testExternalPrincipalDenied() {
given()
.header(TestExternalHeaderAuthenticationMechanism.PRINCIPAL_HEADER, "stranger")
.header(TestExternalHeaderAuthenticationMechanism.ROLES_HEADER, "unknown-role")
.when()
.get("/api/management/v1/catalogs")
.then()
.statusCode(403);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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.extension.auth.opa.test;

import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.polaris.core.auth.PolarisPrincipal;
import org.apache.polaris.service.auth.Authenticator;
import org.apache.polaris.service.auth.PolarisCredential;
import org.jboss.logging.Logger;

/**
* Test-only authentication mechanism that turns test headers into external {@link
* PolarisCredential} instances. This avoids any DB lookups and exercises the external authenticator
* flow.
*/
@ApplicationScoped
@Priority(HttpAuthenticationMechanism.DEFAULT_PRIORITY + 200)
public class TestExternalHeaderAuthenticationMechanism implements HttpAuthenticationMechanism {

private static final Logger LOG =
Logger.getLogger(TestExternalHeaderAuthenticationMechanism.class);

static final String PRINCIPAL_HEADER = "X-External-Principal";
static final String ROLES_HEADER = "X-External-Roles";

@Inject Authenticator authenticator;

@Override
public Uni<SecurityIdentity> authenticate(
RoutingContext context, IdentityProviderManager identityProviderManager) {
String principal = context.request().getHeader(PRINCIPAL_HEADER);
if (principal == null || principal.isBlank()) {
return Uni.createFrom().nullItem();
}
Set<String> roles = parseRoles(context.request().getHeader(ROLES_HEADER));
PolarisCredential credential = PolarisCredential.of(null, principal, roles, true);
PolarisPrincipal polarisPrincipal = authenticator.authenticate(credential);
QuarkusSecurityIdentity identity =
QuarkusSecurityIdentity.builder()
.setPrincipal(polarisPrincipal)
.addCredential(credential)
.addRoles(polarisPrincipal.getRoles())
.setAnonymous(false)
.build();
LOG.debugf(
"Authenticated external principal from headers principal=%s roles=%s", principal, roles);
return Uni.createFrom().item(identity);
}

@Override
public Uni<ChallengeData> getChallenge(RoutingContext context) {
return Uni.createFrom().nullItem();
}

@Override
public Set<Class<? extends io.quarkus.security.identity.request.AuthenticationRequest>>
getCredentialTypes() {
return Collections.emptySet();
}

@Override
public Uni<HttpCredentialTransport> getCredentialTransport(RoutingContext context) {
return Uni.createFrom().nullItem();
}

private Set<String> parseRoles(String header) {
if (header == null || header.isBlank()) {
return Set.of();
}
return Arrays.stream(header.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
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.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.ResolvedPolarisEntity;
import org.apache.polaris.core.persistence.cache.EntityCache;
Expand Down Expand Up @@ -744,13 +745,42 @@ private ResolverStatus resolvePaths(
private ResolverStatus resolveCallerPrincipalAndPrincipalRoles(
List<ResolvedPolarisEntity> toValidate) {

if (isExternalPrincipal()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cf. #3228

PrincipalEntity externalPrincipal = createExternalPrincipalEntity();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an entity without a (real) ID... Some it's a kind of ephemeral entity... Would it be possible to avoid creating it at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question - I'm a bit confused about the role of PrincipalEntity. We do have a public method for getResolvedCallerPrincipal() that returns resolvedCallerPrincipal. It's not being used anywhere in the codebase today, but I thought it'd be safe to populate it as it requires it to be @Nonnull: https://github.com/apache/polaris/blob/23ba2a05adc9c75f3e72aaf2ca370b4886964328/polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/Resolver.java#L272C19-L280

Copy link
Contributor

@dimas-b dimas-b Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceptually, IMHO, the principal entity should only be required for AuthZ checks (which do not actually require it if it's external). So, any intermediate code that requires it may need to be refactored... but TBH, I do not remember that code very well 😅

this.resolvedCallerPrincipal =
new ResolvedPolarisEntity(
diagnostics,
externalPrincipal,
List.of(),
externalPrincipal.getGrantRecordsVersion());
this.resolvedEntriesById.put(
this.resolvedCallerPrincipal.getEntity().getId(), this.resolvedCallerPrincipal);
this.resolvedCallerPrincipalRoles = List.of();
return new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS);
}

// resolve the principal, by name or id
this.resolvedCallerPrincipal =
this.resolveByName(toValidate, PolarisEntityType.PRINCIPAL, polarisPrincipal.getName());

// if the principal was not found, we can end right there
if (this.resolvedCallerPrincipal == null
|| this.resolvedCallerPrincipal.getEntity().isDropped()) {
if (isExternalPrincipal()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be true without returning on line 759?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It cannot - this was before I moved the condition upto line 748 to avoid principal resolution altogether if it is external. I'll remove this check here to remove redundancy

// For external principals we do not maintain principal entities in the metastore,
// so synthesize a placeholder entry and continue without resolving grants.
PrincipalEntity externalPrincipal = createExternalPrincipalEntity();
this.resolvedCallerPrincipal =
new ResolvedPolarisEntity(
diagnostics,
externalPrincipal,
List.of(),
externalPrincipal.getGrantRecordsVersion());
this.resolvedEntriesById.put(
this.resolvedCallerPrincipal.getEntity().getId(), this.resolvedCallerPrincipal);
this.resolvedCallerPrincipalRoles = List.of();
return new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS);
}
return new ResolverStatus(ResolverStatus.StatusEnum.CALLER_PRINCIPAL_DOES_NOT_EXIST);
}

Expand All @@ -764,6 +794,17 @@ private ResolverStatus resolveCallerPrincipalAndPrincipalRoles(
return new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS);
}

private boolean isExternalPrincipal() {
return Boolean.parseBoolean(polarisPrincipal.getProperties().getOrDefault("external", "false"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about making isExternal() a method of PolarisPrincipal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's worth considering. I put it into PolarisPrincipal as a part of the property for now because it was already available, but I think adding it as an attribute or adding a class method that infers the property value is up for discussion

}

private PrincipalEntity createExternalPrincipalEntity() {
return new PrincipalEntity.Builder()
.setName(polarisPrincipal.getName())
.setProperties(polarisPrincipal.getProperties())
.build();
}

/**
* Resolve all principal roles that the principal has grants for
*
Expand Down
Loading