fix: resolve declared dependencies at every hop of a chain, not just the root - #1947
Open
serguei-gorokhov wants to merge 1 commit into
Open
serguei-gorokhov wants to merge 1 commit into
serguei-gorokhov wants to merge 1 commit into
Conversation
…the root ResolveResourceDependenciesFn.apply() skipped resolution whenever a per-request key was already present -- true at every hop past the root, and true on a plain root call behind an interceptor (the post-interceptor handleDeployment re-entry already carries the interceptor's key). Identity was never the obstacle: ApiKeyData.initFromContext propagates extractedClaims/originalKey at every depth, so ProxyContext.userId is always the originating human -- which is why placeholder-path resolution already worked at any depth. Only the reach check needed care: the general permission chain's own-bucket rule deliberately flips to the per-request key holder's own Keys/<app>/ sandbox, the wrong function to ask "does the human own this". AccessService gains a second, purpose-built rule list -- lookupOriginatingUserPermissions, evaluated over own-bucket-via- buildInitiatorBucket + getSharedAccess + getPublicAccess only. Rules that read the current per-request-key holder's own state (getAutoSharedAccess, getPerRequestPermissions, getAppResourceAccess, getAppSelfAccess) are deliberately excluded: including them would let a calling app launder grants it already holds into satisfying the next app's independent reach check, compounding privilege down a chain instead of every hop being checked against the human alone. admin/global-reader/review access are also excluded as a stated scope narrowing (dependency targets are meant to be the human's own/shared/public resources, not operator-role-gated ones). lookupPermissions's existing signature and behavior are unchanged -- zero-behavior-change for all 10 existing call sites. Resolution now runs uniformly for root and chained calls with no depth-conditional branch. A required dependency that does not resolve on a chained or interceptor hop now hard-fails that call (403) -- the same rule the root call has always enforced, but a new, previously-absent failure mode for existing chained deployments. source_deployment is added to the three ResourceDependencyAuditLog events (grant/denial/runtimeFail): once resolution runs at every hop, user_id/ trace_id/actor evidence are identical at every hop, so nothing else in the audit trail distinguished "app2 got the grant" from "app1's call caused app2 to get the grant". Spec: documentation repo, offline-access-delegation/implementation-specs/pr4b-chained-call-resolution.md Co-Authored-By: Claude Code <noreply@anthropic.com>
serguei-gorokhov
requested review from
Oleksii-Klimov and
astsiapanay
as code owners
September 9, 2026 19:05
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Applicable issues
Description of changes
ResolveResourceDependenciesFn.apply()skipped resolution whenever a per-request key was already present — true at every hop past the root, and true on a plain root call behind an interceptor (the post-interceptorhandleDeploymentre-entry already carries the interceptor's key). Identity was never the obstacle:ApiKeyData.initFromContextpropagatesextractedClaims/originalKeyat every depth, soProxyContext.userIdis always the originating human — which is why placeholder-path resolution already worked at any depth. Only the reach check needed care: the general permission chain's own-bucket rule deliberately flips to the per-request key holder's ownKeys/<app>/sandbox — the wrong function to ask "does the human own this".AccessServicegains a second, purpose-built rule list —lookupOriginatingUserPermissions, evaluated over own-bucket-via-buildInitiatorBucket+getSharedAccess+getPublicAccessonly. Rules that read the current per-request-key holder's own state (getAutoSharedAccess,getPerRequestPermissions,getAppResourceAccess,getAppSelfAccess) are deliberately excluded: including them would let a calling app launder grants it already holds into satisfying the next app's independent reach check, compounding privilege down a chain instead of every hop being checked against the human alone.lookupPermissions's existing signature and behavior are unchanged — zero-behavior-change for all 10 existing call sites.Resolution now runs uniformly for root and chained calls with no depth-conditional branch.
source_deploymentis added to the threeResourceDependencyAuditLogevents (grant/denial/runtimeFail): once resolution runs at every hop, user_id/trace_id/actor evidence are identical at every hop, so nothing else in the audit trail distinguished "app2 got the grant" from "app1's call caused app2 to get the grant".Behavior changes flagged for review
requireddependency that does not resolve on a chained or interceptor hop now hard-fails that call with 403. Before this PR such a hop silently skipped resolution and stayed callable regardless. This is the correct semantics (an app never half-works silently) and the same rule the root call has always enforced — but it is a new, previously-absent failure mode for existing chained deployments, reachable by any app that declaresrequired: trueand is invoked non-first in a chain by a user whose reach or consent does not cover the target. Operators who need the old behavior setrequired: false, which degrades exactly as before.getAdminAccess,getGlobalReaderAccessorgetReviewAccess: an admin or global reader invoking a declaring app no longer has operator-role standing counted toward that app's dependency reach. Deliberate (a dependency target is the human's own/shared/public resource, not an operator-role-gated one) — if anyone wants admin standing to count, that is a design conversation, not a code tweak.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
🤖 Generated with Claude Code