diff --git a/.changeset/adr-0105-d8-delegated-admin-org-role.md b/.changeset/adr-0105-d8-delegated-admin-org-role.md new file mode 100644 index 0000000000..6edc86f5c5 --- /dev/null +++ b/.changeset/adr-0105-d8-delegated-admin-org-role.md @@ -0,0 +1,81 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/platform-objects": minor +"@objectstack/spec": minor +"@objectstack/lint": patch +--- + +feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the +`delegated_admin` org role, capped so it cannot mint authority (#3697) + +D8 authorizes invitation *placement* against the issuer's `adminScope` +(ADR-0090 D12), so a delegated plant admin may invite only into their own +subtree. That gate is implemented, unit-proven and reachable — but no principal +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); +- 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 + short-circuits on tenant admins. + +The two sets were disjoint. Issuance placement was bounded by the Layer 0 org +wall (real, and correct) but never by `adminScope`, so D8's motivating story — +"a plant admin invites into their own subtree without a platform admin +finishing the job" — could not happen. + +**Two pieces, and they only ship together.** + +**1. The role.** `delegated_admin` is now registered with the organization +plugin as `memberAc.statements` plus `invitation: ["create"]` — the one +membership grade that may reach `/organization/invite-member` without being an +org admin. Deliberately *not* `invitation: ["cancel"]`: better-auth's cancel +route checks the permission with no inviterId attribution, so it would mean +"cancel anyone's pending invitation in the org". + +The role carries no ObjectStack authority by construction — `mapMembershipRole` +passes it through as a position name, and with no `sys_position_permission_set` +binding that name resolves to nothing. Role = *can reach the endpoint*; +`adminScope` = *what the endpoint permits*. + +`sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a +fourth option. Those selects are **enforced on write** — better-auth's own +invitation and membership inserts are validated like any other row — so +registering the role with the org plugin without listing it in both would have +produced a role nobody could hold and nobody could hand out +(`ValidationError: role must be one of: owner, admin, member`). That is exactly +how the end-to-end regression caught it, twice; neither unit test could. The +three non-English translation bundles carry the English label for the new option +until localized. + +**2. The role cap**, in the framework's own `beforeCreateInvitation` hook, +beside the D8 placement gate. Registering the role alone would have been a +four-step privilege escalation: better-auth's only role-level cap on *what role +you may invite someone as* is its `creatorRole` check (default `owner`), which +blocks inviting an **owner** but not an **admin** — and an accepted `admin` +membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A +subtree-scoped delegate could have manufactured a tenant admin, with every +existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the +acceptance-time membership write runs under better-auth's context, not the +issuer's). + +The cap refuses an invitation whose role outranks the issuer's own, and +restricts a below-admin issuer to plain `member` — not merely "not admin/owner", +because an app-registered role projects into `current_user.positions` and may be +bound to permission sets, making it a capability channel too. A delegate's +channel for capability is the invitation's *placement* intent, which the D12 +gate allowlists position-by-position. The cap applies to every invitation, +placement-carrying or not (the escalation is independent of placement), and +fails closed: an issuer role that cannot be resolved confers nothing above a +plain member. + +**What changes for deployments.** One new class of principal exists: members +holding the `delegated_admin` org role, who can invite into the org — as +`member` only, into the subtree their `adminScope` allows. It is opt-in twice +over (someone must set the membership role *and* grant an adminScope set), so a +default deployment changes not at all. Org owners and admins are unaffected. + +Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so +console and control-plane surfaces name the role from one place. diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index 2b684ec9a4..ab3470acfe 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -136,6 +136,62 @@ Scoped invitations (ADR-0105 D8) are the first consumer: the invitation form narrows its unit/position pickers with this, and the invitation's placement intent is then authorized against the *issuer's* scope at issuance time. +## Letting a delegate invite (`delegated_admin`) + +A scope is *what* you may administer; it does not by itself let you reach the +invitation endpoint. The underlying organization plugin grants +`invitation: ["create"]` to `owner` and `admin` only — and both of those are +auto-elevated to `organization_admin` (a tenant admin), for whom the scope gate +narrows nothing. So the scope-bounded issuance path needs a membership grade +that is neither: **`delegated_admin`**. + +```jsonc +// sys_member — the better-auth membership row +{ "user_id": "usr_plant_admin", "organization_id": "org_acme", "role": "delegated_admin" } +``` + +Two independent switches, and you need both: + +| | Grants | Without it | +|---|---|---| +| `sys_member.role = delegated_admin` | May *reach* `/organization/invite-member` | Refused by the org plugin before any framework hook runs | +| An `adminScope`-carrying permission set | *What* the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing | + +The role carries no ObjectStack authority of its own: it passes through as a +position name, and with no `sys_position_permission_set` binding it resolves to +nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint +permits.** A default deployment is unaffected — nobody holds the role until you +set it. + +### The invitation role cap + +An invitation may add a person; it may never add authority above the issuer's +own. The framework caps every invitation — placement-carrying or not — at +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; +- an issuer whose membership cannot be read confers nothing above a plain + member (fail-closed). + +Without the cap the role would be a ladder: invite an `admin`, and acceptance +auto-elevates that membership to `organization_admin` → tenant admin — the +grade the delegation exists to withhold. `sys_member` is not one of the gate's +governed objects and the acceptance write runs outside the issuer's context, so +the cap is the defense on that path. + +Both halves are pinned end-to-end over the real HTTP route by +`packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts`: a +`delegated_admin` issues a `member` invitation successfully, the same principal +issuing `admin` is refused with no invitation row left behind, and a plain +member still cannot invite at all — so it is demonstrably the role, not some +unrelated loosening, that opened the endpoint. + ## See also - [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on diff --git a/packages/lint/src/validate-approval-approvers.ts b/packages/lint/src/validate-approval-approvers.ts index a4bf939e37..30ff88fe2b 100644 --- a/packages/lint/src/validate-approval-approvers.ts +++ b/packages/lint/src/validate-approval-approvers.ts @@ -45,6 +45,7 @@ import { canonicalApproverType, normalizeDecisionOutputs, } from '@objectstack/spec/automation'; +import { MEMBERSHIP_ROLE_DELEGATED_ADMIN } from '@objectstack/spec'; import { collectCelRootIdentifiers } from '@objectstack/formula'; export const APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = 'approval-approver-not-membership-tier'; @@ -105,7 +106,16 @@ type AnyRec = Record; * authored as `{ type: 'org_membership_level' }` (or its deprecated `role` * spelling) is almost certainly a position name. */ -const MEMBERSHIP_TIERS = new Set(['owner', 'admin', 'member', 'guest']); +const MEMBERSHIP_TIERS = new Set([ + 'owner', + 'admin', + 'member', + 'guest', + // [ADR-0105 D8 / #3697] A real tier since the framework registers it with the + // org plugin — the delegated issuer grade. It is storable in + // `sys_member.role`, so an approver naming it resolves to people, not nobody. + MEMBERSHIP_ROLE_DELEGATED_ADMIN, +]); /** Off-spec dialect spellings we can name a canonical fix for. */ const TYPE_FIX: Record = { diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index f4f9312d4d..8598288d7f 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -583,6 +583,7 @@ export const enObjects: NonNullable = { options: { owner: "Owner", admin: "Admin", + delegated_admin: "Delegated Admin", member: "Member" } } @@ -641,6 +642,7 @@ export const enObjects: NonNullable = { options: { owner: "Owner", admin: "Admin", + delegated_admin: "Delegated Admin", member: "Member" } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 4db6adc44b..fe10171352 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -583,6 +583,7 @@ export const esESObjects: NonNullable = { options: { owner: "Propietario", admin: "Administrador", + delegated_admin: "Delegated Admin", member: "Miembro" } } @@ -641,6 +642,7 @@ export const esESObjects: NonNullable = { options: { owner: "Propietario", admin: "Administrador", + delegated_admin: "Delegated Admin", member: "Miembro" } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 38a2c0425d..d9cf363c93 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -583,6 +583,7 @@ export const jaJPObjects: NonNullable = { options: { owner: "所有者", admin: "管理者", + delegated_admin: "Delegated Admin", member: "メンバー" } } @@ -641,6 +642,7 @@ export const jaJPObjects: NonNullable = { options: { owner: "所有者", admin: "管理者", + delegated_admin: "Delegated Admin", member: "メンバー" } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 806d44ea0d..3c5b507286 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -583,6 +583,7 @@ export const zhCNObjects: NonNullable = { options: { owner: "所有者", admin: "管理员", + delegated_admin: "Delegated Admin", member: "成员" } } @@ -641,6 +642,7 @@ export const zhCNObjects: NonNullable = { options: { owner: "所有者", admin: "管理员", + delegated_admin: "Delegated Admin", member: "成员" } }, diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index c9740b9ff0..7b138a3f79 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -203,6 +203,12 @@ export const SysInvitation = ObjectSchema.create({ 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', diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 53dbbd88eb..edcc7fe8fd 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -172,6 +172,17 @@ export const SysMember = ObjectSchema.create({ 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', diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 01bb6101f5..b390c5dece 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -865,6 +865,168 @@ describe('AuthManager', () => { expect(onInvitationAccepted).toHaveBeenCalledTimes(1); }); + // ── [ADR-0105 D8 / #3697] The scope-bounded issuance path gets a caller ── + // + // Two halves, and they only make sense together: `delegated_admin` is the + // one org role that may reach `/organization/invite-member` WITHOUT being + // an org admin (giving the D8 gate a caller at last), and the role cap + // stops that role from being a ladder to tenant admin. + const bootOrgPlugin = async (opts: Record = {}) => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + ...opts, + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + return capturedConfig.plugins.find((p: any) => p.id === 'organization'); + }; + + /** A `sys_member` reader stand-in — the shape `resolveMembershipRole` uses. + * `role: null` = no membership row, i.e. an issuer whose grade cannot be + * established. */ + const engineWithMemberRole = (role: string | null) => ({ + find: vi.fn(async (object: string) => + object === 'sys_member' && role !== null ? [{ role }] : [], + ), + findOne: vi.fn(async () => null), + count: vi.fn(async () => 0), + insert: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => undefined), + }); + + it('registers `delegated_admin` with invitation:create — the gate finally has a caller', async () => { + const orgPlugin = await bootOrgPlugin(); + + const roles = orgPlugin._opts.roles; + expect(roles).toBeDefined(); + // The whole point: a role that can invite without being owner/admin. + expect(roles.delegated_admin.statements.invitation).toEqual(['create']); + // …and NOT `cancel`: better-auth's cancel route checks only the + // permission, with no inviterId attribution, so it would mean "cancel + // anyone's pending invitation in the org". + expect(roles.delegated_admin.statements.invitation).not.toContain('cancel'); + // Otherwise it is a plain member — no org/member/team/ac authority. + expect(roles.delegated_admin.statements.member).toEqual([]); + expect(roles.delegated_admin.statements.organization).toEqual([]); + expect(roles.delegated_admin.statements.team).toEqual([]); + }); + + it('registering roles keeps the built-in owner/admin/member intact', async () => { + // better-auth's `hasPermission` does `{...options.roles || defaultRoles}`, + // so a custom map that omits the defaults silently strips `owner`'s + // `invitation:create` and 403s every mutation. + const orgPlugin = await bootOrgPlugin({ additionalOrgRoles: ['sales_rep'] }); + + const roles = orgPlugin._opts.roles; + expect(Object.keys(roles)).toEqual( + expect.arrayContaining(['owner', 'admin', 'member', 'delegated_admin', 'sales_rep']), + ); + expect(roles.owner.statements.invitation).toContain('create'); + expect(roles.admin.statements.invitation).toContain('create'); + expect(roles.member.statements.invitation).toEqual([]); + // App roles stay at member level — only `delegated_admin` gains invite. + expect(roles.sales_rep.statements.invitation).toEqual([]); + }); + + it('an app cannot downgrade `delegated_admin` by declaring the same name', async () => { + const orgPlugin = await bootOrgPlugin({ additionalOrgRoles: ['delegated_admin'] }); + expect(orgPlugin._opts.roles.delegated_admin.statements.invitation).toEqual(['create']); + }); + + 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(). + const orgPlugin = await bootOrgPlugin({ + dataEngine: engineWithMemberRole('delegated_admin') as never, + }); + + await expect( + orgPlugin._opts.organizationHooks.beforeCreateInvitation({ + invitation: { organizationId: 'org-42', email: 'e@x.test', role: 'admin' }, + inviter: { id: 'u_delegate' }, + organization: { id: 'org-42' }, + }), + ).rejects.toThrow(/never confer a role above the issuer/); + }); + + it('role cap: a delegate inviting a plain `member` passes — and costs no lookup', async () => { + const engine = engineWithMemberRole('delegated_admin'); + const orgPlugin = await bootOrgPlugin({ dataEngine: engine as never }); + engine.find.mockClear(); + + await expect( + orgPlugin._opts.organizationHooks.beforeCreateInvitation({ + invitation: { organizationId: 'org-42', email: 'e@x.test', role: 'member' }, + inviter: { id: 'u_delegate' }, + organization: { id: 'org-42' }, + }), + ).resolves.toBeUndefined(); + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('role cap: an org owner inviting an `admin` is unaffected', async () => { + const orgPlugin = await bootOrgPlugin({ + dataEngine: engineWithMemberRole('owner') as never, + }); + + await expect( + orgPlugin._opts.organizationHooks.beforeCreateInvitation({ + invitation: { organizationId: 'org-42', email: 'e@x.test', role: 'admin' }, + inviter: { id: 'u_owner' }, + organization: { id: 'org-42' }, + }), + ).resolves.toBeUndefined(); + }); + + it('role cap: an unresolvable issuer membership fails CLOSED', async () => { + const orgPlugin = await bootOrgPlugin({ + dataEngine: engineWithMemberRole(null) as never, + }); + + await expect( + orgPlugin._opts.organizationHooks.beforeCreateInvitation({ + invitation: { organizationId: 'org-42', email: 'e@x.test', role: 'admin' }, + inviter: { id: 'u_ghost' }, + organization: { id: 'org-42' }, + }), + ).rejects.toThrow(/could not be verified/); + }); + + it('role cap: it runs BEFORE the placement gate and applies without placement intent', async () => { + // The escalation is independent of placement — a role-capped invitation + // must be refused whether or not it carries a business unit. + const assertIssuable = vi.fn(async () => {}); + const orgPlugin = await bootOrgPlugin({ + dataEngine: engineWithMemberRole('delegated_admin') as never, + invitationPlacement: async () => ({ assertIssuable, apply: vi.fn() }) as never, + }); + + await expect( + orgPlugin._opts.organizationHooks.beforeCreateInvitation({ + invitation: { + organizationId: 'org-42', + email: 'e@x.test', + role: 'admin', + businessUnitId: 'bu_plant_a', + positions: ['qc_inspector'], + }, + inviter: { id: 'u_delegate' }, + organization: { id: 'org-42' }, + }), + ).rejects.toThrow(/never confer a role above the issuer/); + // Refused before the placement gate was ever consulted. + expect(assertIssuable).not.toHaveBeenCalled(); + }); + it('should register twoFactor plugin with schema mapping when enabled', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index d2c1138745..d8b95eb61f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -13,9 +13,14 @@ import type { import type { IDataEngine } from '@objectstack/core'; import type { IEmailService, ISmsService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types'; -import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec'; +import { + mapMembershipRole, + BUILTIN_IDENTITY_PLATFORM_ADMIN, + MEMBERSHIP_ROLE_DELEGATED_ADMIN, +} from '@objectstack/spec'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; +import { invitationRoleCapFailure, isPlainMemberInvitation } from './invitation-role-cap.js'; import { isPlaceholderEmail } from './placeholder-email.js'; import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js'; import type { TenancyService } from './tenancy-service.js'; @@ -1552,31 +1557,50 @@ export class AuthManager { // (PermissionSets). Here we register them with minimum org-plugin // capabilities (same as the built-in `member` role) so they cannot // inadvertently grant org-level admin powers. + // + // [ADR-0105 D8 / #3697] The map ALSO registers `delegated_admin` — the + // one role that may reach `/organization/invite-member` without being an + // org admin. Without it D8's scope-bounded issuance gate has no caller: + // better-auth grants `invitation:["create"]` to owner/admin only, and + // under a wall-enforcing posture those two are auto-elevated to tenant + // admins, for whom the gate short-circuits and narrows nothing. So the + // map is now built unconditionally, not just when an app supplies extras. + // + // Deliberately `create` WITHOUT `cancel`: better-auth's cancel route + // (`crud-invites.mjs`) checks only `invitation:["cancel"]` — no inviterId + // attribution — so the permission would mean "cancel anyone's pending + // invitation in the org". Attributed cancel needs its own guard first. let customOrgRoles: Record | undefined; - const extra = this.config.additionalOrgRoles; - if (extra && extra.length > 0) { - try { - const accessMod = await import('better-auth/plugins/organization/access'); - const { defaultAc, memberAc, defaultRoles: importedDefaultRoles } = accessMod as any; - // Better-Auth's `hasPermission` does `{...options.roles || defaultRoles}` - // (precedence: `||` then spread). When we pass our own `roles`, the - // built-in owner/admin/member are silently dropped, so even the org - // owner loses `invitation:create` and every mutation 403s. We must - // re-include the defaults alongside our extras. - const defaultRoles = importedDefaultRoles || null; - if (defaultAc && memberAc && typeof memberAc.statements === 'object') { - const built: Record = defaultRoles ? { ...defaultRoles } : {}; - const stmts = memberAc.statements; - for (const name of extra) { - if (!name) continue; - if (built[name]) continue; - built[name] = defaultAc.newRole(stmts); - } - customOrgRoles = built; + const extra = this.config.additionalOrgRoles ?? []; + try { + const accessMod = await import('better-auth/plugins/organization/access'); + const { defaultAc, memberAc, defaultRoles } = accessMod as any; + // Better-Auth's `hasPermission` does `{...options.roles || defaultRoles}` + // (precedence: `||` then spread). When we pass our own `roles`, the + // built-in owner/admin/member are silently dropped, so even the org + // owner loses `invitation:create` and every mutation 403s. We must + // re-include the defaults alongside our extras — and if the library + // ever stops exporting them, build NOTHING and let better-auth fall + // back to its own defaults rather than ship a map missing `owner`. + if (defaultAc && memberAc && defaultRoles && typeof memberAc.statements === 'object') { + const built: Record = { ...defaultRoles }; + const stmts = memberAc.statements; + // Registered BEFORE the app's extras so an app that happens to + // declare a same-named permission cannot downgrade it to a plain + // member role (the loop below skips names already present). + built[MEMBERSHIP_ROLE_DELEGATED_ADMIN] = defaultAc.newRole({ + ...stmts, + invitation: ['create'], + }); + for (const name of extra) { + if (!name) continue; + if (built[name]) continue; + built[name] = defaultAc.newRole(stmts); } - } catch { - customOrgRoles = undefined; + customOrgRoles = built; } + } catch { + customOrgRoles = undefined; } return organization({ schema: buildOrganizationPluginSchema(), @@ -1681,6 +1705,25 @@ export class AuthManager { // denied (auto-org-admin-grant.ts keeps it read-only on those // tables precisely so a fresh org admin cannot rebind themselves). beforeCreateInvitation: async ({ invitation, inviter, organization }: any) => { + const inviterUserId = inviter?.id ?? inviter?.userId ?? null; + const organizationId = organization?.id ?? invitation?.organizationId; + + // [#3697] Role cap — FIRST, and for EVERY invitation, placement or + // not: the escalation it closes (invite an `admin`, acceptance + // auto-elevates to `organization_admin` → tenant admin) needs no + // placement intent at all. Skipped for a plain `member` invitation, + // which can never trip the cap — so the ordinary path pays nothing. + if (!isPlainMemberInvitation(invitation?.role)) { + const capFailure = invitationRoleCapFailure( + invitation?.role, + await this.resolveMembershipRole(inviterUserId, organizationId), + ); + if (capFailure) { + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', { message: capFailure }); + } + } + const intent = readInvitationPlacementIntent(invitation); if (!intent) return; @@ -1693,7 +1736,6 @@ export class AuthManager { }); } - const organizationId = organization?.id ?? invitation?.organizationId; try { await svc.assertIssuable({ intent, @@ -1701,7 +1743,7 @@ export class AuthManager { // user's real grants through the shared authz resolver; a // hand-built context here would carry no positions and refuse // every delegate. - actorUserId: inviter?.id ?? inviter?.userId ?? null, + actorUserId: inviterUserId, organizationId, }); } catch (err: any) { @@ -3223,6 +3265,47 @@ export class AuthManager { } } + /** + * [#3697] The issuer's own better-auth membership role in `orgId` — the + * input to the invitation role cap. + * + * `beforeCreateInvitation` receives `inviter` as the *session user*, not the + * member row (better-auth's `crud-invites.mjs` passes `session.user`), so + * the role has to be read back. Read through `withSystemReadContext`: an + * org-scoped RLS read performed as a non-privileged delegate could return + * nothing and silently grade them as unresolved. + * + * Returns `null` when the role cannot be established — missing keys, no + * engine, no membership row, or a lookup error. That grades BELOW a plain + * member, so the cap refuses anything above a `member` invitation: this + * backs a security gate, and an unverifiable issuer must confer nothing. + * Multiple membership rows (legacy duplicates) are joined so the highest + * grade among them wins — the cap must judge the authority they actually + * hold, not whichever row came back first. + */ + private async resolveMembershipRole( + userId: unknown, + orgId: unknown, + ): Promise { + const uid = typeof userId === 'string' && userId !== '' ? userId : null; + const oid = typeof orgId === 'string' && orgId !== '' ? orgId : null; + if (!uid || !oid) return null; + const engine = this.getDataEngine(); + if (!engine) return null; + try { + const members = await withSystemReadContext(engine).find('sys_member', { + where: { user_id: uid, organization_id: oid }, + limit: 10, + }); + const roles = (Array.isArray(members) ? members : []) + .map((m: any) => (typeof m?.role === 'string' ? m.role : '')) + .filter((r: string) => r.length > 0); + return roles.length > 0 ? roles.join(',') : null; + } catch { + return null; + } + } + /** * Compose the framework's identity-source stamp (`account.create.after`) * and the default active-org stamp (`session.create.before`) with any diff --git a/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts b/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts new file mode 100644 index 0000000000..17c918cfbe --- /dev/null +++ b/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D8 / #3697] The invitation role cap — the half of the fix that + * keeps `delegated_admin` from being a privilege-escalation ladder. + * + * The chain this closes, verbatim from the issue: a subtree-scoped delegate + * invites someone as `admin` → better-auth's `creatorRole` guard (default + * `owner`) does not object → acceptance writes `sys_member(role='admin')` → + * `auto-org-admin-grant.ts` auto-elevates it to `organization_admin` → + * wildcard `modifyAllRecords` → `isTenantAdmin()`. A delegate would have + * manufactured authority strictly greater than their own. + */ + +import { describe, expect, it } from 'vitest'; +import { + invitationRoleCapFailure, + isPlainMemberInvitation, + orgRoleGrade, + parseOrgRoles, +} from './invitation-role-cap.js'; + +const DELEGATE = 'delegated_admin'; + +describe('parseOrgRoles', () => { + it('accepts better-auth’s three role shapes', () => { + expect(parseOrgRoles('admin')).toEqual(['admin']); + expect(parseOrgRoles('owner,admin')).toEqual(['owner', 'admin']); + expect(parseOrgRoles(['owner', 'admin'])).toEqual(['owner', 'admin']); + }); + + it('normalizes case and whitespace — a padded role is the same role', () => { + expect(parseOrgRoles(' Admin , MEMBER ')).toEqual(['admin', 'member']); + }); + + it('non-string, empty and blank values parse to nothing', () => { + for (const v of [undefined, null, 42, {}, '', ' ', ',,']) { + expect(parseOrgRoles(v)).toEqual([]); + } + }); +}); + +describe('orgRoleGrade', () => { + it('ranks owner above admin above everything else', () => { + expect(orgRoleGrade('owner')).toBeGreaterThan(orgRoleGrade('admin')); + expect(orgRoleGrade('admin')).toBeGreaterThan(orgRoleGrade('member')); + }); + + it('an app role and the delegate role grade as ordinary members', () => { + expect(orgRoleGrade(DELEGATE)).toBe(orgRoleGrade('member')); + expect(orgRoleGrade('sales_manager')).toBe(orgRoleGrade('member')); + }); + + it('a comma list takes the HIGHEST grade — smuggling admin in a list gains nothing', () => { + expect(orgRoleGrade('member,admin')).toBe(orgRoleGrade('admin')); + expect(orgRoleGrade(`${DELEGATE},owner`)).toBe(orgRoleGrade('owner')); + }); + + it('an unresolvable role grades BELOW a plain member (fail-closed floor)', () => { + expect(orgRoleGrade(null)).toBeLessThan(orgRoleGrade('member')); + expect(orgRoleGrade('')).toBeLessThan(orgRoleGrade('member')); + }); +}); + +describe('isPlainMemberInvitation', () => { + it('is true only for exactly one plain member role', () => { + expect(isPlainMemberInvitation('member')).toBe(true); + expect(isPlainMemberInvitation(['member'])).toBe(true); + expect(isPlainMemberInvitation(' Member ')).toBe(true); + }); + + it('is false for anything that could carry capability', () => { + expect(isPlainMemberInvitation('admin')).toBe(false); + expect(isPlainMemberInvitation('member,sales_manager')).toBe(false); + expect(isPlainMemberInvitation(DELEGATE)).toBe(false); + expect(isPlainMemberInvitation(undefined)).toBe(false); + }); +}); + +describe('invitationRoleCapFailure — the escalation chain', () => { + it('REFUSES the chain: a delegate inviting an admin', () => { + const failure = invitationRoleCapFailure('admin', DELEGATE); + expect(failure).toMatch(/never confer a role above the issuer/); + }); + + it('REFUSES a delegate inviting an owner', () => { + expect(invitationRoleCapFailure('owner', DELEGATE)).toMatch(/above the issuer/); + }); + + it('REFUSES admin smuggled inside a comma list', () => { + expect(invitationRoleCapFailure('member,admin', DELEGATE)).toMatch(/above the issuer/); + }); + + it('REFUSES a delegate inviting an app role — membership roles are a capability channel too', () => { + // `mapMembershipRole` passes an unknown role through as a position name; if + // the deployment bound sets to `sales_manager`, the invitee would inherit + // them with nothing gating the hand-out. A delegate's channel for + // capability is the invitation's PLACEMENT intent, which the D12 gate + // allowlists position-by-position. + expect(invitationRoleCapFailure('sales_manager', DELEGATE)).toMatch( + /may invite only as 'member'/, + ); + }); + + it('REFUSES a delegate minting another delegate', () => { + expect(invitationRoleCapFailure(DELEGATE, DELEGATE)).toMatch(/may invite only as 'member'/); + }); + + it('ALLOWS a delegate inviting a plain member — the capability the fix exists to grant', () => { + expect(invitationRoleCapFailure('member', DELEGATE)).toBeNull(); + }); +}); + +describe('invitationRoleCapFailure — org owners and admins are unaffected', () => { + it('an owner may invite any grade', () => { + expect(invitationRoleCapFailure('owner', 'owner')).toBeNull(); + expect(invitationRoleCapFailure('admin', 'owner')).toBeNull(); + expect(invitationRoleCapFailure('member', 'owner')).toBeNull(); + expect(invitationRoleCapFailure(DELEGATE, 'owner')).toBeNull(); + }); + + it('an admin may invite an admin, a delegate and app roles — but never an owner', () => { + expect(invitationRoleCapFailure('admin', 'admin')).toBeNull(); + expect(invitationRoleCapFailure(DELEGATE, 'admin')).toBeNull(); + expect(invitationRoleCapFailure('sales_manager', 'admin')).toBeNull(); + expect(invitationRoleCapFailure('owner', 'admin')).toMatch(/above the issuer/); + }); + + it('an issuer holding both roles is judged on the higher one', () => { + expect(invitationRoleCapFailure('owner', 'member,owner')).toBeNull(); + }); +}); + +describe('invitationRoleCapFailure — fail-closed', () => { + it('an unresolvable issuer role confers nothing above a plain member', () => { + for (const unresolved of [null, undefined, '']) { + // Reported as what it is — unverified, not outranked. + expect(invitationRoleCapFailure('admin', unresolved)).toMatch(/could not be verified/); + expect(invitationRoleCapFailure('sales_manager', unresolved)).toMatch( + /could not be verified/, + ); + // …but the ordinary invitation still works: an unreadable membership row + // must not take the whole invite surface down with it. + expect(invitationRoleCapFailure('member', unresolved)).toBeNull(); + } + }); + + it('an invitation with no role at all is left to better-auth’s required-field check', () => { + expect(invitationRoleCapFailure(undefined, 'owner')).toBeNull(); + expect(invitationRoleCapFailure('', DELEGATE)).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/invitation-role-cap.ts b/packages/plugins/plugin-auth/src/invitation-role-cap.ts new file mode 100644 index 0000000000..ca8a0122ff --- /dev/null +++ b/packages/plugins/plugin-auth/src/invitation-role-cap.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D8 / #3697] Invitation role cap — an invitation may add a person, + * never authority above the issuer's own. + * + * Registering `delegated_admin` with `invitation: ["create"]` (the half that + * gives D8's scope gate a caller) is exploitable on its own, because the ONLY + * role-level cap better-auth applies to *what role you may invite someone as* + * is its `creatorRole` check (`crud-invites.mjs`): + * + * ```js + * if (!member.role.split(",").includes(creatorRole) && roles.split(",").includes(creatorRole)) + * throw YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE + * ``` + * + * `creatorRole` defaults to `"owner"`. It blocks inviting an **owner**; it does + * not block inviting an **admin**. So a subtree-scoped delegate could invite + * someone as `admin`, acceptance would write `sys_member(role='admin')`, and + * `auto-org-admin-grant.ts` would auto-elevate that membership to + * `organization_admin` → wildcard `modifyAllRecords` → `isTenantAdmin()`. A + * delegate scoped to one plant would have manufactured a **tenant admin**. + * Every existing defense is off that path: `DelegatedAdminGate`'s + * `GOVERNED_OBJECTS` does not cover `sys_member`, and the acceptance-time + * membership write runs under better-auth's own context, not the issuer's. + * + * Hence this cap, applied in the framework's own `beforeCreateInvitation` hook: + * + * 1. **Grade ceiling** — the invited role may never outrank the issuer's own. + * `creatorRole` cannot express this: it is single-valued, so it can name + * one protected role, not a ladder. + * 2. **Delegates invite plain members only** — an issuer below admin grade may + * issue exactly `member`. Not merely "not admin/owner": an app-registered + * role (`sales_manager`, …) projects into `current_user.positions` via + * `mapMembershipRole` 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 the D12 gate allowlists + * position-by-position — not the membership role, which nothing gates. + * + * Applies to EVERY invitation, placement-carrying or not: the escalation is + * independent of placement. Fail-closed by construction — an issuer role that + * cannot be resolved grades as nothing, so anything above a plain member + * invitation is refused. + */ + +/** better-auth's built-in organization roles. */ +export const MEMBERSHIP_ROLE_OWNER = 'owner'; +export const MEMBERSHIP_ROLE_ADMIN = 'admin'; +export const MEMBERSHIP_ROLE_MEMBER = 'member'; + +/** Grade ladder. Anything unrecognized — `member`, `delegated_admin`, an + * app-registered role — grades as an ordinary member; only better-auth's two + * administrative roles outrank it, because only they are auto-elevated to + * `organization_admin` by `auto-org-admin-grant.ts`. */ +const GRADE_UNRESOLVED = 0; +const GRADE_MEMBER = 1; +const GRADE_ADMIN = 2; +const GRADE_OWNER = 3; + +/** Split a better-auth role value (`"admin"`, `"owner,admin"`, `["a","b"]`). */ +export function parseOrgRoles(raw: unknown): string[] { + const flat = Array.isArray(raw) ? raw.join(',') : raw; + if (typeof flat !== 'string') return []; + return flat + .split(',') + .map((r) => r.trim().toLowerCase()) + .filter((r) => r.length > 0); +} + +/** + * Grade of a role value. `GRADE_UNRESOLVED` (0) only for an absent/unreadable + * value — the fail-closed floor, deliberately BELOW a plain member so an + * unverifiable issuer authorizes nothing. + */ +export function orgRoleGrade(raw: unknown): number { + const roles = parseOrgRoles(raw); + if (roles.length === 0) return GRADE_UNRESOLVED; + let grade = GRADE_MEMBER; + for (const role of roles) { + if (role === MEMBERSHIP_ROLE_OWNER) grade = Math.max(grade, GRADE_OWNER); + else if (role === MEMBERSHIP_ROLE_ADMIN) grade = Math.max(grade, GRADE_ADMIN); + } + return grade; +} + +/** + * Is this invitation exactly a plain `member`? Such an invitation can never + * trip the cap, so the hook skips resolving the issuer's membership row — the + * common path costs no extra query. + */ +export function isPlainMemberInvitation(requestedRole: unknown): boolean { + const roles = parseOrgRoles(requestedRole); + return roles.length === 1 && roles[0] === MEMBERSHIP_ROLE_MEMBER; +} + +/** + * The cap itself. Returns a human-readable refusal reason, or `null` when the + * invitation is within the issuer's grade. + * + * @param requestedRole the role(s) the invitation would confer on acceptance + * @param issuerRole the issuer's own `sys_member.role`; `null`/absent when it + * could not be resolved (graded as nothing — fail closed) + */ +export function invitationRoleCapFailure( + requestedRole: unknown, + issuerRole: unknown, +): string | null { + const requested = parseOrgRoles(requestedRole); + // No role at all: better-auth's body schema requires one, so this is not a + // reachable invitation shape. Nothing to cap — the route rejects it upstream. + if (requested.length === 0) return null; + if (isPlainMemberInvitation(requestedRole)) return null; + + const issuerGrade = orgRoleGrade(issuerRole); + const requestedGrade = orgRoleGrade(requestedRole); + + // Fail-closed floor, reported as itself: an issuer whose membership could not + // be read is not "outranked", they are unverified — say so, or an operator + // chasing a refusal reads a grade comparison against a role that was never + // established. + if (issuerGrade === GRADE_UNRESOLVED) { + return ( + `[Security] Access denied: your organization membership could not be verified, so this ` + + `invitation may confer no role beyond '${MEMBERSHIP_ROLE_MEMBER}' ` + + `(ADR-0090 D12: an unverifiable issuer confers nothing).` + ); + } + + if (requestedGrade > issuerGrade) { + return ( + `[Security] Access denied: you may not invite a member as '${requested.join(', ')}' — ` + + `an invitation can never confer a role above the issuer's own ` + + `(ADR-0090 D12: administration is a scoped capability).` + ); + } + + if (issuerGrade < GRADE_ADMIN) { + return ( + `[Security] Access denied: a delegated issuer may invite only as ` + + `'${MEMBERSHIP_ROLE_MEMBER}' — '${requested.join(', ')}' is an organization role that can ` + + `carry capability of its own. Grant capability through the invitation's placement ` + + `(business unit + positions), which is authorized against your adminScope.` + ); + } + + return null; +} diff --git a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts new file mode 100644 index 0000000000..208d646aa5 --- /dev/null +++ b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D8 / #3697] The scope-bounded issuance path gets a caller — proven + * over the real HTTP route, not against a hook in isolation. + * + * The gap: D8 authorizes invitation *placement* against the issuer's + * `adminScope` (ADR-0090 D12), but nobody could reach that gate in a state + * where it narrowed anything. better-auth grants `invitation: ["create"]` to + * `owner`/`admin` only, and under a wall-enforcing posture those two are + * auto-elevated to tenant admins — for whom the gate short-circuits. The + * `delegated_admin` org role is the missing principal. + * + * Registering it alone would have been a four-step privilege escalation: + * better-auth's `creatorRole` guard (default `owner`) blocks inviting an + * **owner** but not an **admin**, and an accepted `admin` membership is + * auto-elevated to `organization_admin` → wildcard `modifyAllRecords` → + * `isTenantAdmin()`. So the role ships with a role cap in the framework's own + * `beforeCreateInvitation` hook, and this file pins BOTH halves end to end: + * + * 1. a `delegated_admin` may actually issue an invitation (the role reaches + * the endpoint — the unit tests can only prove the roles map was built); + * 2. the same principal issuing `role: "admin"` is refused, with no + * invitation row left behind; + * 3. a plain `member` still cannot invite at all — so it is the ROLE that + * opened the endpoint, not some unrelated loosening. + * + * Harness note: `bootStack` disables the default-org bootstrap, so this file + * mints the Default Organization itself (system context — exactly what the + * bootstrap would do) and sets membership roles the same way, which is the + * only writer better-auth-managed tables accept (ADR-0092). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The membership row the sign-up reconciler writes lands asynchronously + * (better-auth defers `user.create.after` past the signup transaction). */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 40; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('#3697: delegated_admin may invite — as `member` only', () => { + let stack: VerifyStack; + let ql: any; + let orgId: string; + let adminToken: string; + let delegateToken: string; + let plainMemberToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + adminToken = await stack.signIn(); // the seeded dev admin + ql = await stack.kernel.getServiceAsync('objectql'); + + const org = await ql.insert( + 'sys_organization', + { name: 'Default Organization', slug: 'default' }, + { context: SYSTEM_CTX }, + ); + orgId = String(org.id); + + // The dev admin predates the org row, so give them the owner membership the + // single-org bootstrap would have. They are who PROVISIONS a delegate, and + // the reference for "an org owner's behavior is unchanged by any of this". + const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUser.id }, 5); + if (adminMembers.length > 0) { + await ql.update( + 'sys_member', + { id: adminMembers[0].id, organization_id: orgId, role: 'owner' }, + { context: SYSTEM_CTX }, + ); + } else { + await ql.insert( + 'sys_member', + { user_id: adminUser.id, organization_id: orgId, role: 'owner' }, + { context: SYSTEM_CTX }, + ); + } + + // The delegate: an ordinary user promoted to the delegated issuer grade. + // Deliberately NOT owner/admin — that is the whole point of the role. + delegateToken = await stack.signUp('delegate.3697@example.com', 'Delegate!Pass123', 'Delegate 3697'); + const [delegateUser] = await findRows(ql, 'sys_user', { email: 'delegate.3697@example.com' }, 1); + const delegateMember = await waitForMembership(ql, String(delegateUser.id)); + expect(delegateMember.role).toBe('member'); // the reconciler's default + await ql.update( + 'sys_member', + { id: delegateMember.id, role: 'delegated_admin' }, + { context: SYSTEM_CTX }, + ); + + // The control: an ordinary member, left alone. + plainMemberToken = await stack.signUp('plain.3697@example.com', 'Plain!Pass123', 'Plain 3697'); + const [plainUser] = await findRows(ql, 'sys_user', { email: 'plain.3697@example.com' }, 1); + await waitForMembership(ql, String(plainUser.id)); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('a delegated_admin CAN issue an invitation — the gate finally has a caller', async () => { + const res = await stack.apiAs(delegateToken, 'POST', '/auth/organization/invite-member', { + email: 'invitee.member.3697@example.com', + role: 'member', + organizationId: orgId, + }); + + expect(res.status, await res.clone().text()).toBe(200); + const rows = await findRows(ql, 'sys_invitation', { email: 'invitee.member.3697@example.com' }, 5); + expect(rows.length).toBe(1); + expect(rows[0].role).toBe('member'); + }, 30_000); + + it('the same delegate issuing `admin` is REFUSED, with no invitation row left behind', async () => { + // The escalation chain in one request: were this to succeed, acceptance + // would write sys_member(role='admin') and auto-org-admin-grant would turn + // a subtree-scoped delegate's invitee into a tenant admin. + const res = await stack.apiAs(delegateToken, 'POST', '/auth/organization/invite-member', { + email: 'invitee.admin.3697@example.com', + role: 'admin', + organizationId: orgId, + }); + + expect(res.status).toBe(403); + expect(JSON.stringify(await res.json())).toMatch(/never confer a role above the issuer/); + // Rejecting throws out of the endpoint BEFORE the row is written. + expect( + (await findRows(ql, 'sys_invitation', { email: 'invitee.admin.3697@example.com' }, 5)).length, + ).toBe(0); + }, 30_000); + + it('an org owner CAN invite someone AS a delegated_admin — the provisioning flow', async () => { + // How a delegate comes to exist in the first place. Both `sys_invitation.role` + // and `sys_member.role` are enforced selects, so this is also the proof that + // the new role is storable on the invitation as well as the membership — a + // role missing from either list is a role that cannot be handed out. + const res = await stack.apiAs(adminToken, 'POST', '/auth/organization/invite-member', { + email: 'invitee.delegate.3697@example.com', + role: 'delegated_admin', + organizationId: orgId, + }); + + expect(res.status, await res.clone().text()).toBe(200); + const rows = await findRows(ql, 'sys_invitation', { email: 'invitee.delegate.3697@example.com' }, 5); + expect(rows.length).toBe(1); + expect(rows[0].role).toBe('delegated_admin'); + }, 30_000); + + it('a plain member still cannot invite at all — the ROLE is what opened the endpoint', async () => { + const res = await stack.apiAs(plainMemberToken, 'POST', '/auth/organization/invite-member', { + email: 'invitee.frommember.3697@example.com', + role: 'member', + organizationId: orgId, + }); + + expect(res.status).toBe(403); + expect( + (await findRows(ql, 'sys_invitation', { email: 'invitee.frommember.3697@example.com' }, 5)) + .length, + ).toBe(0); + }, 30_000); +}); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index f036ae787b..37adc1b522 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -48,6 +48,7 @@ "F (const)", "GUEST_POSITION (const)", "MAP_SUPPORTED_FIELDS (const)", + "MEMBERSHIP_ROLE_DELEGATED_ADMIN (const)", "METADATA_ALIASES (const)", "MIGRATIONS_BY_MAJOR (const)", "MIGRATION_MAJORS (const)", @@ -4243,6 +4244,7 @@ "Invitation (type)", "InvitationSchema (const)", "InvitationStatus (type)", + "MEMBERSHIP_ROLE_DELEGATED_ADMIN (const)", "Member (type)", "MemberSchema (const)", "ORGANIZATION_ADMIN (const)", diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index 7a1656e1dd..69318fecb3 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -102,6 +102,30 @@ export const BUILTIN_IDENTITY_METADATA: Record