Skip to content

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

Merged
os-zhuang merged 1 commit into
mainfrom
claude/govern-sys-member
Jul 28, 2026
Merged

fix(security): govern sys_member writes — membership is not a delegable capability (#3697 follow-up)#3767
os-zhuang merged 1 commit into
mainfrom
claude/govern-sys-member

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes the one open non-goal from #3697 / #3722.

The gap

DelegatedAdminGate.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.

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 (#3722) 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. So nothing legitimate reaches the new refusal.

It is added so the chain cannot silently reopen the day a direct-write surface is introduced. That is precisely the "declared ≠ enforced, and the call site is what matters" lesson from Prime Directive #10 — the previous state left the invariant true only by accident of routing.

Why tenant-admin-only rather than scope-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: the scope vocabulary is BU subtree / manageAssignments / manageBindings / authorEnvironmentSets / assignablePermissionSets, and not one of them expresses "organization membership". 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 — the thing ADR-0090 D12 exists to prevent.

Adding people to an organization already has a delegable path: the invitation, whose placement this same gate authorizes against the issuer's adminScope and whose role #3722 caps at the issuer's own grade. The refusal message points there rather than dead-ending:

[Security] Access denied: 'insert' 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.

Changes

File What
packages/plugins/plugin-security/src/delegated-admin-gate.ts sys_member joins GOVERNED_OBJECTS; refused for everyone below tenant admin, placed right after the tenant-admin short-circuit so the message is specific rather than falling through to the generic RBAC-table one
packages/plugins/plugin-security/src/delegated-admin-gate.test.ts New suite: tenant admin passes; a real adminScope holder is refused (including for a plain member row — no axis approves any of it); plain CRUD buys nothing; principal-less fails closed; every governed mutation verb covered, not just insert; reads untouched
content/docs/permissions/delegated-administration.mdx Governed-table list + a rule row explaining why membership is the exception, linking to the invitation path

Verification

pnpm --filter @objectstack/plugin-security test32 files, 673 tests passing. Full turbo run test132/132 tasks green.


Generated by Claude Code

…able 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.

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. So
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, and 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 (Prime Directive
#10).

Tenant-admin-only rather than scope-delegable, deliberately: no axis of
`AdminScope` expresses "organization membership" (its vocabulary is BU
subtree, action flags, 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 already has a delegable path: the INVITATION,
whose placement this gate authorizes and whose role the framework caps at the
issuer's own grade. The refusal message points there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015s4qPrCKBvVwmFeSsFHgNp
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 3:07am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

11 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/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/releases/implementation-status.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)

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.

@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 03:44
@os-zhuang
os-zhuang merged commit 307e0fe into main Jul 28, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/govern-sys-member branch July 28, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants