feat(security): let a delegated_admin read the invitations it issued (#8240) - #8425
Conversation
…8240) #8095 narrowed the sys_invitation ledger to owner/admin plus a row-scope carve-out for the addressee. delegated_admin -- the one grade that reaches /organization/invite-member without being an org admin (ADR-0105 D8) -- normalizes to neither org_owner nor org_admin, so it read zero of the ledger, including the invitations it had just issued itself. Maintainer ruling 2026-08-13, option C: one additional row-scope policy, sys_invitation_issuer (inviter_id == current_user.id) in member_default, domained to the delegated_admin grade. Owner/admin visibility unchanged; the ledger's audience is not widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…cy roster rbac-objects.test.ts enumerates member_default's RLS policy names exhaustively; the #8240 issuer carve-out has to appear there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
… issuer scope The D8 item's 'read sys_invitation as the delegated_admin and verify no row was left behind' step could not fail before this card: the delegate read zero rows unconditionally. With the issuer carve-out a leaked row from a refused attempt would carry the delegate's own inviter_id and land inside the scope, so the step is now load-bearing. Measured, not assumed -- the ruling asked for verification rather than a checklist reword, and no checklist edit was needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Fixes #8240
Implements the maintainer's option C ruling of 2026-08-13: a
delegated_adminsees the invitations they themselves issued, not the whole ledger.The gap
delegated_adminis the one grade that reaches/organization/invite-memberwithout being an org admin (ADR-0105 D8 / #3697). #8095 narrowed thesys_invitationledger to an admin admission domained toorg_owner/org_adminplus an addressee carve-out — anddelegated_adminnormalizes to neither of those identities, nor does it receiveorganization_adminfrom the auto-grant. So the issuing principal could create invitations it then could not list, with no second path back (better-auth's ownlist-invitationsroute is owner/admin gated too).The change — one row-scope policy
member_defaultgainssys_invitation_issuer, a sibling of the addressee carve-out already sitting beside it:Same set, same declarative mechanism, same
select— differing only in which end of the invitation it keys on. Owner/admin visibility is untouched; their admission still comes fromsys_invitation_org_admin, and a test asserts that set is byte-identical to before.The
positionsdomain is not decoration. The predicate looks self-domaining (only owner/admin/delegated_admin can ever be aninviter_id, and the first two already read everything), but without the domain a principal demoted out of an administrative grade keeps a permanent window onto what it issued while it held one — wider than what was ruled. The domain only ever widens: a principal it does not match keeps the addressee scope and fails closed.Measured four-persona matrix
Real
GET /api/v1/data/sys_invitationresponses, stock showcase boot, ledger of three invitations from two distinct inviters (two by the org-owner grade, one by the delegate).The "before" column is measured, not recalled: ablation M1 below removes the policy and the delegate drops to zero, which reproduces the card's original probe exactly.
Anti-vacuity — predict-then-mutate ablation
Predictions were written down before any edit. The trap this card names is that a pin asking only "can the delegate see a row" passes under the ruled option and under the rejected option B, so the fixture carries a second inviter and asserts the negative case.
expected [] to have a length of 1 but got +0id != nullexpected [ ... ] to have a length of 1 but got 3positionsdomainM2 is the one that matters: under option B's shape the delegate read 3 rows — the whole ledger — and the negative assertions fire. A single-inviter fixture could not have told the two apart.
M3 came out green exactly as predicted, and that is why the
positionsdomain also carries a structural pin indefault-permission-sets.test.tsrather than relying on the HTTP matrix. Verified complementary: with M3 applied, the unit pin goes red (expected undefined to deeply equal [ 'delegated_admin' ]) while all seven HTTP assertions stay green. The two layers catch different things, measured rather than asserted.ADR-0105 D8 checklist — no rewording needed, and the step stops being vacuous
The ruling predicted the D8 checklist steps become correct as written and asked for verification rather than assumption. Confirmed by measurement;
docs/qa/platform-checklist/areas/identity-auth.jsonis not edited by this PR.One finding beyond the prediction: the item's second step ("read
sys_invitationvia the API and verify NO row was left behind" by the refused admin-role attempt) was not merely broken before — it was vacuously passing. The delegate read zero rows unconditionally, so the step returned "no row" whether or not a row had actually been left behind. With the issuer scope in place, a leaked row would carry the delegate's owninviter_idand therefore land inside the granted scope, so the step is load-bearing for the first time. A test now drives that exact sequence.Tests
packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts— 7 passed. Four personas over real HTTP, plus a by-id probe for the delegate in both directions (refused for a row it did not issue, 200 for the one it did — so the refusal is the row scope talking, not the object being closed).@objectstack/plugin-security— 51 files, 1033 tests passed.@objectstack/dogfoodfull suite — 107 files, 750 tests passed.typecheckclean on both packages.check:nul-bytes,check:engine-double-contract(193 pinned / 2 exempt — this PR declares no engine double),check:error-code-casing,check:agent-model-declared. No ratchet, ledger or baseline touched.Generated by Claude Code