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
17 changes: 17 additions & 0 deletions .changeset/23945-invite-role-cap-codes.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 77 additions & 15 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1198,20 +1198,49 @@ 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<unknown>) => {
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().
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/);
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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
});
Expand Down
71 changes: 57 additions & 14 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Error> {
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
Expand Down Expand Up @@ -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 });
}
}

Expand Down Expand Up @@ -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.
Expand All @@ -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;
}

/**
Expand Down
45 changes: 32 additions & 13 deletions packages/plugins/plugin-auth/src/invitation-role-cap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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']);
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -123,22 +126,38 @@ 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', () => {
expect(invitationRoleCapFailure('owner', 'member,owner')).toBeNull();
});
});

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();
Expand Down
Loading
Loading