feat(authz): scoped invitations — placement intent gated by the issuer's adminScope, applied on acceptance (ADR-0105 D8) - #3663
Merged
Conversation
…r's adminScope, applied on acceptance (ADR-0105 D8) An invitation may now carry PLACEMENT INTENT (target business unit + positions), so a delegated plant admin's invitee arrives already in the right unit and role instead of waiting on a platform admin. Closes the structural gap D8 names for single-posture deployments; the natural admission path under group. The two halves ship together, deliberately — a carrier without a gate would be an escalation hole. organization_admin is deliberately READ-ONLY on the RBAC tables (auto-org-admin-grant.ts) so a fresh org admin cannot rebind themselves; applying an unchecked invitation payload under system context would hand that authority straight back through the invitation surface. - Issuance authorized against the ISSUER's adminScope (ADR-0090 D12) by dry-running the existing DelegatedAdminGate against the very sys_user_position rows the acceptance would write. Reused verbatim: one copy of the subtree/allowlist logic, so the two can never drift. - Acceptance applies it under system context — idempotent (a replayed acceptance converges) and failure-isolated (a placement miss never undoes a valid membership; the host seam still fires). Surface: sys_invitation gains business_unit_id + positions (ADR-0092 extension fields, in the D7 collision-guarded whitelist, NOT generically editable); plugin-security registers the invitation-placement service; plugin-auth wires beforeCreateInvitation/afterAcceptInvitation to it and FAILS CLOSED when the runtime is absent. Verified: plugin-security 602, plugin-auth 528, platform-objects 215, spec 6659, core 386, runtime 654 — all green; five repo grep guards clean. Co-Authored-By: Claude Fable 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 3 package(s): 17 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 13:29
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.
ADR-0105 D8 engine (the open half of "scoped invitations — engine seam open; UX commercial"). Builds on #3645. Tracking: #3541.
Why the gate and the carrier ship in one PR
I started this as a carrier-only change (fields + whitelist) with placement application planned cloud-side, and found that split is unsafe:
organization_adminis deliberately read-only on the RBAC tables (auto-org-admin-grant.ts: "so a freshly-granted org admin cannot rebind themselves toadmin_full_access"). If the platform applied an invitation's placement payload under system context without checking the issuer, an org admin could assign any position to anyone by routing it through an invitation — recovering exactly the authority that read-only stance denies them. And a framework that declares placement fields nothing applies is thedeclared ≠ enforcedtrap AGENTS.md #10 exists to prevent. So: carrier + gate + application, together, open.What
adminScope(ADR-0090 D12) — judged at issuance, while the actor is the inviter, not at acceptance when the actor is the invitee. The check dry-runs the existingDelegatedAdminGateagainst the verysys_user_positionrows the acceptance would write, which is ADR-0105 D8's "the existing anti-escalation gate, reused verbatim" taken literally: one copy of the subtree/allowlist/containment logic, so a future tightening of the gate tightens invitations in the same commit. A refusal rejects the whole invitation (no row created).onInvitationAcceptedhost seam still fires).Surface
platform-objectssys_invitation.business_unit_id+.positionsplugin-authschema.invitation.additionalFieldsso the columns are real and the invite endpoint carries the request;beforeCreateInvitation/afterAcceptInvitationwiring; lazyinvitation-placementresolver (plugin-security starts after plugin-auth)plugin-securityinvitation-placementservice —assertIssuable/applyAn invitation without placement intent never consults the gate and behaves exactly as before.
Verification
granted_by, and the four hook paths (authorized, refused, no-runtime-⇒-refused, ordinary invitation untouched, placement-failure-keeps-membership).Follow-up (cloud, commercial)
The issuance UX on cloud#874: BU/position pickers constrained to the issuer's scope, over this now-gated seam. End-to-end HTTP verification belongs with that PR —
ee-group-showcaseis the natural home, since it already boots the enterprise runtime that registers this service.🤖 Generated with Claude Code
https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
Generated by Claude Code