Skip to content

Commit 307e0fe

Browse files
authored
fix(security): govern sys_member writes — membership is not a delegable capability (#3697 follow-up) (#3767)
Closes the one open non-goal from #3697 / #3722. `DelegatedAdminGate`'s `GOVERNED_OBJECTS` covered the four RBAC link tables but not `sys_member` — so the table that decides who is an org admin was the one authority surface the gate never saw. A membership row is an authority dial: `role` containing `owner`/`admin` is auto-elevated to `organization_admin` by `auto-org-admin-grant.ts`, and that set's wildcard `modifyAllRecords` is exactly what `isTenantAdmin()` tests. Writing one mints a tenant admin — the same escalation the invitation role cap closes on the issuance path, one layer down at the table. Not exploitable today and no working behaviour changes: every writer is a better-auth path running under `isSystem`, short-circuited before this gate, and the ADR-0092 D2 identity write guard refuses user-context writes to better-auth tables upstream. The gate is added so the chain cannot silently reopen when a direct-write surface appears — a `case` label is not enforcement; the call site decides (Prime Directive #10). Tenant-admin-only rather than scope-delegable, deliberately: no axis of `AdminScope` expresses organization membership, so there is nothing for a delegated scope to approve part of, and a delegate who could write one would mint authority strictly greater than their own. Adding people already has a delegable path — the invitation, whose placement this gate authorizes and whose role is capped at the issuer's own grade. The refusal message points there.
1 parent a06b30b commit 307e0fe

4 files changed

Lines changed: 150 additions & 1 deletion

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): govern `sys_member` writes — organization membership is not a delegable capability (#3697 follow-up)
6+
7+
`DelegatedAdminGate`'s `GOVERNED_OBJECTS` covered the four RBAC link tables but
8+
not `sys_member`, so the table that decides *who is an org admin* was the one
9+
authority surface the delegated-administration gate never saw.
10+
11+
That matters because a membership row is an authority dial: `role` containing
12+
`owner`/`admin` is auto-elevated to `organization_admin` by
13+
`auto-org-admin-grant.ts`, and that set's wildcard `modifyAllRecords` is exactly
14+
what `isTenantAdmin()` tests. Writing one mints a tenant admin — the same
15+
escalation the invitation role cap closes on the issuance path, one layer down
16+
at the table.
17+
18+
**Not exploitable today, and this changes no working behaviour.** Every
19+
`sys_member` writer is a better-auth path running under `isSystem`, which
20+
short-circuits the whole security middleware before this gate; the ADR-0092 D2
21+
identity write guard refuses user-context writes to better-auth-managed tables
22+
upstream of it. The gate is added so the chain cannot silently reopen the day a
23+
direct-write surface is introduced — a `case` label is not enforcement, and the
24+
call site is what decides (AGENTS.md Prime Directive #10).
25+
26+
The rule is tenant-admin-only rather than scope-delegable, deliberately: no axis
27+
of `AdminScope` expresses "organization membership" (its vocabulary is BU
28+
subtree, action flags and an assignable-set allowlist), so there is nothing for
29+
a delegated scope to approve part of — and a delegate who could write one would
30+
mint authority strictly greater than their own, which is what ADR-0090 D12
31+
exists to prevent. Adding people to an organization already has a delegable
32+
path: the **invitation**, whose placement is authorized against the issuer's
33+
`adminScope` and whose role is capped at the issuer's own grade. The refusal
34+
message says so.

‎content/docs/permissions/delegated-administration.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ misconfigured name resolves to an empty subtree and **approves nothing**
5252

5353
Writes to the governed tables (`sys_user_position`,
5454
`sys_position_permission_set`, `sys_user_permission_set`,
55-
`sys_permission_set`) pass through the `DelegatedAdminGate`:
55+
`sys_permission_set`, `sys_member`) pass through the `DelegatedAdminGate`:
5656

5757
| Rule | Effect |
5858
|---|---|
@@ -64,6 +64,7 @@ Writes to the governed tables (`sys_user_position`,
6464
| Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked |
6565
| Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically |
6666
| Tenant-level assets stay tenant-level | The `everyone`/`guest` audience anchors and security-domain publishes are untouchable from any delegated scope |
67+
| Membership is not delegable | `sys_member` writes are tenant-admin-only: no `adminScope` axis expresses org membership, and an admin-grade membership row is auto-elevated to `organization_admin` — writing one would mint a tenant admin. Adding people *is* delegable, through an [invitation](#letting-a-delegate-invite-delegated_admin) |
6768
| No scope, no admin | Holders of plain CRUD on the RBAC tables with **no** scope are refused: administration is a scoped capability now, not a side effect of table access |
6869

6970
Every rule above is exercised end-to-end by the showcase permission zoo

‎packages/plugins/plugin-security/src/delegated-admin-gate.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,76 @@ describe('DelegatedAdminGate — tenant admins and outsiders', () => {
146146
});
147147
});
148148

149+
// ── [#3697 follow-up] Organization membership is governed, and NOT delegable ──
150+
//
151+
// A `sys_member` row whose `role` is admin-grade is auto-elevated to
152+
// `organization_admin` (`auto-org-admin-grant.ts`), whose wildcard
153+
// `modifyAllRecords` IS `isTenantAdmin()`. Writing one therefore mints a tenant
154+
// admin — the same escalation the invitation role cap closes on the issuance
155+
// path, one layer down at the table.
156+
//
157+
// Unreachable today: every writer is a better-auth path running under
158+
// `isSystem`, which short-circuits the whole security middleware before this
159+
// gate. These cases exist so the chain cannot silently reopen the day a direct
160+
// write surface is added — the defence has to live at the CALL SITE, not in a
161+
// comment (AGENTS.md Prime Directive #10).
162+
describe('DelegatedAdminGate — organization membership (sys_member)', () => {
163+
const writeMember = (ctx: any, row: any, operation = 'insert') =>
164+
h.gate.assert({ object: 'sys_member', operation, data: row, context: ctx });
165+
166+
it('a tenant admin still passes — delegation constrains delegates, not HQ', async () => {
167+
await expect(
168+
writeMember(h.ctxOf('tenant_admin'), { user_id: 'u_x', organization_id: 'org_1', role: 'admin' }),
169+
).resolves.toBeUndefined();
170+
});
171+
172+
it('a DELEGATE holding a real adminScope is refused — membership is not a delegable axis', async () => {
173+
// The escalation this closes: `role: 'admin'` here resolves to
174+
// organization_admin, i.e. authority strictly greater than the delegate's.
175+
await expect(
176+
writeMember(h.ctxOf('delegate'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'admin' }),
177+
).rejects.toThrow(/tenant-level only/);
178+
});
179+
180+
it('a delegate is refused even for a plain member row — no adminScope axis approves any of it', async () => {
181+
await expect(
182+
writeMember(h.ctxOf('delegate'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'member' }),
183+
).rejects.toThrow(/not a delegable capability/);
184+
});
185+
186+
it('the refusal points at the delegable path that DOES exist', async () => {
187+
await expect(
188+
writeMember(h.ctxOf('delegate'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'member' }),
189+
).rejects.toThrow(/invitation/);
190+
});
191+
192+
it('plain CRUD on the table buys nothing', async () => {
193+
await expect(
194+
writeMember(h.ctxOf('crud_only'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'admin' }),
195+
).rejects.toThrow(/tenant-level only/);
196+
});
197+
198+
it('a principal-less non-system write fails closed', async () => {
199+
await expect(
200+
writeMember({}, { user_id: 'u_east_1', organization_id: 'org_1', role: 'admin' }),
201+
).rejects.toThrow(/authenticated administrator/);
202+
});
203+
204+
it('every governed mutation verb is covered, not just insert', async () => {
205+
for (const op of ['update', 'delete', 'transfer', 'restore', 'purge']) {
206+
await expect(
207+
writeMember(h.ctxOf('delegate'), { id: 'mem_1', role: 'admin' }, op),
208+
).rejects.toThrow(/tenant-level only/);
209+
}
210+
});
211+
212+
it('reads are untouched — this governs writes only', async () => {
213+
await expect(
214+
h.gate.assert({ object: 'sys_member', operation: 'find', context: h.ctxOf('delegate') }),
215+
).resolves.toBeUndefined();
216+
});
217+
});
218+
149219
describe('DelegatedAdminGate — assignments (sys_user_position)', () => {
150220
it('delegate assigns an allowlisted position inside the subtree; granted_by is stamped', async () => {
151221
const row: any = { user_id: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es' };

‎packages/plugins/plugin-security/src/delegated-admin-gate.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
* to an anchor are rejected for every caller (anchors are implicit — a
2626
* stored assignment row is a modeling error).
2727
*
28+
* `sys_member` (organization membership) is likewise tenant-level only: no
29+
* `adminScope` axis expresses membership, and an admin-grade membership row is
30+
* auto-elevated to `organization_admin` — writing one mints a tenant admin
31+
* (#3697 follow-up). The delegable way to add people is an INVITATION, whose
32+
* placement this gate authorizes and whose role the framework caps at the
33+
* issuer's own grade.
34+
*
2835
* System/boot writes carry `isSystem` and short-circuit the security
2936
* middleware before this gate (seeders, publish materializer, better-auth
3037
* reconciliation are unaffected).
@@ -57,6 +64,19 @@ const GOVERNED_OBJECTS = new Set([
5764
'sys_position_permission_set',
5865
'sys_user_permission_set',
5966
'sys_permission_set',
67+
// [#3697 follow-up] Organization MEMBERSHIP is an authority surface too: a
68+
// row whose `role` is admin-grade is auto-elevated to `organization_admin`
69+
// by `auto-org-admin-grant.ts`, whose wildcard `modifyAllRecords` IS
70+
// `isTenantAdmin()`. So writing one mints a tenant admin — the same
71+
// escalation the invitation role cap closes on the issuance path.
72+
//
73+
// Unreachable today (every writer is a better-auth path running under
74+
// `isSystem`, short-circuited before this gate; the ADR-0092 D2 identity
75+
// write guard refuses user-context writes to better-auth tables upstream).
76+
// It is here so the chain does not silently reopen the day someone adds a
77+
// direct-write surface — a `case` label is not enforcement, and the call
78+
// site is what matters (AGENTS.md Prime Directive #10).
79+
'sys_member',
6080
]);
6181
const GOVERNED_OPERATIONS = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']);
6282
const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
@@ -186,6 +206,30 @@ export class DelegatedAdminGate {
186206

187207
if (isTenantAdmin(sets)) return; // status quo — downstream CRUD/RLS decide
188208

209+
// ── [#3697 follow-up] Organization membership is NOT delegable ────────
210+
// Every other governed object has an adminScope axis that can approve part
211+
// of it (a subtree, an action flag, an allowlist). `sys_member` has none:
212+
// no axis of `AdminScope` expresses "org membership", and the row's own
213+
// `role` column is an authority dial — admin-grade membership is
214+
// auto-elevated to `organization_admin`, i.e. to `isTenantAdmin()`. A
215+
// delegate who could write one would mint authority strictly greater than
216+
// their own, which is exactly what ADR-0090 D12 exists to prevent.
217+
//
218+
// So it is tenant-admin-only, full stop — reached only by a hypothetical
219+
// future direct-write surface (better-auth's own writes are `isSystem` and
220+
// never arrive here). Adding people to an organization already has a
221+
// delegable path: the INVITATION, whose placement is authorized against
222+
// the issuer's adminScope and whose role is held to the issuer's own grade.
223+
if (opCtx.object === 'sys_member') {
224+
throw new PermissionDeniedError(
225+
`[Security] Access denied: '${opCtx.operation}' on 'sys_member' is tenant-level only — ` +
226+
`organization membership is not a delegable capability (ADR-0090 D12), and an ` +
227+
`admin-grade membership row resolves to tenant administration. Add people through an ` +
228+
`invitation instead: its placement is authorized against your adminScope.`,
229+
{ operation: opCtx.operation, object: opCtx.object, userId: ctx.userId },
230+
);
231+
}
232+
189233
// ── [ADR-0091 D3] Self-service delegation of duty (职务代理) ──────────
190234
// A write that STAMPS `delegated_from` is a delegation: the holder passes
191235
// their OWN hat, judged by delegation rules — not by an adminScope. This

0 commit comments

Comments
 (0)