Skip to content

Commit 1bd5652

Browse files
authored
feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — delegated_admin, capped (#3697) (#3722)
Closes #3697. D8 authorizes invitation placement against the issuer's adminScope (ADR-0090 D12), but no principal could reach that gate in a state where it narrowed anything: better-auth grants invitation:["create"] to owner/admin only, and under a wall-enforcing posture those two are auto-elevated to tenant admins, for whom the gate short-circuits. Two pieces, shipped together: 1. `delegated_admin` — registered with the organization plugin as memberAc.statements + invitation:["create"], the one membership grade that reaches /organization/invite-member without being an org admin. Not `cancel`: that route has no inviterId attribution. The role carries no ObjectStack authority by construction; placement authority still comes solely from a separately-granted adminScope. 2. The invitation role cap, in the framework's own beforeCreateInvitation hook beside the D8 placement gate. Without it the role would be a four-step escalation to tenant admin (creatorRole blocks inviting an owner but not an admin; an accepted admin membership is auto-elevated to organization_admin). The cap refuses a role outranking the issuer's own, holds a below-admin issuer to plain `member`, applies to every invitation, 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, so the role would otherwise have been unholdable and un-handoutable. Caught by the end-to-end regression, twice. Coverage: pure cap decisions, hook + roles-map wiring, and the whole path over real HTTP (delegate issues a member invitation; the same delegate issuing admin is refused with no row left behind; an owner provisions a delegate by invitation; a plain member still cannot invite). Doubly opt-in — a default deployment changes not at all. Follow-up filed: #3723 (additionalOrgRoles hits the same enforced-select wall for app-registered roles).
1 parent 4ed7ed4 commit 1bd5652

18 files changed

Lines changed: 948 additions & 26 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/spec": minor
5+
"@objectstack/lint": patch
6+
---
7+
8+
feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the
9+
`delegated_admin` org role, capped so it cannot mint authority (#3697)
10+
11+
D8 authorizes invitation *placement* against the issuer's `adminScope`
12+
(ADR-0090 D12), so a delegated plant admin may invite only into their own
13+
subtree. That gate is implemented, unit-proven and reachable — but no principal
14+
could reach it in a state where it did anything:
15+
16+
- better-auth grants `invitation: ["create"]` to `owner` and `admin` only
17+
(`memberAc` holds `invitation: []`, and roles registered through
18+
`additionalOrgRoles` inherited that empty statement);
19+
- under a wall-enforcing posture, owners and admins are auto-elevated to
20+
`organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard
21+
`modifyAllRecords` that makes `isTenantAdmin()` true — and the gate
22+
short-circuits on tenant admins.
23+
24+
The two sets were disjoint. Issuance placement was bounded by the Layer 0 org
25+
wall (real, and correct) but never by `adminScope`, so D8's motivating story —
26+
"a plant admin invites into their own subtree without a platform admin
27+
finishing the job" — could not happen.
28+
29+
**Two pieces, and they only ship together.**
30+
31+
**1. The role.** `delegated_admin` is now registered with the organization
32+
plugin as `memberAc.statements` plus `invitation: ["create"]` — the one
33+
membership grade that may reach `/organization/invite-member` without being an
34+
org admin. Deliberately *not* `invitation: ["cancel"]`: better-auth's cancel
35+
route checks the permission with no inviterId attribution, so it would mean
36+
"cancel anyone's pending invitation in the org".
37+
38+
The role carries no ObjectStack authority by construction — `mapMembershipRole`
39+
passes it through as a position name, and with no `sys_position_permission_set`
40+
binding that name resolves to nothing. Role = *can reach the endpoint*;
41+
`adminScope` = *what the endpoint permits*.
42+
43+
`sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a
44+
fourth option. Those selects are **enforced on write** — better-auth's own
45+
invitation and membership inserts are validated like any other row — so
46+
registering the role with the org plugin without listing it in both would have
47+
produced a role nobody could hold and nobody could hand out
48+
(`ValidationError: role must be one of: owner, admin, member`). That is exactly
49+
how the end-to-end regression caught it, twice; neither unit test could. The
50+
three non-English translation bundles carry the English label for the new option
51+
until localized.
52+
53+
**2. The role cap**, in the framework's own `beforeCreateInvitation` hook,
54+
beside the D8 placement gate. Registering the role alone would have been a
55+
four-step privilege escalation: better-auth's only role-level cap on *what role
56+
you may invite someone as* is its `creatorRole` check (default `owner`), which
57+
blocks inviting an **owner** but not an **admin** — and an accepted `admin`
58+
membership is auto-elevated to `organization_admin``isTenantAdmin()`. A
59+
subtree-scoped delegate could have manufactured a tenant admin, with every
60+
existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the
61+
acceptance-time membership write runs under better-auth's context, not the
62+
issuer's).
63+
64+
The cap refuses an invitation whose role outranks the issuer's own, and
65+
restricts a below-admin issuer to plain `member` — not merely "not admin/owner",
66+
because an app-registered role projects into `current_user.positions` and may be
67+
bound to permission sets, making it a capability channel too. A delegate's
68+
channel for capability is the invitation's *placement* intent, which the D12
69+
gate allowlists position-by-position. The cap applies to every invitation,
70+
placement-carrying or not (the escalation is independent of placement), and
71+
fails closed: an issuer role that cannot be resolved confers nothing above a
72+
plain member.
73+
74+
**What changes for deployments.** One new class of principal exists: members
75+
holding the `delegated_admin` org role, who can invite into the org — as
76+
`member` only, into the subtree their `adminScope` allows. It is opt-in twice
77+
over (someone must set the membership role *and* grant an adminScope set), so a
78+
default deployment changes not at all. Org owners and admins are unaffected.
79+
80+
Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so
81+
console and control-plane surfaces name the role from one place.

content/docs/permissions/delegated-administration.mdx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,62 @@ Scoped invitations (ADR-0105 D8) are the first consumer: the invitation form
136136
narrows its unit/position pickers with this, and the invitation's placement
137137
intent is then authorized against the *issuer's* scope at issuance time.
138138

139+
## Letting a delegate invite (`delegated_admin`)
140+
141+
A scope is *what* you may administer; it does not by itself let you reach the
142+
invitation endpoint. The underlying organization plugin grants
143+
`invitation: ["create"]` to `owner` and `admin` only — and both of those are
144+
auto-elevated to `organization_admin` (a tenant admin), for whom the scope gate
145+
narrows nothing. So the scope-bounded issuance path needs a membership grade
146+
that is neither: **`delegated_admin`**.
147+
148+
```jsonc
149+
// sys_member — the better-auth membership row
150+
{ "user_id": "usr_plant_admin", "organization_id": "org_acme", "role": "delegated_admin" }
151+
```
152+
153+
Two independent switches, and you need both:
154+
155+
| | Grants | Without it |
156+
|---|---|---|
157+
| `sys_member.role = delegated_admin` | May *reach* `/organization/invite-member` | Refused by the org plugin before any framework hook runs |
158+
| An `adminScope`-carrying permission set | *What* the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing |
159+
160+
The role carries no ObjectStack authority of its own: it passes through as a
161+
position name, and with no `sys_position_permission_set` binding it resolves to
162+
nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint
163+
permits.** A default deployment is unaffected — nobody holds the role until you
164+
set it.
165+
166+
### The invitation role cap
167+
168+
An invitation may add a person; it may never add authority above the issuer's
169+
own. The framework caps every invitation — placement-carrying or not — at
170+
issuance:
171+
172+
- an invited role may not outrank the issuer's (`owner` > `admin` > everyone
173+
else);
174+
- an issuer below admin grade may invite as **`member` only**. Not just "not
175+
admin/owner": an app-registered role projects into `current_user.positions`
176+
and may be bound to permission sets, so it is a capability channel too. A
177+
delegate's channel for capability is the invitation's **placement** intent,
178+
which this gate allowlists position-by-position;
179+
- an issuer whose membership cannot be read confers nothing above a plain
180+
member (fail-closed).
181+
182+
Without the cap the role would be a ladder: invite an `admin`, and acceptance
183+
auto-elevates that membership to `organization_admin` → tenant admin — the
184+
grade the delegation exists to withhold. `sys_member` is not one of the gate's
185+
governed objects and the acceptance write runs outside the issuer's context, so
186+
the cap is the defense on that path.
187+
188+
Both halves are pinned end-to-end over the real HTTP route by
189+
`packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts`: a
190+
`delegated_admin` issues a `member` invitation successfully, the same principal
191+
issuing `admin` is refused with no invitation row left behind, and a plain
192+
member still cannot invite at all — so it is demonstrably the role, not some
193+
unrelated loosening, that opened the endpoint.
194+
139195
## See also
140196

141197
- [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on

packages/lint/src/validate-approval-approvers.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
canonicalApproverType,
4646
normalizeDecisionOutputs,
4747
} from '@objectstack/spec/automation';
48+
import { MEMBERSHIP_ROLE_DELEGATED_ADMIN } from '@objectstack/spec';
4849
import { collectCelRootIdentifiers } from '@objectstack/formula';
4950

5051
export const APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = 'approval-approver-not-membership-tier';
@@ -105,7 +106,16 @@ type AnyRec = Record<string, unknown>;
105106
* authored as `{ type: 'org_membership_level' }` (or its deprecated `role`
106107
* spelling) is almost certainly a position name.
107108
*/
108-
const MEMBERSHIP_TIERS = new Set(['owner', 'admin', 'member', 'guest']);
109+
const MEMBERSHIP_TIERS = new Set([
110+
'owner',
111+
'admin',
112+
'member',
113+
'guest',
114+
// [ADR-0105 D8 / #3697] A real tier since the framework registers it with the
115+
// org plugin — the delegated issuer grade. It is storable in
116+
// `sys_member.role`, so an approver naming it resolves to people, not nobody.
117+
MEMBERSHIP_ROLE_DELEGATED_ADMIN,
118+
]);
109119

110120
/** Off-spec dialect spellings we can name a canonical fix for. */
111121
const TYPE_FIX: Record<string, string> = {

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
583583
options: {
584584
owner: "Owner",
585585
admin: "Admin",
586+
delegated_admin: "Delegated Admin",
586587
member: "Member"
587588
}
588589
}
@@ -641,6 +642,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
641642
options: {
642643
owner: "Owner",
643644
admin: "Admin",
645+
delegated_admin: "Delegated Admin",
644646
member: "Member"
645647
}
646648
},

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
583583
options: {
584584
owner: "Propietario",
585585
admin: "Administrador",
586+
delegated_admin: "Delegated Admin",
586587
member: "Miembro"
587588
}
588589
}
@@ -641,6 +642,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
641642
options: {
642643
owner: "Propietario",
643644
admin: "Administrador",
645+
delegated_admin: "Delegated Admin",
644646
member: "Miembro"
645647
}
646648
},

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
583583
options: {
584584
owner: "所有者",
585585
admin: "管理者",
586+
delegated_admin: "Delegated Admin",
586587
member: "メンバー"
587588
}
588589
}
@@ -641,6 +642,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
641642
options: {
642643
owner: "所有者",
643644
admin: "管理者",
645+
delegated_admin: "Delegated Admin",
644646
member: "メンバー"
645647
}
646648
},

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
583583
options: {
584584
owner: "所有者",
585585
admin: "管理员",
586+
delegated_admin: "Delegated Admin",
586587
member: "成员"
587588
}
588589
}
@@ -641,6 +642,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
641642
options: {
642643
owner: "所有者",
643644
admin: "管理员",
645+
delegated_admin: "Delegated Admin",
644646
member: "成员"
645647
}
646648
},

packages/platform-objects/src/identity/sys-invitation.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ export const SysInvitation = ObjectSchema.create({
203203
options: [
204204
{ label: 'Owner', value: 'owner' },
205205
{ label: 'Admin', value: 'admin' },
206+
// [ADR-0105 D8 / #3697] Kept in step with `sys_member.role` — this is
207+
// the value that lands there on acceptance, and inviting is how a
208+
// delegate gets provisioned in the first place. Both selects are
209+
// enforced on write, so a role missing from either one is a role that
210+
// cannot be handed out.
211+
{ label: 'Delegated Admin', value: 'delegated_admin' },
206212
{ label: 'Member', value: 'member' },
207213
],
208214
defaultValue: 'member',

packages/platform-objects/src/identity/sys-member.object.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,17 @@ export const SysMember = ObjectSchema.create({
172172
options: [
173173
{ label: 'Owner', value: 'owner' },
174174
{ label: 'Admin', value: 'admin' },
175+
// [ADR-0105 D8 / #3697] The delegated issuer grade — may reach
176+
// `/organization/invite-member` WITHOUT being an org admin, which is
177+
// what finally gives D8's scope-bounded issuance gate a caller. It
178+
// carries no ObjectStack authority by itself: placement authority
179+
// comes from a separately-granted `adminScope`, and the invitation
180+
// role cap holds it to inviting plain members.
181+
//
182+
// Listed here because this select is ENFORCED on write: better-auth's
183+
// own accept-invitation membership insert is validated like any other
184+
// row, so a role missing from this list is a role nobody can hold.
185+
{ label: 'Delegated Admin', value: 'delegated_admin' },
175186
{ label: 'Member', value: 'member' },
176187
],
177188
defaultValue: 'member',

0 commit comments

Comments
 (0)