runtime: Auth Refactor - Update CatalogHandlers and PolarisAdminService to use resolveAuthorizationInputs#4356
Conversation
There was a problem hiding this comment.
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 withAuthorizationRequest/AuthorizationState-driven pre-resolution. - Added
PolarisSecurableMapperto 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.
| 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(); |
There was a problem hiding this comment.
Thanks Co-pilot, let me take a look into this
There was a problem hiding this comment.
updated PolarisSecurableMapper methods to explicitly validate the inputs: .namespace() expects non-empty Namespace
|
Should we hold this until after 1.5.0 is branched ? |
| TableIdentifier identifier) { | ||
| ensureResolutionManifestForTable(identifier); | ||
| if (resolutionManifest.getPrimaryResolverStatus() == null) { | ||
| if (!ops.isEmpty()) { |
There was a problem hiding this comment.
Should it be a runtime error is ops is empty? 🤔
| PolarisEntitySubType subType, | ||
| TableIdentifier identifier) { | ||
| ensureResolutionManifestForTable(identifier); | ||
| if (resolutionManifest.getPrimaryResolverStatus() == null) { |
There was a problem hiding this comment.
How can we have a resolutionManifest here that has already been resolved?.. Just for my education 😅
There was a problem hiding this comment.
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
|
@sungwy : Is this ready for another review round, I assume? |
| } | ||
|
|
||
| protected void authorizeBasicTableLikeOperationOrThrow( | ||
| protected void resolveBasicTableLikeTargetOrThrow( |
There was a problem hiding this comment.
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.
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. |
# 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
flyrain
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
getPrimaryResolverStatus() is @Nullable, should we check it before using?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
# Conflicts: # runtime/service/src/test/java/org/apache/polaris/service/catalog/generic/PolarisGenericTableCatalogHandlerAuthzTest.java
|
|
||
| ### 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. |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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:
updateTableresolves 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 theresolveAuthorizationInputsjavadoc 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,renameTablesource), 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 |
# Conflicts: # runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java
# Conflicts: # runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogViewTest.java
|
Thanks for the reviews @dimas-b , @flyrain and @flyingImer ! |
Phase 4 of #3779
Use
resolveAuthorizationInputs()at handler and admin call sites so authorization-driven entity resolution is owned by thePolarisAuthorizerSPI rather than being hardcoded in service code.Before this change,
CatalogHandlers andPolarisAdminServiceeagerly 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.TableIdentifier.of(Namespace.empty(), ...)Checklist
CHANGELOG.md(if needed)site/content/in-dev/unreleased(if needed)