-
Notifications
You must be signed in to change notification settings - Fork 493
Entity-level filtering: API and feature flag #4831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,4 +80,19 @@ void authorizeOrThrow( | |
| @NonNull PolarisAuthorizableOperation authzOp, | ||
| @Nullable List<PolarisResolvedPathWrapper> targets, | ||
| @Nullable List<PolarisResolvedPathWrapper> secondaries); | ||
|
|
||
| /** | ||
| * Filters a candidate list of securables to only those the principal is authorized to see. | ||
| * | ||
| * <p>The default implementation returns all candidates unchanged, preserving backward | ||
| * compatibility for authorizers that do not implement visibility filtering. | ||
| * | ||
| * <p>If filtering encounters an error, implementations should throw rather than fall back to | ||
| * returning unfiltered results. | ||
| */ | ||
| @NonNull | ||
| default List<PolarisSecurable> filterByVisibility( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any hope the API evolves to
?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The callers in Polaris code are all synchronous now... what would be the benefit of making just the Authrorizer reactive?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. slowly moving to reactive code to use a remote backend, this has some serious impact on scalability (hundreds vs thousands of concurrent requests)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are virtual threads a possible alternative?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but in general, I'd +1 moving towards more concurrent and more scalable code 😉
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. virtual threads can be an option even if there are still cases they lead to antipatterns, I always prefer to explicit state by the signature the method is not compute only, |
||
| @NonNull AuthorizationState authzState, @NonNull VisibilityFilterRequest request) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This conceptually overlaps with the new Once can call this method and deduce the authorization decision for the entity from the fact that is it excluding (or included) in the output of this method. At the same time, this should always be aligned with passing the data "operation" and "securable" through the Would it be possible to have one main authorization method that would return an WDYT?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the review! I'll address comments one by one, starting with this suggestion.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can update the The request will have some information that is shared across all entities in the list, and implementation are free to use it to short-circuit decisions, of course. Mapping the same decision to each entity in the returned value is probably not a lot of overhead after the decision is made. We may need to change the return type of Does this sound reasonable?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed with @dimas-b here. Reusing the existing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this makes sense to me, I agree on using a batch method rather than a separate filterbyVisibility. Approach 1: reusing existing authorize() method
pros:
cons:
Approach 2: creating a new authorize() method and retrofit the old methods into it
retrofit old method: pros: same as approach 1 + no need to migrate the caller-sites immediately Approach 3: creating a new authorize() method intended for batch keep old method create new batch method pros:
cons:
I'm a bit leaning toward approach 3 since the existing single-entity callers don't benefit from batch method, but the consistency and deduplication are also very appealing point for approach 1&2. What do you think about the single-entity callers concern?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks @gracechen09 for the writeup. Approach 3 makes sense to me. Here are reasons:
Based on the above points. Here is the what it could look like: Filtering becomes caller-side glue, not an SPI method:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re: Re: Perhaps it might be best to migrate all authZ callers to use CC: @sungwy Cf. #5034 |
||
| return request.candidates(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /* | ||
| * 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 java.util.List; | ||
| import org.jspecify.annotations.NonNull; | ||
|
|
||
| /** Carries the context used to filter LIST-operation candidates by visibility. */ | ||
| public record VisibilityFilterRequest( | ||
| @NonNull PolarisAuthorizableOperation listOperation, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| @NonNull PolarisSecurable container, | ||
| @NonNull List<PolarisSecurable> candidates) {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -401,6 +401,18 @@ public static void enforceFeatureEnabledOrThrow( | |
| .defaultValue(false) | ||
| .buildFeatureConfiguration(); | ||
|
|
||
| public static final FeatureConfiguration<Boolean> ENTITY_VISIBILITY_FILTERING_ENABLED = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This flag is not used in this PR, let's defer introducing it until it's actually required. |
||
| PolarisConfiguration.<Boolean>builder() | ||
| .key("ENTITY_VISIBILITY_FILTERING_ENABLED") | ||
| .description( | ||
| "If set to true, entity-level visibility filtering is applied to LIST operations " | ||
| + "(listTables, listViews, listNamespaces, listCatalogs). " | ||
| + "When enabled, each authorizer filters the candidate result set so that only " | ||
| + "entities the principal is authorized to see are returned. " | ||
| + "Requires LIST_PAGINATION_ENABLED to be true.") | ||
| .defaultValue(false) | ||
| .buildFeatureConfiguration(); | ||
|
|
||
| public static final FeatureConfiguration<Boolean> ENABLE_GENERIC_TABLES = | ||
| PolarisConfiguration.<Boolean>builder() | ||
| .key("ENABLE_GENERIC_TABLES") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| /* | ||
| * 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 static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.mockito.Mockito.mock; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Set; | ||
| import org.apache.polaris.core.entity.PolarisBaseEntity; | ||
| import org.apache.polaris.core.entity.PolarisEntityType; | ||
| import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper; | ||
| import org.jspecify.annotations.Nullable; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| public class VisibilityFilterRequestTest { | ||
|
|
||
| /** | ||
| * Minimal concrete authorizer that does not override {@code filterByVisibility}, so the default | ||
| * no-op implementation on the interface is exercised. | ||
| */ | ||
| private static final class NoOpAuthorizer implements PolarisAuthorizer { | ||
| @Override | ||
| public void resolveAuthorizationInputs( | ||
| AuthorizationState authzState, AuthorizationRequest request) {} | ||
|
|
||
| @Override | ||
| public AuthorizationDecision authorize( | ||
| AuthorizationState authzState, AuthorizationRequest request) { | ||
| return AuthorizationDecision.allow(); | ||
| } | ||
|
|
||
| @Override | ||
| public void authorizeOrThrow( | ||
| PolarisPrincipal polarisPrincipal, | ||
| Set<PolarisBaseEntity> activatedEntities, | ||
| PolarisAuthorizableOperation authzOp, | ||
| @Nullable PolarisResolvedPathWrapper target, | ||
| @Nullable PolarisResolvedPathWrapper secondary) {} | ||
|
|
||
| @Override | ||
| public void authorizeOrThrow( | ||
| PolarisPrincipal polarisPrincipal, | ||
| Set<PolarisBaseEntity> activatedEntities, | ||
| PolarisAuthorizableOperation authzOp, | ||
| @Nullable List<PolarisResolvedPathWrapper> targets, | ||
| @Nullable List<PolarisResolvedPathWrapper> secondaries) {} | ||
| } | ||
|
|
||
| private static final PolarisSecurable CATALOG_SECURABLE = | ||
| PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "myCatalog")); | ||
|
|
||
| private static final PolarisSecurable NS_SECURABLE = | ||
| PolarisSecurable.of( | ||
| new PathSegment(PolarisEntityType.CATALOG, "myCatalog"), | ||
| new PathSegment(PolarisEntityType.NAMESPACE, "ns1")); | ||
|
|
||
| private static final PolarisSecurable TABLE_SECURABLE_1 = | ||
| PolarisSecurable.of( | ||
| new PathSegment(PolarisEntityType.CATALOG, "myCatalog"), | ||
| new PathSegment(PolarisEntityType.NAMESPACE, "ns1"), | ||
| new PathSegment(PolarisEntityType.TABLE_LIKE, "table1")); | ||
|
|
||
| private static final PolarisSecurable TABLE_SECURABLE_2 = | ||
| PolarisSecurable.of( | ||
| new PathSegment(PolarisEntityType.CATALOG, "myCatalog"), | ||
| new PathSegment(PolarisEntityType.NAMESPACE, "ns1"), | ||
| new PathSegment(PolarisEntityType.TABLE_LIKE, "table2")); | ||
|
|
||
| @Test | ||
| void recordExposesAllFields() { | ||
| List<PolarisSecurable> candidates = List.of(TABLE_SECURABLE_1, TABLE_SECURABLE_2); | ||
|
|
||
| VisibilityFilterRequest request = | ||
| new VisibilityFilterRequest( | ||
| PolarisAuthorizableOperation.LIST_TABLES, NS_SECURABLE, candidates); | ||
|
|
||
| assertThat(request.listOperation()).isEqualTo(PolarisAuthorizableOperation.LIST_TABLES); | ||
| assertThat(request.container()).isSameAs(NS_SECURABLE); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are we testing here?... that the second constructor parameter is the |
||
| assertThat(request.candidates()).isSameAs(candidates); | ||
| } | ||
|
|
||
| @Test | ||
| void defaultFilterByVisibilityReturnsAllCandidatesUnchanged() { | ||
| // The default no-op implementation on PolarisAuthorizer must return all candidates | ||
| // unchanged, preserving backward compatibility for authorizers that do not override it. | ||
| PolarisAuthorizer authorizer = new NoOpAuthorizer(); | ||
| List<PolarisSecurable> candidates = List.of(TABLE_SECURABLE_1, TABLE_SECURABLE_2); | ||
|
|
||
| VisibilityFilterRequest request = | ||
| new VisibilityFilterRequest( | ||
| PolarisAuthorizableOperation.LIST_TABLES, NS_SECURABLE, candidates); | ||
|
|
||
| List<PolarisSecurable> result = | ||
| authorizer.filterByVisibility(mock(AuthorizationState.class), request); | ||
|
|
||
| assertThat(result).isSameAs(candidates); | ||
| } | ||
|
|
||
| @Test | ||
| void defaultFilterByVisibilityWithEmptyCandidatesReturnsEmpty() { | ||
| PolarisAuthorizer authorizer = new NoOpAuthorizer(); | ||
|
|
||
| VisibilityFilterRequest request = | ||
| new VisibilityFilterRequest( | ||
| PolarisAuthorizableOperation.LIST_NAMESPACES, CATALOG_SECURABLE, List.of()); | ||
|
|
||
| List<PolarisSecurable> result = | ||
| authorizer.filterByVisibility(mock(AuthorizationState.class), request); | ||
|
|
||
| assertThat(result).isEmpty(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From my POV using
defaultSPI methods is rather confusing. Implementations will be tempted to forget implementing this method, when they should 🤔By contrast, the
defaultauthorizeOrThrow()method on line 61 is not an SPI method IMHO, but a convenience method.