Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .changeset/sys-invitation-row-scope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
---
"@objectstack/plugin-security": patch
---

fix(plugin-security): a plain member can no longer read the organization's whole invitation ledger (#8095)

**Security — narrowing.** Any authenticated `member` of an organization could
read **every** `sys_invitation` row of that organization through the data API:
each invitee's email address, the role they were about to be granted, who
invited them, and the expiry. Measured live: `GET /api/v1/data/sys_invitation`
as a plain member returned `200` with the same rows the org **owner** sees.

The grant was declared, not accidental. `sys_invitation` is in
`BETTER_AUTH_MANAGED_OBJECTS`, whose blanket `denyWritesOnManagedObjects()`
entry sets `allowRead: true` on every managed identity table for
`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 an object with no
applicable policy compiles to a null business-RLS filter, i.e. no row scope at
all. `sys_member` is a staff directory and reads org-wide on purpose; a pending
invitation is administrative *intent* about people who are not members and never
consented to a directory listing — and *who is about to become an admin* is
enough for targeted social engineering.

**What changed.** `member_default` and `viewer_readonly` each gain one
row-level policy, `sys_invitation_self` (`select`,
`email == current_user.email`), and `organization_admin` (with the wall-less
`organization_admin_no_bypass` variant derived from it) gains
`sys_invitation_org_admin` — the org-administration side of the same ledger,
scoped by `positions` to `org_owner` / `org_admin`.

The second policy is not decoration. `member_default` resolves for **every**
authenticated principal (the `everyone` anchor), so the addressee scope reaches
org admins too, and on the **default** `single` posture neither mechanism that
normally keeps an admin whole is present: the wildcard
`viewAllRecords` short-circuit is withheld from a wall-less deployment
(ADR-0105 D4), and `sys_invitation_org` is stripped as a platform tenant policy
when org isolation is inactive (ADR-0105 D3). Measured on a stock boot with only
the member-side scope in place, the org **owner** read zero invitations — the
Invitations page would have gone blank for the one persona entitled to it.
`sys_invitation_org_admin` states the admission on the axis that survives both,
carrying no tenant token for the strip to key on; the organization boundary
remains Layer 0's, which AND-composes ahead of it, so its widest reach is the
admin's own organization — exactly what `sys_invitation_org` already declared.

**The invitee still sees their own invitation**, and that half is not
incidental: the recipient-side row actions on `sys_invitation`
(`accept_invitation` / `reject_invitation`) are gated on
`record.email == ctx.user.email`, so an addressee who cannot read their row
cannot act on it. The object-level read bit is therefore deliberately left open
and the narrowing done at the row level — closing the object would have broken
acceptance while looking like the same fix.

**Not covered by the ruling, and therefore unchanged here:** a
`delegated_admin` normalizes to neither `org_owner` nor `org_admin`, so that
role now reads only its own row through the data API even though it may issue
invitations. Filed separately rather than decided in this PR.

**Unaffected.** Every better-auth organization endpoint
(`invite-member`, `accept-invitation`, `reject-invitation`,
`cancel-invitation`, `list-invitations`, `list-user-invitations`,
`get-invitation`) reads and writes `sys_invitation` through the identity
adapter under a system context, so the invitation lifecycle and the console's
accept page — which use those endpoints, not the data API — behave exactly as
before. Owners and admins are unchanged, in both the wall-enforcing
(`organization_admin`) and wall-less (`organization_admin_no_bypass`)
variants. Platform admins are unchanged.

**You may notice** that a principal who is neither owner nor admin no longer
sees other people's invitations on a generic `sys_invitation` grid — including
the Setup app's Invitations page and the Organization record's Invitations tab
if a non-admin reaches them. That is the fix, not a regression. A deployment
that genuinely wants a wider invitation read should declare it on an
application permission set rather than rely on the managed-object baseline.
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,3 +94,74 @@ describe('default permission sets carry the managed denies (static baseline)', (
expect(Object.keys(admin.objects)).toEqual(['*']);
});
});

/**
* [#8095] The `sys_invitation` row scope — a DELETION TRIPWIRE, not the proof.
*
* The proof that the narrowing works lives over HTTP, in
* `packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts`: an
* assertion whose expectation and reality both come from this module cannot
* fail, so nothing here is evidence that a member is actually narrowed. What
* this block does buy is the one thing the HTTP fixture cannot — it names the
* predicate and the sets it must appear in, so removing a carve-out (or adding
* a managed object to `BETTER_AUTH_MANAGED_OBJECTS` and assuming the blanket
* read is safe for it) fails HERE, in the file being edited, instead of in a
* suite the author may not run.
*
* The predicate is written out rather than imported for the same reason.
*/
describe('sys_invitation is row-scoped to its addressee (#8095)', () => {
const SELF_PREDICATE = 'email == current_user.email';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const policiesFor = (setName: string, object: string): any[] =>
(setByName(setName)?.rowLevelSecurity ?? []).filter((p: any) => p.object === object);

it.each(['member_default', 'viewer_readonly'])(
'%s scopes sys_invitation to the addressee — the blanket managed read is NOT the whole story',
(setName) => {
// The object-level read bit stays open: it is what makes the invitee's OWN
// row reachable, and closing it would break the accept flow rather than
// narrow it. The narrowing is the row predicate.
expect(setByName(setName).objects.sys_invitation.allowRead).toBe(true);

const scoped = policiesFor(setName, 'sys_invitation');
expect(scoped.map((p) => p.name)).toEqual(['sys_invitation_self']);
expect(scoped[0].using).toBe(SELF_PREDICATE);
// Read the operation off THIS policy. The same-named carve-outs across
// these sets do not all agree (`sys_api_key_self` is spelled three times
// at two different operations), so a sibling's value is not evidence.
expect(scoped[0].operation).toBe('select');
expect(scoped[0].enabled).not.toBe(false);
},
);

it('organization_admin keeps the ORG-wide ledger — the ruling narrowed members, not admins', () => {
// The other half of the ruling, and the half that is easy to get wrong:
// `member_default` resolves for admins too, so the member-side scope reaches
// them, and the two mechanisms that look like they already protect an admin
// are both absent on the DEFAULT `single` posture — the `viewAllRecords`
// short-circuit is withheld from `organization_admin_no_bypass` (ADR-0105
// D4), and `sys_invitation_org` is stripped as a platform tenant policy when
// org isolation is inactive (ADR-0105 D3). Only `sys_invitation_org_admin`
// survives both, which is why it must be present on BOTH variants.
for (const setName of ['organization_admin', 'organization_admin_no_bypass']) {
const scoped = policiesFor(setName, 'sys_invitation');
expect(scoped.map((p) => p.name).sort(), `${setName} sys_invitation policies`).toEqual([
'sys_invitation_org',
'sys_invitation_org_admin',
]);

const admission = scoped.find((p) => p.name === 'sys_invitation_org_admin');
// No tenant token — that is what keeps the strip from taking it.
expect(admission.using).not.toContain('current_user.organization_id');
expect(admission.using).toBe('id != null');
// Domained to the org-administration identities, so it can only WIDEN.
// A principal it does not match keeps the addressee scope and fails
// closed; putting the domain on the narrowing instead would make "no
// matching position" mean "no policy" — i.e. no row filter at all.
expect(admission.positions.sort()).toEqual(['org_admin', 'org_owner']);
expect(admission.operation).toBe('select');
}
});
});
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec';
import {
ORGANIZATION_ADMIN,
ORGANIZATION_ADMIN_NO_BYPASS,
BUILTIN_IDENTITY_ORG_ADMIN,
BUILTIN_IDENTITY_ORG_OWNER,
} from '@objectstack/spec';
import {
MCP_AGENT_PERMISSION_SET_READ,
MCP_AGENT_PERMISSION_SET_WRITE,
Expand All@@ -23,6 +28,18 @@ import {
* permission sets keep their `*` wildcard so they can rescue data
* directly when needed.
*
* ⚠️ "Subject to the rest of the RLS chain" is the load-bearing half, and it is
* a BLANKET grant — read this list as 22 objects whose object-level read bit is
* open, each narrowed (or not) by whatever `rowLevelSecurity` its holder set
* declares for it. An object named here with NO `_self` / `_org` policy in
* `member_default` is org-wide readable by every authenticated member. That is
* intended for the staff-directory shapes (`sys_member`, `sys_user` via
* `sys_user_org_members`) and was NOT intended for `sys_invitation`
* (maintainer ruling 2026-08-12) — see `sys_invitation_self` below. Do not
* "fix" a future instance of this class by editing the blanket: dropping
* `allowRead` here retires the read on all 22 at once, and it would take the
* invitee's own row with it. The per-object row scope is the narrow instrument.
*
* This is the COMPILE-TIME BASELINE. At `kernel:ready` it is unioned with the
* live registry by `applyManagedWriteDenies` (see `managed-object-write-denies.ts`),
* which injects a deny entry for every registered `managedBy: 'better-auth'`
Expand DownExpand Up@@ -302,6 +319,55 @@ const baseDefaultPermissionSets: PermissionSet[] = [
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
// [#8095] The org-admin half of the invitation narrowing, and the reason
// it needs a SECOND policy rather than leaning on the one above.
//
// `member_default` now row-scopes `sys_invitation` to the addressee, and
// that set resolves for EVERY authenticated principal (the `everyone`
// anchor) — org owners and admins included. Policies OR-combine, so an
// admin is only unnarrowed while some policy of theirs still admits the
// rest of the ledger. Two mechanisms were supposed to do that, and in the
// DEFAULT posture neither does:
// - the wildcard `viewAllRecords` short-circuit skips Layer 1 whole —
// but ADR-0105 D4 withholds those bits from a wall-less deployment,
// which is where `organization_admin_no_bypass` comes from;
// - `sys_invitation_org` above is a PLATFORM TENANT POLICY, and
// `collectRLSPolicies` strips those when org isolation is inactive
// (ADR-0105 D3) — correctly, since `current_user.organization_id`
// means nothing without a wall.
// Measured on a stock `single`-posture boot: with only the member-side
// scope in place, the org OWNER read ZERO invitations. The Invitations
// page would have gone blank for the one persona entitled to it, on the
// default posture, as a side effect of a member-side fix.
//
// So the admission is stated in its own right, on the axis that survives
// both mechanisms: `positions` (ADR-0090 P2's applicability domain — the
// same lever `owner_only_writes` uses to keep a members-only restriction
// off admins), with a predicate carrying no tenant token for the strip to
// key on. `id != null` is every row of this object, said plainly: the
// organization boundary is NOT this policy's job — Layer 0 is the tenant
// wall (ADR-0095 D1) and AND-composes ahead of it, so the widest this can
// ever reach is the admin's own organization, which is exactly what
// `sys_invitation_org` already declares. It is the same grant, spelled so
// a wall-less deployment keeps it.
//
// Why a `positions` DOMAIN rather than dropping the member-side scope's
// reach: the domain here only ever WIDENS, so a principal it does not
// match keeps the addressee scope and fails closed. Putting the domain on
// the narrowing instead would invert that — anyone outside the listed
// positions (an org-less session, a role that normalizes to neither name)
// would match no policy at all, and no policy means NO row filter, which
// is precisely the wide read #8095 is about.
//
// `delegated_admin` is deliberately absent: the ruling narrows the ledger
// to owner/admin, and that role normalizes to neither name.
{
name: 'sys_invitation_org_admin',
object: 'sys_invitation',
operation: 'select',
using: 'id != null',
positions: [BUILTIN_IDENTITY_ORG_OWNER, BUILTIN_IDENTITY_ORG_ADMIN],
},
{
name: 'sys_team_org',
object: 'sys_team',
Expand DownExpand Up@@ -597,6 +663,63 @@ const baseDefaultPermissionSets: PermissionSet[] = [
operation: 'select',
using: 'user_id == current_user.id',
},
// [#8095] The invitation ledger is ADMINISTRATIVE INTENT, not a staff
// directory — row-scoped to the addressee.
//
// `sys_invitation` is in BETTER_AUTH_MANAGED_OBJECTS, so the blanket above
// grants read on it; this set declared NO policy for the object, and an
// object with no applicable policy compiles to a null Layer 1 — i.e. no
// row filter at all (`RLSCompiler.compileFilter` returns null on an empty
// applicable set). Measured live: a plain `member` of an org got
// `200, total: 2` on `GET /api/v1/data/sys_invitation` — byte-identical to
// what the org OWNER sees, including other people's email addresses, the
// role each is about to be granted, the inviter and the expiry. Pending
// invitees are not members and never consented to a directory listing, and
// "who is about to become an admin" is the wrong thing to broadcast.
//
// Maintainer ruling (2026-08-12): narrow the read to owner/admin, PLUS a
// row-scope carve-out so an invitee still sees THEIR OWN invitation. Both
// halves are load-bearing and this policy is both of them at once:
// - NARROWING — for a rank-and-file member this is now the only
// applicable policy, so the readable set is exactly `{their own row}`;
// - CARVE-OUT — the accept flow's surfaces are record-scoped
// (`sys_invitation`'s `accept_invitation` / `reject_invitation` row
// actions declare `visible: record.email == ctx.user.email`), so an
// invitee who cannot READ their row cannot act on it. Narrowing
// without this predicate would break acceptance while looking like a
// permissions fix.
// ⚠️ This set resolves for EVERY authenticated principal (the `everyone`
// anchor), so the predicate binds org owners and admins too — and the
// arrival of a first policy on an object that had none is a NARROWING for
// whoever it reaches, not a no-op. Keeping the admin whole therefore takes
// an explicit admission on their side: `sys_invitation_org_admin` in
// `organization_admin` (which the `_no_bypass` variant inherits). Read its
// comment before touching either one — the two mechanisms that look like
// they already cover the admin (the `viewAllRecords` short-circuit and
// `sys_invitation_org`) are BOTH absent on the default `single` posture,
// and with only this policy in place the org owner measurably read zero
// invitations.
//
// `email` (not `user_id`): an invitation predates the account it invites,
// so the addressee is identified by address. `current_user.email` is the
// auth-enforced, unique-by-construction identity `RLSUserContext` exposes
// for exactly this (the display `name` is deliberately not exposed). When
// it cannot be resolved the policy compiles to nothing and the single
// applicable policy yields `RLS_DENY_FILTER` — zero rows, fail-closed.
//
// `select` (not `all`), matching `sys_inbox_message_self` above: every
// write on this table is denied at the object layer by the managed-object
// block, refused again by the ADR-0092 D2 identity write guard, and
// answered 405 by `apiMethods: ['get', 'list']` before either. The #7665
// derive-from-select rule additionally lends this predicate to the write
// classes should a write bit ever be granted, so `all` would buy nothing
// and would overstate what the policy is for.
{
name: 'sys_invitation_self',
object: 'sys_invitation',
operation: 'select',
using: 'email == current_user.email',
},
],
}),
PermissionSetSchema.parse({
Expand DownExpand Up@@ -698,6 +821,24 @@ const baseDefaultPermissionSets: PermissionSet[] = [
operation: 'select',
using: 'user_id == current_user.id',
},
// [#8095] Same row scope as `member_default`'s `sys_invitation_self`, and
// it must be repeated here rather than inherited: this set is resolved
// INSTEAD of the member baseline for a read-only principal, and its `'*'`
// wildcard read is if anything wider — carrying no `viewAllRecords`, it
// does NOT take the Layer 1 short-circuit, so without a policy of its own
// a viewer would read the whole org's invitation ledger that a member can
// no longer see. `select` here matches this set's own convention (every
// carve-out above is `select`; `viewer_readonly` grants no write bit at
// all) and happens to agree with `member_default`'s — which is NOT a rule
// to generalise from: the `sys_api_key_self` carve-out is spelled three
// times across these sets at two different `operation` values. Read the
// `operation` on each policy; never infer it from a same-named sibling.
{
name: 'sys_invitation_self',
object: 'sys_invitation',
operation: 'select',
using: 'email == current_user.email',
},
],
}),

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