You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by su...@apache.org on 2026/03/23 10:07:43 UTC
(polaris) branch main updated: core: refactor `PolarisSecurable` with `PathSegment` (#4016)
This is an automated email from the ASF dual-hosted git repository.
sungwy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new fdcd5f522b core: refactor `PolarisSecurable` with `PathSegment` (#4016)
fdcd5f522b is described below
commit fdcd5f522bd161dd8d315acbeec08ba47437cd8a
Author: Sung Yun <10...@users.noreply.github.com>
AuthorDate: Mon Mar 23 03:07:38 2026 -0700
core: refactor `PolarisSecurable` with `PathSegment` (#4016)
* refactor PolarisSecurable with PathSegment
* adopt feedback from copilot
* adopt feedback - thanks alex
* adopt nits
---
.../polaris/core/auth/AuthorizationRequest.java | 2 +-
.../org/apache/polaris/core/auth/PathSegment.java | 30 +++++
.../apache/polaris/core/auth/PolarisSecurable.java | 83 ++++++++++---
.../core/auth/AuthorizationRequestTest.java | 29 +++--
.../apache/polaris/core/auth/PathSegmentTest.java | 41 +++++++
.../polaris/core/auth/PolarisSecurableTest.java | 129 +++++++++++++++++++++
6 files changed, 292 insertions(+), 22 deletions(-)
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 c02432dc0d..6f4916f59b 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
@@ -98,7 +98,7 @@ public interface AuthorizationRequest {
}
static boolean containsType(PolarisSecurable securable, PolarisEntityType... types) {
- PolarisEntityType entityType = securable.getEntityType();
+ PolarisEntityType entityType = securable.getLeaf().entityType();
for (PolarisEntityType type : types) {
if (entityType == type) {
return true;
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PathSegment.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PathSegment.java
new file mode 100644
index 0000000000..3c582542cd
--- /dev/null
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PathSegment.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 com.google.common.base.Preconditions;
+import org.apache.polaris.core.entity.PolarisEntityType;
+
+/** One segment in a fully qualified resource path. */
+public record PathSegment(PolarisEntityType entityType, String name) {
+ public PathSegment {
+ Preconditions.checkNotNull(entityType, "entityType must be non-null");
+ Preconditions.checkNotNull(name, "name must be non-null");
+ }
+}
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisSecurable.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisSecurable.java
index 739815fab9..d6ddc061eb 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisSecurable.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisSecurable.java
@@ -18,30 +18,85 @@
*/
package org.apache.polaris.core.auth;
+import com.google.common.base.Preconditions;
import jakarta.annotation.Nonnull;
import java.util.List;
import org.apache.polaris.core.entity.PolarisEntityType;
import org.apache.polaris.immutables.PolarisImmutable;
+import org.immutables.value.Value;
-/**
- * Intent-only target reference for authorization decisions.
- *
- * <p>Represents the target name and entity type without any resolved RBAC state. This captures the
- * pure authorization intent of a request. Callers should provide the full name path as {@link
- * #getNameParts()} for hierarchical entities.
- */
+/** Fully qualified resource path represented as ordered PathSegments. */
@PolarisImmutable
public interface PolarisSecurable {
- static PolarisSecurable of(
- @Nonnull PolarisEntityType entityType, @Nonnull List<String> nameParts) {
- return ImmutablePolarisSecurable.builder().entityType(entityType).nameParts(nameParts).build();
+ static PolarisSecurable of(@Nonnull PathSegment leaf) {
+ return ImmutablePolarisSecurable.builder().addPathSegment(leaf).build();
}
- /** Returns the entity type of the securable. */
+ /**
+ * Creates a securable from a full ordered path.
+ *
+ * <p>The segments must be ordered from the furthest parent segment to the leaf segment. For
+ * example, a table path should be ordered as {@code CATALOG, NAMESPACE, TABLE_LIKE}.
+ */
+ static PolarisSecurable of(@Nonnull PathSegment first, @Nonnull PathSegment... rest) {
+ return ImmutablePolarisSecurable.builder().addPathSegment(first).addPathSegments(rest).build();
+ }
+
+ /**
+ * Returns the full ordered path from the highest parent segment to the leaf segment.
+ *
+ * <p>For example, a table path would be ordered as {@code [CATALOG, NAMESPACE, TABLE_LIKE]}.
+ */
+ @Nonnull
+ List<PathSegment> getPathSegments();
+
+ /** Returns the leaf segment of the path. */
@Nonnull
- PolarisEntityType getEntityType();
+ @Value.Derived
+ default PathSegment getLeaf() {
+ List<PathSegment> pathSegments = getPathSegments();
+ Preconditions.checkState(
+ !pathSegments.isEmpty(), "PathSegments must contain at least one segment");
+ return pathSegments.get(pathSegments.size() - 1);
+ }
- /** Returns the name parts that identify the securable in hierarchical order. */
+ /** Returns ordered parent segments from furthest parent to immediate parent. */
@Nonnull
- List<String> getNameParts();
+ @Value.Derived
+ default List<PathSegment> getParents() {
+ List<PathSegment> pathSegments = getPathSegments();
+ Preconditions.checkState(
+ !pathSegments.isEmpty(), "PathSegments must contain at least one segment");
+ return pathSegments.subList(0, pathSegments.size() - 1);
+ }
+
+ @Value.Check
+ default void validate() {
+ Preconditions.checkState(
+ !getPathSegments().isEmpty(), "PathSegments must contain at least one segment");
+ Preconditions.checkState(
+ getPathSegments().get(0).entityType().isTopLevel(),
+ "PathSegments must start with a top-level entity");
+ for (PathSegment segment : getPathSegments()) {
+ Preconditions.checkState(
+ segment.entityType() != PolarisEntityType.ROOT, "PathSegments must not include ROOT");
+ }
+ if (getLeaf().entityType().isTopLevel()) {
+ Preconditions.checkState(
+ getParents().isEmpty(),
+ "top-level securable leaf=%s must not declare parents",
+ getLeaf());
+ } else {
+ for (int i = 1; i < getPathSegments().size(); i++) {
+ PathSegment parent = getPathSegments().get(i - 1);
+ PathSegment child = getPathSegments().get(i);
+ Preconditions.checkState(
+ child.entityType().getParentType() == parent.entityType()
+ || (child.entityType().isParentSelfReference()
+ && child.entityType() == parent.entityType()),
+ "PathSegments must follow declared parent hierarchy for child=%s",
+ child);
+ }
+ }
+ }
}
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 b46cb906a1..5e2e1790b6 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
@@ -19,6 +19,7 @@
package org.apache.polaris.core.auth;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import java.util.Map;
@@ -36,7 +37,8 @@ public class AuthorizationRequestTest {
PolarisAuthorizableOperation.LOAD_TABLE,
List.of(
AuthorizationTargetBinding.of(
- PolarisSecurable.of(PolarisEntityType.PRINCIPAL, List.of("alice")), null)));
+ PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "alice")),
+ null)));
assertThat(request.hasSecurableType(PolarisEntityType.PRINCIPAL)).isTrue();
}
@@ -49,9 +51,9 @@ public class AuthorizationRequestTest {
PolarisAuthorizableOperation.ASSIGN_PRINCIPAL_ROLE,
List.of(
AuthorizationTargetBinding.of(
- PolarisSecurable.of(PolarisEntityType.PRINCIPAL, List.of("alice")),
+ PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "alice")),
PolarisSecurable.of(
- PolarisEntityType.PRINCIPAL_ROLE, List.of("analytics-admin")))));
+ new PathSegment(PolarisEntityType.PRINCIPAL_ROLE, "analytics-admin")))));
assertThat(request.hasSecurableType(PolarisEntityType.PRINCIPAL_ROLE)).isTrue();
}
@@ -64,11 +66,15 @@ public class AuthorizationRequestTest {
PolarisAuthorizableOperation.ASSIGN_CATALOG_ROLE_TO_PRINCIPAL_ROLE,
List.of(
AuthorizationTargetBinding.of(
- PolarisSecurable.of(PolarisEntityType.NAMESPACE, List.of("catalog", "ns")),
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.CATALOG, "catalog"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns")),
null),
AuthorizationTargetBinding.of(
- PolarisSecurable.of(PolarisEntityType.CATALOG, List.of("catalog")),
- PolarisSecurable.of(PolarisEntityType.CATALOG_ROLE, List.of("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();
}
@@ -81,8 +87,17 @@ public class AuthorizationRequestTest {
PolarisAuthorizableOperation.LOAD_VIEW,
List.of(
AuthorizationTargetBinding.of(
- PolarisSecurable.of(PolarisEntityType.CATALOG, List.of("catalog")), null)));
+ PolarisSecurable.of(new PathSegment(PolarisEntityType.CATALOG, "catalog")),
+ null)));
assertThat(request.hasSecurableType(PolarisEntityType.PRINCIPAL_ROLE)).isFalse();
}
+
+ @Test
+ void throwsWhenSecurableDoesNotStartWithTopLevelEntity() {
+ assertThatThrownBy(
+ () -> PolarisSecurable.of(new PathSegment(PolarisEntityType.NAMESPACE, "ns")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must start with a top-level entity");
+ }
}
diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/PathSegmentTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/PathSegmentTest.java
new file mode 100644
index 0000000000..30d254f3c4
--- /dev/null
+++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/PathSegmentTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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.assertThatThrownBy;
+
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.junit.jupiter.api.Test;
+
+public class PathSegmentTest {
+
+ @Test
+ void pathSegmentRejectsNullEntityType() {
+ assertThatThrownBy(() -> new PathSegment(null, "name"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("entityType must be non-null");
+ }
+
+ @Test
+ void pathSegmentRejectsNullName() {
+ assertThatThrownBy(() -> new PathSegment(PolarisEntityType.CATALOG, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("name must be non-null");
+ }
+}
diff --git a/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisSecurableTest.java b/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisSecurableTest.java
new file mode 100644
index 0000000000..28a4f69b99
--- /dev/null
+++ b/polaris-core/src/test/java/org/apache/polaris/core/auth/PolarisSecurableTest.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.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.junit.jupiter.api.Test;
+
+public class PolarisSecurableTest {
+
+ @Test
+ void topLevelSecurableHasLeafOnly() {
+ PolarisSecurable principal =
+ PolarisSecurable.of(new PathSegment(PolarisEntityType.PRINCIPAL, "principalA"));
+
+ assertThat(principal.getLeaf())
+ .isEqualTo(new PathSegment(PolarisEntityType.PRINCIPAL, "principalA"));
+ assertThat(principal.getParents()).isEmpty();
+ assertThat(principal.getPathSegments())
+ .containsExactly(new PathSegment(PolarisEntityType.PRINCIPAL, "principalA"));
+ }
+
+ @Test
+ void catalogScopedSecurableHasCorrectParentEntities() {
+ PolarisSecurable table =
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1"),
+ new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"));
+
+ assertThat(table.getLeaf()).isEqualTo(new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"));
+ assertThat(table.getParents())
+ .containsExactly(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1"));
+ assertThat(table.getPathSegments())
+ .containsExactly(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1"),
+ new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"));
+ }
+
+ @Test
+ void securableAllowsNestedNamespaces() {
+ PolarisSecurable table =
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns2"),
+ new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"));
+
+ assertThat(table.getLeaf()).isEqualTo(new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"));
+ assertThat(table.getParents())
+ .containsExactly(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns2"));
+ }
+
+ @Test
+ void securableRejectsRootInPath() {
+ assertThatThrownBy(
+ () ->
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.ROOT, "root"),
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must not include ROOT");
+ }
+
+ @Test
+ void securableRejectsRootLeaf() {
+ assertThatThrownBy(() -> PolarisSecurable.of(new PathSegment(PolarisEntityType.ROOT, "root")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must not include ROOT");
+ }
+
+ @Test
+ void securableRejectsInvalidInChainParentHierarchy() {
+ assertThatThrownBy(
+ () ->
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA"),
+ new PathSegment(PolarisEntityType.TABLE_LIKE, "table1"),
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must follow declared parent hierarchy");
+ }
+
+ @Test
+ void securableRejectsPathThatDoesNotStartWithTopLevelEntity() {
+ assertThatThrownBy(
+ () ->
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.NAMESPACE, "ns1"),
+ new PathSegment(PolarisEntityType.TABLE_LIKE, "table1")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must start with a top-level entity");
+ }
+
+ @Test
+ void topLevelSecurableRejectsParents() {
+ assertThatThrownBy(
+ () ->
+ PolarisSecurable.of(
+ new PathSegment(PolarisEntityType.CATALOG, "catalogParent"),
+ new PathSegment(PolarisEntityType.CATALOG, "catalogA")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must not declare parents");
+ }
+}