Skip to content

Commit 558fe4d

Browse files
authored
fix(auth): app-declared org roles are storable, not just registerable (#3723) (#3747)
One membership-role list, materialized into both gatekeepers: better-auth's org-plugin role registry and the enforced `sys_invitation.role` / `sys_member.role` selects. Also unifies the four hosts that boot AuthPlugin from a stack (two of which never passed roles at all), and carries each role's declared label into the picker.
2 parents a8d1e24 + d404ae5 commit 558fe4d

22 files changed

Lines changed: 1096 additions & 100 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/verify': patch
5+
'@objectstack/spec': patch
6+
'@objectstack/cli': patch
7+
---
8+
9+
fix(auth): app-declared organization roles are now storable, not just registerable (#3723)
10+
11+
`AuthManagerOptions.additionalOrgRoles` registered every `permission` /
12+
`position` name a stack declared with better-auth's organization plugin, so
13+
`POST /organization/invite-member { role: 'sales_rep' }` passed the role check —
14+
and then the write failed, because `sys_invitation.role` and `sys_member.role`
15+
were closed selects listing `owner|admin|member` only:
16+
17+
```
18+
ValidationError: role must be one of: owner, admin, member
19+
{ field: 'role', code: 'invalid_option' }
20+
```
21+
22+
A select is enforced on write and better-auth's own inserts are not exempt (they
23+
run through the ordinary ObjectQL validator), so any stack declaring role names
24+
was registering roles that could be requested and never stored.
25+
26+
Both gatekeepers now read one list. `normalizeAdditionalOrgRoles` is the single
27+
normalizer; its output feeds better-auth's role map **and** the two `select`
28+
option lists, so neither side can accept a name the other rejects. The built-in
29+
roles (`owner`, `admin`, `delegated_admin`, `member`) live in
30+
`@objectstack/spec` as `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`, which is all the
31+
platform objects declare statically — app roles are appended at boot.
32+
33+
New exports:
34+
35+
- `@objectstack/spec``MEMBERSHIP_ROLE_{OWNER,ADMIN,MEMBER,DELEGATED_ADMIN}`,
36+
`BUILTIN_MEMBERSHIP_ROLES`, `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`,
37+
`MEMBERSHIP_ROLE_NAME_PATTERN`, `MEMBERSHIP_ROLE_NAME_MIN_LENGTH`
38+
(`MEMBERSHIP_ROLE_DELEGATED_ADMIN` moved from `identity/eval-user.zod` to
39+
`identity/membership-role`; the package-level export path is unchanged).
40+
- `@objectstack/plugin-auth``collectStackOrgRoles`,
41+
`normalizeAdditionalOrgRoles`, `membershipRoleOptions`,
42+
`withMembershipRoleOptions`.
43+
44+
Hosts that boot `AuthPlugin` from a loaded stack should derive
45+
`additionalOrgRoles` with `collectStackOrgRoles(stack)` rather than walking the
46+
stack themselves — `objectstack serve`, the `@objectstack/verify` harness and
47+
`DevPlugin` now all do. The harness previously passed none, which is why a
48+
dogfood proof could boot a stack whose declared roles better-auth had never
49+
heard of; `DevPlugin` documents itself as equivalent to the full stack and
50+
silently excluded app roles from that equivalence.
51+
52+
`additionalOrgRoles` accepts `{ name, label }` alongside a bare name, and
53+
`collectStackOrgRoles` now returns those descriptors. The label is what the
54+
declaring `position` / `permission` metadata already says, so the role picker
55+
shows `Executive` for a position declared as such instead of title-casing the
56+
machine name into `Exec` — a third source of truth for one string. Presentation
57+
only: better-auth sees just the name, and the stored value is always the name.
58+
Passing `string[]` keeps working unchanged.
59+
60+
Behaviour change worth noting: a declared role name that is not a valid machine
61+
name (`/^[a-z][a-z0-9_]*$/`, min 2 chars) is no longer registered at all, with a
62+
boot warning. `Field.select` strips characters outside `[a-z0-9_]`, so such a
63+
name would be registered verbatim and stored mangled — the same mismatch with
64+
extra steps. Every name that passes `SnakeCaseIdentifierSchema` is unaffected.

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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +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/member).
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)).
2023
2124
```typescript
2225
import type { Position } from '@objectstack/spec/identity';

packages/cli/src/commands/serve.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ export default class Serve extends Command {
13961396
if (!hasAuthPlugin && tierEnabled('auth')) {
13971397
try {
13981398
const authPkg = '@objectstack/plugin-auth';
1399-
const { AuthPlugin } = await import(/* webpackIgnore: true */ authPkg);
1399+
const { AuthPlugin, collectStackOrgRoles } = await import(/* webpackIgnore: true */ authPkg);
14001400

14011401
// In dev, fall back to a stable local secret so users don't have
14021402
// to set OS_AUTH_SECRET just to try the login/register flow.
@@ -1493,40 +1493,20 @@ export default class Serve extends Command {
14931493

14941494
// Collect application-defined org roles from the stack so
14951495
// Better-Auth's organization plugin accepts invitations to
1496-
// those names (otherwise it 400s with `ROLE_NOT_FOUND`).
1497-
// better-auth boundary: its API keeps the word "roles"
1498-
// (ADR-0090 D3 exception). Sources:
1499-
// - top-level `positions[]` (flat distribution groups)
1500-
// - `permissions[]` PermissionSet names
1501-
// Real RBAC enforcement is still owned by SecurityPlugin.
1502-
const additionalOrgRoles = new Set<string>();
1503-
try {
1504-
const stackAny: any = config ?? {};
1505-
const collect = (arr: any) => {
1506-
if (!Array.isArray(arr)) return;
1507-
for (const r of arr) {
1508-
const n = typeof r === 'string' ? r : (r && typeof r.name === 'string' ? r.name : null);
1509-
if (n && n !== 'owner' && n !== 'admin' && n !== 'member') additionalOrgRoles.add(n);
1510-
}
1511-
};
1512-
collect(stackAny.positions);
1513-
if (Array.isArray(stackAny.permissions)) {
1514-
for (const p of stackAny.permissions) {
1515-
if (p && typeof p.name === 'string') {
1516-
if (p.name !== 'owner' && p.name !== 'admin' && p.name !== 'member') additionalOrgRoles.add(p.name);
1517-
}
1518-
}
1519-
}
1520-
} catch {
1521-
// best-effort
1522-
}
1496+
// those names (otherwise it 400s with `ROLE_NOT_FOUND`) AND the
1497+
// `sys_invitation.role` / `sys_member.role` selects accept them on
1498+
// write. #3723: the walk lives in plugin-auth so `serve`, the
1499+
// `@objectstack/verify` harness and any embedder derive the list
1500+
// identically — a second copy of it here is how the harness came to
1501+
// boot without app roles while `serve` had them.
1502+
const additionalOrgRoles = collectStackOrgRoles(config);
15231503

15241504
await kernel.use(new AuthPlugin({
15251505
secret,
15261506
baseUrl,
15271507
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
15281508
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
1529-
...(additionalOrgRoles.size > 0 ? { additionalOrgRoles: Array.from(additionalOrgRoles) } : {}),
1509+
...(additionalOrgRoles.length > 0 ? { additionalOrgRoles } : {}),
15301510
// Enable the admin plugin by default so the Setup app's
15311511
// ban/unban/set-password/impersonate/set-role row actions
15321512
// resolve to real endpoints. The plugin self-gates by role

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, MEMBERSHIP_ROLE_MEMBER } from '@objectstack/spec/identity';
45

56
/**
67
* sys_invitation — System Invitation Object
@@ -196,22 +197,17 @@ export const SysInvitation = ObjectSchema.create({
196197
description: 'Email address of the invited user',
197198
}),
198199

200+
// [#3723] Same list as `sys_member.role`, from the same constant — this is
201+
// the value that lands there on acceptance, so the two can never be allowed
202+
// to drift. App-declared organization roles are appended at boot by
203+
// plugin-auth's `withMembershipRoleOptions`, from the same normalized array
204+
// that registers them with better-auth; do not hand-add one here.
199205
role: Field.select({
200206
label: 'Role',
201207
required: false,
202208
description: 'Role to assign upon acceptance',
203-
options: [
204-
{ label: 'Owner', value: 'owner' },
205-
{ 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' },
212-
{ label: 'Member', value: 'member' },
213-
],
214-
defaultValue: 'member',
209+
options: [...BUILTIN_MEMBERSHIP_ROLE_OPTIONS],
210+
defaultValue: MEMBERSHIP_ROLE_MEMBER,
215211
}),
216212

217213
status: Field.select(['pending', 'accepted', 'rejected', 'expired', 'canceled'], {

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

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, MEMBERSHIP_ROLE_MEMBER } from '@objectstack/spec/identity';
45

56
/**
67
* sys_member — System Member Object
@@ -165,27 +166,22 @@ export const SysMember = ObjectSchema.create({
165166
required: true,
166167
}),
167168

169+
// [#3723] The framework's built-in roles ONLY. App-declared organization
170+
// roles are appended at boot by plugin-auth's `withMembershipRoleOptions`,
171+
// from the SAME normalized array that registers them with better-auth —
172+
// adding one by hand here re-creates the two-lists-that-must-agree bug.
173+
//
174+
// This select is ENFORCED on write: better-auth's own accept-invitation
175+
// membership insert is validated like any other row (system context does
176+
// not exempt it), so a role better-auth accepts and this list omits is a
177+
// role nobody can hold. `delegated_admin` (ADR-0105 D8 / #3697) is in the
178+
// built-in list for exactly that reason.
168179
role: Field.select({
169180
label: 'Role',
170181
required: false,
171182
description: 'Member role within the organization',
172-
options: [
173-
{ label: 'Owner', value: 'owner' },
174-
{ 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' },
186-
{ label: 'Member', value: 'member' },
187-
],
188-
defaultValue: 'member',
183+
options: [...BUILTIN_MEMBERSHIP_ROLE_OPTIONS],
184+
defaultValue: MEMBERSHIP_ROLE_MEMBER,
189185
}),
190186
},
191187

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,23 @@ describe('AuthManager', () => {
942942
expect(orgPlugin._opts.roles.delegated_admin.statements.invitation).toEqual(['create']);
943943
});
944944

945+
it('#3723: a role name the write path cannot store is not registered either', async () => {
946+
// `Field.select` strips the dot, so `showcase.export_data` would be
947+
// registered with better-auth verbatim and stored as
948+
// `showcaseexport_data` — the two lists agreeing on the name and
949+
// disagreeing on the value is the original bug with extra steps.
950+
// Refusing it here makes the invitation fail at the door
951+
// (`ROLE_NOT_FOUND`) instead of at the `sys_invitation` insert.
952+
const orgPlugin = await bootOrgPlugin({
953+
additionalOrgRoles: ['showcase.export_data', 'sales_rep'],
954+
});
955+
956+
const roles = orgPlugin._opts.roles;
957+
expect(Object.keys(roles)).toContain('sales_rep');
958+
expect(Object.keys(roles)).not.toContain('showcase.export_data');
959+
expect(Object.keys(roles)).not.toContain('showcaseexport_data');
960+
});
961+
945962
it('role cap: a delegate inviting an `admin` is REFUSED (the escalation chain)', async () => {
946963
// delegate invites admin → sys_member(role='admin') → auto-org-admin-grant
947964
// → organization_admin → wildcard modifyAllRecords → isTenantAdmin().

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
2222
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
2323
import { invitationRoleCapFailure, isPlainMemberInvitation } from './invitation-role-cap.js';
24+
import { normalizeAdditionalOrgRoles, orgRoleNames, type OrgRoleInput } from './org-roles.js';
2425
import { isPlaceholderEmail } from './placeholder-email.js';
2526
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
2627
import type { TenancyService } from './tenancy-service.js';
@@ -403,12 +404,18 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
403404
* Better-Auth's `member` role) so it cannot inadvertently grant org-level
404405
* admin capabilities.
405406
*
406-
* Typical source: the union of `permission` metadata names that have
407-
* declared names, collected from the loaded stack at CLI boot.
407+
* Typical source: `collectStackOrgRoles(stack)` — the one walk every host
408+
* shares (`objectstack serve`, the verify harness, DevPlugin).
408409
*
409-
* @example ['sales_rep', 'sales_manager', 'service_agent']
410+
* Accepts a bare name, or `{ name, label }` to carry the declaring
411+
* metadata's own display label into the role picker (#3723) — without it the
412+
* picker would title-case the machine name and contradict a position that
413+
* already says `销售代表`. The label is presentation only: better-auth sees
414+
* just the name, and the stored value is always the name.
415+
*
416+
* @example ['sales_rep', { name: 'sales_manager', label: '销售经理' }]
410417
*/
411-
additionalOrgRoles?: string[];
418+
additionalOrgRoles?: OrgRoleInput[];
412419

413420
/**
414421
* Optional outbound email service used by better-auth callbacks
@@ -1571,7 +1578,15 @@ export class AuthManager {
15711578
// attribution — so the permission would mean "cancel anyone's pending
15721579
// invitation in the org". Attributed cancel needs its own guard first.
15731580
let customOrgRoles: Record<string, any> | undefined;
1574-
const extra = this.config.additionalOrgRoles ?? [];
1581+
// [#3723] The SAME normalized array `AuthPlugin` stamps onto the
1582+
// `sys_invitation.role` / `sys_member.role` selects. Registering a name
1583+
// the write path cannot store is the bug this closes, so a name that
1584+
// cannot round-trip through `Field.select` is refused HERE too — the
1585+
// invitation then fails at better-auth's door (`ROLE_NOT_FOUND`) rather
1586+
// than at the insert.
1587+
// (Idempotent: `AuthPlugin` normalizes before constructing the manager,
1588+
// so this pass only warns for a caller wiring `AuthManager` directly.)
1589+
const extra = normalizeAdditionalOrgRoles(this.config.additionalOrgRoles, this.config.logger);
15751590
try {
15761591
const accessMod = await import('better-auth/plugins/organization/access');
15771592
const { defaultAc, memberAc, defaultRoles } = accessMod as any;
@@ -1592,7 +1607,9 @@ export class AuthManager {
15921607
...stmts,
15931608
invitation: ['create'],
15941609
});
1595-
for (const name of extra) {
1610+
// Names only — a descriptor's `label` is presentation for the role
1611+
// picker and means nothing to better-auth.
1612+
for (const name of orgRoleNames(extra)) {
15961613
if (!name) continue;
15971614
if (built[name]) continue;
15981615
built[name] = defaultAc.newRole(stmts);

0 commit comments

Comments
 (0)