Skip to content

core: implement new authorize SPI in PolarisAuthorizerImpl and OpaPolarisAuthorizer#3999

Draft
sungwy wants to merge 5 commits intoapache:mainfrom
sungwy:auth-phase-3
Draft

core: implement new authorize SPI in PolarisAuthorizerImpl and OpaPolarisAuthorizer#3999
sungwy wants to merge 5 commits intoapache:mainfrom
sungwy:auth-phase-3

Conversation

@sungwy
Copy link
Copy Markdown
Contributor

@sungwy sungwy commented Mar 15, 2026

This PR implements phases 2 and 3 of the authorization refactor tracking issue. (#3779)

It introduces request-based authorization with AuthorizationRequest, in both PolarisAuthorizerImpl and OpaPolarisAuthorizer. The new SPI method authorize is still not called by Polaris endpoint handlers. That will be done in phase 4 and 5 of the authorization refactoring effort. Hence, no behavior changes are expected in existing endpoint handling, except for ROOT handling in OpaPolarisAuthorizer noted below.

In order to decouple RBAC semantics from Polaris's core, we move RBAC operation semantics like Privileges and newly introduced Scope semantic into RbacOperationSemantics. The PR also adds FullyQualifiedPath as a lexical path with parent representation that OpaPolarisAuthorizer can use to construct the OPA request input payload.

Breaking change

OPA input payloads no longer include ROOT in resource parent chains. This is intentional, since ROOT is an internal Polaris RBAC semantic and should not be part of the external OPA request contract

Checklist

  • 🛡️ Don't disclose security issues! (contact security@apache.org)
  • 🔗 Clearly explained why the changes are needed, or linked related issues: Fixes #
  • 🧪 Added/updated tests with good coverage, or manually tested (and explained how)
  • 💡 Added comments for complex logic
  • 🧾 Updated CHANGELOG.md (if needed)
  • 📚 Updated documentation in site/content/in-dev/unreleased (if needed)

import org.apache.polaris.core.persistence.ResolvedPolarisEntity;

/** Lexical fully qualified resource path split into leaf and ordered parent segments. */
public record FullyQualifiedPath(PathSegment leaf, List<PathSegment> parents) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: this class was needed to absorb the logic required in converting a PolarisSecurable to a fully qualified path representation that included the correct parents and their respective PolarisEntityType that could be constructed and inserted into the OPA request payload.

I'm on the fence as to whether we'd want this to be the representation in AuthorizationRequest instead of PolarisSecurable. Leaning towards keeping them separate for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this class is in polaris-core, which tends to indicate that it's not only for the OPA authorizer. Having both models in polaris-core seems a bit overkill.

This model is imho richer than PolarisSecurable. I wonder if you couldn't merge both models together, e.g.:

@PolarisImmutable
public interface PolarisSecurable {

  /** Returns the path segments that identify the securable, in hierarchical order. */
  @Nonnull
  List<PathSegment> getPathSegments();

  default PathSegment getLeafPathSegment() {
    return getPathSegments().getLast();
  }

  @Value.Check
  default void validate() {
    Preconditions.checkState(!getPathSegments().isEmpty(), "path segments must not be empty");
  }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having both models in polaris-core seems a bit overkill.

I thought about this as well, I'm currently leaning towards keeping it in polaris-core because new PolarisAuthorizer implementations like the Ranger Authorizer may also need this.

This model is imho richer than PolarisSecurable. I wonder if you couldn't merge both models together, e.g.:

I'm open to this idea. In order to correctly construct the parent though, we'd need to pass the referenceCatalogName to an arg referenceCatalogName arg here like

default PathSegment getParents(@Nullable String referenceCatalogName) {
    ...
}

Would that make sense?

Copy link
Copy Markdown
Contributor

@dimas-b dimas-b Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to think PolarisSecurable must know its catalog name anyway. Permission checks in Polaris are always performed within the context of a catalog 🤔 WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a good point.

I'm starting to think that the shape of FullyQualifiedPath is more in line with what we'd want to have in place of PolarisSecurable in the AuthorizationRequest, instead of having it represented as a PolarisEntityType and a List of String as its name.

Having clear PathSegment with ordered parents and reference catalog is a better representation of the resource... What do you folks think about just replacing PolarisSecurable with FullyQualifiedPath's shape instead?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you folks think about just replacing PolarisSecurable with FullyQualifiedPath's shape instead?

Works for me 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds good yeah.

Comment on lines +77 to +80
// Omit ROOT from fully qualified paths. It is an internal RBAC-only ancestor
if (resolvedParent.getEntity().getType() == PolarisEntityType.ROOT) {
continue;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: important behavior change to remove ROOT semantics from OPA request payload, since this is an RBAC-only semantic

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack, but we are introducing a specific behavior in polaris-core. Shouldn't we instead keep the ROOT entity here, and let specific authorizers filter it out? I feel like the ROOT case deserves some more thinking. It's also troubling that the current code sometimes prepends it, and sometimes not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fully agree.

public static FullyQualifiedPath of(PolarisResolvedPathWrapper wrapper)

This is only a temporary constructor introduced for compatibility, and will be removed from FullyQualifiedPath in the future. Which means ROOT semantics will no longer be in this module.

Copy link
Copy Markdown
Contributor Author

@sungwy sungwy Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: large refactoring here to move PolarisPrivilege semantics out of PolarisAuthorizableOperation, and into a completely separate module specific to RBAC instead. See RbacOperationSemantics.java

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to this sub-section of the current PR. Does it depend on other changes a lot? I think it might be preferable to do this part in a dedicated PR. This way, OPA changes will hopefully be isolated from the internal RBAC changes in that commit (or at least less intertwined) WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I had that thought as well. I'm happy to do it in a dedicated PR...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweet, let's merge that first :)

TargetType targetType) {
boolean prependRootContainer =
switch (targetType) {
case TARGET -> semantics.targetScope() == PathEvaluationScope.ROOT;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused here. Every operation uses PathEvaluationScope.ROOT except the policy attach/detach operations which use CATALOG.

Is this correct for catalog-scoped operations like LIST_NAMESPACES, LIST_TABLES, ...

These operations semantically happen within a catalog context. If the existing authorizeOrThrow callers already prepend the root container themselves, then ROOT make sense for backward compatibility. Maybe worth to add a comment here to clarify.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @jbonofre - thanks for the early feedback on the PR :)

I was confused initially as well, so your confusion isn't unfounded. the ROOT scope here refers to whether ROOT container should be prepended to the resolved path, and hence whether the ROOT container resource should be included for privilege evaluation.

For example, PolarisAuthorizableOperation.LIST_NAMESPACES prepends root container:

resolutionManifest.getResolvedPath(ResolvedPathKey.ofNamespace(namespace), true);

PolarisAuthorizableOperation.GET_APPLICABLE_POLICIES_ON_CATALOG sets prependRootContainer to false.

I think the name PathEvaluationScope may be misleading. I'll think of a better name for this enum

import org.apache.polaris.core.persistence.ResolvedPolarisEntity;

/** Lexical fully qualified resource path split into leaf and ordered parent segments. */
public record FullyQualifiedPath(PathSegment leaf, List<PathSegment> parents) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this class is in polaris-core, which tends to indicate that it's not only for the OPA authorizer. Having both models in polaris-core seems a bit overkill.

This model is imho richer than PolarisSecurable. I wonder if you couldn't merge both models together, e.g.:

@PolarisImmutable
public interface PolarisSecurable {

  /** Returns the path segments that identify the securable, in hierarchical order. */
  @Nonnull
  List<PathSegment> getPathSegments();

  default PathSegment getLeafPathSegment() {
    return getPathSegments().getLast();
  }

  @Value.Check
  default void validate() {
    Preconditions.checkState(!getPathSegments().isEmpty(), "path segments must not be empty");
  }
}

Comment on lines +77 to +80
// Omit ROOT from fully qualified paths. It is an internal RBAC-only ancestor
if (resolvedParent.getEntity().getType() == PolarisEntityType.ROOT) {
continue;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack, but we are introducing a specific behavior in polaris-core. Shouldn't we instead keep the ROOT entity here, and let specific authorizers filter it out? I feel like the ROOT case deserves some more thinking. It's also troubling that the current code sometimes prepends it, and sometimes not.

Comment on lines +71 to +75
@Nullable
@Value.Default
default String getReferenceCatalogName() {
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be enough:

Suggested change
@Nullable
@Value.Default
default String getReferenceCatalogName() {
return null;
}
@Nullable
String getReferenceCatalogName();

But how about using an Optional here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

ResolvedPathRooting targetScope,
ResolvedPathRooting secondaryScope) {

private RbacOperationSemantics(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure we need this constructor, why don't you update RBAC_SEMANTICS_BY_OPERATION using EnumSet as below:

 RBAC_SEMANTICS_BY_OPERATION.put(
        PolarisAuthorizableOperation.LIST_NAMESPACES,
        new RbacOperationSemantics(EnumSet.of(NAMESPACE_LIST), null, ResolvedPathRooting.ROOT, null));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was more for convenience / beautification. Do you think using EnumSet directly is better? I'm okay either way

EnumSet<PolarisPrivilege> targetPrivileges,
EnumSet<PolarisPrivilege> secondaryPrivileges,
ResolvedPathRooting targetScope,
ResolvedPathRooting secondaryScope) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secondaryScope is either null (90% of cases), or identical to targetScope. Can we use one single ResolvedPathRooting maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a great insight, but I also didn't want to make assumptions so I went with a safer approach of keeping them separate (feels more logical to do so)

On second thought, maybe we keep them as the same for now, since this is an internally used SPI, maybe it's okay to change it to decouple them in the future if we need to

import org.apache.polaris.core.persistence.ResolvedPolarisEntity;

/** Lexical fully qualified resource path split into leaf and ordered parent segments. */
public record FullyQualifiedPath(PathSegment leaf, List<PathSegment> parents) {
Copy link
Copy Markdown
Contributor

@dimas-b dimas-b Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: swap arguments maybe? In JRE's Path parents go first 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, but parents could be an empty list though. Feels counterintuitive to have an empty list as the first argument as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we only have one List<PathSegment> in this record and add convenience methods for creating child paths based on an existing parent path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds great actually

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a linked-list-like approach be more appropriate? E.g.

public record PathSegment(PolarisEntityType entityType, String name, @Nullable PathSegment parent) {

  public PathSegment(PolarisEntityType entityType, String name) {
    this(entityType, name, null);
  }

  public String fullyQualifiedName() {
    return parent == null ? name : parent.fullyQualifiedName() + "/" + name;
  }
}

Then we might not need FullyQualifiedPath at all (but path traversals may be less efficient).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably change recursion to iteration if it becomes critical.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with either approach.

Minor comment: if we go with linked lists, I think it might be preferable to name the object something like EntityPath rather than PathSegment because it contains full path data down to the root.

Copy link
Copy Markdown
Contributor

@dimas-b dimas-b left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM overall - thank for keeping our AuthZ improvements going, @sungwy 😉

@flyrain
Copy link
Copy Markdown
Contributor

flyrain commented Mar 25, 2026

Hi @sungwy , is this PR ready for review?

@sungwy
Copy link
Copy Markdown
Contributor Author

sungwy commented Mar 25, 2026

Hi @flyrain - not yet, I will need to update the branch with some recent changes.

I've been at KubeCon this week, I'll get back to it soon :)

sungwy added 3 commits March 28, 2026 17:15
# Conflicts:
#	polaris-core/src/main/java/org/apache/polaris/core/auth/PathSegment.java
#	polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizableOperation.java
#	polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java
#	polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisSecurable.java
#	polaris-core/src/main/java/org/apache/polaris/core/auth/RbacOperationSemantics.java
#	polaris-core/src/test/java/org/apache/polaris/core/auth/AuthorizationRequestTest.java
#	polaris-core/src/test/java/org/apache/polaris/core/auth/RbacOperationSemanticsTest.java
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants