diff --git a/.changeset/23945-invite-role-cap-codes.md b/.changeset/23945-invite-role-cap-codes.md new file mode 100644 index 00000000000..5d1e89b75ba --- /dev/null +++ b/.changeset/23945-invite-role-cap-codes.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): the invitation role cap's refusals on `POST /organization/invite-member` carry an error code, and a membership read that fails answers `503` instead of a refusal + +Clause-②: no + +The role cap in `beforeCreateInvitation` refused with `403` and a message but no `code`, while better-auth's own `owner` refusal on the same endpoint carries one. A client, the console's Invite User dialog among them, could not tell the refusals apart without reading English prose. Each refusal now carries the code the error-code ledger registers for it under `@objectstack/plugin-auth`, in the same `{ message, code }` body: + +- `INVITE_ROLE_ABOVE_ISSUER`: the requested role grades above the issuer's own, for example a `delegated_admin` inviting as `admin`. +- `INVITE_ISSUER_MEMBER_ONLY`: an issuer below admin grade asked for a role other than a plain `member`, for example a `delegated_admin` inviting as `delegated_admin`. +- `INVITE_ISSUER_UNVERIFIED`: fail-closed. The issuer's membership role was not established: the read found no usable `sys_member` row, or the `AuthManager` has no data engine to read from. + +A membership read that throws is a store outage, not a verdict about the issuer. It used to answer the same `403` as an unverified issuer. It now answers `503` with the code `SERVICE_UNAVAILABLE`, as the organization slug guard does for its own read faults. No invitation is created either way. + +Unchanged: which invitations are admitted, the status of every refusal (`403`), better-auth's own `owner` refusal, and the exports. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 534faa18e4f..326e0c71089 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1198,6 +1198,28 @@ describe('AuthManager', () => { expect(roles.sales_rep).toBeUndefined(); }); + /** + * [#23945] A cap refusal asserted on its envelope (ADR-0112): the status + * and the `code` better-auth serialises into the body — never the bare + * throw, which a codeless `FORBIDDEN` passes just as well. + */ + const capAnswer = async (attempt: Promise) => { + const err = (await attempt.then( + () => undefined, + (e: unknown) => e, + )) as + | { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown }; cause?: unknown } + | undefined; + expect(err, 'the invitation was admitted').toBeTruthy(); + return err; + }; + const inviteAs = (orgPlugin: any, role: string, inviterId = 'u_delegate') => + orgPlugin._opts.organizationHooks.beforeCreateInvitation({ + invitation: { organizationId: 'org-42', email: 'e@x.test', role }, + inviter: { id: inviterId }, + organization: { id: 'org-42' }, + }); + 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(). @@ -1205,13 +1227,20 @@ describe('AuthManager', () => { 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/); + const err = await capAnswer(inviteAs(orgPlugin, 'admin')); + expect(err?.statusCode).toBe(403); + expect(err?.status).toBe('FORBIDDEN'); + expect(err?.body?.code).toBe('INVITE_ROLE_ABOVE_ISSUER'); + }); + + it('role cap: a delegate minting another delegate is REFUSED as member-only, not as outranked', async () => { + const orgPlugin = await bootOrgPlugin({ + dataEngine: engineWithMemberRole('delegated_admin') as never, + }); + + const err = await capAnswer(inviteAs(orgPlugin, 'delegated_admin')); + expect(err?.statusCode).toBe(403); + expect(err?.body?.code).toBe('INVITE_ISSUER_MEMBER_ONLY'); }); it('role cap: a delegate inviting a plain `member` passes — and costs no lookup', async () => { @@ -1244,17 +1273,50 @@ describe('AuthManager', () => { }); it('role cap: an unresolvable issuer membership fails CLOSED', async () => { + // Pinned HERE, not on the wire: through `POST /organization/invite-member` + // this input is not reachable. better-auth reads the inviter's membership + // before the hook runs and refuses a non-member (`MEMBER_NOT_FOUND`, 400) + // or a role without `invitation:create` (403) first, and the cap re-reads + // the same `sys_member` rows in system context — so a read that answers + // "no usable row" needs the row to vanish between the two reads. 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/); + const err = await capAnswer(inviteAs(orgPlugin, 'admin', 'u_ghost')); + expect(err?.statusCode).toBe(403); + expect(err?.body?.code).toBe('INVITE_ISSUER_UNVERIFIED'); + }); + + it('role cap: NO data engine to read fails CLOSED too — "cannot check" is never "does not apply"', async () => { + // Not reachable through `AuthPlugin`, which hard-depends on ObjectQL and + // always passes its `data` service; only a bare `AuthManager` built + // without `dataEngine` (better-auth then runs on its in-memory adapter) + // reaches the hook with nothing to read. + const orgPlugin = await bootOrgPlugin(); + + const err = await capAnswer(inviteAs(orgPlugin, 'admin', 'u_any')); + expect(err?.statusCode).toBe(403); + expect(err?.body?.code).toBe('INVITE_ISSUER_UNVERIFIED'); + }); + + it('role cap: a membership read that THROWS is an outage (503 SERVICE_UNAVAILABLE), not a refusal', async () => { + // An owner's invitation, so the only thing between it and admission is + // the read: the verdict the store would have given is "admitted". + const engine = engineWithMemberRole('owner'); + engine.find.mockImplementation(async () => { + throw new Error('store unavailable'); + }); + const orgPlugin = await bootOrgPlugin({ dataEngine: engine as never }); + + const err = await capAnswer(inviteAs(orgPlugin, 'admin', 'u_owner')); + expect(err?.statusCode).toBe(503); + expect(err?.status).toBe('SERVICE_UNAVAILABLE'); + expect(err?.body?.code).toBe('SERVICE_UNAVAILABLE'); + expect(err?.body?.code).not.toBe('INVITE_ISSUER_UNVERIFIED'); + // The driver's own failure is kept for the operator. + expect((err?.cause as Error | undefined)?.message).toBe('store unavailable'); + expect(engine.find).toHaveBeenCalledTimes(1); }); it('role cap: it runs BEFORE the placement gate and applies without placement intent', async () => { @@ -1278,7 +1340,7 @@ describe('AuthManager', () => { inviter: { id: 'u_delegate' }, organization: { id: 'org-42' }, }), - ).rejects.toThrow(/never confer a role above the issuer/); + ).rejects.toMatchObject({ statusCode: 403, body: { code: 'INVITE_ROLE_ABOVE_ISSUER' } }); // Refused before the placement gate was ever consulted. expect(assertIssuable).not.toHaveBeenCalled(); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index ee5de7578bc..698973c2e6c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -73,6 +73,7 @@ import { } from '@objectstack/spec'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; +import type { StandardErrorCode } from '@objectstack/spec/api'; import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js'; import { permissionSetInEffect } from './catalog-set-in-effect.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; @@ -1364,6 +1365,34 @@ async function slugGuardReadFaultApiError(object: string, cause: unknown): Promi return err; } +/** [#23945] The standard ADR-0112 code a store outage answers with — the status's own name. */ +const INVITATION_ROLE_CAP_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILABLE'; + +/** + * [#23945] The invitation role cap's answer when its one read — the issuer's + * `sys_member` rows — cannot answer: `503 SERVICE_UNAVAILABLE`, as the slug + * guard's read fault answers ({@link slugGuardReadFaultApiError}). + * + * Not the cap's `403 INVITE_ISSUER_UNVERIFIED`: that code is a verdict — the + * store answered and established no role — while a read that threw reached no + * verdict, so it must not wear the refusal's code. Still fail-closed: the + * throw ends the endpoint, so no invitation row is written. Unlike the slug + * guard's body, this one carries the code too, so every answer from the + * invitation endpoint has one. The driver's own failure rides `cause`. + */ +async function invitationRoleCapReadFaultApiError(cause: unknown): Promise { + const { APIError } = await import('better-auth/api'); + const err = new APIError('SERVICE_UNAVAILABLE', { + message: + 'The invitation role cap could not read `sys_member`, so the role the issuer holds was never ' + + 'determined, and no invitation was created. This is a server-side outage, not a refusal of the ' + + 'invitation: retry once the data store is reachable.', + code: INVITATION_ROLE_CAP_READ_FAULT_CODE, + }); + (err as { cause?: unknown }).cause = cause; + return err; +} + /** * [#24166, ruling 6232069920 A] The access-token claim that carries the * organization an OAuth consent bound. Minted by `customAccessTokenClaims` on @@ -3487,13 +3516,18 @@ export class AuthManager { // 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( + // [#23945] Each refusal carries its ADR-0112 code beside its + // message, the way better-auth's own `owner` refusal on this + // endpoint does. A membership read that THROWS never reaches + // this verdict: `resolveMembershipRole` answers it as an outage + // (503 `SERVICE_UNAVAILABLE`), not as a refusal. + const refusal = invitationRoleCapFailure( invitation?.role, await this.resolveMembershipRole(inviterUserId, organizationId), ); - if (capFailure) { + if (refusal) { const { APIError } = await import('better-auth/api'); - throw new APIError('FORBIDDEN', { message: capFailure }); + throw new APIError('FORBIDDEN', { message: refusal.message, code: refusal.code }); } } @@ -7813,10 +7847,18 @@ export class AuthManager { * 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. + * Returns `null` when the role is not established — missing keys, no + * engine, or a read that answered with no usable membership row. That + * grades BELOW a plain member, so the cap refuses anything above a `member` + * invitation with `INVITE_ISSUER_UNVERIFIED`: this backs a security gate, + * and an issuer it cannot check confers nothing — "no engine to read" is + * "cannot check", never "the cap does not apply". + * + * [#23945] A read that THROWS is not one of those answers: it throws + * `503 SERVICE_UNAVAILABLE` ({@link invitationRoleCapReadFaultApiError}), so + * an outage is never reported as a verdict about the issuer. Still no + * invitation is created. + * * 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. @@ -7830,18 +7872,19 @@ export class AuthManager { if (!uid || !oid) return null; const engine = this.getDataEngine(); if (!engine) return null; + let members: unknown; try { - const members = await withSystemReadContext(engine).find('sys_member', { + 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; + } catch (err) { + throw await invitationRoleCapReadFaultApiError(err); } + 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; } /** diff --git a/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts b/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts index 17c918cfbe9..2954e5a910e 100644 --- a/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts +++ b/packages/plugins/plugin-auth/src/invitation-role-cap.test.ts @@ -14,6 +14,9 @@ import { describe, expect, it } from 'vitest'; import { + INVITE_ISSUER_MEMBER_ONLY_CODE, + INVITE_ISSUER_UNVERIFIED_CODE, + INVITE_ROLE_ABOVE_ISSUER_CODE, invitationRoleCapFailure, isPlainMemberInvitation, orgRoleGrade, @@ -22,6 +25,9 @@ import { const DELEGATE = 'delegated_admin'; +/** The refusal's ADR-0112 code — what a client branches on (#23945). */ +const codeOf = (requested: unknown, issuer: unknown) => invitationRoleCapFailure(requested, issuer)?.code; + describe('parseOrgRoles', () => { it('accepts better-auth’s three role shapes', () => { expect(parseOrgRoles('admin')).toEqual(['admin']); @@ -79,16 +85,15 @@ describe('isPlainMemberInvitation', () => { 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/); + expect(codeOf('admin', DELEGATE)).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE); }); it('REFUSES a delegate inviting an owner', () => { - expect(invitationRoleCapFailure('owner', DELEGATE)).toMatch(/above the issuer/); + expect(codeOf('owner', DELEGATE)).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE); }); it('REFUSES admin smuggled inside a comma list', () => { - expect(invitationRoleCapFailure('member,admin', DELEGATE)).toMatch(/above the issuer/); + expect(codeOf('member,admin', DELEGATE)).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE); }); it('REFUSES a delegate inviting an app role — membership roles are a capability channel too', () => { @@ -97,13 +102,11 @@ describe('invitationRoleCapFailure — the escalation chain', () => { // 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'/, - ); + expect(codeOf('sales_manager', DELEGATE)).toBe(INVITE_ISSUER_MEMBER_ONLY_CODE); }); it('REFUSES a delegate minting another delegate', () => { - expect(invitationRoleCapFailure(DELEGATE, DELEGATE)).toMatch(/may invite only as 'member'/); + expect(codeOf(DELEGATE, DELEGATE)).toBe(INVITE_ISSUER_MEMBER_ONLY_CODE); }); it('ALLOWS a delegate inviting a plain member — the capability the fix exists to grant', () => { @@ -123,7 +126,7 @@ describe('invitationRoleCapFailure — org owners and admins are unaffected', () expect(invitationRoleCapFailure('admin', 'admin')).toBeNull(); expect(invitationRoleCapFailure(DELEGATE, 'admin')).toBeNull(); expect(invitationRoleCapFailure('sales_manager', 'admin')).toBeNull(); - expect(invitationRoleCapFailure('owner', 'admin')).toMatch(/above the issuer/); + expect(codeOf('owner', 'admin')).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE); }); it('an issuer holding both roles is judged on the higher one', () => { @@ -131,14 +134,30 @@ describe('invitationRoleCapFailure — org owners and admins are unaffected', () }); }); +describe('invitationRoleCapFailure — each refusal answers its own code (#23945)', () => { + it('the three codes, by value — the strings the error-code ledger registers under plugin-auth', () => { + expect([INVITE_ROLE_ABOVE_ISSUER_CODE, INVITE_ISSUER_MEMBER_ONLY_CODE, INVITE_ISSUER_UNVERIFIED_CODE]).toEqual([ + 'INVITE_ROLE_ABOVE_ISSUER', + 'INVITE_ISSUER_MEMBER_ONLY', + 'INVITE_ISSUER_UNVERIFIED', + ]); + }); + + it('a refusal carries its reason beside the code', () => { + for (const [requested, issuer] of [['admin', DELEGATE], [DELEGATE, DELEGATE], ['admin', null]] as const) { + const refusal = invitationRoleCapFailure(requested, issuer); + expect(refusal?.message, `${requested} by ${issuer}`).toEqual(expect.any(String)); + expect(refusal?.message.length).toBeGreaterThan(0); + } + }); +}); + 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/, - ); + expect(codeOf('admin', unresolved)).toBe(INVITE_ISSUER_UNVERIFIED_CODE); + expect(codeOf('sales_manager', unresolved)).toBe(INVITE_ISSUER_UNVERIFIED_CODE); // …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(); diff --git a/packages/plugins/plugin-auth/src/invitation-role-cap.ts b/packages/plugins/plugin-auth/src/invitation-role-cap.ts index 3c91370e95f..5fd4b086c83 100644 --- a/packages/plugins/plugin-auth/src/invitation-role-cap.ts +++ b/packages/plugins/plugin-auth/src/invitation-role-cap.ts @@ -59,9 +59,30 @@ import { INVITATION_ROLE_ANY_ISSUER, invitationRoleGrantors, } from '@objectstack/spec/identity'; +import type { RegisteredErrorCode } from '@objectstack/spec/api'; export { MEMBERSHIP_ROLE_OWNER, MEMBERSHIP_ROLE_ADMIN, MEMBERSHIP_ROLE_MEMBER }; +// [#23945, ADR-0112] The cap's three refusal codes, one per branch, so a client +// (the console's Invite User dialog among them) can tell the refusals apart +// without reading their prose. Registered under `@objectstack/plugin-auth` in +// the error-code ledger; typed `RegisteredErrorCode` so a removed or misspelled +// ledger row fails `tsc` here instead of shipping a code `ApiErrorSchema` +// rejects. + +/** The requested role grades above the issuer's own. */ +export const INVITE_ROLE_ABOVE_ISSUER_CODE: RegisteredErrorCode = 'INVITE_ROLE_ABOVE_ISSUER'; +/** An issuer below admin grade asked for a role other than a plain `member`. */ +export const INVITE_ISSUER_MEMBER_ONLY_CODE: RegisteredErrorCode = 'INVITE_ISSUER_MEMBER_ONLY'; +/** The issuer's membership role was not established (fail-closed floor). */ +export const INVITE_ISSUER_UNVERIFIED_CODE: RegisteredErrorCode = 'INVITE_ISSUER_UNVERIFIED'; + +/** A refusal the cap answers: the code a client branches on, and the prose an operator reads. */ +export interface InvitationRoleCapRefusal { + readonly code: RegisteredErrorCode; + readonly message: string; +} + /** 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 @@ -149,8 +170,10 @@ function grantTableAdmits(requested: readonly string[], issuerRole: unknown): bo } /** - * The cap itself. Returns a human-readable refusal reason, or `null` when the - * invitation is within the issuer's grade. + * The cap itself. Returns the refusal — its ADR-0112 `code` and a + * human-readable reason — or `null` when the invitation is within the issuer's + * grade. Each refusal branch answers its own code; the verdicts themselves + * (which invitations are admitted) do not depend on the codes. * * @param requestedRole the role(s) the invitation would confer on acceptance * @param issuerRole the issuer's own `sys_member.role`; `null`/absent when it @@ -159,7 +182,7 @@ function grantTableAdmits(requested: readonly string[], issuerRole: unknown): bo export function invitationRoleCapFailure( requestedRole: unknown, issuerRole: unknown, -): string | null { +): InvitationRoleCapRefusal | 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. @@ -173,28 +196,34 @@ export function invitationRoleCapFailure( // 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).` - ); + return { + code: INVITE_ISSUER_UNVERIFIED_CODE, + message: + `[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 (grantTableAdmits(requested, issuerRole)) return null; // Refused — name the reason by the ladder. if (orgRoleGrade(requestedRole) > 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).` - ); + return { + code: INVITE_ROLE_ABOVE_ISSUER_CODE, + message: + `[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).`, + }; } - 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 { + code: INVITE_ISSUER_MEMBER_ONLY_CODE, + message: + `[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.`, + }; } diff --git a/packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts b/packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts index ce396578121..5511d9ba4bf 100644 --- a/packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts +++ b/packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts @@ -90,33 +90,41 @@ function oracleGrade(raw: unknown): number { return grade; } -function oracleCap(requestedRole: unknown, issuerRole: unknown): string | null { +// [#23945] Each refusal branch answers its own code; the oracle spells them as +// literals, so the parity grid below pins the branch → code mapping as well. +function oracleCap(requestedRole: unknown, issuerRole: unknown): { code: string; message: string } | null { const requested = oracleParse(requestedRole); if (requested.length === 0) return null; if (requested.length === 1 && requested[0] === 'member') return null; const issuerGrade = oracleGrade(issuerRole); const requestedGrade = oracleGrade(requestedRole); if (issuerGrade === 0) { - return ( - `[Security] Access denied: your organization membership could not be verified, so this ` + - `invitation may confer no role beyond 'member' ` + - `(ADR-0090 D12: an unverifiable issuer confers nothing).` - ); + return { + code: 'INVITE_ISSUER_UNVERIFIED', + message: + `[Security] Access denied: your organization membership could not be verified, so this ` + + `invitation may confer no role beyond '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).` - ); + return { + code: 'INVITE_ROLE_ABOVE_ISSUER', + message: + `[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 < 2) { - return ( - `[Security] Access denied: a delegated issuer may invite only as ` + - `'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 { + code: 'INVITE_ISSUER_MEMBER_ONLY', + message: + `[Security] Access denied: a delegated issuer may invite only as ` + + `'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; } @@ -145,7 +153,7 @@ describe('invitationRoleCapFailure — unchanged by reading the grant table', () expect( invitationRoleCapFailure(requested, issuer), `requested ${JSON.stringify(requested)} by issuer ${JSON.stringify(issuer)}`, - ).toBe(expected); + ).toEqual(expected); } } // The grid exercises refusals and admissions both, so agreement is not diff --git a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts index 8d758c4c730..335de43514e 100644 --- a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts @@ -25,6 +25,13 @@ * 3. a plain `member` still cannot invite at all — so it is the ROLE that * opened the endpoint, not some unrelated loosening. * + * [#23945] Each cap refusal is pinned on its ADR-0112 `code`, the way + * better-auth's own `owner` refusal on this endpoint carries one — a client + * (the console's Invite User dialog) branches on it, never on the prose. The + * cap's third code, `INVITE_ISSUER_UNVERIFIED`, has no input that reaches it + * through this door (better-auth refuses a non-member before the hook runs); + * it is pinned at the unit level in plugin-auth's `auth-manager.test.ts`. + * * Harness note: `bootStack` boots the production `single` shape, so the * Default Organization exists from the boot (ADR-0131 D3); this file sets * membership roles in system context, which is the only writer @@ -137,14 +144,33 @@ describe('#3697: delegated_admin may invite — as `member` only', () => { organizationId: orgId, }); - expect(res.status).toBe(403); - expect(JSON.stringify(await res.json())).toMatch(/never confer a role above the issuer/); + const body = await res.clone().json(); + expect(res.status, JSON.stringify(body)).toBe(403); + expect(body?.code).toBe('INVITE_ROLE_ABOVE_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('the same delegate minting another delegate is REFUSED as member-only, with no invitation row', async () => { + // [#23945] Not outranked — `delegated_admin` grades as a member — but a + // delegated issuer may invite only as `member`: a distinct refusal, so a + // distinct code. + const res = await stack.apiAs(delegateToken, 'POST', '/auth/organization/invite-member', { + email: 'invitee.subdelegate.3697@example.com', + role: 'delegated_admin', + organizationId: orgId, + }); + + const body = await res.clone().json(); + expect(res.status, JSON.stringify(body)).toBe(403); + expect(body?.code).toBe('INVITE_ISSUER_MEMBER_ONLY'); + expect( + (await findRows(ql, 'sys_invitation', { email: 'invitee.subdelegate.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 diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d1a58557f81..69956f14fc9 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -570,11 +570,10 @@ export const ERROR_CODE_LEDGER = { 'INVALID_EMAIL', 'INVALID_PHONE', 'INVITE_EMAIL_FAILED', - // [#24082] The invitation role cap's three refusals (`invitation-role-cap.ts`, - // thrown 403 from `beforeCreateInvitation`), registered AHEAD of their - // producer: today the cap throws a bare `FORBIDDEN`, and #23945 stamps these. + // [#24082] The invitation role cap's three refusals (`invitation-role-cap.ts`), + // each thrown 403 from `beforeCreateInvitation` with its code (#23945). 'INVITE_ISSUER_MEMBER_ONLY', // [#24082] 403: an issuer below admin grade may invite only as a plain `member`; the requested role does not outrank theirs but may carry capability of its own - 'INVITE_ISSUER_UNVERIFIED', // [#24082] 403, fail-closed: the issuer's `sys_member` role could not be established (no row, or the lookup did not answer), so the invitation may confer nothing beyond `member` + 'INVITE_ISSUER_UNVERIFIED', // [#24082] 403, fail-closed: the issuer's `sys_member` role was not established (the read answered with no usable row, or the composition has no data engine to read), so the invitation may confer nothing beyond `member`; a read that throws answers `SERVICE_UNAVAILABLE` (503) instead 'INVITE_REQUIRES_EMAIL', 'INVITE_ROLE_ABOVE_ISSUER', // [#24082] 403: the requested role grades above the issuer's own (an invitation never confers authority above its issuer) 'INVALID_REQUEST',