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 fad800f298e..d26984ea373 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 @@ -80,4 +80,19 @@ void authorizeOrThrow( @NonNull PolarisAuthorizableOperation authzOp, @Nullable List targets, @Nullable List secondaries); + + /** + * Filters a candidate list of securables to only those the principal is authorized to see. + * + *

The default implementation returns all candidates unchanged, preserving backward + * compatibility for authorizers that do not implement visibility filtering. + * + *

If filtering encounters an error, implementations should throw rather than fall + * back to returning unfiltered results. + */ + @NonNull + default List filterByVisibility( + @NonNull AuthorizationState authzState, @NonNull VisibilityFilterRequest request) { + return request.candidates(); + } } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/VisibilityFilterRequest.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/VisibilityFilterRequest.java new file mode 100644 index 00000000000..0b3fa7acf9e --- /dev/null +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/VisibilityFilterRequest.java @@ -0,0 +1,30 @@ +/* + * 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, + @NonNull PolarisSecurable container, + @NonNull List candidates) {} diff --git a/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java b/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java index 31db1d987e9..57a8287f0fd 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/config/FeatureConfiguration.java @@ -401,6 +401,18 @@ public static void enforceFeatureEnabledOrThrow( .defaultValue(false) .buildFeatureConfiguration(); + public static final FeatureConfiguration ENTITY_VISIBILITY_FILTERING_ENABLED = + PolarisConfiguration.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 ENABLE_GENERIC_TABLES = PolarisConfiguration.builder() .key("ENABLE_GENERIC_TABLES") diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/VisibilityFilterRequestTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/VisibilityFilterRequestTest.java new file mode 100644 index 00000000000..cd36288a74c --- /dev/null +++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/VisibilityFilterRequestTest.java @@ -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 activatedEntities, + PolarisAuthorizableOperation authzOp, + @Nullable PolarisResolvedPathWrapper target, + @Nullable PolarisResolvedPathWrapper secondary) {} + + @Override + public void authorizeOrThrow( + PolarisPrincipal polarisPrincipal, + Set activatedEntities, + PolarisAuthorizableOperation authzOp, + @Nullable List targets, + @Nullable List 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 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); + 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 candidates = List.of(TABLE_SECURABLE_1, TABLE_SECURABLE_2); + + VisibilityFilterRequest request = + new VisibilityFilterRequest( + PolarisAuthorizableOperation.LIST_TABLES, NS_SECURABLE, candidates); + + List 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 result = + authorizer.filterByVisibility(mock(AuthorizationState.class), request); + + assertThat(result).isEmpty(); + } +}