Skip to content

Commit a39cb6c

Browse files
committed
docs(permissions): move the membership-tier note to the better-auth boundary
The previous commit put an "app roles" section on the positions page, which tripped `check:role-word` — the ADR-0090 D3 ratchet — by adding 11 uses of the reserved word to the one page whose thesis is that positions are NOT roles. The ratchet was right; raising its baseline there would have spent exactly what D3 bought. `positions.mdx` keeps only the factual correction to the tier list (the parenthetical was already missing `delegated_admin`, and app-declared names now belong in it), phrased to leave the file's word count unchanged at 6. The substance moves to `authentication.mdx` under Organizations — the better-auth boundary page, where `sys_member.role` is the sanctioned exception. Baseline for that file ratcheted 2 → 5: two `sys_member.role` mentions and the literal `"role"` JSON field in the invite sample.
1 parent 4111bdf commit a39cb6c

3 files changed

Lines changed: 39 additions & 36 deletions

File tree

content/docs/permissions/authentication.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,40 @@ new AuthPlugin({
668668
})
669669
```
670670

671+
#### Positions as membership tiers
672+
673+
`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:
679+
680+
```http
681+
POST /api/v1/auth/organization/invite-member
682+
{ "email": "new.hire@example.com", "role": "sales_rep" }
683+
```
684+
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.
704+
671705
### Admin User Management
672706

673707
With `plugins: { admin: true }` (forced on when SCIM is enabled), platform

content/docs/permissions/positions.mdx

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ 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` (org-membership tier: `owner`, `admin`,
20-
> `delegated_admin`, `member`). A position name you declare here is also
21-
> registered as a valid `sys_member.role` value, so someone can be invited
22-
> directly into the position — see [Positions as membership
23-
> roles](#positions-as-membership-roles).
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)).
2423
2524
```typescript
2625
import type { Position } from '@objectstack/spec/identity';
@@ -90,36 +89,6 @@ is no singular `current_user.position` at runtime). Framework-seeded names:
9089

9190
Test membership in RLS/CEL with `'org_admin' in current_user.positions`.
9291

93-
## Positions as membership roles
94-
95-
Every position and permission-set name your stack declares is registered as a
96-
valid organization role, so an invitation can name it directly:
97-
98-
```http
99-
POST /api/v1/auth/organization/invite-member
100-
{ "email": "new.hire@example.com", "role": "sales_rep" }
101-
```
102-
103-
On acceptance the value lands in `sys_member.role`, and the membership
104-
projection passes an unrecognized value straight through as a position name —
105-
so the invitee holds `sales_rep` in `current_user.positions` and resolves
106-
whatever permission sets are bound to it. Both `sys_invitation.role` and
107-
`sys_member.role` are pickers built from that same registered set, so the
108-
Setup UI offers your names alongside the built-ins.
109-
110-
Two limits are worth knowing before you use this as a provisioning path:
111-
112-
- **It is not a way to hand out more authority than you have.** An issuer
113-
below `admin` grade may invite only as plain `member`, and no invitation may
114-
ever confer a role above the issuer's own. A delegated admin grants
115-
capability through the invitation's *placement* (business unit + positions),
116-
which is authorized against their `adminScope` — not through the membership
117-
role, which nothing scopes.
118-
- **Only spec-compliant names are registered.** A declared name that is not
119-
lowercase snake_case (`/^[a-z][a-z0-9_]*$/`, min 2 characters) is skipped
120-
with a boot warning, and an invitation naming it is refused with
121-
`ROLE_NOT_FOUND`.
122-
12392
## Audience anchors: `everyone` and `guest` (ADR-0090 D5/D9)
12493

12594
Two more built-in positions are **implicit** — they are never stored as

scripts/role-word-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"content/docs/kernel/contracts/data-engine.mdx": 1,
1414
"content/docs/kernel/events.mdx": 1,
1515
"content/docs/kernel/services-checklist.mdx": 4,
16-
"content/docs/permissions/authentication.mdx": 2,
16+
"content/docs/permissions/authentication.mdx": 5,
1717
"content/docs/permissions/authorization.mdx": 3,
1818
"content/docs/permissions/delegated-administration.mdx": 11,
1919
"content/docs/permissions/index.mdx": 1,

0 commit comments

Comments
 (0)