diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java index c1aac773dd..69bdb984b6 100644 --- a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.IOException; @@ -109,25 +110,55 @@ public OpaPolarisAuthorizer( */ @Override public void resolveAuthorizationInputs( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { + authzState.getResolutionManifest().resolveAll(); + } + + @Override + public void resolveAuthorizationInputs( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + Preconditions.checkArgument( + !requests.isEmpty(), "Authorization request batch must contain at least one request"); authzState.getResolutionManifest().resolveAll(); } @Override @Nonnull public AuthorizationDecision authorize( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { boolean allowed = queryOpa( buildOpaAuthorizationInput( - request.getPrincipal(), + polarisPrincipal, request.getOperation(), - toResourceEntitiesFromSecurables(request.getTargets()), - toResourceEntitiesFromSecurables(request.getSecondaries()))); + toResourceEntitiesFromSecurable(request.getTarget()), + toResourceEntitiesFromSecurable(request.getSecondary()))); return allowed ? AuthorizationDecision.allow() : AuthorizationDecision.deny( - "OPA denied authorization for " + request.formatForAuthorizationMessage()); + "OPA denied authorization for principal=" + + polarisPrincipal.getName() + + " operation=" + + request.getOperation()); + } + + @Override + @Nonnull + public AuthorizationDecision authorize( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + Preconditions.checkArgument( + !requests.isEmpty(), "Authorization request batch must contain at least one request"); + // Batch OPA evaluation remains sequential for backward compatibility until OPA adopts a + // batch-native payload schema. + return PolarisAuthorizer.super.authorize(authzState, polarisPrincipal, requests); } /** @@ -297,8 +328,8 @@ private ImmutableContext buildContext() { private ImmutableResource buildResource( List targets, List secondaries) { // Backward compatibility: keep the existing OPA input shape with separate target and - // secondary lists. Future work can align this with AuthorizationTargetBinding semantics - // using binding tuples like [(target, secondary), ...]. + // secondary lists. Future work can align this with richer request shapes if OPA starts + // consuming pairwise authorization intent directly. return ImmutableResource.builder().targets(targets).secondaries(secondaries).build(); } @@ -362,16 +393,8 @@ private List toResourceEntitiesFromResolvedPaths( } @Nonnull - private List toResourceEntitiesFromSecurables( - @Nullable List securables) { - if (securables == null || securables.isEmpty()) { - return List.of(); - } - - List entities = new ArrayList<>(); - for (PolarisSecurable securable : securables) { - entities.add(buildResourceEntity(securable)); - } - return entities; + private List toResourceEntitiesFromSecurable( + @Nullable PolarisSecurable securable) { + return securable == null ? List.of() : List.of(buildResourceEntity(securable)); } } diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java index ad9cc1e801..f64bfebdef 100644 --- a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java +++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java @@ -34,6 +34,7 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -51,7 +52,6 @@ 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.AuthorizationTargetBinding; import org.apache.polaris.core.auth.PathSegment; import org.apache.polaris.core.auth.PolarisAuthorizableOperation; import org.apache.polaris.core.auth.PolarisPrincipal; @@ -579,8 +579,9 @@ void resolveAuthorizationInputsResolvesAll() { PolarisResolutionManifest resolutionManifest = mock(PolarisResolutionManifest.class); AuthorizationState authzState = new AuthorizationState(); authzState.setResolutionManifest(resolutionManifest); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); - authorizer.resolveAuthorizationInputs(authzState, requestWithCatalogTarget()); + authorizer.resolveAuthorizationInputs(authzState, principal, requestWithCatalogTarget()); verify(resolutionManifest).resolveAll(); } @@ -589,6 +590,7 @@ void resolveAuthorizationInputsResolvesAll() { void authorizeUsesIntentInputsAndAllows() throws Exception { final String[] capturedRequestBody = new String[1]; AuthorizationRequest request = requestWithCatalogTarget(); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); HttpEntity mockEntity = HttpEntities.create("{\"result\":{\"allow\":true}}"); @SuppressWarnings("resource") ClassicHttpResponse mockResponse = new BasicClassicHttpResponse(200); @@ -613,7 +615,7 @@ T httpClientExecute( } }; - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); ObjectMapper mapper = JsonMapper.builder().build(); JsonNode root = mapper.readTree(capturedRequestBody[0]); @@ -637,6 +639,7 @@ T httpClientExecute( @Test void authorizeDeniesWhenOpaDenies() { AuthorizationRequest request = requestWithCatalogTarget(); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); HttpEntity mockEntity = HttpEntities.create("{\"result\":{\"allow\":false}}"); @SuppressWarnings("resource") ClassicHttpResponse mockResponse = new BasicClassicHttpResponse(200); @@ -659,7 +662,7 @@ T httpClientExecute( } }; - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); assertThat(decision.isAllowed()).isFalse(); assertThat(decision.getMessage()) @@ -668,9 +671,7 @@ T httpClientExecute( assertThat(message) .contains("OPA denied authorization") .contains("operation=GET_CATALOG") - .contains("principal=alice") - .contains("targets=[CATALOG:catalog-1]") - .contains("secondaries=[]")); + .contains("principal=alice")); } @Test @@ -678,16 +679,13 @@ void authorizeIncludesStructuredParentsFromSecurable() throws Exception { final String[] capturedRequestBody = new String[1]; AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")), PolarisAuthorizableOperation.LOAD_TABLE, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of( - new PathSegment(PolarisEntityType.CATALOG, "catalog1"), - new PathSegment(PolarisEntityType.NAMESPACE, "ns1"), - new PathSegment(PolarisEntityType.NAMESPACE, "ns2"), - new PathSegment(PolarisEntityType.TABLE_LIKE, "table1")), - null))); + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog1"), + new PathSegment(PolarisEntityType.NAMESPACE, "ns1"), + new PathSegment(PolarisEntityType.NAMESPACE, "ns2"), + new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"))); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); HttpEntity mockEntity = HttpEntities.create("{\"result\":{\"allow\":true}}"); @SuppressWarnings("resource") ClassicHttpResponse mockResponse = new BasicClassicHttpResponse(200); @@ -712,7 +710,7 @@ T httpClientExecute( } }; - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); ObjectMapper mapper = JsonMapper.builder().build(); JsonNode root = mapper.readTree(capturedRequestBody[0]); @@ -868,18 +866,16 @@ void authorizeRenameIncludesTargetAndSecondaryPaths() throws Exception { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")), PolarisAuthorizableOperation.RENAME_TABLE, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of( - new PathSegment(PolarisEntityType.CATALOG, "catalog1"), - new PathSegment(PolarisEntityType.NAMESPACE, "src_ns"), - new PathSegment(PolarisEntityType.TABLE_LIKE, "src_tbl")), - PolarisSecurable.of( - new PathSegment(PolarisEntityType.CATALOG, "catalog1"), - new PathSegment(PolarisEntityType.NAMESPACE, "dst_ns"), - new PathSegment(PolarisEntityType.TABLE_LIKE, "dst_tbl"))))); + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog1"), + new PathSegment(PolarisEntityType.NAMESPACE, "src_ns"), + new PathSegment(PolarisEntityType.TABLE_LIKE, "src_tbl")), + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog1"), + new PathSegment(PolarisEntityType.NAMESPACE, "dst_ns"), + new PathSegment(PolarisEntityType.TABLE_LIKE, "dst_tbl"))); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); OpaPolarisAuthorizer authorizer = new OpaPolarisAuthorizer( @@ -897,7 +893,7 @@ T httpClientExecute( } }; - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); ObjectMapper mapper = JsonMapper.builder().build(); JsonNode root = mapper.readTree(capturedRequestBody[0]); @@ -934,14 +930,135 @@ T httpClientExecute( .isEqualTo(expectedDestination); } + @Test + void authorizeSingleOperationBatchEvaluatesSequentially() throws Exception { + final List capturedRequestBodies = new ArrayList<>(); + HttpEntity mockEntity = HttpEntities.create("{\"result\":{\"allow\":true}}"); + @SuppressWarnings("resource") + ClassicHttpResponse mockResponse = new BasicClassicHttpResponse(200); + mockResponse.setEntity(mockEntity); + + PolarisResolutionManifest resolutionManifest = mock(PolarisResolutionManifest.class); + AuthorizationState authzState = new AuthorizationState(); + authzState.setResolutionManifest(resolutionManifest); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); + + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer( + URI.create("http://opa.example.com:8181/v1/data/polaris/allow"), + mock(CloseableHttpClient.class), + JsonMapper.builder().build(), + null) { + @Override + T httpClientExecute( + ClassicHttpRequest request, HttpClientResponseHandler responseHandler) + throws HttpException, IOException { + capturedRequestBodies.add( + new String( + request.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8)); + return responseHandler.handleResponse(mockResponse); + } + }; + + AuthorizationDecision decision = + authorizer.authorize( + authzState, + principal, + List.of( + AuthorizationRequest.of( + PolarisAuthorizableOperation.GET_CATALOG, + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog-1"))), + AuthorizationRequest.of( + PolarisAuthorizableOperation.GET_CATALOG, + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog-2"))))); + + ObjectMapper mapper = JsonMapper.builder().build(); + assertThat(decision.isAllowed()).isTrue(); + assertThat(capturedRequestBodies).hasSize(2); + JsonNode firstRoot = mapper.readTree(capturedRequestBodies.get(0)); + JsonNode secondRoot = mapper.readTree(capturedRequestBodies.get(1)); + assertThat(firstRoot.path("input").path("action").asText()).isEqualTo("GET_CATALOG"); + assertThat(secondRoot.path("input").path("action").asText()).isEqualTo("GET_CATALOG"); + JsonNode expectedFirstTargets = + mapper.readTree( + """ + [ + { + "type": "CATALOG", + "name": "catalog-1", + "parents": [] + } + ] + """); + JsonNode expectedSecondTargets = + mapper.readTree( + """ + [ + { + "type": "CATALOG", + "name": "catalog-2", + "parents": [] + } + ] + """); + assertThat(firstRoot.path("input").path("resource").path("targets")) + .isEqualTo(expectedFirstTargets); + assertThat(secondRoot.path("input").path("resource").path("targets")) + .isEqualTo(expectedSecondTargets); + assertThat(firstRoot.path("input").path("resource").path("secondaries")).isEmpty(); + assertThat(secondRoot.path("input").path("resource").path("secondaries")).isEmpty(); + } + + @Test + void authorizeMultiOperationBatchEvaluatesSequentially() throws Exception { + final int[] requestCount = new int[1]; + HttpEntity mockEntity = HttpEntities.create("{\"result\":{\"allow\":true}}"); + @SuppressWarnings("resource") + ClassicHttpResponse mockResponse = new BasicClassicHttpResponse(200); + mockResponse.setEntity(mockEntity); + + PolarisResolutionManifest resolutionManifest = mock(PolarisResolutionManifest.class); + AuthorizationState authzState = new AuthorizationState(); + authzState.setResolutionManifest(resolutionManifest); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")); + + OpaPolarisAuthorizer authorizer = + new OpaPolarisAuthorizer( + URI.create("http://opa.example.com:8181/v1/data/polaris/allow"), + mock(CloseableHttpClient.class), + JsonMapper.builder().build(), + null) { + @Override + T httpClientExecute( + ClassicHttpRequest request, HttpClientResponseHandler responseHandler) + throws HttpException, IOException { + requestCount[0]++; + return responseHandler.handleResponse(mockResponse); + } + }; + + AuthorizationDecision decision = + authorizer.authorize( + authzState, + principal, + List.of( + AuthorizationRequest.of( + PolarisAuthorizableOperation.GET_CATALOG, + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog-1"))), + AuthorizationRequest.of( + PolarisAuthorizableOperation.LIST_NAMESPACES, + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog-1"), + new PathSegment(PolarisEntityType.NAMESPACE, "ns"))))); + + assertThat(decision.isAllowed()).isTrue(); + assertThat(requestCount[0]).isEqualTo(2); + } + private AuthorizationRequest requestWithCatalogTarget() { return AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role-1")), PolarisAuthorizableOperation.GET_CATALOG, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog-1")), - null))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog-1"))); } private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) { diff --git a/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisAuthorizer.java b/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisAuthorizer.java index 271282d0e1..b37d6a59b2 100644 --- a/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisAuthorizer.java +++ b/extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisAuthorizer.java @@ -82,13 +82,26 @@ public void setRealmContext(RealmContext aRealmContext) { @Override public void resolveAuthorizationInputs( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { throw new UnsupportedOperationException("resolveAuthorizationInputs is not implemented yet"); } + @Override + public void resolveAuthorizationInputs( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + throw new UnsupportedOperationException( + "Batch resolveAuthorizationInputs is not implemented yet"); + } + @Override public @Nonnull AuthorizationDecision authorize( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { throw new UnsupportedOperationException("authorize is not implemented yet"); } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationRequest.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationRequest.java index 576c1046f2..db90085c1d 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationRequest.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationRequest.java @@ -19,102 +19,52 @@ package org.apache.polaris.core.auth; import jakarta.annotation.Nonnull; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; +import jakarta.annotation.Nullable; import org.apache.polaris.core.entity.PolarisEntityType; -import org.apache.polaris.immutables.PolarisImmutable; -import org.immutables.value.Value; /** * Authorization request inputs for pre-authorization and core authorization. * - *

This wrapper keeps authorization inputs together and conveys the intent to be authorized via - * {@link AuthorizationTargetBinding} target bindings. + *

This hierarchy makes the target shape explicit on the request itself. */ -@PolarisImmutable -public interface AuthorizationRequest { +public sealed interface AuthorizationRequest + permits UntargetedAuthorizationRequest, + SingleTargetAuthorizationRequest, + PairwiseTargetAuthorizationRequest { + static AuthorizationRequest of(@Nonnull PolarisAuthorizableOperation operation) { + return new UntargetedAuthorizationRequest(operation); + } + static AuthorizationRequest of( - @Nonnull PolarisPrincipal principal, - @Nonnull PolarisAuthorizableOperation operation, - @Nonnull List targetBindings) { - return ImmutableAuthorizationRequest.builder() - .principal(principal) - .operation(operation) - .targetBindings(targetBindings) - .build(); + @Nonnull PolarisAuthorizableOperation operation, @Nonnull PolarisSecurable target) { + return new SingleTargetAuthorizationRequest(operation, target); } - /** Returns the principal requesting authorization. */ - @Nonnull - PolarisPrincipal getPrincipal(); + static AuthorizationRequest of( + @Nonnull PolarisAuthorizableOperation operation, + @Nullable PolarisSecurable target, + @Nullable PolarisSecurable secondary) { + return new PairwiseTargetAuthorizationRequest(operation, target, secondary); + } /** Returns the operation being authorized. */ @Nonnull PolarisAuthorizableOperation getOperation(); - /** Returns the target/secondary target bindings. */ - @Nonnull - List getTargetBindings(); + /** Returns the primary target securable, if any. */ + @Nullable + PolarisSecurable getTarget(); - /** - * Returns the primary target securables, if any. - * - *

Compatibility accessor derived from {@link #getTargetBindings()}. - */ - @Nonnull - @Value.Derived - default List getTargets() { - return getTargetBindings().stream() - .map(AuthorizationTargetBinding::getTarget) - .filter(Objects::nonNull) - .toList(); - } - - /** - * Returns secondary securables, if any. - * - *

Compatibility accessor derived from {@link #getTargetBindings()}. - */ - @Nonnull - @Value.Derived - default List getSecondaries() { - return getTargetBindings().stream() - .map(AuthorizationTargetBinding::getSecondary) - .filter(Objects::nonNull) - .toList(); - } - - /** - * Returns a stable debug string for authorization messages. - * - *

Includes the operation, principal name, formatted targets, and formatted secondaries. - */ - @Nonnull - default String formatForAuthorizationMessage() { - return String.format( - "operation=%s principal=%s targets=%s secondaries=%s", - getOperation(), - getPrincipal().getName(), - formatSecurables(getTargets()), - formatSecurables(getSecondaries())); - } - - private static String formatSecurables(List securables) { - return securables.stream() - .map(PolarisSecurable::formatForAuthorizationMessage) - .collect(Collectors.joining(", ", "[", "]")); - } + /** Returns the secondary securable, if any. */ + @Nullable + PolarisSecurable getSecondary(); default boolean hasSecurableType(PolarisEntityType... types) { - for (AuthorizationTargetBinding targetBinding : getTargetBindings()) { - if (targetBinding.getTarget() != null && containsType(targetBinding.getTarget(), types)) { - return true; - } - if (targetBinding.getSecondary() != null - && containsType(targetBinding.getSecondary(), types)) { - return true; - } + if (getTarget() != null && containsType(getTarget(), types)) { + return true; + } + if (getSecondary() != null && containsType(getSecondary(), types)) { + return true; } return false; } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationTargetBinding.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationTargetBinding.java deleted file mode 100644 index 5ac3599f96..0000000000 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthorizationTargetBinding.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.polaris.core.auth; - -import com.google.common.base.Preconditions; -import jakarta.annotation.Nullable; -import org.apache.polaris.immutables.PolarisImmutable; -import org.immutables.value.Value; - -/** A resource binding containing a primary target and optional secondary. */ -@PolarisImmutable -public interface AuthorizationTargetBinding { - static AuthorizationTargetBinding of( - @Nullable PolarisSecurable target, @Nullable PolarisSecurable secondary) { - return ImmutableAuthorizationTargetBinding.builder() - .target(target) - .secondary(secondary) - .build(); - } - - /** Returns the primary target securable for the binding, if any. */ - @Nullable - PolarisSecurable getTarget(); - - /** - * Returns the optional secondary securable associated with the target. - * - *

Secondaries are related resources needed to evaluate the authorization decision but are not - * the direct object of the operation. Examples in current Polaris authorization flows include: - * - *

    - *
  • Table rename: the destination namespace (target is the source table). - *
  • Role grants: the grantee role/principal (target may be the role or the resource being - * granted on). - *
  • Policy attach/detach: the catalog/namespace/table being attached to (target is the - * policy). - *
- */ - @Nullable - PolarisSecurable getSecondary(); - - @Value.Check - default void validate() { - Preconditions.checkState( - getTarget() != null || getSecondary() != null, - "AuthorizationTargetBinding must contain a target or secondary"); - } -} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PairwiseTargetAuthorizationRequest.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PairwiseTargetAuthorizationRequest.java new file mode 100644 index 0000000000..b901142209 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PairwiseTargetAuthorizationRequest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.core.auth; + +import com.google.common.base.Preconditions; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; + +/** + * Authorization request for operations that may carry both a primary target and a related secondary + * target. + * + *

The primary target may be omitted for legacy root-scoped flows that rely on an implicit root + * primary plus an explicit secondary target. + */ +public record PairwiseTargetAuthorizationRequest( + @Nonnull PolarisAuthorizableOperation operation, + @Nullable PolarisSecurable target, + @Nullable PolarisSecurable secondary) + implements AuthorizationRequest { + public PairwiseTargetAuthorizationRequest { + Preconditions.checkNotNull(operation, "operation must be non-null"); + Preconditions.checkState( + target != null || secondary != null, + "PairwiseTargetAuthorizationRequest must contain a target or secondary"); + } + + @Override + public @Nonnull PolarisAuthorizableOperation getOperation() { + return operation; + } + + @Override + public @Nullable PolarisSecurable getTarget() { + return target; + } + + @Override + public @Nullable PolarisSecurable getSecondary() { + return secondary; + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizer.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizer.java index 975eae5f03..aad4cbaa5d 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizer.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizer.java @@ -18,6 +18,7 @@ */ package org.apache.polaris.core.auth; +import com.google.common.base.Preconditions; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.List; @@ -38,17 +39,57 @@ public interface PolarisAuthorizer { *

This method should not perform authorization decisions directly. */ void resolveAuthorizationInputs( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request); + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request); + + /** + * Resolve authorizer-specific inputs for a batch of authorization requests that share one + * principal. + * + *

Implementations must define their own batch pre-resolution behavior explicitly because + * manifest registration is authorizer-specific. + */ + void resolveAuthorizationInputs( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests); /** * Core authorization entry point for the new SPI. * *

Implementations should rely on any required state in {@link AuthorizationState} and the - * intent captured by {@link AuthorizationRequest} (principal, operation, and target securables). + * intent captured by {@link AuthorizationRequest} (operation and target securables), together + * with the explicit {@link PolarisPrincipal} argument. */ @Nonnull AuthorizationDecision authorize( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request); + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request); + + /** + * Core authorization entry point for a batch of requests that share one principal. + * + *

The default behavior preserves semantics by evaluating requests independently in order and + * returning the first denial. Implementations may override this to use a single batched + * downstream authorization call. + */ + @Nonnull + default AuthorizationDecision authorize( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + Preconditions.checkArgument( + !requests.isEmpty(), "Authorization request batch must contain at least one request"); + for (AuthorizationRequest request : requests) { + AuthorizationDecision decision = authorize(authzState, polarisPrincipal, request); + if (!decision.isAllowed()) { + return decision; + } + } + return AuthorizationDecision.allow(); + } /** * Convenience method that throws a {@link ForbiddenException} when authorization is denied. @@ -56,8 +97,27 @@ AuthorizationDecision authorize( *

Implementations should provide allow/deny decisions via {@link #authorize}. */ default void authorizeOrThrow( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { - AuthorizationDecision decision = authorize(authzState, request); + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { + AuthorizationDecision decision = authorize(authzState, polarisPrincipal, request); + if (!decision.isAllowed()) { + String message = decision.getMessage().orElse("Authorization denied"); + throw new ForbiddenException("%s", message); + } + } + + /** + * Convenience method that throws when any request in the batch is denied. + * + *

The default behavior delegates to {@link #authorize(AuthorizationState, PolarisPrincipal, + * List)}. + */ + default void authorizeOrThrow( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + AuthorizationDecision decision = authorize(authzState, polarisPrincipal, requests); if (!decision.isAllowed()) { String message = decision.getMessage().orElse("Authorization denied"); throw new ForbiddenException("%s", message); diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java index bf84a2ad4c..69dd3438a0 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java @@ -749,36 +749,51 @@ public PolarisAuthorizerImpl(RealmConfig realmConfig) { @Override public void resolveAuthorizationInputs( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { PolarisResolutionManifest resolutionManifest = authzState.getResolutionManifest(); resolutionManifest.resolveAll(); } + @Override + public void resolveAuthorizationInputs( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + Preconditions.checkArgument( + !requests.isEmpty(), "Authorization request batch must contain at least one request"); + authzState.getResolutionManifest().resolveAll(); + } + @Override @Nonnull public AuthorizationDecision authorize( - @Nonnull AuthorizationState authzState, @Nonnull AuthorizationRequest request) { + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull AuthorizationRequest request) { PolarisResolutionManifest resolutionManifest = authzState.getResolutionManifest(); RbacOperationSemantics semantics = RbacOperationSemantics.forOperation(request.getOperation()); boolean prependRootContainer = semantics.rooting() == ResolvedPathRooting.ROOT; try { - List targets = request.getTargets(); List resolvedTargets; - if (targets.isEmpty()) { + PolarisSecurable target = request.getTarget(); + if (target == null) { resolvedTargets = prependRootContainer ? List.of(resolutionManifest.getResolvedRootContainerEntityAsPath()) : null; } else { - resolvedTargets = getResolvedSecurables(resolutionManifest, targets, prependRootContainer); + resolvedTargets = + List.of(getResolvedSecurable(resolutionManifest, target, prependRootContainer)); } - List secondaries = request.getSecondaries(); + PolarisSecurable secondary = request.getSecondary(); List resolvedSecondaries = - semantics.secondaryPrivileges().isEmpty() || secondaries.isEmpty() + semantics.secondaryPrivileges().isEmpty() || secondary == null ? null - : getResolvedSecurables(resolutionManifest, secondaries, prependRootContainer); + : List.of(getResolvedSecurable(resolutionManifest, secondary, prependRootContainer)); authorizeOrThrow( - request.getPrincipal(), + polarisPrincipal, resolutionManifest.getAllActivatedCatalogRoleAndPrincipalRoles(), request.getOperation(), resolvedTargets, @@ -787,47 +802,47 @@ public AuthorizationDecision authorize( } catch (ForbiddenException e) { LOGGER.debug( "Authorization denied for principalName {} operation {} targets {} secondaries {}", - request.getPrincipal().getName(), + polarisPrincipal.getName(), request.getOperation(), - request.getTargets(), - request.getSecondaries(), + request.getTarget(), + request.getSecondary(), e); return AuthorizationDecision.deny(e.getMessage()); } } - private List getResolvedSecurables( - PolarisResolutionManifest resolutionManifest, - List securables, - boolean prependRootContainer) { - return securables.stream() - .map( - securable -> { - PolarisResolvedPathWrapper resolvedSecurable = - getResolvedSecurable(resolutionManifest, securable, prependRootContainer); - Preconditions.checkState( - resolvedSecurable != null, - "Resolved path for securable is null for entityType=%s leaf=%s parents=%s", - securable.getLeaf().entityType(), - securable.getLeaf(), - securable.getParents()); - return resolvedSecurable; - }) - .toList(); + @Override + @Nonnull + public AuthorizationDecision authorize( + @Nonnull AuthorizationState authzState, + @Nonnull PolarisPrincipal polarisPrincipal, + @Nonnull List requests) { + Preconditions.checkArgument( + !requests.isEmpty(), "Authorization request batch must contain at least one request"); + // RBAC has no external batch payload contract to preserve, so batch authorization remains a + // sequential evaluation of single-request checks. + return PolarisAuthorizer.super.authorize(authzState, polarisPrincipal, requests); } private PolarisResolvedPathWrapper getResolvedSecurable( PolarisResolutionManifest resolutionManifest, PolarisSecurable securable, boolean prependRootContainer) { - if (securable.getLeaf().entityType().isTopLevel()) { - // Ignore prependRootContainer for top-level entities. - return resolutionManifest.getResolvedTopLevelEntity( - securable.getLeaf().name(), securable.getLeaf().entityType()); - } - return resolutionManifest.getResolvedPath( - ResolvedPathKey.of(getPathNamesWithinCatalog(securable), securable.getLeaf().entityType()), - prependRootContainer); + PolarisResolvedPathWrapper resolvedSecurable = + securable.getLeaf().entityType().isTopLevel() + ? resolutionManifest.getResolvedTopLevelEntity( + securable.getLeaf().name(), securable.getLeaf().entityType()) + : resolutionManifest.getResolvedPath( + ResolvedPathKey.of( + getPathNamesWithinCatalog(securable), securable.getLeaf().entityType()), + prependRootContainer); + Preconditions.checkState( + resolvedSecurable != null, + "Resolved path for securable is null for entityType=%s leaf=%s parents=%s", + securable.getLeaf().entityType(), + securable.getLeaf(), + securable.getParents()); + return resolvedSecurable; } private List getPathNamesWithinCatalog(PolarisSecurable securable) { diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/SingleTargetAuthorizationRequest.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/SingleTargetAuthorizationRequest.java new file mode 100644 index 0000000000..60ee18ee88 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/SingleTargetAuthorizationRequest.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.core.auth; + +import com.google.common.base.Preconditions; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; + +/** Authorization request for operations with one explicit target. */ +public record SingleTargetAuthorizationRequest( + @Nonnull PolarisAuthorizableOperation operation, @Nonnull PolarisSecurable target) + implements AuthorizationRequest { + public SingleTargetAuthorizationRequest { + Preconditions.checkNotNull(operation, "operation must be non-null"); + Preconditions.checkNotNull(target, "target must be non-null"); + } + + @Override + public @Nonnull PolarisAuthorizableOperation getOperation() { + return operation; + } + + @Override + public @Nonnull PolarisSecurable getTarget() { + return target; + } + + @Override + public @Nullable PolarisSecurable getSecondary() { + return null; + } +} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/UntargetedAuthorizationRequest.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/UntargetedAuthorizationRequest.java new file mode 100644 index 0000000000..1747186dc9 --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/UntargetedAuthorizationRequest.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.core.auth; + +import com.google.common.base.Preconditions; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; + +/** Authorization request for operations with no explicit securable target. */ +public record UntargetedAuthorizationRequest(@Nonnull PolarisAuthorizableOperation operation) + implements AuthorizationRequest { + public UntargetedAuthorizationRequest { + Preconditions.checkNotNull(operation, "operation must be non-null"); + } + + @Override + public @Nonnull PolarisAuthorizableOperation getOperation() { + return operation; + } + + @Override + public @Nullable PolarisSecurable getTarget() { + return null; + } + + @Override + public @Nullable PolarisSecurable getSecondary() { + return null; + } +} diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/AuthorizationRequestTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/AuthorizationRequestTest.java index 5bf66e69cf..43e3bd6e69 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/AuthorizationRequestTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/AuthorizationRequestTest.java @@ -21,9 +21,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.util.List; -import java.util.Map; -import java.util.Set; import org.apache.polaris.core.entity.PolarisEntityType; import org.junit.jupiter.api.Test; @@ -33,13 +30,10 @@ public class AuthorizationRequestTest { void hasSecurableTypeReturnsTrueForPrincipalTarget() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.LOAD_TABLE, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "alice")), - null))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "alice"))); + assertThat(request).isInstanceOf(SingleTargetAuthorizationRequest.class); assertThat(request.hasSecurableType(PolarisEntityType.PRINCIPAL)).isTrue(); } @@ -47,34 +41,24 @@ void hasSecurableTypeReturnsTrueForPrincipalTarget() { void hasSecurableTypeReturnsTrueForPrincipalRoleSecondary() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.ASSIGN_PRINCIPAL_ROLE, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "alice")), - PolarisSecurable.of( - new PathSegment(PolarisEntityType.PRINCIPAL_ROLE, "analytics-admin"))))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "alice")), + PolarisSecurable.of( + new PathSegment(PolarisEntityType.PRINCIPAL_ROLE, "analytics-admin"))); + assertThat(request).isInstanceOf(PairwiseTargetAuthorizationRequest.class); assertThat(request.hasSecurableType(PolarisEntityType.PRINCIPAL_ROLE)).isTrue(); } @Test - void hasSecurableTypeReturnsTrueForCatalogRoleAcrossMultipleBindings() { + void hasSecurableTypeReturnsTrueForCatalogRoleSecondary() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.ASSIGN_CATALOG_ROLE_TO_PRINCIPAL_ROLE, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of( - new PathSegment(PolarisEntityType.CATALOG, "catalog"), - new PathSegment(PolarisEntityType.NAMESPACE, "ns")), - null), - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")), - PolarisSecurable.of( - new PathSegment(PolarisEntityType.CATALOG, "catalog"), - new PathSegment(PolarisEntityType.CATALOG_ROLE, "catalog-role"))))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")), + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog"), + new PathSegment(PolarisEntityType.CATALOG_ROLE, "catalog-role"))); assertThat(request.hasSecurableType(PolarisEntityType.CATALOG_ROLE)).isTrue(); } @@ -83,34 +67,41 @@ void hasSecurableTypeReturnsTrueForCatalogRoleAcrossMultipleBindings() { void hasSecurableTypeReturnsFalseWhenTypeAbsent() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.LOAD_VIEW, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")), - null))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog"))); assertThat(request.hasSecurableType(PolarisEntityType.PRINCIPAL_ROLE)).isFalse(); } @Test - void allowsEmptyTargetBindings() { + void allowsUntargetedRequest() { AuthorizationRequest request = - AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), - PolarisAuthorizableOperation.LIST_CATALOGS, - List.of()); + AuthorizationRequest.of(PolarisAuthorizableOperation.LIST_CATALOGS); - assertThat(request.getTargetBindings()).isEmpty(); - assertThat(request.getTargets()).isEmpty(); - assertThat(request.getSecondaries()).isEmpty(); + assertThat(request).isInstanceOf(UntargetedAuthorizationRequest.class); + assertThat(request.getTarget()).isNull(); + assertThat(request.getSecondary()).isNull(); } @Test - void throwsWhenTargetBindingHasNoTargetOrSecondary() { - assertThatThrownBy(() -> AuthorizationTargetBinding.of(null, null)) + void throwsWhenTargetedFactoryHasNoTargetOrSecondary() { + assertThatThrownBy( + () -> AuthorizationRequest.of(PolarisAuthorizableOperation.GET_CATALOG, null, null)) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("must contain a target or secondary"); + .hasMessageContaining( + "PairwiseTargetAuthorizationRequest must contain a target or secondary"); + } + + @Test + void threeArgFactoryAlwaysCreatesPairwiseRequest() { + PolarisSecurable target = + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")); + AuthorizationRequest request = + AuthorizationRequest.of(PolarisAuthorizableOperation.GET_CATALOG, target, null); + + assertThat(request).isInstanceOf(PairwiseTargetAuthorizationRequest.class); + assertThat(request.getTarget()).isEqualTo(target); + assertThat(request.getSecondary()).isNull(); } @Test diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisAuthorizerImplTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisAuthorizerImplTest.java index 7bee960f72..0cb752b4c4 100644 --- a/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisAuthorizerImplTest.java +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisAuthorizerImplTest.java @@ -25,6 +25,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -63,18 +64,15 @@ void resolveAuthorizationInputsResolvesAll() { PolarisAuthorizerImpl authorizer = new PolarisAuthorizerImpl(mock(RealmConfig.class)); AuthorizationState authzState = new AuthorizationState(); PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.GET_CATALOG, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")), - null))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog"))); authzState.setResolutionManifest(manifest); - authorizer.resolveAuthorizationInputs(authzState, request); + authorizer.resolveAuthorizationInputs(authzState, principal, request); verify(manifest).resolveAll(); } @@ -88,6 +86,7 @@ void authorizeUsesRootTargetForRootGrantRequestWithoutPrimaryTarget() { PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); PolarisResolvedPathWrapper rootWrapper = mock(PolarisResolvedPathWrapper.class); PolarisResolvedPathWrapper principalRoleWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); authzState.setResolutionManifest(manifest); when(manifest.getResolvedRootContainerEntityAsPath()).thenReturn(rootWrapper); @@ -105,20 +104,17 @@ void authorizeUsesRootTargetForRootGrantRequestWithoutPrimaryTarget() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.ADD_ROOT_GRANT_TO_PRINCIPAL_ROLE, - List.of( - AuthorizationTargetBinding.of( - null, - PolarisSecurable.of( - new PathSegment(PolarisEntityType.PRINCIPAL_ROLE, "analytics-admin"))))); + null, + PolarisSecurable.of( + new PathSegment(PolarisEntityType.PRINCIPAL_ROLE, "analytics-admin"))); - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); assertThat(decision.isAllowed()).isTrue(); verify(authorizer) .authorizeOrThrow( - eq(request.getPrincipal()), + eq(principal), eq(Set.of()), eq(PolarisAuthorizableOperation.ADD_ROOT_GRANT_TO_PRINCIPAL_ROLE), eq(List.of(rootWrapper)), @@ -133,6 +129,7 @@ void authorizeUsesRootTargetForListCatalogsRequestWithoutPrimaryTarget() { AuthorizationState authzState = new AuthorizationState(); PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); PolarisResolvedPathWrapper rootWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); authzState.setResolutionManifest(manifest); when(manifest.getResolvedRootContainerEntityAsPath()).thenReturn(rootWrapper); @@ -147,17 +144,14 @@ void authorizeUsesRootTargetForListCatalogsRequestWithoutPrimaryTarget() { ArgumentMatchers.>any()); AuthorizationRequest request = - AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), - PolarisAuthorizableOperation.LIST_CATALOGS, - List.of()); + AuthorizationRequest.of(PolarisAuthorizableOperation.LIST_CATALOGS); - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); assertThat(decision.isAllowed()).isTrue(); verify(authorizer) .authorizeOrThrow( - eq(request.getPrincipal()), + eq(principal), eq(Set.of()), eq(PolarisAuthorizableOperation.LIST_CATALOGS), eq(List.of(rootWrapper)), @@ -172,6 +166,7 @@ void authorizeResolvesNamespaceTargetUsingCatalog() { AuthorizationState authzState = new AuthorizationState(); PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); PolarisResolvedPathWrapper namespaceWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); authzState.setResolutionManifest(manifest); when(manifest.getResolvedPath( @@ -189,35 +184,154 @@ void authorizeResolvesNamespaceTargetUsingCatalog() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.LIST_NAMESPACES, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of( - new PathSegment(PolarisEntityType.CATALOG, "catalog"), - new PathSegment(PolarisEntityType.NAMESPACE, "ns")), - null))); + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog"), + new PathSegment(PolarisEntityType.NAMESPACE, "ns"))); - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); assertThat(decision.isAllowed()).isTrue(); verify(manifest) .getResolvedPath(ResolvedPathKey.of(List.of("ns"), PolarisEntityType.NAMESPACE), true); verify(authorizer) .authorizeOrThrow( - eq(request.getPrincipal()), + eq(principal), eq(Set.of()), eq(PolarisAuthorizableOperation.LIST_NAMESPACES), eq(List.of(namespaceWrapper)), eq(null)); } + @Test + void authorizeSingleOperationBatchEvaluatesSequentially() { + PolarisAuthorizerImpl authorizer = spy(new PolarisAuthorizerImpl(mock(RealmConfig.class))); + AuthorizationState authzState = new AuthorizationState(); + PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); + PolarisResolvedPathWrapper firstCatalogWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper secondCatalogWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); + + authzState.setResolutionManifest(manifest); + when(manifest.getResolvedTopLevelEntity("catalog1", PolarisEntityType.CATALOG)) + .thenReturn(firstCatalogWrapper); + when(manifest.getResolvedTopLevelEntity("catalog2", PolarisEntityType.CATALOG)) + .thenReturn(secondCatalogWrapper); + when(manifest.getAllActivatedCatalogRoleAndPrincipalRoles()).thenReturn(Set.of()); + doNothing() + .when(authorizer) + .authorizeOrThrow( + any(PolarisPrincipal.class), + ArgumentMatchers.any(), + eq(PolarisAuthorizableOperation.GET_CATALOG), + ArgumentMatchers.any(), + ArgumentMatchers.>any()); + + AuthorizationDecision decision = + authorizer.authorize( + authzState, + principal, + List.of( + AuthorizationRequest.of( + PolarisAuthorizableOperation.GET_CATALOG, + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog1"))), + AuthorizationRequest.of( + PolarisAuthorizableOperation.GET_CATALOG, + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog2"))))); + + assertThat(decision.isAllowed()).isTrue(); + verify(authorizer, times(1)) + .authorizeOrThrow( + eq(principal), + eq(Set.of()), + eq(PolarisAuthorizableOperation.GET_CATALOG), + eq(List.of(firstCatalogWrapper)), + eq(null)); + verify(authorizer, times(1)) + .authorizeOrThrow( + eq(principal), + eq(Set.of()), + eq(PolarisAuthorizableOperation.GET_CATALOG), + eq(List.of(secondCatalogWrapper)), + eq(null)); + } + + @Test + void authorizeMultiOperationBatchEvaluatesSequentially() { + PolarisAuthorizerImpl authorizer = spy(new PolarisAuthorizerImpl(mock(RealmConfig.class))); + AuthorizationState authzState = new AuthorizationState(); + PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); + PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisResolvedPathWrapper namespaceWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); + + authzState.setResolutionManifest(manifest); + when(manifest.getResolvedTopLevelEntity("catalog", PolarisEntityType.CATALOG)) + .thenReturn(catalogWrapper); + when(manifest.getResolvedPath( + ResolvedPathKey.of(List.of("ns"), PolarisEntityType.NAMESPACE), true)) + .thenReturn(namespaceWrapper); + when(manifest.getAllActivatedCatalogRoleAndPrincipalRoles()).thenReturn(Set.of()); + doNothing() + .when(authorizer) + .authorizeOrThrow( + any(PolarisPrincipal.class), + ArgumentMatchers.any(), + any(PolarisAuthorizableOperation.class), + ArgumentMatchers.any(), + ArgumentMatchers.>any()); + + AuthorizationDecision decision = + authorizer.authorize( + authzState, + principal, + List.of( + AuthorizationRequest.of( + PolarisAuthorizableOperation.GET_CATALOG, + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog"))), + AuthorizationRequest.of( + PolarisAuthorizableOperation.LIST_NAMESPACES, + PolarisSecurable.of( + new PathSegment(PolarisEntityType.CATALOG, "catalog"), + new PathSegment(PolarisEntityType.NAMESPACE, "ns"))))); + + assertThat(decision.isAllowed()).isTrue(); + verify(authorizer, times(1)) + .authorizeOrThrow( + eq(principal), + eq(Set.of()), + eq(PolarisAuthorizableOperation.GET_CATALOG), + eq(List.of(catalogWrapper)), + eq(null)); + verify(authorizer, times(1)) + .authorizeOrThrow( + eq(principal), + eq(Set.of()), + eq(PolarisAuthorizableOperation.LIST_NAMESPACES), + eq(List.of(namespaceWrapper)), + eq(null)); + } + + @Test + void authorizeBatchThrowsWhenEmpty() { + PolarisAuthorizerImpl authorizer = new PolarisAuthorizerImpl(mock(RealmConfig.class)); + AuthorizationState authzState = new AuthorizationState(); + authzState.setResolutionManifest(mock(PolarisResolutionManifest.class)); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> authorizer.authorize(authzState, principal, List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must contain at least one request"); + } + @Test void authorizeReturnsDenyDecision() { PolarisAuthorizerImpl authorizer = spy(new PolarisAuthorizerImpl(mock(RealmConfig.class))); AuthorizationState authzState = new AuthorizationState(); PolarisResolutionManifest manifest = mock(PolarisResolutionManifest.class); PolarisResolvedPathWrapper catalogWrapper = mock(PolarisResolvedPathWrapper.class); + PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role")); authzState.setResolutionManifest(manifest); when(manifest.getResolvedTopLevelEntity("catalog", PolarisEntityType.CATALOG)) @@ -234,14 +348,10 @@ void authorizeReturnsDenyDecision() { AuthorizationRequest request = AuthorizationRequest.of( - PolarisPrincipal.of("alice", Map.of(), Set.of("role")), PolarisAuthorizableOperation.GET_CATALOG, - List.of( - AuthorizationTargetBinding.of( - PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")), - null))); + PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog"))); - AuthorizationDecision decision = authorizer.authorize(authzState, request); + AuthorizationDecision decision = authorizer.authorize(authzState, principal, request); assertThat(decision.isAllowed()).isFalse(); assertThat(decision.getMessage()).hasValue("missing privilege");