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
64 changes: 64 additions & 0 deletions .changeset/app-org-roles-storable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
'@objectstack/platform-objects': patch
'@objectstack/plugin-auth': patch
'@objectstack/verify': patch
'@objectstack/spec': patch
'@objectstack/cli': patch
---

fix(auth): app-declared organization roles are now storable, not just registerable (#3723)

`AuthManagerOptions.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), so any stack declaring role names
was registering roles that could be requested and never stored.

Both gatekeepers now read one list. `normalizeAdditionalOrgRoles` is the single
normalizer; its output feeds better-auth's role map **and** the two `select`
option lists, so neither side can accept a name the other rejects. The built-in
roles (`owner`, `admin`, `delegated_admin`, `member`) live in
`@objectstack/spec` as `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`, which is all the
platform objects declare statically — app roles are appended at boot.

New exports:

- `@objectstack/spec` — `MEMBERSHIP_ROLE_{OWNER,ADMIN,MEMBER,DELEGATED_ADMIN}`,
`BUILTIN_MEMBERSHIP_ROLES`, `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`,
`MEMBERSHIP_ROLE_NAME_PATTERN`, `MEMBERSHIP_ROLE_NAME_MIN_LENGTH`
(`MEMBERSHIP_ROLE_DELEGATED_ADMIN` moved from `identity/eval-user.zod` to
`identity/membership-role`; the package-level export path is unchanged).
- `@objectstack/plugin-auth` — `collectStackOrgRoles`,
`normalizeAdditionalOrgRoles`, `membershipRoleOptions`,
`withMembershipRoleOptions`.

Hosts that boot `AuthPlugin` from a loaded stack should derive
`additionalOrgRoles` with `collectStackOrgRoles(stack)` rather than walking the
stack themselves — `objectstack serve`, the `@objectstack/verify` harness and
`DevPlugin` now all do. The harness previously passed none, which is why a
dogfood proof could boot a stack whose declared roles better-auth had never
heard of; `DevPlugin` documents itself as equivalent to the full stack and
silently excluded app roles from that equivalence.

`additionalOrgRoles` accepts `{ name, label }` alongside a bare name, and
`collectStackOrgRoles` now returns those descriptors. The label is what the
declaring `position` / `permission` metadata already says, so the role picker
shows `Executive` for a position declared as such instead of title-casing the
machine name into `Exec` — a third source of truth for one string. Presentation
only: better-auth sees just the name, and the stored value is always the name.
Passing `string[]` keeps working unchanged.

Behaviour change worth noting: a declared role name that is not a valid machine
name (`/^[a-z][a-z0-9_]*$/`, min 2 chars) is no longer registered at all, with a
boot warning. `Field.select` strips characters outside `[a-z0-9_]`, so such a
name would be registered verbatim and stored mangled — the same mismatch with
extra steps. Every name that passes `SnakeCaseIdentifierSchema` is unaffected.
34 changes: 34 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,40 @@ new AuthPlugin({
})
```

#### Positions as membership tiers

`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:

```http
POST /api/v1/auth/organization/invite-member
{ "email": "new.hire@example.com", "role": "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.

### Admin User Management

With `plugins: { admin: true }` (forced on when SCIM is enabled), platform
Expand Down
5 changes: 4 additions & 1 deletion content/docs/permissions/positions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ 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` (org-membership tier: owner/admin/member).
> 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)).

```typescript
import type { Position } from '@objectstack/spec/identity';
Expand Down
38 changes: 9 additions & 29 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1388,7 +1388,7 @@ export default class Serve extends Command {
if (!hasAuthPlugin && tierEnabled('auth')) {
try {
const authPkg = '@objectstack/plugin-auth';
const { AuthPlugin } = await import(/* webpackIgnore: true */ authPkg);
const { AuthPlugin, collectStackOrgRoles } = await import(/* webpackIgnore: true */ authPkg);

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

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

await kernel.use(new AuthPlugin({
secret,
baseUrl,
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
...(additionalOrgRoles.size > 0 ? { additionalOrgRoles: Array.from(additionalOrgRoles) } : {}),
...(additionalOrgRoles.length > 0 ? { additionalOrgRoles } : {}),
// Enable the admin plugin by default so the Setup app's
// ban/unban/set-password/impersonate/set-role row actions
// resolve to real endpoints. The plugin self-gates by role
Expand Down
20 changes: 8 additions & 12 deletions packages/platform-objects/src/identity/sys-invitation.object.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

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

// [#3723] Same list as `sys_member.role`, from the same constant — this is
// the value that lands there on acceptance, so the two can never be allowed
// to drift. App-declared organization roles are appended at boot by
// plugin-auth's `withMembershipRoleOptions`, from the same normalized array
// that registers them with better-auth; do not hand-add one here.
role: Field.select({
label: 'Role',
required: false,
description: 'Role to assign upon acceptance',
options: [
{ label: 'Owner', value: 'owner' },
{ label: 'Admin', value: 'admin' },
// [ADR-0105 D8 / #3697] Kept in step with `sys_member.role` — this is
// the value that lands there on acceptance, and inviting is how a
// delegate gets provisioned in the first place. Both selects are
// enforced on write, so a role missing from either one is a role that
// cannot be handed out.
{ label: 'Delegated Admin', value: 'delegated_admin' },
{ label: 'Member', value: 'member' },
],
defaultValue: 'member',
options: [...BUILTIN_MEMBERSHIP_ROLE_OPTIONS],
defaultValue: MEMBERSHIP_ROLE_MEMBER,
}),

status: Field.select(['pending', 'accepted', 'rejected', 'expired', 'canceled'], {
Expand Down
30 changes: 13 additions & 17 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* sys_member — System Member Object
Expand Down Expand Up @@ -165,27 +166,22 @@ export const SysMember = ObjectSchema.create({
required: true,
}),

// [#3723] The framework's built-in roles ONLY. App-declared organization
// roles are appended at boot by plugin-auth's `withMembershipRoleOptions`,
// from the SAME normalized array that registers them with better-auth —
// adding one by hand here re-creates the two-lists-that-must-agree bug.
//
// This select is ENFORCED on write: better-auth's own accept-invitation
// membership insert is validated like any other row (system context does
// not exempt it), so a role better-auth accepts and this list omits is a
// role nobody can hold. `delegated_admin` (ADR-0105 D8 / #3697) is in the
// built-in list for exactly that reason.
role: Field.select({
label: 'Role',
required: false,
description: 'Member role within the organization',
options: [
{ label: 'Owner', value: 'owner' },
{ label: 'Admin', value: 'admin' },
// [ADR-0105 D8 / #3697] The delegated issuer grade — may reach
// `/organization/invite-member` WITHOUT being an org admin, which is
// what finally gives D8's scope-bounded issuance gate a caller. It
// carries no ObjectStack authority by itself: placement authority
// comes from a separately-granted `adminScope`, and the invitation
// role cap holds it to inviting plain members.
//
// Listed here because this select is ENFORCED on write: better-auth's
// own accept-invitation membership insert is validated like any other
// row, so a role missing from this list is a role nobody can hold.
{ label: 'Delegated Admin', value: 'delegated_admin' },
{ label: 'Member', value: 'member' },
],
defaultValue: 'member',
options: [...BUILTIN_MEMBERSHIP_ROLE_OPTIONS],
defaultValue: MEMBERSHIP_ROLE_MEMBER,
}),
},

Expand Down
17 changes: 17 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,23 @@ describe('AuthManager', () => {
expect(orgPlugin._opts.roles.delegated_admin.statements.invitation).toEqual(['create']);
});

it('#3723: a role name the write path cannot store is not registered either', async () => {
// `Field.select` strips the dot, so `showcase.export_data` would be
// registered with better-auth verbatim and stored as
// `showcaseexport_data` — the two lists agreeing on the name and
// disagreeing on the value is the original bug with extra steps.
// Refusing it here makes the invitation fail at the door
// (`ROLE_NOT_FOUND`) instead of at the `sys_invitation` insert.
const orgPlugin = await bootOrgPlugin({
additionalOrgRoles: ['showcase.export_data', 'sales_rep'],
});

const roles = orgPlugin._opts.roles;
expect(Object.keys(roles)).toContain('sales_rep');
expect(Object.keys(roles)).not.toContain('showcase.export_data');
expect(Object.keys(roles)).not.toContain('showcaseexport_data');
});

it('role cap: a delegate inviting an `admin` is REFUSED (the escalation chain)', async () => {
// delegate invites admin → sys_member(role='admin') → auto-org-admin-grant
// → organization_admin → wildcard modifyAllRecords → isTenantAdmin().
Expand Down
29 changes: 23 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
import { invitationRoleCapFailure, isPlainMemberInvitation } from './invitation-role-cap.js';
import { normalizeAdditionalOrgRoles, orgRoleNames, type OrgRoleInput } from './org-roles.js';
import { isPlaceholderEmail } from './placeholder-email.js';
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
import type { TenancyService } from './tenancy-service.js';
Expand Down Expand Up @@ -403,12 +404,18 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
* Better-Auth's `member` role) so it cannot inadvertently grant org-level
* admin capabilities.
*
* Typical source: the union of `permission` metadata names that have
* declared names, collected from the loaded stack at CLI boot.
* Typical source: `collectStackOrgRoles(stack)` — the one walk every host
* shares (`objectstack serve`, the verify harness, DevPlugin).
*
* @example ['sales_rep', 'sales_manager', 'service_agent']
* Accepts a bare name, or `{ name, label }` to carry the declaring
* metadata's own display label into the role picker (#3723) — without it the
* picker would title-case the machine name and contradict a position that
* already says `销售代表`. The label is presentation only: better-auth sees
* just the name, and the stored value is always the name.
*
* @example ['sales_rep', { name: 'sales_manager', label: '销售经理' }]
*/
additionalOrgRoles?: string[];
additionalOrgRoles?: OrgRoleInput[];

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