Skip to content

Commit 4f99860

Browse files
os-zhuangclaude
andauthored
fix(plugin-security): row-scope sys_invitation read to its addressee; keep the full ledger with owner/admin (#8095) (#8242)
* fix(plugin-security): row-scope sys_invitation read to the addressee (#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 * fix(plugin-security): keep the org-admin invitation read whole on the 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d34d9c9 commit 4f99860

5 files changed

Lines changed: 565 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): a plain member can no longer read the organization's whole invitation ledger (#8095)
6+
7+
**Security — narrowing.** Any authenticated `member` of an organization could
8+
read **every** `sys_invitation` row of that organization through the data API:
9+
each invitee's email address, the role they were about to be granted, who
10+
invited them, and the expiry. Measured live: `GET /api/v1/data/sys_invitation`
11+
as a plain member returned `200` with the same rows the org **owner** sees.
12+
13+
The grant was declared, not accidental. `sys_invitation` is in
14+
`BETTER_AUTH_MANAGED_OBJECTS`, whose blanket `denyWritesOnManagedObjects()`
15+
entry sets `allowRead: true` on every managed identity table for
16+
`member_default` and `viewer_readonly` — reads permitted, "subject to the rest
17+
of the RLS chain". For this object there was no rest of the chain: neither set
18+
declared a row-level policy for `sys_invitation`, and an object with no
19+
applicable policy compiles to a null business-RLS filter, i.e. no row scope at
20+
all. `sys_member` is a staff directory and reads org-wide on purpose; a pending
21+
invitation is administrative *intent* about people who are not members and never
22+
consented to a directory listing — and *who is about to become an admin* is
23+
enough for targeted social engineering.
24+
25+
**What changed.** `member_default` and `viewer_readonly` each gain one
26+
row-level policy, `sys_invitation_self` (`select`,
27+
`email == current_user.email`), and `organization_admin` (with the wall-less
28+
`organization_admin_no_bypass` variant derived from it) gains
29+
`sys_invitation_org_admin` — the org-administration side of the same ledger,
30+
scoped by `positions` to `org_owner` / `org_admin`.
31+
32+
The second policy is not decoration. `member_default` resolves for **every**
33+
authenticated principal (the `everyone` anchor), so the addressee scope reaches
34+
org admins too, and on the **default** `single` posture neither mechanism that
35+
normally keeps an admin whole is present: the wildcard
36+
`viewAllRecords` short-circuit is withheld from a wall-less deployment
37+
(ADR-0105 D4), and `sys_invitation_org` is stripped as a platform tenant policy
38+
when org isolation is inactive (ADR-0105 D3). Measured on a stock boot with only
39+
the member-side scope in place, the org **owner** read zero invitations — the
40+
Invitations page would have gone blank for the one persona entitled to it.
41+
`sys_invitation_org_admin` states the admission on the axis that survives both,
42+
carrying no tenant token for the strip to key on; the organization boundary
43+
remains Layer 0's, which AND-composes ahead of it, so its widest reach is the
44+
admin's own organization — exactly what `sys_invitation_org` already declared.
45+
46+
**The invitee still sees their own invitation**, and that half is not
47+
incidental: the recipient-side row actions on `sys_invitation`
48+
(`accept_invitation` / `reject_invitation`) are gated on
49+
`record.email == ctx.user.email`, so an addressee who cannot read their row
50+
cannot act on it. The object-level read bit is therefore deliberately left open
51+
and the narrowing done at the row level — closing the object would have broken
52+
acceptance while looking like the same fix.
53+
54+
**Not covered by the ruling, and therefore unchanged here:** a
55+
`delegated_admin` normalizes to neither `org_owner` nor `org_admin`, so that
56+
role now reads only its own row through the data API even though it may issue
57+
invitations. Filed separately rather than decided in this PR.
58+
59+
**Unaffected.** Every better-auth organization endpoint
60+
(`invite-member`, `accept-invitation`, `reject-invitation`,
61+
`cancel-invitation`, `list-invitations`, `list-user-invitations`,
62+
`get-invitation`) reads and writes `sys_invitation` through the identity
63+
adapter under a system context, so the invitation lifecycle and the console's
64+
accept page — which use those endpoints, not the data API — behave exactly as
65+
before. Owners and admins are unchanged, in both the wall-enforcing
66+
(`organization_admin`) and wall-less (`organization_admin_no_bypass`)
67+
variants. Platform admins are unchanged.
68+
69+
**You may notice** that a principal who is neither owner nor admin no longer
70+
sees other people's invitations on a generic `sys_invitation` grid — including
71+
the Setup app's Invitations page and the Organization record's Invitations tab
72+
if a non-admin reaches them. That is the fix, not a regression. A deployment
73+
that genuinely wants a wider invitation read should declare it on an
74+
application permission set rather than rely on the managed-object baseline.

‎packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,74 @@ describe('default permission sets carry the managed denies (static baseline)', (
9494
expect(Object.keys(admin.objects)).toEqual(['*']);
9595
});
9696
});
97+
98+
/**
99+
* [#8095] The `sys_invitation` row scope — a DELETION TRIPWIRE, not the proof.
100+
*
101+
* The proof that the narrowing works lives over HTTP, in
102+
* `packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts`: an
103+
* assertion whose expectation and reality both come from this module cannot
104+
* fail, so nothing here is evidence that a member is actually narrowed. What
105+
* this block does buy is the one thing the HTTP fixture cannot — it names the
106+
* predicate and the sets it must appear in, so removing a carve-out (or adding
107+
* a managed object to `BETTER_AUTH_MANAGED_OBJECTS` and assuming the blanket
108+
* read is safe for it) fails HERE, in the file being edited, instead of in a
109+
* suite the author may not run.
110+
*
111+
* The predicate is written out rather than imported for the same reason.
112+
*/
113+
describe('sys_invitation is row-scoped to its addressee (#8095)', () => {
114+
const SELF_PREDICATE = 'email == current_user.email';
115+
116+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117+
const policiesFor = (setName: string, object: string): any[] =>
118+
(setByName(setName)?.rowLevelSecurity ?? []).filter((p: any) => p.object === object);
119+
120+
it.each(['member_default', 'viewer_readonly'])(
121+
'%s scopes sys_invitation to the addressee — the blanket managed read is NOT the whole story',
122+
(setName) => {
123+
// The object-level read bit stays open: it is what makes the invitee's OWN
124+
// row reachable, and closing it would break the accept flow rather than
125+
// narrow it. The narrowing is the row predicate.
126+
expect(setByName(setName).objects.sys_invitation.allowRead).toBe(true);
127+
128+
const scoped = policiesFor(setName, 'sys_invitation');
129+
expect(scoped.map((p) => p.name)).toEqual(['sys_invitation_self']);
130+
expect(scoped[0].using).toBe(SELF_PREDICATE);
131+
// Read the operation off THIS policy. The same-named carve-outs across
132+
// these sets do not all agree (`sys_api_key_self` is spelled three times
133+
// at two different operations), so a sibling's value is not evidence.
134+
expect(scoped[0].operation).toBe('select');
135+
expect(scoped[0].enabled).not.toBe(false);
136+
},
137+
);
138+
139+
it('organization_admin keeps the ORG-wide ledger — the ruling narrowed members, not admins', () => {
140+
// The other half of the ruling, and the half that is easy to get wrong:
141+
// `member_default` resolves for admins too, so the member-side scope reaches
142+
// them, and the two mechanisms that look like they already protect an admin
143+
// are both absent on the DEFAULT `single` posture — the `viewAllRecords`
144+
// short-circuit is withheld from `organization_admin_no_bypass` (ADR-0105
145+
// D4), and `sys_invitation_org` is stripped as a platform tenant policy when
146+
// org isolation is inactive (ADR-0105 D3). Only `sys_invitation_org_admin`
147+
// survives both, which is why it must be present on BOTH variants.
148+
for (const setName of ['organization_admin', 'organization_admin_no_bypass']) {
149+
const scoped = policiesFor(setName, 'sys_invitation');
150+
expect(scoped.map((p) => p.name).sort(), `${setName} sys_invitation policies`).toEqual([
151+
'sys_invitation_org',
152+
'sys_invitation_org_admin',
153+
]);
154+
155+
const admission = scoped.find((p) => p.name === 'sys_invitation_org_admin');
156+
// No tenant token — that is what keeps the strip from taking it.
157+
expect(admission.using).not.toContain('current_user.organization_id');
158+
expect(admission.using).toBe('id != null');
159+
// Domained to the org-administration identities, so it can only WIDEN.
160+
// A principal it does not match keeps the addressee scope and fails
161+
// closed; putting the domain on the narrowing instead would make "no
162+
// matching position" mean "no policy" — i.e. no row filter at all.
163+
expect(admission.positions.sort()).toEqual(['org_admin', 'org_owner']);
164+
expect(admission.operation).toBe('select');
165+
}
166+
});
167+
});

‎packages/plugins/plugin-security/src/objects/default-permission-sets.ts‎

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
4-
import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec';
4+
import {
5+
ORGANIZATION_ADMIN,
6+
ORGANIZATION_ADMIN_NO_BYPASS,
7+
BUILTIN_IDENTITY_ORG_ADMIN,
8+
BUILTIN_IDENTITY_ORG_OWNER,
9+
} from '@objectstack/spec';
510
import {
611
MCP_AGENT_PERMISSION_SET_READ,
712
MCP_AGENT_PERMISSION_SET_WRITE,
@@ -23,6 +28,18 @@ import {
2328
* permission sets keep their `*` wildcard so they can rescue data
2429
* directly when needed.
2530
*
31+
* ⚠️ "Subject to the rest of the RLS chain" is the load-bearing half, and it is
32+
* a BLANKET grant — read this list as 22 objects whose object-level read bit is
33+
* open, each narrowed (or not) by whatever `rowLevelSecurity` its holder set
34+
* declares for it. An object named here with NO `_self` / `_org` policy in
35+
* `member_default` is org-wide readable by every authenticated member. That is
36+
* intended for the staff-directory shapes (`sys_member`, `sys_user` via
37+
* `sys_user_org_members`) and was NOT intended for `sys_invitation`
38+
* (maintainer ruling 2026-08-12) — see `sys_invitation_self` below. Do not
39+
* "fix" a future instance of this class by editing the blanket: dropping
40+
* `allowRead` here retires the read on all 22 at once, and it would take the
41+
* invitee's own row with it. The per-object row scope is the narrow instrument.
42+
*
2643
* This is the COMPILE-TIME BASELINE. At `kernel:ready` it is unioned with the
2744
* live registry by `applyManagedWriteDenies` (see `managed-object-write-denies.ts`),
2845
* which injects a deny entry for every registered `managedBy: 'better-auth'`
@@ -302,6 +319,55 @@ const baseDefaultPermissionSets: PermissionSet[] = [
302319
operation: 'select',
303320
using: 'organization_id == current_user.organization_id',
304321
},
322+
// [#8095] The org-admin half of the invitation narrowing, and the reason
323+
// it needs a SECOND policy rather than leaning on the one above.
324+
//
325+
// `member_default` now row-scopes `sys_invitation` to the addressee, and
326+
// that set resolves for EVERY authenticated principal (the `everyone`
327+
// anchor) — org owners and admins included. Policies OR-combine, so an
328+
// admin is only unnarrowed while some policy of theirs still admits the
329+
// rest of the ledger. Two mechanisms were supposed to do that, and in the
330+
// DEFAULT posture neither does:
331+
// - the wildcard `viewAllRecords` short-circuit skips Layer 1 whole —
332+
// but ADR-0105 D4 withholds those bits from a wall-less deployment,
333+
// which is where `organization_admin_no_bypass` comes from;
334+
// - `sys_invitation_org` above is a PLATFORM TENANT POLICY, and
335+
// `collectRLSPolicies` strips those when org isolation is inactive
336+
// (ADR-0105 D3) — correctly, since `current_user.organization_id`
337+
// means nothing without a wall.
338+
// Measured on a stock `single`-posture boot: with only the member-side
339+
// scope in place, the org OWNER read ZERO invitations. The Invitations
340+
// page would have gone blank for the one persona entitled to it, on the
341+
// default posture, as a side effect of a member-side fix.
342+
//
343+
// So the admission is stated in its own right, on the axis that survives
344+
// both mechanisms: `positions` (ADR-0090 P2's applicability domain — the
345+
// same lever `owner_only_writes` uses to keep a members-only restriction
346+
// off admins), with a predicate carrying no tenant token for the strip to
347+
// key on. `id != null` is every row of this object, said plainly: the
348+
// organization boundary is NOT this policy's job — Layer 0 is the tenant
349+
// wall (ADR-0095 D1) and AND-composes ahead of it, so the widest this can
350+
// ever reach is the admin's own organization, which is exactly what
351+
// `sys_invitation_org` already declares. It is the same grant, spelled so
352+
// a wall-less deployment keeps it.
353+
//
354+
// Why a `positions` DOMAIN rather than dropping the member-side scope's
355+
// reach: the domain here only ever WIDENS, so a principal it does not
356+
// match keeps the addressee scope and fails closed. Putting the domain on
357+
// the narrowing instead would invert that — anyone outside the listed
358+
// positions (an org-less session, a role that normalizes to neither name)
359+
// would match no policy at all, and no policy means NO row filter, which
360+
// is precisely the wide read #8095 is about.
361+
//
362+
// `delegated_admin` is deliberately absent: the ruling narrows the ledger
363+
// to owner/admin, and that role normalizes to neither name.
364+
{
365+
name: 'sys_invitation_org_admin',
366+
object: 'sys_invitation',
367+
operation: 'select',
368+
using: 'id != null',
369+
positions: [BUILTIN_IDENTITY_ORG_OWNER, BUILTIN_IDENTITY_ORG_ADMIN],
370+
},
305371
{
306372
name: 'sys_team_org',
307373
object: 'sys_team',
@@ -597,6 +663,63 @@ const baseDefaultPermissionSets: PermissionSet[] = [
597663
operation: 'select',
598664
using: 'user_id == current_user.id',
599665
},
666+
// [#8095] The invitation ledger is ADMINISTRATIVE INTENT, not a staff
667+
// directory — row-scoped to the addressee.
668+
//
669+
// `sys_invitation` is in BETTER_AUTH_MANAGED_OBJECTS, so the blanket above
670+
// grants read on it; this set declared NO policy for the object, and an
671+
// object with no applicable policy compiles to a null Layer 1 — i.e. no
672+
// row filter at all (`RLSCompiler.compileFilter` returns null on an empty
673+
// applicable set). Measured live: a plain `member` of an org got
674+
// `200, total: 2` on `GET /api/v1/data/sys_invitation` — byte-identical to
675+
// what the org OWNER sees, including other people's email addresses, the
676+
// role each is about to be granted, the inviter and the expiry. Pending
677+
// invitees are not members and never consented to a directory listing, and
678+
// "who is about to become an admin" is the wrong thing to broadcast.
679+
//
680+
// Maintainer ruling (2026-08-12): narrow the read to owner/admin, PLUS a
681+
// row-scope carve-out so an invitee still sees THEIR OWN invitation. Both
682+
// halves are load-bearing and this policy is both of them at once:
683+
// - NARROWING — for a rank-and-file member this is now the only
684+
// applicable policy, so the readable set is exactly `{their own row}`;
685+
// - CARVE-OUT — the accept flow's surfaces are record-scoped
686+
// (`sys_invitation`'s `accept_invitation` / `reject_invitation` row
687+
// actions declare `visible: record.email == ctx.user.email`), so an
688+
// invitee who cannot READ their row cannot act on it. Narrowing
689+
// without this predicate would break acceptance while looking like a
690+
// permissions fix.
691+
// ⚠️ This set resolves for EVERY authenticated principal (the `everyone`
692+
// anchor), so the predicate binds org owners and admins too — and the
693+
// arrival of a first policy on an object that had none is a NARROWING for
694+
// whoever it reaches, not a no-op. Keeping the admin whole therefore takes
695+
// an explicit admission on their side: `sys_invitation_org_admin` in
696+
// `organization_admin` (which the `_no_bypass` variant inherits). Read its
697+
// comment before touching either one — the two mechanisms that look like
698+
// they already cover the admin (the `viewAllRecords` short-circuit and
699+
// `sys_invitation_org`) are BOTH absent on the default `single` posture,
700+
// and with only this policy in place the org owner measurably read zero
701+
// invitations.
702+
//
703+
// `email` (not `user_id`): an invitation predates the account it invites,
704+
// so the addressee is identified by address. `current_user.email` is the
705+
// auth-enforced, unique-by-construction identity `RLSUserContext` exposes
706+
// for exactly this (the display `name` is deliberately not exposed). When
707+
// it cannot be resolved the policy compiles to nothing and the single
708+
// applicable policy yields `RLS_DENY_FILTER` — zero rows, fail-closed.
709+
//
710+
// `select` (not `all`), matching `sys_inbox_message_self` above: every
711+
// write on this table is denied at the object layer by the managed-object
712+
// block, refused again by the ADR-0092 D2 identity write guard, and
713+
// answered 405 by `apiMethods: ['get', 'list']` before either. The #7665
714+
// derive-from-select rule additionally lends this predicate to the write
715+
// classes should a write bit ever be granted, so `all` would buy nothing
716+
// and would overstate what the policy is for.
717+
{
718+
name: 'sys_invitation_self',
719+
object: 'sys_invitation',
720+
operation: 'select',
721+
using: 'email == current_user.email',
722+
},
600723
],
601724
}),
602725
PermissionSetSchema.parse({
@@ -698,6 +821,24 @@ const baseDefaultPermissionSets: PermissionSet[] = [
698821
operation: 'select',
699822
using: 'user_id == current_user.id',
700823
},
824+
// [#8095] Same row scope as `member_default`'s `sys_invitation_self`, and
825+
// it must be repeated here rather than inherited: this set is resolved
826+
// INSTEAD of the member baseline for a read-only principal, and its `'*'`
827+
// wildcard read is if anything wider — carrying no `viewAllRecords`, it
828+
// does NOT take the Layer 1 short-circuit, so without a policy of its own
829+
// a viewer would read the whole org's invitation ledger that a member can
830+
// no longer see. `select` here matches this set's own convention (every
831+
// carve-out above is `select`; `viewer_readonly` grants no write bit at
832+
// all) and happens to agree with `member_default`'s — which is NOT a rule
833+
// to generalise from: the `sys_api_key_self` carve-out is spelled three
834+
// times across these sets at two different `operation` values. Read the
835+
// `operation` on each policy; never infer it from a same-named sibling.
836+
{
837+
name: 'sys_invitation_self',
838+
object: 'sys_invitation',
839+
operation: 'select',
840+
using: 'email == current_user.email',
841+
},
701842
],
702843
}),
703844

‎packages/plugins/plugin-security/src/objects/rbac-objects.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ describe('default permission sets', () => {
106106
// `_self` shape for the same reason: no `organization_id`, so Layer 0 is
107107
// inert and these policies are the row scoping for their read grants.
108108
'sys_inbox_message_self',
109+
// [#8095] The invitation ledger's row scope. Keyed on `email`, not
110+
// `user_id`, because an invitation predates the account it invites — the
111+
// addressee is identified by address. Its object DOES carry
112+
// `organization_id`, so unlike the two above this is not "Layer 0 is
113+
// inert here": Layer 0 was engaged and correctly org-scoped, and the org
114+
// is precisely the audience the row must be hidden from.
115+
'sys_invitation_self',
109116
'sys_notification_receipt_self',
110117
'sys_oauth_access_token_self',
111118
'sys_oauth_application_self',

0 commit comments

Comments
 (0)