Skip to content

fix(plugin-security): row-scope sys_invitation read to its addressee; keep the full ledger with owner/admin (#8095) - #8242

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8095-narrow-sys-invitation-read
Aug 12, 2026
Merged

fix(plugin-security): row-scope sys_invitation read to its addressee; keep the full ledger with owner/admin (#8095)#8242
os-zhuang merged 2 commits into
mainfrom
claude/issue-8095-narrow-sys-invitation-read

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8095

Maintainer ruling of 2026-08-12 (option B): sys_invitation read 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_invitation is in BETTER_AUTH_MANAGED_OBJECTS, so denyWritesOnManagedObjects() grants allowRead: true on it to member_default and viewer_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 for sys_invitation, and RLSCompiler.compileFilter returns null on 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:

setpolicypredicateeffect
member_defaultsys_invitation_selfemail == current_user.emailaddressee only
viewer_readonlysys_invitation_selfemail == current_user.emailaddressee only
organization_admin (+ the derived _no_bypass variant)sys_invitation_org_adminid != null, positions: [org_owner, org_admin]the full ledger

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 on record.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_default resolves for every authenticated principal (the everyone anchor), so the addressee scope reaches org admins too — and on the defaultsingle 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 in place, the org owner read zero invitations. sys_invitation_org_admin states 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 what sys_invitation_org already declared. The positions domain 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.ts drives real GET /api/v1/data/sys_invitation requests through the in-process HTTP stack, against a stock showcase boot with two invitations created by the real invite-member endpoint. An assertion whose expectation and reality both came from default-permission-sets.ts could not fail, so nothing here reads that constant.

owner 200 total: 2 both rows, with role: "admin" and inviter_id
member 200 total: 0 (was 200, total: 2 — the card's measurement)
invitee 200 total: 1 their own row, and only theirs
delegated_admin 200 total: 0 see #8240
member GET /data/sys_invitation/{outsider row id}
-> 404 {"error":"Record DDxGgiWlVOgYq5mZLSH89QfZEAJ6YBmi not found in sys_invitation",
"code":"RECORD_NOT_FOUND","object":"sys_invitation"}
— the address does not appear in the body

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_self from 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:

#predicted redmeasured
1dogfood: a plain MEMBER reads NONE of itred — 2 rows, expected 0
2dogfood: the INVITEE still reads their own rowred — 2 rows, expected 1
3dogfood: the narrowing holds against a by-id fetchred — 200, expected 403/404
4default-permission-sets.test.ts member_default casered
5default-permission-sets.test.ts viewer_readonly casered
6rbac-objects.test.ts exhaustive policy-name listred
dogfood: the org OWNER still reads the full ledgergreen, as predicted

The ablation reproduces the card's original symptom exactly: the member gets 2 rows. Restored with git checkout from the commit; re-verified green afterwards.

Consumption-radius sweep

Everything that reads sys_invitation, and its disposition:

  • better-auth organization endpointsinvite-member, accept-invitation, reject-invitation, cancel-invitation, list-invitations, list-user-invitations, get-invitation. All go through plugin-auth's ObjectQL adapter, which injects context: { isSystem: true }, so RLS never runs. Unaffected — the invitation lifecycle is untouched.
  • objectui consoleAcceptInvitationPage / InvitationsPage call the auth SDK (getInvitation, acceptInvitation), not the data API. Unaffected.
  • client SDKorganizations.invitations.list — wraps list-invitations, already documented "Requires owner/admin role on that org". Unaffected.
  • Setup nav nav_invitations, the sys_organization record page's Invitations tab, and the four sys_invitation list views — generic data-API grids. Admins keep the full ledger; a non-admin reaching them now sees only their own row. Intended change.
  • The recipient row actions — require the addressee to read their own row through the data API. Preserved by the carve-out, and asserted.
  • invitation-placement.ts, adopt-membership.ts, invitation-role-cap.ts — all inside better-auth hooks under system context. Unaffected.
  • Existing dogfood tests (delegated-admin-invite, membership-role-vocabulary, membership-actor-attribution) — read via ql.find under a system context. Unaffected; full suite green.
  • sys_member / sys_team — deliberately left alone. The card calls membership a legitimate staff directory; sys_team has had no ruling. Both are in the latent state described in [finding] adding an RLS narrowing to member_default silently narrows ORG ADMINS on the default single posture — both of their widening mechanisms are absent there #8241.
  • docs/qa/platform-checklist/areas/identity-auth.json — the ADR-0105 D8 item has steps reading sys_invitation via the data API as the delegated_admin, which now return nothing. Left alone, reported as delegated_admin can 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:authz-resolver OK
check:cross-package-test-inputs OK
check:docs-audit-scope OK
check:test-source-alias OK
check:query-options-erasure OK (ratchet holds, none new)
check:type-check-coverage OK
check:type-check-debt OK — 33 entries re-measured, none above its recorded number
check:engine-double-contract OK (no new fake engine)
check:nul-bytes OK + targeted control-byte scan of every touched file
plugin-security test/typecheck 51 files, 1032 tests, all green
dogfood test/typecheck 102 files, 685 tests, all green

check:type-check-debt was run from the repo root after a full turbo run build closure — it refuses to run without one, and the ledger was neither raised nor lowered.

Out of scope, filed


Generated by Claude Code

…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
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 10:07pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 22:21
@os-zhuang
os-zhuang added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 4f99860Aug 12, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8095-narrow-sys-invitation-read branch August 12, 2026 22:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] org_member can read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended?

2 participants

@os-zhuang@claude