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
81 changes: 81 additions & 0 deletions .changeset/adr-0105-d8-delegated-admin-org-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
"@objectstack/plugin-auth": minor
"@objectstack/platform-objects": minor
"@objectstack/spec": minor
"@objectstack/lint": patch
---

feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the
`delegated_admin` org role, capped so it cannot mint authority (#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 did anything:

- better-auth grants `invitation: ["create"]` to `owner` and `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`), which carries the wildcard
`modifyAllRecords` that makes `isTenantAdmin()` true — and the gate
short-circuits on tenant admins.

The two sets were disjoint. Issuance placement was bounded by the Layer 0 org
wall (real, and correct) but never by `adminScope`, so D8's motivating story —
"a plant admin invites into their own subtree without a platform admin
finishing the job" — could not happen.

**Two pieces, and they only ship together.**

**1. The role.** `delegated_admin` is now registered with the organization
plugin as `memberAc.statements` plus `invitation: ["create"]` — the one
membership grade that may reach `/organization/invite-member` without being an
org admin. Deliberately *not* `invitation: ["cancel"]`: better-auth's cancel
route checks 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*.

`sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a
fourth 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 with the org plugin without listing it in both would have
produced a role nobody could hold and nobody could hand out
(`ValidationError: role must be one of: owner, admin, member`). That is exactly
how the end-to-end regression caught it, twice; neither unit test could. The
three non-English translation bundles carry the English label for the new option
until localized.

**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 privilege escalation: better-auth's only role-level cap on *what role
you may invite someone as* is its `creatorRole` check (default `owner`), which
blocks inviting an **owner** but not an **admin** — and an accepted `admin`
membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A
subtree-scoped delegate could have manufactured a tenant admin, with every
existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the
acceptance-time membership write runs under better-auth's context, not the
issuer's).

The cap refuses an invitation whose role outranks the issuer's own, and
restricts 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, 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. The cap applies to every invitation,
placement-carrying or not (the escalation is independent of placement), and
fails closed: an issuer role that cannot be resolved confers nothing above a
plain member.

**What changes for deployments.** One new class of principal exists: members
holding the `delegated_admin` org role, who can invite into the org — as
`member` only, into the subtree their `adminScope` allows. It is 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.

Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so
console and control-plane surfaces name the role from one place.
56 changes: 56 additions & 0 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,62 @@ Scoped invitations (ADR-0105 D8) are the first consumer: the invitation form
narrows its unit/position pickers with this, and the invitation's placement
intent is then authorized against the *issuer's* scope at issuance time.

## Letting a delegate invite (`delegated_admin`)

A scope is *what* you may administer; it does not by itself let you reach the
invitation endpoint. The underlying organization plugin grants
`invitation: ["create"]` to `owner` and `admin` only — and both of those are
auto-elevated to `organization_admin` (a tenant admin), for whom the scope gate
narrows nothing. So the scope-bounded issuance path needs a membership grade
that is neither: **`delegated_admin`**.

```jsonc
// sys_member — the better-auth membership row
{ "user_id": "usr_plant_admin", "organization_id": "org_acme", "role": "delegated_admin" }
```

Two independent switches, and you need both:

| | Grants | Without it |
|---|---|---|
| `sys_member.role = delegated_admin` | May *reach* `/organization/invite-member` | Refused by the org plugin before any framework hook runs |
| An `adminScope`-carrying permission set | *What* the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing |

The role carries no ObjectStack authority of its own: it passes through as a
position name, and with no `sys_position_permission_set` binding it resolves to
nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint
permits.** A default deployment is unaffected — nobody holds the role until you
set it.

### The invitation role cap

An invitation may add a person; it may never add authority above the issuer's
own. The framework caps every invitation — placement-carrying or not — at
issuance:

- an invited role may not outrank the issuer's (`owner` > `admin` > everyone
else);
- an issuer below admin grade may invite as **`member` only**. Not just "not
admin/owner": an app-registered role projects into `current_user.positions`
and may be bound to permission sets, so it is a capability channel too. A
delegate's channel for capability is the invitation's **placement** intent,
which this gate allowlists position-by-position;
- an issuer whose membership cannot be read confers nothing above a plain
member (fail-closed).

Without the cap the role would be a ladder: invite an `admin`, and acceptance
auto-elevates that membership to `organization_admin` → tenant admin — the
grade the delegation exists to withhold. `sys_member` is not one of the gate's
governed objects and the acceptance write runs outside the issuer's context, so
the cap is the defense on that path.

Both halves are pinned end-to-end over the real HTTP route by
`packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts`: a
`delegated_admin` issues a `member` invitation successfully, the same principal
issuing `admin` is refused with no invitation row left behind, and a plain
member still cannot invite at all — so it is demonstrably the role, not some
unrelated loosening, that opened the endpoint.

## See also

- [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on
Expand Down
12 changes: 11 additions & 1 deletion packages/lint/src/validate-approval-approvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
canonicalApproverType,
normalizeDecisionOutputs,
} from '@objectstack/spec/automation';
import { MEMBERSHIP_ROLE_DELEGATED_ADMIN } from '@objectstack/spec';
import { collectCelRootIdentifiers } from '@objectstack/formula';

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

/** Off-spec dialect spellings we can name a canonical fix for. */
const TYPE_FIX: Record<string, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "Owner",
admin: "Admin",
delegated_admin: "Delegated Admin",
member: "Member"
}
}
Expand Down Expand Up @@ -641,6 +642,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "Owner",
admin: "Admin",
delegated_admin: "Delegated Admin",
member: "Member"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "Propietario",
admin: "Administrador",
delegated_admin: "Delegated Admin",
member: "Miembro"
}
}
Expand Down Expand Up @@ -641,6 +642,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "Propietario",
admin: "Administrador",
delegated_admin: "Delegated Admin",
member: "Miembro"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "所有者",
admin: "管理者",
delegated_admin: "Delegated Admin",
member: "メンバー"
}
}
Expand Down Expand Up @@ -641,6 +642,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "所有者",
admin: "管理者",
delegated_admin: "Delegated Admin",
member: "メンバー"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "所有者",
admin: "管理员",
delegated_admin: "Delegated Admin",
member: "成员"
}
}
Expand Down Expand Up @@ -641,6 +642,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
options: {
owner: "所有者",
admin: "管理员",
delegated_admin: "Delegated Admin",
member: "成员"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ export const SysInvitation = ObjectSchema.create({
options: [
{ label: 'Owner', value: 'owner' },
{ label: 'Admin', value: 'admin' },
// [ADR-0105 D8 / #3697] Kept in step with `sys_member.role` — this is
// the value that lands there on acceptance, and inviting is how a
// delegate gets provisioned in the first place. Both selects are
// enforced on write, so a role missing from either one is a role that
// cannot be handed out.
{ label: 'Delegated Admin', value: 'delegated_admin' },
{ label: 'Member', value: 'member' },
],
defaultValue: 'member',
Expand Down
11 changes: 11 additions & 0 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ export const SysMember = ObjectSchema.create({
options: [
{ label: 'Owner', value: 'owner' },
{ label: 'Admin', value: 'admin' },
// [ADR-0105 D8 / #3697] The delegated issuer grade — may reach
// `/organization/invite-member` WITHOUT being an org admin, which is
// what finally gives D8's scope-bounded issuance gate a caller. It
// carries no ObjectStack authority by itself: placement authority
// comes from a separately-granted `adminScope`, and the invitation
// role cap holds it to inviting plain members.
//
// Listed here because this select is ENFORCED on write: better-auth's
// own accept-invitation membership insert is validated like any other
// row, so a role missing from this list is a role nobody can hold.
{ label: 'Delegated Admin', value: 'delegated_admin' },
{ label: 'Member', value: 'member' },
],
defaultValue: 'member',
Expand Down
Loading
Loading