Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-security): row-scope sys_invitation read to its addressee; keep the full ledger with owner/admin (#8095) - #8242
Conversation
…8095) A plain org member could read every sys_invitation row of the org through the data API — other invitees' email addresses, the role each was about to be granted, the inviter and the expiry — because the BETTER_AUTH_MANAGED_OBJECTS blanket grants allowRead on every managed identity table while neither member_default nor viewer_readonly declared a row policy for this object, and an object with no applicable policy compiles to a null Layer 1. member_default and viewer_readonly each gain sys_invitation_self (select, email == current_user.email). The full ledger stays with owner/admin via organization_admin's sys_invitation_org. The object-level read bit is deliberately left open so the addressee still reads their OWN row — the accept/reject row actions are gated on record.email == ctx.user.email, so closing the object would break acceptance while looking like the same fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
… wall-less posture (#8095) The member-side row scope reaches org admins too — member_default resolves for every authenticated principal via the everyone anchor — and on the DEFAULT single posture neither mechanism that normally keeps an admin whole is present: ADR-0105 D4 withholds the viewAllRecords short-circuit from a wall-less deployment (organization_admin_no_bypass), and ADR-0105 D3 strips sys_invitation_org as a platform tenant policy when org isolation is inactive. Measured on a stock boot with only the member-side scope: the org OWNER read zero invitations. organization_admin now states the admission in its own right as sys_invitation_org_admin, positions-domained to org_owner/org_admin with a predicate carrying no tenant token for the strip to key on. The domain only ever widens, so a principal it does not match keeps the addressee scope and fails closed. 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:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8095
Maintainer ruling of 2026-08-12 (option B):
sys_invitationread narrows to owner/admin, plus a row-scope carve-out so an invitee still sees their own invitation. Fixed at the permission-set producer, not with a per-route filter.The defect, mechanically
sys_invitationis inBETTER_AUTH_MANAGED_OBJECTS, sodenyWritesOnManagedObjects()grantsallowRead: trueon it tomember_defaultandviewer_readonly— reads permitted, "subject to the rest of the RLS chain". For this object there was no rest of the chain: neither set declared a row-level policy forsys_invitation, andRLSCompiler.compileFilterreturnsnullon an empty applicable set, which the caller applies as no row filter. A plain member therefore read the organization's entire invitation ledger — every invitee's address, the role each was about to be granted, the inviter, the expiry.What changed
Three policies, all in
packages/plugins/plugin-security/src/objects/default-permission-sets.ts:member_defaultsys_invitation_selfemail == current_user.emailviewer_readonlysys_invitation_selfemail == current_user.emailorganization_admin(+ the derived_no_bypassvariant)sys_invitation_org_adminid != null,positions: [org_owner, org_admin]The object-level read bit is deliberately left open. Closing it would take the invitee's own row with it, and the recipient-side row actions (
accept_invitation/reject_invitation) are gated onrecord.email == ctx.user.email— an addressee who cannot read their row cannot act on it. That breakage would have passed a member-only assertion looking exactly like "permissions fixed", which is why the fixture asserts all three personas.The third policy is not decoration, and it is the half I got wrong first.
member_defaultresolves for every authenticated principal (theeveryoneanchor), so the addressee scope reaches org admins too — and on the defaultsingleposture neither mechanism that normally keeps an admin whole is present: ADR-0105 D4 withholds theviewAllRecordsshort-circuit from a wall-less deployment (organization_admin_no_bypass), and ADR-0105 D3 stripssys_invitation_orgas a platform tenant policy when org isolation is inactive. Measured on a stock boot with only the member-side scope in place, the org owner read zero invitations.sys_invitation_org_adminstates the admission on the axis that survives both, carrying no tenant token for the strip to key on; Layer 0 remains the tenant wall and AND-composes ahead of it, so its widest reach is the admin's own organization — exactly whatsys_invitation_orgalready declared. Thepositionsdomain is on the widening so it can only add: a principal it does not match keeps the addressee scope and fails closed. On the narrowing it would invert that, since "no matching position" means no policy, which means no row filter at all.Verification — the live probe, four personas
packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.tsdrives realGET /api/v1/data/sys_invitationrequests through the in-process HTTP stack, against a stockshowcaseboot with two invitations created by the realinvite-memberendpoint. An assertion whose expectation and reality both came fromdefault-permission-sets.tscould not fail, so nothing here reads that constant.The member read is 200 with zero rows rather than 403 — this is a row narrowing, and a 403 would be the object-level lockout that also closes the invitee's row.
Ablation, predicted before measured
Removed
sys_invitation_selffrom both sets, keeping the admin widening. Predicted plain red in exactly 6 cases; measured exactly those 6, no others, direction as predicted, and the owner control stayed green:default-permission-sets.test.tsmember_default casedefault-permission-sets.test.tsviewer_readonly caserbac-objects.test.tsexhaustive policy-name listThe ablation reproduces the card's original symptom exactly: the member gets 2 rows. Restored with
git checkoutfrom the commit; re-verified green afterwards.Consumption-radius sweep
Everything that reads
sys_invitation, and its disposition:invite-member,accept-invitation,reject-invitation,cancel-invitation,list-invitations,list-user-invitations,get-invitation. All go throughplugin-auth's ObjectQL adapter, which injectscontext: { isSystem: true }, so RLS never runs. Unaffected — the invitation lifecycle is untouched.AcceptInvitationPage/InvitationsPagecall the auth SDK (getInvitation,acceptInvitation), not the data API. Unaffected.organizations.invitations.list— wrapslist-invitations, already documented "Requires owner/admin role on that org". Unaffected.nav_invitations, thesys_organizationrecord page's Invitations tab, and the foursys_invitationlist views — generic data-API grids. Admins keep the full ledger; a non-admin reaching them now sees only their own row. Intended change.invitation-placement.ts,adopt-membership.ts,invitation-role-cap.ts— all inside better-auth hooks under system context. Unaffected.delegated-admin-invite,membership-role-vocabulary,membership-actor-attribution) — read viaql.findunder a system context. Unaffected; full suite green.sys_member/sys_team— deliberately left alone. The card calls membership a legitimate staff directory;sys_teamhas had no ruling. Both are in the latent state described in [finding] adding an RLS narrowing tomember_defaultsilently narrows ORG ADMINS on the defaultsingleposture — both of their widening mechanisms are absent there #8241.docs/qa/platform-checklist/areas/identity-auth.json— the ADR-0105 D8 item has steps readingsys_invitationvia the data API as thedelegated_admin, which now return nothing. Left alone, reported asdelegated_admincan issue invitations but can no longer read them via the data API — the #8095 narrowing lands on owner/admin only #8240; the item's preferred lane (the pinned dogfood test) reads under a system context and is unaffected.Gates
Derived with
scripts/pm/dispatch-gates.mjs, plus the three it cannot derive:check:type-check-debtwas run from the repo root after a fullturbo run buildclosure — it refuses to run without one, and the ledger was neither raised nor lowered.Out of scope, filed
delegated_admincan issue invitations but can no longer read them via the data API — the #8095 narrowing lands on owner/admin only #8240 —delegated_admincan issue invitations but no longer reads them via the data API. Concrete; three options laid out, none taken here.member_defaultsilently narrows ORG ADMINS on the defaultsingleposture — both of their widening mechanisms are absent there #8241 — the general trap: any narrowing added tomember_defaultsilently narrows org admins on the default posture. Observation-class.Generated by Claude Code