fix(security): resolve the issuer's real grants when authorizing invitation placement (ADR-0105 D8) - #3695
Merged
Conversation
…tation placement
Scoped-invitation issuance (ADR-0105 D8) dry-runs `DelegatedAdminGate` against
the `sys_user_position` rows the acceptance would write. The gate reads
authority off `context.positions` / `context.permissions`
(`resolvePermissionSetsForContext`) — but `beforeCreateInvitation` handed it a
hand-built `{ userId, tenantId }`, which carries neither. Every delegated
administrator resolved to the additive baseline alone and was refused with
"requires tenant-level administration or a delegated adminScope".
Fail-closed, but dead: only a tenant admin could issue a placement, which is
the one caller the feature was not for. Caught by cloud's group-posture
dogfood driving the real HTTP path with a real delegate.
`assertIssuable` now takes `actorUserId` and resolves that user's grants
itself via `@objectstack/core` `resolveUserAuthzGrants` — the userId-driven
half of the single authz resolver, producing the same envelope a transport
would have carried, from the same reads. A better-auth hook has no request to
resolve a context from, so the id is what a caller can honestly supply and the
resolution belongs behind the service boundary rather than in front of it.
A principal-less call still reaches the gate with an empty context: the gate
owns that refusal too, so there stays exactly one place an issuance is denied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 27, 2026 14:50
This was referenced Jul 27, 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.
What's wrong
Scoped-invitation issuance (#3663, ADR-0105 D8) dry-runs
DelegatedAdminGateagainst the
sys_user_positionrows the acceptance would write — the rightdesign, wired wrong.
The gate judges authority from
context.positions/context.permissions(
security-plugin.tsresolvePermissionSetsForContext). ButbeforeCreateInvitationhanded it a hand-built context:No
positions, nopermissions. Every issuer therefore resolved to theadditive baseline and nothing else, so the gate refused them all:
Fail-closed, but dead: only a tenant admin could ever issue a placement,
and a tenant admin is the one caller the feature was not built for. The whole
point of D8 is letting a plant admin invite straight into their own subtree.
Caught by cloud's
ee-group-showcasegroup-posture dogfood — the first thingto drive the real HTTP path with a real delegate rather than a mocked gate.
My unit tests passed because they asserted the context was forwarded, never
that it carried authority.
The fix
assertIssuabletakesactorUserIdinstead of a caller-built context andresolves that user's grants itself, through the single authz resolver:
resolveUserAuthzGrantsis the userId-driven half ofresolveAuthzContext—factored out precisely so a surface that knows who the principal is, with no
HTTP request to resolve it from, builds the same envelope from the same
reads (
sys_member,sys_user_position,sys_*_permission_set) instead ofre-deriving them. A better-auth hook is exactly that surface.
Why the id and not a context: a hook can honestly supply who is issuing; it
cannot supply what they may do. Pushing resolution behind the service
boundary means the next caller cannot reintroduce this bug by passing a
plausible-looking object.
A principal-less call still reaches the gate with an empty context on purpose —
the gate's own
!ctx.userIdbranch owns that refusal, so the security boundarykeeps exactly one place an issuance can be denied.
Tests
invitation-placement.test.ts): an issuer holdingplant_adminviasys_user_positionnow reaches the gate with thatposition on the context. This is the assertion the original tests were
missing — they pinned forwarding, not authority.
{}(gate owns the refusal).auth-manager.test.tsupdated to expectactorUserId.Compatibility
InvitationPlacementServiceis an internal kernel service (registered bySecurityPlugin, consumed by plugin-auth's invitation hooks). The signaturechange is contained to that seam — no REST/SDK surface moves, no schema change.
Shipped as a patch: the feature never worked for its intended caller, so
there is no working behaviour to preserve.
🤖 Generated with Claude Code
https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
Generated by Claude Code