Skip to content

Commit e9b11df

Browse files
committed
fix(auth): app-declared org roles are storable, not just registerable (#3723)
`additionalOrgRoles` registered every `permission` / `position` name a stack declared with better-auth's organization plugin, so `POST /organization/invite-member { role: 'sales_rep' }` passed the role check — and then the write failed, because `sys_invitation.role` and `sys_member.role` were closed selects listing `owner|admin|member` only: ValidationError: role must be one of: owner, admin, member { field: 'role', code: 'invalid_option' } A select is enforced on write and better-auth's own inserts are not exempt (they run through the ordinary ObjectQL validator, after the security middleware), so every deployment declaring role names was registering roles that could be requested and never stored — `declared ≠ enforced` one layer below the declaration. Contract-first fix: one list, materialized into both consumers. - `spec/identity/membership-role.ts` holds the built-in roles + their select options. `MEMBERSHIP_ROLE_DELEGATED_ADMIN` moves here from `eval-user.zod` (package-level export path unchanged). - `plugin-auth/org-roles.ts` owns the derivation: `normalizeAdditionalOrgRoles` is the single normalizer, and its output feeds better-auth's role map AND the two select option lists (`withMembershipRoleOptions`, stamped onto the manifest at registration — copy-on-write, the shared definitions are never mutated). Neither side keeps a list of its own. - `sys_invitation` / `sys_member` declare the built-in baseline only. - `collectStackOrgRoles` is the one producer-side walk; `objectstack serve` and the `@objectstack/verify` harness both use it. The harness passed no roles at all, which is why the surface that drives the real HTTP route was blind to this class of bug. A declared name that is not a valid machine name is now refused on BOTH sides with a boot warning, rather than registered and then stored mangled: `Field.select` strips characters outside `[a-z0-9_]`, so `showcase.export_data` would be accepted as itself and written as `showcaseexport_data` — the same mismatch with extra steps. Verified: `app-org-role-invite.dogfood.test.ts` drives the real invite route on the showcase stack and fails with the exact reported ValidationError when the materialization step is removed.
1 parent 1bd5652 commit e9b11df

17 files changed

Lines changed: 902 additions & 91 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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` and the `@objectstack/verify` harness now
47+
both do (the harness previously passed none, which is why a dogfood proof could
48+
boot a stack whose declared roles better-auth had never heard of).
49+
50+
Behaviour change worth noting: a declared role name that is not a valid machine
51+
name (`/^[a-z][a-z0-9_]*$/`, min 2 chars) is no longer registered at all, with a
52+
boot warning. `Field.select` strips characters outside `[a-z0-9_]`, so such a
53+
name would be registered verbatim and stored mangled — the same mismatch with
54+
extra steps. Every name that passes `SnakeCaseIdentifierSchema` is unaffected.

packages/cli/src/commands/serve.ts

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

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

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

15161496
await kernel.use(new AuthPlugin({
15171497
secret,
15181498
baseUrl,
15191499
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
15201500
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
1521-
...(additionalOrgRoles.size > 0 ? { additionalOrgRoles: Array.from(additionalOrgRoles) } : {}),
1501+
...(additionalOrgRoles.length > 0 ? { additionalOrgRoles } : {}),
15221502
// Enable the admin plugin by default so the Setup app's
15231503
// ban/unban/set-password/impersonate/set-role row actions
15241504
// 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: 10 additions & 1 deletion
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 } 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';
@@ -1571,7 +1572,15 @@ export class AuthManager {
15711572
// attribution — so the permission would mean "cancel anyone's pending
15721573
// invitation in the org". Attributed cancel needs its own guard first.
15731574
let customOrgRoles: Record<string, any> | undefined;
1574-
const extra = this.config.additionalOrgRoles ?? [];
1575+
// [#3723] The SAME normalized array `AuthPlugin` stamps onto the
1576+
// `sys_invitation.role` / `sys_member.role` selects. Registering a name
1577+
// the write path cannot store is the bug this closes, so a name that
1578+
// cannot round-trip through `Field.select` is refused HERE too — the
1579+
// invitation then fails at better-auth's door (`ROLE_NOT_FOUND`) rather
1580+
// than at the insert.
1581+
// (Idempotent: `AuthPlugin` normalizes before constructing the manager,
1582+
// so this pass only warns for a caller wiring `AuthManager` directly.)
1583+
const extra = normalizeAdditionalOrgRoles(this.config.additionalOrgRoles, this.config.logger);
15751584
try {
15761585
const accessMod = await import('better-auth/plugins/organization/access');
15771586
const { defaultAc, memberAc, defaultRoles } = accessMod as any;

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
authIdentityObjects,
3636
authPluginManifestHeader,
3737
} from './manifest.js';
38+
import { normalizeAdditionalOrgRoles, withMembershipRoleOptions } from './org-roles.js';
3839

3940
/**
4041
* Auth Plugin Options
@@ -200,8 +201,16 @@ export class AuthPlugin implements Plugin {
200201
ctx.logger.warn('No data engine service found - auth will use in-memory storage');
201202
}
202203

204+
// [#3723] Normalize the app-declared organization roles ONCE, here: the
205+
// same array feeds better-auth's role registry (via AuthManager) and the
206+
// `sys_invitation.role` / `sys_member.role` selects (via the manifest
207+
// below). Two derivations from one caller-supplied list is exactly the
208+
// drift that made a registered role unstorable.
209+
const additionalOrgRoles = normalizeAdditionalOrgRoles(this.options.additionalOrgRoles, ctx.logger);
210+
203211
const authConfig: AuthManagerOptions & AuthPluginOptions = {
204212
...this.options,
213+
additionalOrgRoles,
205214
dataEngine,
206215
logger: ctx.logger,
207216
// ADR-0093 D2/D3 — the membership reconciler consults the tenancy service
@@ -330,7 +339,14 @@ export class AuthPlugin implements Plugin {
330339
...(this.options.manifestDatasource
331340
? { defaultDatasource: this.options.manifestDatasource }
332341
: {}),
333-
objects: authIdentityObjects,
342+
// [#3723] App-declared organization roles widen the `sys_invitation.role`
343+
// / `sys_member.role` selects, from the SAME normalized array that
344+
// registers them with better-auth (`AuthManager`'s org-plugin roles map).
345+
// Without this the two lists drift and a registered role is one
346+
// better-auth accepts and the write path rejects — the registration is
347+
// half a feature. Copy-on-write: `authIdentityObjects` is a shared
348+
// module-level array, never mutated.
349+
objects: withMembershipRoleOptions(authIdentityObjects, additionalOrgRoles),
334350
// ADR-0048 — Setup/Studio/Account apps (and the Setup nav contributions)
335351
// moved to their own one-app packages (@objectstack/{setup,studio,account}),
336352
// each registering under its own package id so /apps/<packageId> resolves

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@ export * from './auth-schema-config.js';
3030
// compose the reconciler into their own hooks; embeddings query tenancy mode).
3131
export * from './reconcile-membership.js';
3232
export * from './tenancy-service.js';
33+
// #3723 — organization roles. Exported because every host that boots AuthPlugin
34+
// from a loaded stack (`objectstack serve`, the @objectstack/verify harness, the
35+
// cloud per-project kernel) must derive `additionalOrgRoles` the SAME way; a
36+
// second copy of the walk is how the harness came to boot without app roles.
37+
export * from './org-roles.js';
3338
export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';

packages/plugins/plugin-auth/src/invitation-role-cap.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@
4343
* invitation is refused.
4444
*/
4545

46-
/** better-auth's built-in organization roles. */
47-
export const MEMBERSHIP_ROLE_OWNER = 'owner';
48-
export const MEMBERSHIP_ROLE_ADMIN = 'admin';
49-
export const MEMBERSHIP_ROLE_MEMBER = 'member';
46+
// better-auth's built-in organization roles — from the one membership-role
47+
// list (#3723), the same constants the `sys_invitation.role` / `sys_member.role`
48+
// selects are built from. Re-exported to keep this module's existing surface.
49+
import {
50+
MEMBERSHIP_ROLE_OWNER,
51+
MEMBERSHIP_ROLE_ADMIN,
52+
MEMBERSHIP_ROLE_MEMBER,
53+
} from '@objectstack/spec/identity';
54+
55+
export { MEMBERSHIP_ROLE_OWNER, MEMBERSHIP_ROLE_ADMIN, MEMBERSHIP_ROLE_MEMBER };
5056

5157
/** Grade ladder. Anything unrecognized — `member`, `delegated_admin`, an
5258
* app-registered role — grades as an ordinary member; only better-auth's two

0 commit comments

Comments
 (0)