Skip to content

runtime: Auth Refactor - Update CatalogHandlers and PolarisAdminService to use resolveAuthorizationInputs#4356

Merged
sungwy merged 24 commits into
apache:mainfrom
sungwy:auth-refactor-phase4
Jul 8, 2026
Merged

runtime: Auth Refactor - Update CatalogHandlers and PolarisAdminService to use resolveAuthorizationInputs#4356
sungwy merged 24 commits into
apache:mainfrom
sungwy:auth-refactor-phase4

Conversation

@sungwy

@sungwy sungwy commented May 2, 2026

Copy link
Copy Markdown
Contributor

Phase 4 of #3779

Use resolveAuthorizationInputs() at handler and admin call sites so authorization-driven entity resolution is owned by the PolarisAuthorizer SPI rather than being hardcoded in service code.

Before this change, CatalogHandlers and PolarisAdminService eagerly performed broad Polaris resolution before authorization, including the target entity and other RBAC-related entities such as the reference catalog, caller principal, principal roles, and catalog roles. That meant resolution scope was effectively fixed by the handlers, even for authorizers that would not need Polaris RBAC state.

After this change, handlers build authorization inputs and pass them to PolarisAuthorizer.resolveAuthorizationInputs(). The authorizer then decides what Polaris entities, if any, need to be resolved in order to authorize the request.

Important call outs:

  • updateTable() now resolves the reference catalog separately before auth so it can read catalog-scoped config for fine-grained update privileges, then runs normal authorizer-controlled pre-resolution for authorization. This removes the old unconditional full RBAC-style pre-auth resolution, but introduces a less-atomic two-resolution window for catalog config.
  • Empty-namespace table-like identifiers are no longer tolerated in the migrated paths. Table-like targets are now expected to have a valid namespace. This removes legacy compatibility for invalid admin grant inputs like TableIdentifier.of(Namespace.empty(), ...)

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)

Copilot AI left a comment

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.

Pull request overview

This PR advances the runtime auth refactor from issue #3779 by moving pre-authorization entity resolution out of handlers/admin call sites and into PolarisAuthorizer.resolveAuthorizationInputs(), so authorizers can decide what Polaris state they need to resolve before authorization.

Changes:

  • Replaced direct resolutionManifest.resolveAll() calls in catalog/admin handlers with AuthorizationRequest/AuthorizationState-driven pre-resolution.
  • Added PolarisSecurableMapper to translate handler/admin identifiers into canonical authorization targets.
  • Updated test scaffolding and mocks to simulate the new pre-resolution flow, and adjusted updateTable() to read catalog-scoped config via a separate catalog-only resolution step.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java Updates shared test fixture authorizer mock to resolve inputs through the new SPI path.
runtime/service/src/test/java/org/apache/polaris/service/admin/PolarisAdminServiceTest.java Adapts admin service tests to the new pre-resolution contract and revised table-like grant setup.
runtime/service/src/main/java/org/apache/polaris/service/catalog/policy/PolicyCatalogHandler.java Migrates policy handler auth pre-resolution to AuthorizationRequest inputs.
runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java Refactors updateTable() auth setup to resolve catalog config separately before auth.
runtime/service/src/main/java/org/apache/polaris/service/catalog/common/PolarisSecurableMapper.java New helper for mapping catalog/domain identifiers into PolarisSecurable paths.
runtime/service/src/main/java/org/apache/polaris/service/catalog/common/CatalogHandler.java Migrates shared catalog handler auth helpers to authorizer-controlled input resolution.
runtime/service/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java Migrates admin auth helpers and grant flows to resolveAuthorizationInputs().
polaris-core/src/main/java/org/apache/polaris/core/persistence/resolver/PolarisResolutionManifest.java Exposes primary resolver status for callers that now read post-resolution status after authorizer-controlled resolution.
extensions/auth/ranger/impl/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisAuthorizer.java Adds temporary backward-compatible resolveAuthorizationInputs() implementation for Ranger.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +50 to +57
public static PolarisSecurable namespace(String catalogName, Namespace namespace) {
ImmutablePolarisSecurable.Builder builder =
ImmutablePolarisSecurable.builder()
.addPathSegment(new PathSegment(PolarisEntityType.CATALOG, catalogName));
Arrays.stream(namespace.levels())
.map(level -> new PathSegment(PolarisEntityType.NAMESPACE, level))
.forEach(builder::addPathSegment);
return builder.build();

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.

Thanks Co-pilot, let me take a look into this

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.

updated PolarisSecurableMapper methods to explicitly validate the inputs: .namespace() expects non-empty Namespace

@sungwy
sungwy marked this pull request as ready for review May 5, 2026 11:41
@dimas-b

dimas-b commented May 13, 2026

Copy link
Copy Markdown
Contributor

Should we hold this until after 1.5.0 is branched ?

TableIdentifier identifier) {
ensureResolutionManifestForTable(identifier);
if (resolutionManifest.getPrimaryResolverStatus() == null) {
if (!ops.isEmpty()) {

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.

Should it be a runtime error is ops is empty? 🤔

PolarisEntitySubType subType,
TableIdentifier identifier) {
ensureResolutionManifestForTable(identifier);
if (resolutionManifest.getPrimaryResolverStatus() == 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.

How can we have a resolutionManifest here that has already been resolved?.. Just for my education 😅

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.

This is a good call out. I broke down resolution and authorization into two separate methods so we can have the caller ensure that resolution is being called once

@dimas-b

dimas-b commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

@sungwy : Is this ready for another review round, I assume?

}

protected void authorizeBasicTableLikeOperationOrThrow(
protected void resolveBasicTableLikeTargetOrThrow(

@sungwy sungwy Jun 3, 2026

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: we have a few cases where we resolve only once, and then authorize using the same manifest in IcebergCatalogHandler like in updateTable and loadTable.

Having two separate methods for resolution and authorization ensures that we are resolving just once, without relying on a logic that checks if we have yet to resolve - like if (resolutionManifest.getPrimaryResolverStatus() == null) - which is more errorprone.

dimas-b
dimas-b previously approved these changes Jun 3, 2026

@dimas-b dimas-b left a comment

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.

LGTM 👍 Thanks, @sungwy !

@github-project-automation github-project-automation Bot moved this from PRs In Progress to Ready to merge in Basic Kanban Board Jun 3, 2026
@dimas-b
dimas-b requested review from dennishuo and flyrain June 3, 2026 21:37
@sungwy

sungwy commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

LGTM 👍 Thanks, @sungwy !

Thanks @dimas-b !

My apologies, but looking at the CI, I think there's some additional changes I'd need to make in order to update the existing test suite to conform to the new pattern.

My hunch is that a CatalogHander instance is being reused across requests in the test suite, which doesn't mirror how we do things in production today.

sungwy added 3 commits June 9, 2026 06:31
# Conflicts:
#	runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
#	runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java
dimas-b
dimas-b previously approved these changes Jun 16, 2026

@flyrain flyrain left a comment

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.

Thanks @sungwy for working on it. Left some comments.
Also empty-namespace table-like identifiers (TableIdentifier.of(Namespace.empty(), ...)) are now rejected on migrated admin-grant paths. It's user-observable, worth a CHANGELOG.md line even though the bulk is an internal refactor.

List.of(
new SingleTargetAuthorizationIntent(
op, PolarisSecurable.of(new PathSegment(entityType, topLevelEntityName))))));
ResolverStatus status = resolutionManifest.getPrimaryResolverStatus();

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.

getPrimaryResolverStatus() is @Nullable, should we check it before using?

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's a good call out. I've changed the public method to getPrimaryResolverStatusOrThrow() which throws if the PolarisResolutionManifest had not yet resolved.

return builder.build();
}

public static PolarisSecurable tableLike(String catalogName, TableIdentifier identifier) {

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.

namespace() throws on an empty namespace, but tableLike() here silently maps an empty namespace to catalog/<name>. Since this PR explicitly drops empty-namespace table-like support, add the same identifier.namespace().isEmpty() guard here rather than relying on the resolver to reject it downstream.

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.

Thanks @flyrain - this is a good callout. When I first ran into this, I decided that it would be safe to make this change because we don't support empty namespaces in the IcebergCatalogHandler today in Polaris.

But I agree that it's good to document any behavioral changes in the CHANGELOG


public abstract PolarisAuthorizer authorizer();

public abstract AuthorizationState authorizationState();

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.

AuthorizationState looks like a per-call value, not request-scoped shared state.

Tracing how it's used: the handler already holds the manifest in its own resolutionManifest field (CatalogHandler.java:96). Each authorize method does resolutionManifest = newResolutionManifest(), then authorizationState().setResolutionManifest(resolutionManifest), then reads results back from its own field, never from authorizationState().getResolutionManifest(). So AuthorizationState's only job is to be the parameter type carrying the manifest into resolveAuthorizationInputs(...) so the authorizer can read it back out.

That leaves two holders of the same reference with contradictory semantics: the handler field gets freely reassigned, but AuthorizationState.setResolutionManifest is write-once (compareAndSet(null, ...) that throws on a second set). The write-once guard only makes sense because the instance is @RequestScoped and shared, and it's effectively dead defensive code, since every call site just wants to wrap a freshly-built manifest and pass it along.

Since every setResolutionManifest is really "replace with the new manifest I just built," I think this should just be a fresh object per authorize call:

authorizer().resolveAuthorizationInputs(new AuthorizationState(resolutionManifest), request);

That would let us drop the @Produces @RequestScoped producer, the abstract authorizationState() accessor + injected factory fields, and the AtomicReference/compareAndSet guard (just a final field). This simplify the plumbing quite a bit. It also removes a latent landmine: any future flow that runs two authorize passes in one request would hit "resolution manifest already set" on the shared write-once instance.

If the intent was for AuthorizationState to accumulate state across auth phases, then the write-once guard contradicts that and we should design it as a real accumulator instead. Either way the current shape feels in-between. 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.

Hi @flyrain thanks for the detailed review.

I see why it reads that way, but I think the intended contract is different.

AuthorizationState is meant to be request-scoped state that the PolarisAuthorizer can manipulate throughout the request. This is basically a renamed version of the “auth call context” idea we discussed in the Polaris Community Sync 1/27/2026, which allows us to avoid having to do a large refactoring of the Persistence/Resolution layer to reach the same outcome today of skipping RBAC resolution for external authorizers.

The write-once behavior per request is intentional to me. For a given request, we should know the full path of the resource we want to resolve up front. If we try to swap the manifest halfway through the request, I’d rather fail loudly than silently authorize/execute against a different resolved view.

I agree the current duplicate storage is awkward: the handler field and AuthorizationState point to the same thing. But I think that becomes a supporting case for a refactoring of the Resolver/Persistence layer, rather than making AuthorizationState a disposable per-call wrapper.

@flyrain flyrain Jun 18, 2026

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.

Thanks for all the context, @sungwy . Do we expect adding more attributes to the AuthorizationState? Are they expected to be request scoped? It'd be nice to share more forward looking thoughts if you don't mind.

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 @flyrain I don’t currently have a concrete second attribute in mind for AuthorizationState. The main state I care about here is the PolarisResolutionManifest, because it represents the resolved view we use across authz and execution.

The forward-looking part is that this was meant to preserve today’s resolution semantics while moving the decision of what to resolve into the PolarisAuthorizer. Longer term, I still think a larger Persistence/Resolution refactor is probably the cleaner direction, so handlers and authorizers are less coupled to a callsite-managed PolarisResolutionManifest directly. Keeping AuthorizationState request-scoped felt like a small step in that direction, but I agree it is not the full design.

Given that the larger refactor has not been designed yet, I’m okay relaxing this for now and treating AuthorizationState as a per-resolution wrapper around the manifest. That keeps this PR focused on moving resolution choice into the authorizer, while leaving request-scoped persistence/resolution state for a more concrete follow-up design.

The tradeoff is that we lose the request-lifetime guard and keep responsibility for resolved-view consistency at the call sites, which is basically where it already lives today.

@flyrain , @dimas-b - does that sound like a good way to move this PR forward?

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.

@sungwy : that works for me 👍

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.

Works for me, thanks @sungwy !

# Conflicts:
#	runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/PolarisGenericTableCatalogHandlerAuthzTest.java
dimas-b
dimas-b previously approved these changes Jun 23, 2026
Comment thread CHANGELOG.md

### Breaking changes
- The `MaintenanceService.performMaintenance()` signature now requires an explicit `OptionalLong overrideRunId` argument to supersede the latest unfinished maintenance run.
- Admin grant APIs now reject table-like privilege targets with an empty namespace. A table-like target without a namespace is considered invalid input.

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.

nit: I'm not sure this is actually a breaking change from the end user perspective, because IRC catalogs do not allow creating tables/views without a namespace, AFAIK... at least not in the Iceberg java client 🤔

@flyingImer flyingImer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this. Read against the #3779 arc, the shape is right. The request side now says what to authorize as plain intent, which is what lets an authorizer participate without inheriting Polaris RBAC. That was #3760's stated aim, and this PR takes it live at the call sites.

The piece I'd settle before the decision path cuts over: the request became implementation-neutral, but the state object didn't. AuthorizationState still hands every authorizer a PolarisResolutionManifest, and each one just calls resolveAll() on it, so an authorizer that doesn't want Polaris-native resolution still has to accept and drive it to satisfy the contract. The intent half lets an authorizer bring its own machinery; the state half still requires the native one. Since the point of the series is to let the authorizer decide whether native resolution happens at all, I'd make that state opaque or optional before phases 5 and 6 lock the contract in.

Two smaller things in the same spirit:

  • updateTable resolves with a placeholder operation to read catalog config, then authorizes a different set. It's fine today, but it quietly assumes resolution is operation-independent, which your own comment flags. That assumption is part of the contract now, so it belongs in the resolveAuthorizationInputs javadoc rather than a TODO. It's exactly what a selective authorizer would break.
  • The empty-namespace rejection turns a 404 into a 400 on public catalog paths (commitTransaction, renameTable source), not just admin grants, and the new mapper applies the guard unevenly (policy() is missing it). The 400 is the right call; the CHANGELOG just scopes it too narrowly.

Direction's good. The contract is easiest to get neutral now, while the new decision API is still dormant at the call sites.

@sungwy

sungwy commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for this. Read against the #3779 arc, the shape is right. The request side now says what to authorize as plain intent, which is what lets an authorizer participate without inheriting Polaris RBAC. That was #3760's stated aim, and this PR takes it live at the call sites.

The piece I'd settle before the decision path cuts over: the request became implementation-neutral, but the state object didn't. AuthorizationState still hands every authorizer a PolarisResolutionManifest, and each one just calls resolveAll() on it, so an authorizer that doesn't want Polaris-native resolution still has to accept and drive it to satisfy the contract. The intent half lets an authorizer bring its own machinery; the state half still requires the native one. Since the point of the series is to let the authorizer decide whether native resolution happens at all, I'd make that state opaque or optional before phases 5 and 6 lock the contract in.

Two smaller things in the same spirit:

  • updateTable resolves with a placeholder operation to read catalog config, then authorizes a different set. It's fine today, but it quietly assumes resolution is operation-independent, which your own comment flags. That assumption is part of the contract now, so it belongs in the resolveAuthorizationInputs javadoc rather than a TODO. It's exactly what a selective authorizer would break.
  • The empty-namespace rejection turns a 404 into a 400 on public catalog paths (commitTransaction, renameTable source), not just admin grants, and the new mapper applies the guard unevenly (policy() is missing it). The 400 is the right call; the CHANGELOG just scopes it too narrowly.

Direction's good. The contract is easiest to get neutral now, while the new decision API is still dormant at the call sites.

Hi @flyingImer - thanks for the review. Are these blocking concerns in your opinion? I'm looking to do one more pass to incorporate the consensus I've been able to reach with @dimas-b @flyrain on the AuthorizationState. If these aren't blocking concerns, I'd like to move this PR forward

sungwy added 2 commits July 7, 2026 11:12
# Conflicts:
#	runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java
dimas-b
dimas-b previously approved these changes Jul 7, 2026
flyrain
flyrain previously approved these changes Jul 7, 2026

@flyrain flyrain left a comment

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 Thanks @sungwy for working on it.

@sungwy
sungwy dismissed stale reviews from flyrain and dimas-b via 59abcf1 July 7, 2026 23:14
@sungwy
sungwy requested a review from dimas-b July 8, 2026 01:30
dimas-b
dimas-b previously approved these changes Jul 8, 2026
# Conflicts:
#	runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogViewTest.java
@sungwy

sungwy commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews @dimas-b , @flyrain and @flyingImer !

@sungwy
sungwy merged commit 896841e into apache:main Jul 8, 2026
25 checks passed
@github-project-automation github-project-automation Bot moved this from Ready to merge to Done in Basic Kanban Board Jul 8, 2026
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