Skip to content

feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — delegated_admin, capped (#3697) - #3722

Merged
os-zhuang merged 2 commits into
mainfrom
claude/scope-bounded-issuance-caller-6mpzh8
Jul 28, 2026
Merged

feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — delegated_admin, capped (#3697)#3722
os-zhuang merged 2 commits into
mainfrom
claude/scope-bounded-issuance-caller-6mpzh8

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes #3697.

⚠️ Founder decision. The issue is explicitly marked "Awaiting a founder decision before implementation — this changes who can send invitations." This PR is opened as a draft implementing the issue's own revised recommendation (role + cap, shipped together). The two open sub-questions are answered as follows, both cheap to change before merge:

  1. Proceed at all — assumed yes.
  2. Role namedelegated_admin, the issue's recommendation, matching ADR-0090 D12's vocabulary. It is a single exported constant (MEMBERSHIP_ROLE_DELEGATED_ADMIN in @objectstack/spec) plus two select-option values, so org_delegate or a configurable name is a small follow-up diff.

The gap

D8 authorizes invitation placement against the issuer's adminScope (ADR-0090 D12), so a delegated plant admin may invite only into their own subtree. That gate is implemented, unit-proven and reachable — but no principal could reach it in a state where it narrowed anything:

  • better-auth grants invitation: ["create"] to owner/admin only (memberAc holds invitation: [], and roles registered through additionalOrgRoles inherited that empty statement);
  • under a wall-enforcing posture, owners and admins are auto-elevated to organization_admin (auto-org-admin-grant.ts), whose wildcard modifyAllRecords makes isTenantAdmin() true — and DelegatedAdminGate.assert short-circuits on tenant admins.

The two sets were disjoint. Issuance was bounded by the Layer 0 org wall (real, and correct) but never by adminScope.

The fix — two pieces, shipped together

1. The role. delegated_admin is registered with the organization plugin as memberAc.statements plus invitation: ["create"] — the one membership grade that reaches /organization/invite-member without being an org admin. The roles map is now built unconditionally (previously only when an app supplied additionalOrgRoles), and the guard was tightened so a missing defaultRoles export builds nothing rather than a map without owner.

Deliberately not invitation: ["cancel"] — that route checks only the permission, with no inviterId attribution, so it would mean "cancel anyone's pending invitation in the org".

The role carries no ObjectStack authority by construction: mapMembershipRole passes it through as a position name, and with no sys_position_permission_set binding that name resolves to nothing. Role = can reach the endpoint; adminScope = what the endpoint permits.

2. The role cap, in the framework's own beforeCreateInvitation hook, beside the D8 placement gate. Registering the role alone would have been a four-step escalation — better-auth's creatorRole guard (default owner) blocks inviting an owner but not an admin, and an accepted admin membership is auto-elevated to organization_adminisTenantAdmin(). Every existing defense is off that path (sys_member is not a GOVERNED_OBJECT; the acceptance write runs under better-auth's context, not the issuer's).

The cap:

  • refuses an invited role that outranks the issuer's own (owner > admin > everyone else);
  • holds a below-admin issuer to plain member only — not merely "not admin/owner", because an app-registered role projects into current_user.positions via mapMembershipRole and may be bound to permission sets, making it a capability channel too. A delegate's channel for capability is the invitation's placement intent, which the D12 gate allowlists position-by-position;
  • applies to every invitation, placement-carrying or not (the escalation is independent of placement) — it runs before the placement gate;
  • fails closed: an issuer whose sys_member row cannot be read confers nothing above a plain member. The common path (role: "member") short-circuits before any lookup, so it costs nothing.

What the end-to-end test found that no unit test could

sys_member.role and sys_invitation.role are Field.select with a closed option list, and those selects are enforced on write — better-auth's own invitation and membership inserts are validated like any other row. Registering the role with the org plugin without listing it in both would have shipped a role nobody could hold and nobody could hand out:

ValidationError: role must be one of: owner, admin, member

The dogfood regression caught this twice (once per object). Both now carry delegated_admin; translation bundles were regenerated with pnpm i18n:extract, and the three non-English locales carry the English label for the new option until localized.

Changes

File What
packages/spec/src/identity/eval-user.zod.ts (+ index.ts) MEMBERSHIP_ROLE_DELEGATED_ADMIN — one place the role is named
packages/plugins/plugin-auth/src/invitation-role-cap.ts New. The pure cap decision (grade ladder, plain-member short-circuit, refusal reasons)
packages/plugins/plugin-auth/src/auth-manager.ts Registers the role; runs the cap in beforeCreateInvitation; resolveMembershipRole reads the issuer's sys_member.role through the system context
packages/platform-objects/src/identity/sys-member.object.ts, sys-invitation.object.ts delegated_admin added to both enforced role selects
packages/platform-objects/src/apps/translations/*.generated.ts Regenerated (pnpm i18n:extract)
packages/lint/src/validate-approval-approvers.ts MEMBERSHIP_TIERS gains the new tier, so an approver naming it is no longer flagged as "not a membership tier"
content/docs/permissions/delegated-administration.mdx New sections: letting a delegate invite, and the role cap

Coverage

  • invitation-role-cap.test.ts — the pure cap: the escalation chain refused, comma-list smuggling, app roles, delegates minting delegates, owners/admins unaffected, fail-closed floor.
  • auth-manager.test.ts — roles map (invitation: ["create"], no cancel, defaults intact, an app cannot downgrade the role by declaring the same name) and hook wiring (refusals, the no-lookup fast path, cap-runs-before-placement).
  • packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts — the whole path over real HTTP: a delegated_admin issues a member invitation; the same delegate issuing admin is refused with no invitation row left behind; an org owner provisions a delegate by invitation; a plain member still cannot invite at all (so it is demonstrably the role that opened the endpoint).

pnpm test (turbo, all 132 tasks) green on the rebased tree; pnpm check:i18n in sync.

What changes for deployments

One new class of principal: members holding the delegated_admin org role, who can invite into the org — as member only, into the subtree their adminScope allows. Opt-in twice over (someone must set the membership role and grant an adminScope set), so a default deployment changes not at all. Org owners and admins are unaffected.

Non-goals (issue's own list, still open)

  • sys_member absent from DelegatedAdminGate's GOVERNED_OBJECTS — unexploitable today (all writers are better-auth paths), belongs to an ADR-0090 hardening pass.
  • Attributed cancel for delegates.

Found while implementing, filed separately

#3723additionalOrgRoles has the same wall this PR just discovered: an app registering e.g. sales_rep gets a role better-auth accepts on /organization/invite-member, but sys_invitation.role / sys_member.role reject it on write. Pre-existing and orthogonal, so it is filed rather than widened into this PR.

…egated_admin`, capped (#3697)

D8 authorizes invitation PLACEMENT against the issuer's `adminScope`
(ADR-0090 D12), so a delegated plant admin may invite only into their own
subtree. That gate is implemented, unit-proven and reachable — but no
principal could reach it in a state where it narrowed anything:

- better-auth grants `invitation:["create"]` to `owner`/`admin` only
  (`memberAc` holds `invitation: []`, and roles registered through
  `additionalOrgRoles` inherited that empty statement);
- under a wall-enforcing posture, owners and admins are auto-elevated to
  `organization_admin`, whose wildcard `modifyAllRecords` makes
  `isTenantAdmin()` true — and the gate short-circuits on tenant admins.

The two sets were disjoint. Issuance was bounded by the Layer 0 org wall
(real, and correct) but never by `adminScope`.

Two pieces, and they only ship together.

1. The role. `delegated_admin` is registered with the organization plugin
   as `memberAc.statements` plus `invitation: ["create"]` — the one
   membership grade that reaches `/organization/invite-member` without
   being an org admin. Deliberately not `cancel`: that route has no
   inviterId attribution, so the permission would mean "cancel anyone's
   pending invitation in the org". The role carries no ObjectStack
   authority by construction — it passes through as a position name and
   with no `sys_position_permission_set` binding resolves to nothing.
   Role = can reach the endpoint; adminScope = what the endpoint permits.

2. The role cap, in the framework's own `beforeCreateInvitation` hook,
   beside the D8 placement gate. The role alone would have been a
   four-step escalation: better-auth's `creatorRole` guard (default
   `owner`) blocks inviting an owner but not an admin, and an accepted
   `admin` membership is auto-elevated to `organization_admin` →
   `isTenantAdmin()`. Every existing defense is off that path
   (`sys_member` is not a GOVERNED_OBJECT; the acceptance write runs
   under better-auth's context, not the issuer's). The cap refuses a role
   outranking the issuer's own and holds a below-admin issuer to plain
   `member` — not merely "not admin/owner", because an app-registered
   role projects into `current_user.positions` and may be bound to
   permission sets. It applies to every invitation, placement-carrying or
   not, and fails closed on an unresolvable issuer membership.

`sys_member.role` and `sys_invitation.role` each gain the new option.
Those selects are enforced on write — better-auth's own invitation and
membership inserts are validated like any other row — so registering the
role without listing it in both would have shipped a role nobody could
hold and nobody could hand out. The end-to-end regression caught that
twice; neither unit test could.

Coverage: pure cap decisions (`invitation-role-cap.test.ts`), hook and
roles-map wiring (`auth-manager.test.ts`), and the whole path over real
HTTP (`delegated-admin-invite.dogfood.test.ts`) — a delegate issues a
`member` invitation, the same delegate issuing `admin` is refused with no
row left behind, an org owner provisions a delegate by invitation, and a
plain member still cannot invite at all.

Net effect on deployments: one new, doubly-opt-in class of principal
(membership role AND an adminScope grant). A default deployment changes
not at all; org owners and admins are unaffected.

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

vercel Bot commented Jul 27, 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 27, 2026 4:47pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/platform-objects, @objectstack/plugin-auth, packages/qa, @objectstack/spec.

108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, packages/qa, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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.

…admin doc; refresh the spec API surface

Two CI ratchets that a new export and new prose trip by construction:

- `check:role-word` (ADR-0090 D3 vocabulary ratchet) flagged 11 new uses
  of "role" in `delegated-administration.mdx`. All 11 are the better-auth
  organization membership role (`sys_member.role`, `sys_invitation.role`,
  the `delegated_admin` value) — the "genuine boundary (better-auth)" case
  the ratchet documents as baseline-worthy. Renaming them would obscure
  the very field the section is about; D3's reserved vocabulary governs
  ObjectStack capability nouns, not better-auth's own column.
- `check:api-surface` — regenerated for the additive
  `MEMBERSHIP_ROLE_DELEGATED_ADMIN` export (0 breaking, 2 added).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015s4qPrCKBvVwmFeSsFHgNp
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 00:32
@os-zhuang
os-zhuang merged commit 1bd5652 into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/scope-bounded-issuance-caller-6mpzh8 branch July 28, 2026 00:32
os-zhuang added a commit that referenced this pull request Jul 28, 2026
…able 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.
os-zhuang added a commit to objectstack-ai/objectui that referenced this pull request Jul 28, 2026
…pickers (framework#3697) (#2891)

Follow-on to objectstack-ai/objectstack#3722, which registered a fourth
organization role. Companion to #2868, which shipped the placement half.

#3722 gave ADR-0105 D8's scope-bounded issuance gate its missing caller:
`delegated_admin`, the grade that may reach `/organization/invite-member`
without being an org admin. But the console could not select the role at
all — `MembersPage` and `InviteMemberDialog` each inlined
`type Role = 'owner' | 'admin' | 'member'` — so the capability the
framework grew was unreachable from either screen.

One vocabulary, not two: role names, labels and narrowing rules move
into `@object-ui/auth`'s new `org-roles` module and both screens consume
it. The list still MIRRORS the server rather than deriving from it
(`/auth/config` publishes feature flags but no role vocabulary); the
module says so and points at objectstack-ai/objectstack#3723.

Both pickers now narrow to what the server will accept, mirroring
DIFFERENT gates:

- invite role  ← `beforeCreateInvitation`'s role cap: never above the
  issuer's own grade; a below-admin issuer may invite as `member` only.
  A `delegated_admin` picking "Admin" would have 403'd.
- change role  ← better-auth's `update-member-role`: needs
  `member:["update"]` (owner/admin only), and only an owner may set
  `owner` or re-role an existing owner. An actor who may re-role nobody
  gets no items rather than three that would 403.

Narrowing is convenience, not the boundary — the server re-checks — and
it fails toward less. An ordinary invitation's request body is
byte-identical to before.

Verified: 654 files / 7684 tests passing; lint 0 errors on both
packages; tsc 29/29.
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0105 D8: the scope-bounded issuance path has no caller — every principal who may invite is already a tenant admin

2 participants