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");
+  }
+}