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
34 changes: 34 additions & 0 deletions .changeset/govern-sys-member-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/plugin-security": patch
---

fix(security): govern `sys_member` writes — organization membership is not a delegable capability (#3697 follow-up)

`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 delegated-administration gate never saw.

That matters because 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 this changes no working behaviour.** Every
`sys_member` writer is a better-auth path running under `isSystem`, which
short-circuits the whole security middleware before this gate; the ADR-0092 D2
identity write guard refuses user-context writes to better-auth-managed tables
upstream of it. The gate is added so the chain cannot silently reopen the day a
direct-write surface is introduced — a `case` label is not enforcement, and the
call site is what decides (AGENTS.md Prime Directive #10).

The rule is tenant-admin-only rather than scope-delegable, deliberately: no axis
of `AdminScope` expresses "organization membership" (its vocabulary is BU
subtree, action flags and an assignable-set allowlist), 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, which is what ADR-0090 D12
exists to prevent. Adding people to an organization already has a delegable
path: the **invitation**, whose placement is authorized against the issuer's
`adminScope` and whose role is capped at the issuer's own grade. The refusal
message says so.
3 changes: 2 additions & 1 deletion content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ misconfigured name resolves to an empty subtree and **approves nothing**

Writes to the governed tables (`sys_user_position`,
`sys_position_permission_set`, `sys_user_permission_set`,
`sys_permission_set`) pass through the `DelegatedAdminGate`:
`sys_permission_set`, `sys_member`) pass through the `DelegatedAdminGate`:

| Rule | Effect |
|---|---|
Expand All @@ -64,6 +64,7 @@ Writes to the governed tables (`sys_user_position`,
| Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked |
| Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically |
| Tenant-level assets stay tenant-level | The `everyone`/`guest` audience anchors and security-domain publishes are untouchable from any delegated scope |
| 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) |
| 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 |

Every rule above is exercised end-to-end by the showcase permission zoo
Expand Down
70 changes: 70 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,76 @@ describe('DelegatedAdminGate — tenant admins and outsiders', () => {
});
});

// ── [#3697 follow-up] Organization membership is governed, and NOT delegable ──
//
// A `sys_member` row whose `role` is admin-grade is auto-elevated to
// `organization_admin` (`auto-org-admin-grant.ts`), whose wildcard
// `modifyAllRecords` IS `isTenantAdmin()`. Writing one therefore mints a tenant
// admin — the same escalation the invitation role cap closes on the issuance
// path, one layer down at the table.
//
// Unreachable today: every writer is a better-auth path running under
// `isSystem`, which short-circuits the whole security middleware before this
// gate. These cases exist so the chain cannot silently reopen the day a direct
// write surface is added — the defence has to live at the CALL SITE, not in a
// comment (AGENTS.md Prime Directive #10).
describe('DelegatedAdminGate — organization membership (sys_member)', () => {
const writeMember = (ctx: any, row: any, operation = 'insert') =>
h.gate.assert({ object: 'sys_member', operation, data: row, context: ctx });

it('a tenant admin still passes — delegation constrains delegates, not HQ', async () => {
await expect(
writeMember(h.ctxOf('tenant_admin'), { user_id: 'u_x', organization_id: 'org_1', role: 'admin' }),
).resolves.toBeUndefined();
});

it('a DELEGATE holding a real adminScope is refused — membership is not a delegable axis', async () => {
// The escalation this closes: `role: 'admin'` here resolves to
// organization_admin, i.e. authority strictly greater than the delegate's.
await expect(
writeMember(h.ctxOf('delegate'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'admin' }),
).rejects.toThrow(/tenant-level only/);
});

it('a delegate is refused even for a plain member row — no adminScope axis approves any of it', async () => {
await expect(
writeMember(h.ctxOf('delegate'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'member' }),
).rejects.toThrow(/not a delegable capability/);
});

it('the refusal points at the delegable path that DOES exist', async () => {
await expect(
writeMember(h.ctxOf('delegate'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'member' }),
).rejects.toThrow(/invitation/);
});

it('plain CRUD on the table buys nothing', async () => {
await expect(
writeMember(h.ctxOf('crud_only'), { user_id: 'u_east_1', organization_id: 'org_1', role: 'admin' }),
).rejects.toThrow(/tenant-level only/);
});

it('a principal-less non-system write fails closed', async () => {
await expect(
writeMember({}, { user_id: 'u_east_1', organization_id: 'org_1', role: 'admin' }),
).rejects.toThrow(/authenticated administrator/);
});

it('every governed mutation verb is covered, not just insert', async () => {
for (const op of ['update', 'delete', 'transfer', 'restore', 'purge']) {
await expect(
writeMember(h.ctxOf('delegate'), { id: 'mem_1', role: 'admin' }, op),
).rejects.toThrow(/tenant-level only/);
}
});

it('reads are untouched — this governs writes only', async () => {
await expect(
h.gate.assert({ object: 'sys_member', operation: 'find', context: h.ctxOf('delegate') }),
).resolves.toBeUndefined();
});
});

describe('DelegatedAdminGate — assignments (sys_user_position)', () => {
it('delegate assigns an allowlisted position inside the subtree; granted_by is stamped', async () => {
const row: any = { user_id: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es' };
Expand Down
44 changes: 44 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
* to an anchor are rejected for every caller (anchors are implicit — a
* stored assignment row is a modeling error).
*
* `sys_member` (organization membership) is likewise tenant-level only: no
* `adminScope` axis expresses membership, and an admin-grade membership row is
* auto-elevated to `organization_admin` — writing one mints a tenant admin
* (#3697 follow-up). The delegable way to add people is an INVITATION, whose
* placement this gate authorizes and whose role the framework caps at the
* issuer's own grade.
*
* System/boot writes carry `isSystem` and short-circuit the security
* middleware before this gate (seeders, publish materializer, better-auth
* reconciliation are unaffected).
Expand Down Expand Up @@ -57,6 +64,19 @@ const GOVERNED_OBJECTS = new Set([
'sys_position_permission_set',
'sys_user_permission_set',
'sys_permission_set',
// [#3697 follow-up] Organization MEMBERSHIP is an authority surface too: a
// row whose `role` is admin-grade is auto-elevated to `organization_admin`
// by `auto-org-admin-grant.ts`, whose wildcard `modifyAllRecords` IS
// `isTenantAdmin()`. So writing one mints a tenant admin — the same
// escalation the invitation role cap closes on the issuance path.
//
// Unreachable today (every writer is a better-auth path running under
// `isSystem`, short-circuited before this gate; the ADR-0092 D2 identity
// write guard refuses user-context writes to better-auth tables upstream).
// It is here so the chain does not silently reopen the day someone adds a
// direct-write surface — a `case` label is not enforcement, and the call
// site is what matters (AGENTS.md Prime Directive #10).
'sys_member',
]);
const GOVERNED_OPERATIONS = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']);
const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
Expand Down Expand Up @@ -186,6 +206,30 @@ export class DelegatedAdminGate {

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

// ── [#3697 follow-up] Organization membership is NOT delegable ────────
// Every other governed object has an adminScope axis that can approve part
// of it (a subtree, an action flag, an allowlist). `sys_member` has none:
// no axis of `AdminScope` expresses "org membership", and the row's own
// `role` column is an authority dial — admin-grade membership is
// auto-elevated to `organization_admin`, i.e. to `isTenantAdmin()`. A
// delegate who could write one would mint authority strictly greater than
// their own, which is exactly what ADR-0090 D12 exists to prevent.
//
// So it is tenant-admin-only, full stop — reached only by a hypothetical
// future direct-write surface (better-auth's own writes are `isSystem` and
// never arrive here). Adding people to an organization already has a
// delegable path: the INVITATION, whose placement is authorized against
// the issuer's adminScope and whose role is held to the issuer's own grade.
if (opCtx.object === 'sys_member') {
throw new PermissionDeniedError(
`[Security] Access denied: '${opCtx.operation}' on 'sys_member' is tenant-level only — ` +
`organization membership is not a delegable capability (ADR-0090 D12), and an ` +
`admin-grade membership row resolves to tenant administration. Add people through an ` +
`invitation instead: its placement is authorized against your adminScope.`,
{ operation: opCtx.operation, object: opCtx.object, userId: ctx.userId },
);
}

// ── [ADR-0091 D3] Self-service delegation of duty (职务代理) ──────────
// A write that STAMPS `delegated_from` is a delegation: the holder passes
// their OWN hat, judged by delegation rules — not by an adminScope. This
Expand Down
Loading