Skip to content

Commit 0045682

Browse files
authored
feat(auth)!: membership grade is not a capability channel — close the sys_member.role vocabulary (ADR-0108, #3723) (#3802)
`sys_member.role` answers "what is your standing in this organization". It does not answer "what may you do" — that is what positions are for. `resolve-authz-context` projects EVERY value stored in `sys_member.role` into `current_user.positions`, so a business role handed out through the membership role was capability, granted with none of ADR-0090 D12's controls: no `granted_by`, no ADR-0091 validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. ADR-0057 D4 ruled that out ("never as the authority for RBAC"), ADR-0090 D3's word ban restates it (distribution = `position`), and ADR-0095 D3 keeps the better-auth role out of the enforcement path. No ADR authorized the widening — it arrived as a bug fix (#3747) and was then made automatic in every host (#3779). The vocabulary is closed to owner / admin / delegated_admin / member. `additionalOrgRoles`, `org-roles.ts` and the `kernel:ready` derivation hook are removed; capability at admission time goes through ADR-0105 D8 invitation placement, which is governed and reaches further (a delegated admin may use it within their subtree, where the membership-role route was org-admin-only). Both reversed changesets were unreleased, so no published version ever offered the behaviour. Downstream `objectstack-ai/cloud` audited at b168e94: no compile-time or runtime impact. Also: lint's MEMBERSHIP_TIERS now derives from BUILTIN_MEMBERSHIP_ROLES. The hand-kept copy carried `guest`, which the select has never offered, so an approver naming it resolved to nobody while the lint whose job is to catch that stayed silent.
1 parent 8dd814d commit 0045682

30 files changed

Lines changed: 790 additions & 1285 deletions

.changeset/adr-0105-d8-delegated-admin-org-role.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ subtree. That gate is implemented, unit-proven and reachable — but no principa
1414
could reach it in a state where it did anything:
1515

1616
- better-auth grants `invitation: ["create"]` to `owner` and `admin` only
17-
(`memberAc` holds `invitation: []`, and roles registered through
18-
`additionalOrgRoles` inherited that empty statement);
17+
(`memberAc` holds `invitation: []`, which every other registered role
18+
inherits);
1919
- under a wall-enforcing posture, owners and admins are auto-elevated to
2020
`organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard
2121
`modifyAllRecords` that makes `isTenantAdmin()` true — and the gate

.changeset/app-org-roles-storable.md

Lines changed: 0 additions & 64 deletions
This file was deleted.

.changeset/auth-org-roles-self-derived.md

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/platform-objects': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/lint': patch
6+
'@objectstack/cli': patch
7+
'@objectstack/verify': patch
8+
'@objectstack/plugin-dev': patch
9+
'@objectstack/runtime': patch
10+
---
11+
12+
feat(auth)!: membership grade is not a capability channel — the `sys_member.role`
13+
vocabulary is closed (ADR-0108, #3723)
14+
15+
`sys_member.role` answers "what is your standing in this organization". It does
16+
not answer "what may you do" — that is what positions are for. One column was
17+
answering both.
18+
19+
`resolve-authz-context` projects EVERY value stored in `sys_member.role` into
20+
`current_user.positions`, alongside the rows read from `sys_user_position`. So a
21+
business role handed out through the membership role *was* capability — granted
22+
with none of the position system's controls: no `granted_by`, no ADR-0091
23+
validity window, no BU-subtree check, no `assignablePermissionSets` allowlist.
24+
That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so
25+
invitations are accepted — **never as the authority for RBAC**"), what
26+
ADR-0090 D3's word ban restates (distribution = `position`), and what
27+
ADR-0095 D3 keeps out of the enforcement path.
28+
29+
The vocabulary is therefore closed to the four framework-owned names:
30+
`owner` / `admin` / `delegated_admin` / `member`.
31+
32+
**BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and
33+
`AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full
34+
(`collectStackOrgRoles`, `collectRegisteredOrgRoles`,
35+
`normalizeAdditionalOrgRoles`, `membershipRoleOptions`,
36+
`withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`,
37+
`MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`,
38+
`OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From
39+
`@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and
40+
`MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate
41+
app-supplied names. A TypeScript error is the intended failure: an option that
42+
is silently ignored is `declared ≠ enforced` one more time.
43+
44+
FROM → TO:
45+
46+
```diff
47+
- new AuthPlugin({ additionalOrgRoles: ['sales_rep'] })
48+
+ new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ })
49+
50+
- POST /organization/invite-member { email, role: 'sales_rep' }
51+
+ POST /organization/invite-member { email, role: 'member',
52+
+ businessUnitId, positions: ['sales_rep'] }
53+
```
54+
55+
For an existing member, assign the position through `sys_user_position` (the
56+
governed write path). Invitation placement (ADR-0105 D8) is the one-step
57+
admission flow: issuance is authorized against the issuer's `adminScope` by
58+
dry-running `DelegatedAdminGate`, and acceptance writes real
59+
`sys_user_position` rows with a `granted_by` stamp. It reaches **further** than
60+
what it replaces — a delegated admin may use it within their subtree, where the
61+
membership-role route was open to org admins only (the invitation role cap holds
62+
anyone below admin grade to plain `member`).
63+
64+
An invitation naming an app role now fails at better-auth's door with
65+
`ROLE_NOT_FOUND`, before any row is written.
66+
67+
This reverses two changesets that were never consumed into a release
68+
(`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published
69+
version ever offered the behaviour; both are removed rather than shipped and
70+
retracted in the same changelog. A pre-existing deployment could only have
71+
stored a custom value by direct DB write.
72+
73+
Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS`
74+
now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept
75+
copy carried `guest`, which the `sys_member.role` select has never offered — an
76+
approver authored as `{ type: 'org_membership_level', value: 'guest' }`
77+
resolved to nobody and the lint whose whole job is to catch that stayed silent.

content/docs/permissions/authentication.mdx

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -668,39 +668,52 @@ new AuthPlugin({
668668
})
669669
```
670670

671-
#### Positions as membership tiers
671+
#### Membership tiers are a closed list
672672

673673
`sys_member.role` is better-auth's own column and the one place ObjectStack
674-
keeps that vocabulary (ADR-0090 D3's single boundary exception). Beyond the
675-
four built-in tiers — `owner`, `admin`, `delegated_admin`, `member` — every
676-
[position](/docs/permissions/positions) and
677-
[permission set](/docs/permissions/permission-sets) your stack declares is
678-
registered as a valid value, so an invitation can name one directly:
674+
keeps that vocabulary (ADR-0090 D3's single boundary exception). It holds
675+
exactly four values — `owner`, `admin`, `delegated_admin`, `member` — and your
676+
stack cannot add to them.
677+
678+
That is deliberate (ADR-0108). A membership tier answers *what is your standing
679+
in this organization*, which decides what you can **reach**: `delegated_admin`,
680+
for instance, is the tier that may reach `/organization/invite-member` without
681+
being an org admin. What you may **do** with records is a
682+
[position](/docs/permissions/positions), and every position assignment carries
683+
an audit stamp (`granted_by`), a validity window, and a scope check that a tier
684+
never had.
685+
686+
To invite someone straight into a position, put it in the invitation's
687+
*placement* rather than its membership tier:
679688

680689
```http
681690
POST /api/v1/auth/organization/invite-member
682-
{ "email": "new.hire@example.com", "role": "sales_rep" }
691+
{
692+
"email": "new.hire@example.com",
693+
"role": "member",
694+
"businessUnitId": "bu_field_sales",
695+
"positions": ["sales_rep"]
696+
}
683697
```
684698

685-
On acceptance the value lands in `sys_member.role`, and the membership
686-
projection passes an unrecognized value through as a position name — so the
687-
invitee holds `sales_rep` in `current_user.positions` and resolves whatever
688-
permission sets are bound to it. Both `sys_invitation` and `sys_member` build
689-
their pickers from that same registered set, so the Setup UI offers your names
690-
alongside the built-ins.
691-
692-
Two limits bound this as a provisioning path:
693-
694-
- **It never hands out more authority than the issuer has.** An issuer below
695-
`admin` grade may invite as plain `member` only, and no invitation may confer
696-
a tier above the issuer's own. A delegated admin grants capability through
697-
the invitation's *placement* (business unit + positions), authorized against
698-
their `adminScope` — not through the membership tier, which nothing scopes.
699-
- **Only spec-compliant names are registered.** A declared name that is not
700-
lowercase snake_case (`/^[a-z][a-z0-9_]*$/`, minimum 2 characters) is skipped
701-
with a boot warning, and an invitation naming it is refused with
702-
`ROLE_NOT_FOUND` — better-auth never learns a name the write path could not
703-
store.
699+
The tier stays `member`; the capability rides in `positions`. Placement is
700+
authorized at issuance against the issuer's `adminScope`, so a delegated admin
701+
can use it inside their own subtree — where the tier route was open to org
702+
admins only. On acceptance the positions become real `sys_user_position` rows.
703+
704+
<Callout type="warn">
705+
Earlier releases registered every declared position and permission-set name as
706+
a valid membership tier, so naming one directly in the invitation was accepted.
707+
That path is removed: because every value stored in the tier column is
708+
projected into `current_user.positions`, it granted capability with none of the
709+
controls above. Naming a position as an invitation's tier is now refused with
710+
`ROLE_NOT_FOUND`. Use `positions` as shown.
711+
</Callout>
712+
713+
One limit still bounds invitations as a provisioning path: **they never hand out
714+
more authority than the issuer has.** An issuer below `admin` grade may invite
715+
as plain `member` only, and no invitation may confer a tier above the issuer's
716+
own.
704717

705718
### Admin User Management
706719

content/docs/permissions/delegated-administration.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,12 @@ issuance:
173173
- an invited role may not outrank the issuer's (`owner` > `admin` > everyone
174174
else);
175175
- an issuer below admin grade may invite as **`member` only**. Not just "not
176-
admin/owner": an app-registered role projects into `current_user.positions`
177-
and may be bound to permission sets, so it is a capability channel too. A
178-
delegate's channel for capability is the invitation's **placement** intent,
179-
which this gate allowlists position-by-position;
176+
admin/owner": every value stored in `sys_member.role` projects into
177+
`current_user.positions`, so any tier above plain member is a capability
178+
channel too. A delegate's channel for capability is the invitation's
179+
**placement** intent, which this gate allowlists position-by-position — and
180+
since ADR-0108 that is the *only* way capability travels on an invitation,
181+
because the tier vocabulary is closed to four framework-owned names;
180182
- an issuer whose membership cannot be read confers nothing above a plain
181183
member (fail-closed).
182184

content/docs/permissions/positions.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ of their own** — they only decide *who gets which sets*.
1616
> security identifiers and labels). The vocabulary is now unambiguous:
1717
> **permission set** = capability, **position** = distribution,
1818
> **business unit** = hierarchy. The single exception is better-auth's
19-
> internal `sys_member.role` — the org-membership tier (`owner`, `admin`,
20-
> `delegated_admin`, `member`, **plus every position name your stack
21-
> declares**, so a person can be invited straight into a position; see
22-
> [Authentication](/docs/permissions/authentication#positions-as-membership-tiers)).
19+
> internal `sys_member.role` — the org-membership tier, a **closed** list of
20+
> `owner`, `admin`, `delegated_admin`, `member` and nothing else (ADR-0108). A
21+
> tier decides what you can *reach*; a position decides what you may *do*. To
22+
> invite someone straight into a position, use the invitation's `positions`
23+
> placement, not its membership tier — see
24+
> [Authentication](/docs/permissions/authentication#membership-tiers-are-a-closed-list).
2325
2426
```typescript
2527
import type { Position } from '@objectstack/spec/identity';

0 commit comments

Comments
 (0)