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
4 changes: 2 additions & 2 deletions .changeset/adr-0105-d8-delegated-admin-org-role.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ subtree. That gate is implemented, unit-proven and reachable — but no principa
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);
(`memberAc` holds `invitation: []`, which every other registered role
inherits);
- 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
Expand Down
64 changes: 0 additions & 64 deletions .changeset/app-org-roles-storable.md

This file was deleted.

31 changes: 0 additions & 31 deletions .changeset/auth-org-roles-self-derived.md

This file was deleted.

77 changes: 77 additions & 0 deletions .changeset/membership-grade-not-capability-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
'@objectstack/plugin-auth': minor
'@objectstack/platform-objects': minor
'@objectstack/spec': minor
'@objectstack/lint': patch
'@objectstack/cli': patch
'@objectstack/verify': patch
'@objectstack/plugin-dev': patch
'@objectstack/runtime': patch
---

feat(auth)!: membership grade is not a capability channel — the `sys_member.role`
vocabulary is closed (ADR-0108, #3723)

`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. One column was
answering both.

`resolve-authz-context` projects EVERY value stored in `sys_member.role` into
`current_user.positions`, alongside the rows read from `sys_user_position`. So a
business role handed out through the membership role *was* capability — granted
with none of the position system's controls: no `granted_by`, no ADR-0091
validity window, no BU-subtree check, no `assignablePermissionSets` allowlist.
That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so
invitations are accepted — **never as the authority for RBAC**"), what
ADR-0090 D3's word ban restates (distribution = `position`), and what
ADR-0095 D3 keeps out of the enforcement path.

The vocabulary is therefore closed to the four framework-owned names:
`owner` / `admin` / `delegated_admin` / `member`.

**BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and
`AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full
(`collectStackOrgRoles`, `collectRegisteredOrgRoles`,
`normalizeAdditionalOrgRoles`, `membershipRoleOptions`,
`withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`,
`MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`,
`OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From
`@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and
`MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate
app-supplied names. A TypeScript error is the intended failure: an option that
is silently ignored is `declared ≠ enforced` one more time.

FROM → TO:

```diff
- new AuthPlugin({ additionalOrgRoles: ['sales_rep'] })
+ new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ })

- POST /organization/invite-member { email, role: 'sales_rep' }
+ POST /organization/invite-member { email, role: 'member',
+ businessUnitId, positions: ['sales_rep'] }
```

For an existing member, assign the position through `sys_user_position` (the
governed write path). Invitation placement (ADR-0105 D8) is the one-step
admission flow: issuance is authorized against the issuer's `adminScope` by
dry-running `DelegatedAdminGate`, and acceptance writes real
`sys_user_position` rows with a `granted_by` stamp. It reaches **further** than
what it replaces — a delegated admin may use it within their subtree, where the
membership-role route was open to org admins only (the invitation role cap holds
anyone below admin grade to plain `member`).

An invitation naming an app role now fails at better-auth's door with
`ROLE_NOT_FOUND`, before any row is written.

This reverses two changesets that were never consumed into a release
(`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published
version ever offered the behaviour; both are removed rather than shipped and
retracted in the same changelog. A pre-existing deployment could only have
stored a custom value by direct DB write.

Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS`
now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept
copy carried `guest`, which the `sys_member.role` select has never offered — an
approver authored as `{ type: 'org_membership_level', value: 'guest' }`
resolved to nobody and the lint whose whole job is to catch that stayed silent.
65 changes: 39 additions & 26 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -668,39 +668,52 @@ new AuthPlugin({
})
```

#### Positions as membership tiers
#### Membership tiers are a closed list

`sys_member.role` is better-auth's own column and the one place ObjectStack
keeps that vocabulary (ADR-0090 D3's single boundary exception). Beyond the
four built-in tiers — `owner`, `admin`, `delegated_admin`, `member` — every
[position](/docs/permissions/positions) and
[permission set](/docs/permissions/permission-sets) your stack declares is
registered as a valid value, so an invitation can name one directly:
keeps that vocabulary (ADR-0090 D3's single boundary exception). It holds
exactly four values — `owner`, `admin`, `delegated_admin`, `member` — and your
stack cannot add to them.

That is deliberate (ADR-0108). A membership tier answers *what is your standing
in this organization*, which decides what you can **reach**: `delegated_admin`,
for instance, is the tier that may reach `/organization/invite-member` without
being an org admin. What you may **do** with records is a
[position](/docs/permissions/positions), and every position assignment carries
an audit stamp (`granted_by`), a validity window, and a scope check that a tier
never had.

To invite someone straight into a position, put it in the invitation's
*placement* rather than its membership tier:

```http
POST /api/v1/auth/organization/invite-member
{ "email": "new.hire@example.com", "role": "sales_rep" }
{
"email": "new.hire@example.com",
"role": "member",
"businessUnitId": "bu_field_sales",
"positions": ["sales_rep"]
}
```

On acceptance the value lands in `sys_member.role`, and the membership
projection passes an unrecognized value through as a position name — so the
invitee holds `sales_rep` in `current_user.positions` and resolves whatever
permission sets are bound to it. Both `sys_invitation` and `sys_member` build
their pickers from that same registered set, so the Setup UI offers your names
alongside the built-ins.

Two limits bound this as a provisioning path:

- **It never hands out more authority than the issuer has.** An issuer below
`admin` grade may invite as plain `member` only, and no invitation may confer
a tier above the issuer's own. A delegated admin grants capability through
the invitation's *placement* (business unit + positions), authorized against
their `adminScope` — not through the membership tier, which nothing scopes.
- **Only spec-compliant names are registered.** A declared name that is not
lowercase snake_case (`/^[a-z][a-z0-9_]*$/`, minimum 2 characters) is skipped
with a boot warning, and an invitation naming it is refused with
`ROLE_NOT_FOUND` — better-auth never learns a name the write path could not
store.
The tier stays `member`; the capability rides in `positions`. Placement is
authorized at issuance against the issuer's `adminScope`, so a delegated admin
can use it inside their own subtree — where the tier route was open to org
admins only. On acceptance the positions become real `sys_user_position` rows.

<Callout type="warn">
Earlier releases registered every declared position and permission-set name as
a valid membership tier, so naming one directly in the invitation was accepted.
That path is removed: because every value stored in the tier column is
projected into `current_user.positions`, it granted capability with none of the
controls above. Naming a position as an invitation's tier is now refused with
`ROLE_NOT_FOUND`. Use `positions` as shown.
</Callout>

One limit still bounds invitations as a provisioning path: **they never hand out
more authority than the issuer has.** An issuer below `admin` grade may invite
as plain `member` only, and no invitation may confer a tier above the issuer's
own.

### Admin User Management

Expand Down
10 changes: 6 additions & 4 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,12 @@ 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;
admin/owner": every value stored in `sys_member.role` projects into
`current_user.positions`, so any tier above plain member is a capability
channel too. A delegate's channel for capability is the invitation's
**placement** intent, which this gate allowlists position-by-position — and
since ADR-0108 that is the *only* way capability travels on an invitation,
because the tier vocabulary is closed to four framework-owned names;
- an issuer whose membership cannot be read confers nothing above a plain
member (fail-closed).

Expand Down
10 changes: 6 additions & 4 deletions content/docs/permissions/positions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ of their own** — they only decide *who gets which sets*.
> security identifiers and labels). The vocabulary is now unambiguous:
> **permission set** = capability, **position** = distribution,
> **business unit** = hierarchy. The single exception is better-auth's
> internal `sys_member.role` — the org-membership tier (`owner`, `admin`,
> `delegated_admin`, `member`, **plus every position name your stack
> declares**, so a person can be invited straight into a position; see
> [Authentication](/docs/permissions/authentication#positions-as-membership-tiers)).
> internal `sys_member.role` — the org-membership tier, a **closed** list of
> `owner`, `admin`, `delegated_admin`, `member` and nothing else (ADR-0108). A
> tier decides what you can *reach*; a position decides what you may *do*. To
> invite someone straight into a position, use the invitation's `positions`
> placement, not its membership tier — see
> [Authentication](/docs/permissions/authentication#membership-tiers-are-a-closed-list).

```typescript
import type { Position } from '@objectstack/spec/identity';
Expand Down
Loading
Loading