From c8b4b5fbda70baa6848b0304d715cfc6e0bbe54d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:56:30 +0000 Subject: [PATCH 1/8] wip(plugin-auth): platform-admin door to mint and revoke SCIM connection credentials Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 27 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 45 +++ .../src/auth-route-ledger.conformance.test.ts | 7 + .../plugin-auth/src/auth-route-ledger.ts | 4 + .../plugin-auth/src/scim-connection-admin.ts | 379 ++++++++++++++++++ .../src/scim-connection-service.ts | 43 ++ 6 files changed, 505 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/scim-connection-admin.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index ec74d129b5f..a7348817879 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -213,6 +213,7 @@ import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-i import { buildPluginList, type PluginListHost } from './auth-manager-plugin-list.js'; import { resolveTurnstileKeys } from './turnstile-sign-up.js'; import { assertTrustedAppSchemes, registeredAppSchemesOf } from './one-time-token-handoff.js'; +import type { MintScimConnectionCredentialInput, MintedScimConnectionCredential } from './scim-connection-service.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -6143,6 +6144,32 @@ export class AuthManager { return this.config.trustProxy === true; } + /** + * [#30958] Is the SCIM 2.0 service provider mounted on this deployment? The + * same decision `buildPluginList` mounts `@better-auth/scim` on + * ({@link resolveScimEnabled}), so the credential door's "SCIM is off" + * refusal can never disagree with the `/scim/v2` routes actually served. + */ + public isScimWired(): boolean { + return resolveScimEnabled(this.config.plugins); + } + + /** + * [#30958] Mint a SCIM connection bearer with this deployment's auth secret, + * the key the mounted SCIM plugin's verifier digests with + * (`buildPluginList`), so the bearer is one that plugin accepts. The secret + * never leaves the manager. The only caller is the platform-admin + * `/admin/scim/mint-credential` mount (`scim-connection-admin.ts`), which + * hands the engine to write through. + */ + public async mintScimConnectionCredential( + engine: IDataEngine, + input: MintScimConnectionCredentialInput, + ): Promise { + const { mintScimConnectionCredential } = await import('./scim-connection-service.js'); + return mintScimConnectionCredential(engine as never, this.resolveAuthSecret(), input); + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 222700f251e..f1ac86ea8ec 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3874,6 +3874,51 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // [#30958] ADR-0134 — admin: mint and revoke the bearer credentials SCIM + // connections authenticate with. The SCIM plugin verifies against + // `sys_scim_connection_credential` rows, which the data API never creates + // or updates (engine-owned, list only), so these two mounts are the + // credential lifecycle's only door. Same family as `set-user-manager`: the + // shared platform-admin gate runs first, ahead of any body read, and the + // store writes are credited to the admin. The minted bearer is answered + // once, `no-store`, and kept nowhere; see `scim-connection-admin.ts`. + { + const scimCredentialDeps = (actor: PlatformAdminActor): import('./scim-connection-admin.js').ScimCredentialAdminDeps => ({ + getDataEngine: () => this.authManager!.getDataEngine(), + isScimEnabled: () => this.authManager!.isScimWired(), + mint: (engine, input) => this.authManager!.mintScimConnectionCredential(engine, input), + attributed: (fn) => runAttributedToUser(actor, fn), + logger: { warn: (m) => ctx.logger.warn(m), error: (m) => ctx.logger.error(m) }, + }); + rawApp.post(`${basePath}/admin/scim/mint-credential`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const { runMintScimCredential } = await import('./scim-connection-admin.js'); + const { status, body, headers } = await runMintScimCredential(scimCredentialDeps(actor), actor, c.req.raw); + return c.json(body, status as any, headers); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/scim/mint-credential failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500); + } + }); + rawApp.post(`${basePath}/admin/scim/revoke-credential`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const { runRevokeScimCredential } = await import('./scim-connection-admin.js'); + const { status, body } = await runRevokeScimCredential(scimCredentialDeps(actor), actor, c.req.raw); + return c.json(body, status as any); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/scim/revoke-credential failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500); + } + }); + } + // ──────────────────────────────────────────────────────────────────── // #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed // (managedBy better-auth), and until now the only add-a-teammate path was diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index c9dd78849f0..af16ba27ec1 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -246,6 +246,13 @@ describe('auth route ledger hygiene', () => { // below holds it to the same proof — better-auth serves no roster. 'POST /api/v1/auth/admin/list-platform-admins', 'POST /api/v1/auth/admin/oauth2/toggle-disabled', + // [#30958] On this pin's own terms: `auth-plugin.ts` mounts both itself + // (`rawApp.post` on the `${basePath}/admin/scim/mint-credential` and + // `${basePath}/admin/scim/revoke-credential` templates, ahead of the + // catch-all), and the `live.has(route)` loop below holds them to the + // same proof — stable `@better-auth/scim` serves no credential endpoint. + 'POST /api/v1/auth/admin/scim/mint-credential', + 'POST /api/v1/auth/admin/scim/revoke-credential', // [#16678] The thirteenth, added on this pin's own terms exactly as the // set-initial-password row below was: `auth-plugin.ts` mounts it itself // (a `rawApp.post` on the `${basePath}/admin/set-user-manager` diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 5cdd2ad576a..06167579e23 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -238,6 +238,10 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068). A dedicated identity-import door rather than the generic sys_user import, because a direct ObjectQL write would bypass better-auth\'s password hashing and mint no sys_account credential' }, { route: 'POST /api/v1/auth/admin/list-platform-admins', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user list_platform_admins action posts it directly from the Users list; answers who holds platform-admin standing (the last-admin guard\'s enumerator, each id confirmed by hasPlatformAdminStanding), served only to a caller who holds that standing; reads no body. ADR-0095 D3' }, { route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' }, + // #30958 — the tracker id stays HERE and never in the `note` string, per the + // #16678 note below. + { route: 'POST /api/v1/auth/admin/scim/mint-credential', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'scim', note: 'no SDK method builds this URL — an operator script or the identity-provider setup posts it; mints the bearer a SCIM connection authenticates with (scim-connection-service.ts) and answers the plaintext once, Cache-Control: no-store — only its keyed one-way digest is stored, and nothing logs it; refused 503 SERVICE_UNAVAILABLE while SCIM is off; one sys_audit_log row per mint naming the admin and the connection, never the credential; platform-admin gated (ADR-0068), ADR-0134' }, + { route: 'POST /api/v1/auth/admin/scim/revoke-credential', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — an operator script posts it with the credentialId the mint answered; switches that credential row inactive, so the SCIM verifier refuses its bearer from the next request on (rotation is a second mint for the same connection, then this); 404 for an unknown credential, 409 when already revoked; one sys_audit_log row per revoke naming the admin and the connection; served whether or not SCIM is on; platform-admin gated (ADR-0068), ADR-0134' }, { route: 'POST /api/v1/auth/admin/sso/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register action posts flat form fields; ObjectStack bridge re-dispatching into @better-auth/sso /sso/register, platform-admin gated ahead of the delegation (ADR-0068 D4), so registering an identity provider never rests on the vendor\'s own check alone. Distinct path from the vendor\'s own /sso/register, which the catch-all serves' }, { route: 'POST /api/v1/auth/admin/sso/register-saml', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register_saml_provider action posts flat fields the bridge reshapes into better-auth\'s nested samlConfig; platform-admin gated ahead of the delegation (ADR-0068 D4), ADR-0069 P3' }, { route: 'POST /api/v1/auth/admin/sso/request-domain-verification', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it and renders the returned DNS TXT record; ObjectStack bridge over @better-auth/sso, additionally gated on the opt-in ssoDomainVerification switch (OS_SSO_DOMAIN_VERIFICATION) — off means the inner endpoint 404s, the mount itself is unconditional; platform-admin gated (ADR-0068 D4), ADR-0135 D6' }, diff --git a/packages/plugins/plugin-auth/src/scim-connection-admin.ts b/packages/plugins/plugin-auth/src/scim-connection-admin.ts new file mode 100644 index 00000000000..e6a77029702 --- /dev/null +++ b/packages/plugins/plugin-auth/src/scim-connection-admin.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The platform-admin door that mints and revokes SCIM connection credentials: + * `POST /api/v1/auth/admin/scim/mint-credential` and + * `POST /api/v1/auth/admin/scim/revoke-credential` (#30958, ADR-0134). + * + * ## Why it exists + * + * ObjectStack owns the whole SCIM credential lifecycle (`scim-connection-service.ts`): + * the stable `@better-auth/scim` plugin stores no bearer of its own and resolves + * every connection from a `sys_scim_connection_credential` row at request time. + * The verifier was wired and the mint was written, but nothing called the mint: + * the object refuses create on the data API (`enable.apiMethods: ['list']`, + * `managedBy: 'engine-owned'`), so no identity provider could ever be handed a + * bearer. These two mounts are that door, and the only one. + * + * ## Who may use it + * + * The platform administrator only. Both mounts run the shared + * `judgePlatformAdmin` gate (ADR-0068 D2, with ADR-0069 D3's held-session + * refusal) in `auth-plugin.ts` BEFORE this module reads anything, so an + * anonymous caller is answered 401 and a signed-in non-admin 403 whatever the + * body says. ⛔ Who may mint is a security boundary: widening it to anyone + * else (an organization administrator, say) is a maintainer decision, never a + * change made here. + * + * ## The secret is shown once + * + * The mint answers the plaintext bearer in its own response, with + * `Cache-Control: no-store`, and nowhere else: the store keeps only the keyed + * one-way digest (`token_digest`, declared `internal: true`, so the generic + * data path never serves it), the audit row names the connection and never the + * credential, and nothing here logs the bearer or its digest. A storage + * failure on the mint is reported WITHOUT its cause text, because a driver + * error can echo the inserted row, and that row carries the digest. + * + * ## Revoke, and what rotate is + * + * Revoking switches the row's `active` off, which is what + * `verifyScimBearerToken` refuses on, so the revoked bearer answers 401 at the + * next SCIM request. The row stays: its digest is one-way, and the audit trail + * and the rotation history point at it. Rotation is not a third operation: + * several rows may authenticate one `connection_id` (staged rotation, the + * object header says), so rotating is a second mint for the same connection, + * moving the identity provider to the new bearer, then revoking the old one. + * + * ## Audit + * + * Each mint and each revoke writes one `sys_audit_log` row: the administrator + * as `user_id` / `actor`, the credential as `object_name` + `record_id`, and + * `metadata` naming the event and the `connectionId` ({@link ScimCredentialAuditDecisions}, + * a closed type with no room for the bearer). It is written directly, as + * `/admin/create-user` writes its own, so the trail does not depend on what the + * generic audit mirror decides to record for this object. A refused audit + * write does not undo the operation (the bearer is already shown); it is + * reported once, at `warn`, naming the remedy, exactly as the admin user + * endpoints report theirs. plugin-audit UNINSTALLED means no `sys_audit_log` + * object at all: a declared skip, silent. + */ + +import { findDefaultOrganization } from '@objectstack/core'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { withSystemContext } from './objectql-adapter.js'; +import type { PlatformAdminActor } from './platform-admin-gate.js'; +import { readJsonRequestBody } from './request-json-body.js'; +import { + SCIM_CREDENTIAL_OBJECT, + revokeScimConnectionCredential, + type MintScimConnectionCredentialInput, + type MintedScimConnectionCredential, +} from './scim-connection-service.js'; + +/** The two mounts, relative to the auth base path. */ +export const SCIM_MINT_CREDENTIAL_PATH = '/admin/scim/mint-credential'; +export const SCIM_REVOKE_CREDENTIAL_PATH = '/admin/scim/revoke-credential'; + +/** The longest value the credential row's text columns store. */ +const MAX_TEXT = 255; + +/** Execution context for the store reads and the audit write. */ +const SYSTEM_CTX = { isSystem: true } as const; + +/** The response a mount hands back: status, body and any headers. */ +export interface ScimCredentialAdminResult { + status: 200 | 400 | 404 | 409 | 500 | 503; + body: + | { success: true; data: Record } + | { success: false; error: { code: string; message: string } }; + headers?: Record; +} + +export interface ScimCredentialAdminDeps { + /** The data engine every read and write goes through. */ + getDataEngine: () => IDataEngine | null | undefined; + /** + * Is the SCIM plugin mounted on this deployment? The mint refuses while it + * is not: a bearer nothing can verify is a credential with no use. + */ + isScimEnabled: () => boolean; + /** + * Mint through `mintScimConnectionCredential` with the deployment's auth + * secret, the key the mounted SCIM plugin's verifier digests with. The + * secret stays inside the auth manager; `engine` is the one to write through + * (this module hands it the system-context wrapper, `withSystemContext`). + */ + mint: (engine: IDataEngine, input: MintScimConnectionCredentialInput) => Promise; + /** + * Run the store writes credited to the administrator (attribution only; the + * write authorizes as the system, the gate already authorized the caller). + */ + attributed: (fn: () => Promise) => Promise; + logger?: { warn: (message: string) => void; error: (message: string) => void }; +} + +/** + * What a credential audit row's `metadata` may carry: the event and the + * connection, and nothing else. Closed on purpose — there is no key a bearer, + * a digest or a field value of the credential could ride in. + */ +export interface ScimCredentialAuditDecisions { + event: 'scim_credential_minted' | 'scim_credential_revoked'; + connectionId: string; +} + +function fail(status: ScimCredentialAdminResult['status'], code: string, message: string): ScimCredentialAdminResult { + return { status, body: { success: false, error: { code, message } } }; +} + +/** An optional text member: absent, or a string of at most {@link MAX_TEXT} characters. */ +function optionalText(body: Record, key: string): { ok: true; value?: string } | { ok: false; message: string } { + const value = body[key]; + if (value === undefined || value === null) return { ok: true }; + if (typeof value !== 'string') return { ok: false, message: `${key} must be a string when it is given` }; + const trimmed = value.trim(); + if (trimmed === '') return { ok: true }; + if (trimmed.length > MAX_TEXT) return { ok: false, message: `${key} is longer than ${MAX_TEXT} characters` }; + return { ok: true, value: trimmed }; +} + +/** + * Is `name` registered on the engine? An engine that cannot say (no + * `getSchema`) is taken at its word that it serves the object, so the read or + * write that follows answers for itself. + */ +function objectRegistered(engine: IDataEngine, name: string): boolean { + const getSchema = (engine as unknown as { getSchema?: (object: string) => unknown }).getSchema; + return typeof getSchema !== 'function' || Boolean(getSchema.call(engine, name)); +} + +async function readBody(request: Request): Promise<{ ok: true; body: Record } | { ok: false; result: ScimCredentialAdminResult }> { + const reading = await readJsonRequestBody(request); + if (!reading.ok) return { ok: false, result: fail(400, 'INVALID_REQUEST', reading.message) }; + const body = reading.body; + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return { ok: false, result: fail(400, 'INVALID_REQUEST', 'The request body must be a JSON object') }; + } + return { ok: true, body: body as Record }; +} + +/** + * The organization the credential row is owned by: the one the request names, + * which must exist, else the Default Organization (ADR-0131 D3/D5, the owner + * of platform-level rows). A composition without organizations owns nothing. + */ +async function resolveOwningOrganization( + engine: IDataEngine, + requested: string | undefined, +): Promise<{ ok: true; organizationId?: string } | { ok: false; result: ScimCredentialAdminResult }> { + const registered = objectRegistered(engine, 'sys_organization'); + if (requested !== undefined) { + const row = registered + ? await engine.findOne('sys_organization', { where: { id: requested }, fields: ['id'] }, { context: SYSTEM_CTX } as never) + : null; + if (!row) { + return { ok: false, result: fail(400, 'INVALID_REQUEST', `organizationId '${requested}' names no organization on this deployment`) }; + } + return { ok: true, organizationId: requested }; + } + if (!registered) return { ok: true }; + const found = await findDefaultOrganization(async (where, limit) => { + const rows = await engine.find('sys_organization', { where: { ...where }, fields: ['id'], limit }, { context: SYSTEM_CTX } as never); + return Array.isArray(rows) ? rows : []; + }); + if (found.id !== undefined) return { ok: true, organizationId: found.id }; + if (found.several) { + return { + ok: false, + result: fail( + 400, + 'INVALID_REQUEST', + 'This deployment holds several organizations and none is the Default Organization: name the organization that owns the connection with organizationId', + ), + }; + } + return { ok: true }; +} + +async function writeCredentialAudit( + deps: ScimCredentialAdminDeps, + engine: IDataEngine, + entry: { action: 'create' | 'update'; actor: PlatformAdminActor; credentialId: string; metadata: ScimCredentialAuditDecisions }, +): Promise { + // plugin-audit UNINSTALLED: no `sys_audit_log` object, so nothing ever + // claimed this action would be audited. A declared skip, not a swallow. + if (!objectRegistered(engine, 'sys_audit_log')) return; + try { + await engine.insert( + 'sys_audit_log', + { + action: entry.action, + user_id: entry.actor.id, + actor: entry.actor.id, + object_name: SCIM_CREDENTIAL_OBJECT, + record_id: entry.credentialId, + metadata: JSON.stringify(entry.metadata), + }, + { context: SYSTEM_CTX } as never, + ); + } catch (error) { + // The operation already happened (a minted bearer is already in the + // response; a revoked one is already refused), so it is not undone. But + // the trail is gone and nothing retries it, so say so once, with the + // remedy, and never with the credential. + deps.logger?.warn( + `[AuthPlugin] the sys_audit_log row for this SCIM credential event (${entry.metadata.event}, connection ` + + `'${entry.metadata.connectionId}', credential ${entry.credentialId}) was NOT written — the operation itself ` + + 'SUCCEEDED and the endpoint answers 200, so nothing looks wrong. plugin-audit is installed ' + + '(sys_audit_log is registered), so this is a REFUSED write, not an absent plugin, and nothing retries it. ' + + 'Remedy: restore write access to sys_audit_log (permissions, driver connectivity), then treat this line as ' + + `the audit record. Cause: ${(error as Error)?.message ?? String(error)}`, + ); + } +} + +/** + * `POST /admin/scim/mint-credential` — mint a bearer for a SCIM connection and + * answer it once. The caller is the platform administrator the mount's gate + * admitted. + */ +export async function runMintScimCredential( + deps: ScimCredentialAdminDeps, + actor: PlatformAdminActor, + request: Request, +): Promise { + const reading = await readBody(request); + if (!reading.ok) return reading.result; + const body = reading.body; + + const connectionId = body.connectionId; + if (typeof connectionId !== 'string' || connectionId.length === 0 || connectionId.trim() !== connectionId || connectionId.length > MAX_TEXT) { + return fail( + 400, + 'INVALID_REQUEST', + `connectionId is required: the connection's identifier (e.g. "okta-prod"), a non-empty string of at most ${MAX_TEXT} characters with no surrounding whitespace`, + ); + } + const optional: Record<'label' | 'provisioningDomainId' | 'organizationId', string | undefined> = { + label: undefined, + provisioningDomainId: undefined, + organizationId: undefined, + }; + for (const key of Object.keys(optional) as Array) { + const read = optionalText(body, key); + if (!read.ok) return fail(400, 'INVALID_REQUEST', read.message); + optional[key] = read.value; + } + let expiresAt: Date | undefined; + if (body.expiresAt !== undefined && body.expiresAt !== null) { + const parsed = typeof body.expiresAt === 'string' ? new Date(body.expiresAt) : undefined; + if (!parsed || Number.isNaN(parsed.getTime())) { + return fail(400, 'INVALID_REQUEST', 'expiresAt must be an ISO 8601 date-time when it is given'); + } + if (parsed.getTime() <= Date.now()) { + return fail(400, 'INVALID_REQUEST', 'expiresAt is not in the future: a credential that is already expired would authenticate nothing'); + } + expiresAt = parsed; + } + + if (!deps.isScimEnabled()) { + return fail( + 503, + 'SERVICE_UNAVAILABLE', + 'SCIM provisioning is not enabled on this deployment, so a credential minted now would authenticate nothing. ' + + 'Enable it (OS_SCIM_ENABLED=true, or plugins.scim) and mint again.', + ); + } + const engine = deps.getDataEngine(); + if (!engine) return fail(503, 'SERVICE_UNAVAILABLE', 'Data engine unavailable'); + + const owner = await resolveOwningOrganization(engine, optional.organizationId); + if (!owner.ok) return owner.result; + + let minted: MintedScimConnectionCredential; + try { + minted = await deps.attributed(() => + deps.mint(withSystemContext(engine), { + connectionId, + ...(optional.provisioningDomainId ? { provisioningDomainId: optional.provisioningDomainId } : {}), + ...(owner.organizationId ? { organizationId: owner.organizationId } : {}), + ...(optional.label ? { label: optional.label } : {}), + ...(expiresAt ? { expiresAt } : {}), + mintedByUserId: actor.id, + }), + ); + } catch (error) { + // ⛔ The cause text is withheld on purpose: a driver error can echo the + // inserted row, and that row carries the bearer's digest. + const named = (error as { code?: unknown })?.code; + deps.logger?.error( + `[AuthPlugin] admin/scim/mint-credential: the credential for connection '${connectionId}' was NOT stored, so no ` + + `bearer was issued (${(error as Error)?.name ?? 'Error'}${typeof named === 'string' ? ` ${named}` : ''}; the ` + + 'driver message is withheld because it can echo the inserted row). Check the data engine and the ' + + 'sys_scim_connection_credential table, then mint again.', + ); + return fail(500, 'INTERNAL_ERROR', 'The credential was not stored, so no bearer was issued. Nothing needs revoking; mint again.'); + } + + await writeCredentialAudit(deps, engine, { + action: 'create', + actor, + credentialId: minted.credentialId, + metadata: { event: 'scim_credential_minted', connectionId }, + }); + + return { + status: 200, + body: { + success: true, + data: { + credentialId: minted.credentialId, + connectionId, + token: minted.token, + expiresAt: expiresAt ? expiresAt.toISOString() : null, + }, + }, + headers: { 'Cache-Control': 'no-store' }, + }; +} + +/** + * `POST /admin/scim/revoke-credential` — switch one credential off, so its + * bearer is refused from the next SCIM request on. + */ +export async function runRevokeScimCredential( + deps: ScimCredentialAdminDeps, + actor: PlatformAdminActor, + request: Request, +): Promise { + const reading = await readBody(request); + if (!reading.ok) return reading.result; + const credentialId = reading.body.credentialId; + if (typeof credentialId !== 'string' || credentialId.trim() === '') { + return fail(400, 'INVALID_REQUEST', 'credentialId is required: the credentialId the mint answered'); + } + + const engine = deps.getDataEngine(); + if (!engine) return fail(503, 'SERVICE_UNAVAILABLE', 'Data engine unavailable'); + + const outcome = await deps.attributed(() => revokeScimConnectionCredential(withSystemContext(engine), credentialId)); + if (outcome.outcome === 'not_found') { + return fail(404, 'RESOURCE_NOT_FOUND', `No SCIM connection credential '${credentialId}' exists`); + } + if (outcome.outcome === 'already_revoked') { + return fail(409, 'RESOURCE_CONFLICT', `SCIM connection credential '${credentialId}' is already revoked; nothing changed`); + } + + await writeCredentialAudit(deps, engine, { + action: 'update', + actor, + credentialId: outcome.credentialId, + metadata: { event: 'scim_credential_revoked', connectionId: outcome.connectionId }, + }); + + return { + status: 200, + body: { success: true, data: { credentialId: outcome.credentialId, connectionId: outcome.connectionId, active: false } }, + }; +} diff --git a/packages/plugins/plugin-auth/src/scim-connection-service.ts b/packages/plugins/plugin-auth/src/scim-connection-service.ts index ce429163916..b318b7b452e 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-service.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-service.ts @@ -241,3 +241,46 @@ export async function verifyScimBearerToken( ...(expiresAt ? { expiresAt } : {}), }; } + +/** Minimal engine surface {@link revokeScimConnectionCredential} needs. */ +interface RevocationEngine { + findOne( + object: string, + query: { where: Record; fields?: string[] }, + options?: { context?: { isSystem?: boolean } }, + ): Promise | null>; + update(object: string, data: Record): Promise; +} + +/** What {@link revokeScimConnectionCredential} found and did. */ +export type ScimCredentialRevocation = + | { outcome: 'revoked'; credentialId: string; connectionId: string } + | { outcome: 'already_revoked'; credentialId: string; connectionId: string } + | { outcome: 'not_found' }; + +/** + * Revoke one credential (#30958): switch its row's `active` off — the switch + * {@link verifyScimBearerToken} refuses on — so its bearer is refused from the + * next SCIM request on. The row is kept (its digest is one-way; the audit + * trail and the staged-rotation history point at it). Rotation is a second + * {@link mintScimConnectionCredential} for the same `connectionId` followed by + * this. A row already inactive is reported as such and not written again. + */ +export async function revokeScimConnectionCredential( + engine: RevocationEngine, + credentialId: string, +): Promise { + const row = await engine.findOne( + SCIM_CREDENTIAL_OBJECT, + { where: { id: credentialId }, fields: ['id', 'connection_id', 'active'] }, + CREDENTIAL_PROBE_CONTEXT, + ); + if (!row) return { outcome: 'not_found' }; + const connectionId = String((row as { connection_id?: unknown }).connection_id ?? ''); + const id = String((row as { id?: unknown }).id); + // Read `active` the way the verifier does: anything not affirmatively active is off. + const active = (row as { active?: unknown }).active; + if (active === false || active === 0 || active == null) return { outcome: 'already_revoked', credentialId: id, connectionId }; + await engine.update(SCIM_CREDENTIAL_OBJECT, { id, active: false }); + return { outcome: 'revoked', credentialId: id, connectionId }; +} From 70847b8cc77e2dbd707ecad5a59a2a058c2f4d99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:00:04 +0000 Subject: [PATCH 2/8] test(plugin-auth): pin the SCIM credential door end to end Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../src/scim-connection-admin.test.ts | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/scim-connection-admin.test.ts diff --git a/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts b/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts new file mode 100644 index 00000000000..a7485d8e057 --- /dev/null +++ b/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts @@ -0,0 +1,383 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30958] The platform-admin door that mints and revokes SCIM connection + * credentials — `POST /api/v1/auth/admin/scim/mint-credential` and + * `POST /api/v1/auth/admin/scim/revoke-credential` — end to end. + * + * ## What is real + * + * The two mounts are the plugin's REAL route registration on a real Hono app + * (the `request-json-body-refusal.test.ts` harness), so the shared + * `judgePlatformAdmin` gate in front of them is inside the system under test. + * Behind them sits a real `AuthManager` with SCIM on, over a real `ObjectQL` on + * better-sqlite3 `:memory:`: the bearer the mint answers is driven into the + * vendor's own `POST /scim/v2/Users`, verified by the production + * `verifyBearerToken` wiring, so a mint that digested with the wrong key, or a + * revoke that wrote nothing, fails at the IdP's door and not at a helper. + * + * Only the session is chosen per case: the gate reads it through the auth + * service's in-process `getSession`, which the harness answers with a platform + * admin, a signed-in member, or nobody. + * + * ## The secret is shown once + * + * Every case that mints asserts the plaintext bearer is in the mint's answer + * and NOWHERE else this file can see: not in the credential row, not in any + * `sys_audit_log` row, not in any line the plugin logger or the console was + * handed. The keyed digest is held to the same absence everywhere but its own + * column. + */ + +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { envelopeViolations } from '@objectstack/spec/api'; +import type { PluginContext } from '@objectstack/core'; +import { AuthPlugin } from './auth-plugin.js'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { createTenancyService } from './tenancy-service.js'; +import { SCIM_BEARER_PREFIX, SCIM_CREDENTIAL_OBJECT, digestScimBearerToken } from './scim-connection-service.js'; +// The mounts load this module with a dynamic `import()` on first request; paid +// here at collection instead of inside the first case. +import './scim-connection-admin.js'; +import 'better-auth'; +import 'better-auth/plugins'; +import 'better-auth/plugins/admin'; +import 'better-auth/plugins/organization'; +import '@better-auth/scim'; + +const ORIGIN = 'http://localhost:3000'; +const AUTH = `${ORIGIN}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long-30958'; +const SYSTEM = { context: { isSystem: true } } as const; +const DEFAULT_ORG = 'org_default'; +const OTHER_ORG = 'org_other'; + +const ADMIN = { user: { id: 'usr_platform_admin', email: 'admin@example.test', isPlatformAdmin: true }, session: {} }; +const MEMBER = { user: { id: 'usr_member', email: 'member@example.test', isPlatformAdmin: false, role: 'user' }, session: {} }; + +/** + * The columns `sys_audit_log` declares that this door writes — the fixture the + * plugin-auth harness carries, since plugin-audit (the object's owner) is not a + * dependency of this package. + */ +const sysAuditLog = { + name: 'sys_audit_log', + label: 'Audit Log', + // As the real object declares it (ADR-0131 D7): a deployment-level ledger + // with no injected organization column. + systemFields: { tenant: false }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + action: { name: 'action', type: 'text' as const }, + user_id: { name: 'user_id', type: 'text' as const }, + actor: { name: 'actor', type: 'text' as const }, + object_name: { name: 'object_name', type: 'text' as const }, + record_id: { name: 'record_id', type: 'text' as const }, + metadata: { name: 'metadata', type: 'textarea' as const }, + }, +}; + +let engine: ObjectQL; +let manager: AuthManager; +let app: Hono; +let session: unknown = ADMIN; +const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; +const consoleSpies: Array> = []; + +beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of [...authIdentityObjects, sysAuditLog]) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); + await engine.insert('sys_organization', { id: OTHER_ORG, name: 'Other Organization', slug: 'other' }, SYSTEM); + + manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine as never, + getTenancy: () => createTenancyService({ requested: 'isolated', probeIsolation: () => true }), + plugins: { scim: true, organization: true }, + } as never); + // The gate reads the caller through the auth service's in-process session read. + vi.spyOn(manager, 'getApi').mockImplementation(async () => ({ getSession: async () => session }) as never); + + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger, + getKernel: vi.fn(), + } as unknown as PluginContext; + const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } }); + await plugin.init(ctx); + (plugin as unknown as { authManager: AuthManager }).authManager = manager; + app = new Hono(); + (plugin as unknown as { registerAuthRoutes(server: unknown, c: PluginContext): void }) + .registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + + for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) { + consoleSpies.push(vi.spyOn(console, method)); + } +}, 60_000); + +afterEach(() => { + session = ADMIN; +}); + +afterAll(() => { + for (const spy of consoleSpies) spy.mockRestore(); +}); + +// ── Requests ──────────────────────────────────────────────────────────────── + +async function door(path: 'mint-credential' | 'revoke-credential', body: unknown): Promise<{ status: number; body: any; cacheControl: string | null }> { + const res = await app.request(`${AUTH}/admin/scim/${path}`, { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') }; +} + +/** `POST /Users` as an identity provider sends it, with `token` as the bearer. */ +async function provision(token: string, localPart: string): Promise { + const email = `${localPart}@example.com`; + return manager.handleRequest( + new Request(`${AUTH}/scim/v2/Users`, { + method: 'POST', + headers: { origin: ORIGIN, authorization: `Bearer ${token}`, 'content-type': 'application/scim+json' }, + body: JSON.stringify({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: email, + name: { givenName: localPart, familyName: 'Example' }, + emails: [{ value: email, primary: true, type: 'work' }], + active: true, + }), + }), + ); +} + +// ── Reads ─────────────────────────────────────────────────────────────────── + +async function credentialRows(connectionId: string): Promise>> { + return (await engine.find(SCIM_CREDENTIAL_OBJECT, { where: { connection_id: connectionId } }, SYSTEM)) as never; +} + +async function auditRows(credentialId: string): Promise>> { + return (await engine.find('sys_audit_log', { where: { record_id: credentialId } }, SYSTEM)) as never; +} + +/** Every line the plugin logger and the console were handed, as one string. */ +function everythingLogged(): string { + const calls = [ + ...Object.values(logger).flatMap((fn) => fn.mock.calls), + ...consoleSpies.flatMap((spy) => spy.mock.calls), + ]; + return calls.map((args) => args.map((a) => (a instanceof Error ? `${a.message} ${a.stack}` : typeof a === 'string' ? a : JSON.stringify(a))).join(' ')).join('\n'); +} + +/** The bearer and its digest appear in no audit row and no log line. */ +async function expectSecretNowhereElse(token: string, credentialId: string): Promise { + const digest = digestScimBearerToken(SECRET, token); + const audit = JSON.stringify(await engine.find('sys_audit_log', {}, SYSTEM)); + expect(audit).not.toContain(token); + expect(audit).not.toContain(digest); + const logged = everythingLogged(); + expect(logged).not.toContain(token); + expect(logged).not.toContain(digest); + // At rest (read below the engine, as `credential-at-rest-posture.test.ts` + // does) the row holds the digest and never the bearer. + const driver = (engine as unknown as { getDriver(o: string): { find(o: string, q: unknown): Promise } }) + .getDriver(SCIM_CREDENTIAL_OBJECT); + const atRest = ((await driver.find(SCIM_CREDENTIAL_OBJECT, { where: { id: credentialId } })) as Array>)[0]; + expect(atRest.token_digest).toBe(digest); + expect(JSON.stringify(atRest)).not.toContain(token); + // Through the engine — even a system read — the digest column is not served + // at all (`internal: true`), so no read the data API builds on can return it. + const served = JSON.stringify(await engine.find(SCIM_CREDENTIAL_OBJECT, { where: { id: credentialId } }, SYSTEM)); + expect(served).toContain(credentialId); + expect(served).not.toContain(digest); + expect(served).not.toContain(token); +} + +// ── The cases ─────────────────────────────────────────────────────────────── + +describe('[#30958] mint: the platform admin gets a bearer the SCIM plugin accepts, shown once', () => { + it('an admin mints, and SCIM POST /Users with that bearer answers 201', async () => { + const minted = await door('mint-credential', { connectionId: 'okta-30958', label: 'first rotation' }); + expect(minted.status, JSON.stringify(minted.body)).toBe(200); + expect(envelopeViolations(minted.body)).toEqual([]); + expect(minted.cacheControl).toBe('no-store'); + const { token, credentialId, connectionId, expiresAt } = minted.body.data; + expect(typeof token).toBe('string'); + expect(token.startsWith(SCIM_BEARER_PREFIX)).toBe(true); + expect(connectionId).toBe('okta-30958'); + expect(expiresAt).toBeNull(); + + const created = await provision(token, 'mint-30958'); + expect(created.status, `SCIM POST /Users: ${await created.clone().text()}`).toBe(201); + + // The row the mint stored: owned by the Default Organization, minted by the admin, active. + const [row] = await credentialRows('okta-30958'); + expect(row.id).toBe(credentialId); + expect(row.organization_id).toBe(DEFAULT_ORG); + expect(row.user_id).toBe(ADMIN.user.id); + expect(row.label).toBe('first rotation'); + expect(Boolean(row.active)).toBe(true); + + // One audit row, naming the admin and the connection. + const audit = await auditRows(credentialId); + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ action: 'create', user_id: ADMIN.user.id, actor: ADMIN.user.id, object_name: SCIM_CREDENTIAL_OBJECT }); + expect(JSON.parse(String(audit[0].metadata))).toEqual({ event: 'scim_credential_minted', connectionId: 'okta-30958' }); + + await expectSecretNowhereElse(token, credentialId); + }, 30_000); + + it('the owning organization is the one the request names, which must exist', async () => { + const named = await door('mint-credential', { connectionId: 'entra-other-org', organizationId: OTHER_ORG }); + expect(named.status, JSON.stringify(named.body)).toBe(200); + expect((await credentialRows('entra-other-org'))[0].organization_id).toBe(OTHER_ORG); + + const unknown = await door('mint-credential', { connectionId: 'entra-nowhere', organizationId: 'org_missing' }); + expect(unknown.status).toBe(400); + expect(unknown.body.error.code).toBe('INVALID_REQUEST'); + expect(await credentialRows('entra-nowhere')).toEqual([]); + }); + + it('a malformed request is refused before anything is stored', async () => { + for (const body of [ + {}, + { connectionId: '' }, + { connectionId: ' padded ' }, + { connectionId: 42 }, + { connectionId: 'x'.repeat(256) }, + { connectionId: 'okta-bad-expiry', expiresAt: 'not-a-date' }, + { connectionId: 'okta-bad-expiry', expiresAt: new Date(Date.now() - 60_000).toISOString() }, + { connectionId: 'okta-bad-label', label: 7 }, + ]) { + const res = await door('mint-credential', body); + expect(res.status, JSON.stringify(body)).toBe(400); + expect(res.body.error.code).toBe('INVALID_REQUEST'); + } + expect(await credentialRows('okta-bad-expiry')).toEqual([]); + expect(await credentialRows('okta-bad-label')).toEqual([]); + }); + + it('a future expiry is stored and answered', async () => { + const at = new Date(Date.now() + 86_400_000).toISOString(); + const res = await door('mint-credential', { connectionId: 'okta-expiring', expiresAt: at }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.data.expiresAt).toBe(at); + expect(new Date(String((await credentialRows('okta-expiring'))[0].expires_at)).toISOString()).toBe(at); + }); + + it('refused 503 while SCIM is off, and nothing is stored', async () => { + const off = vi.spyOn(manager, 'isScimWired').mockReturnValue(false); + try { + const res = await door('mint-credential', { connectionId: 'okta-scim-off' }); + expect(res.status).toBe(503); + expect(res.body.error.code).toBe('SERVICE_UNAVAILABLE'); + expect(await credentialRows('okta-scim-off')).toEqual([]); + } finally { + off.mockRestore(); + } + }); +}); + +describe('[#30958] only the platform admin mints or revokes', () => { + it('a signed-in non-admin is refused 403 and an anonymous caller 401, on both doors, and nothing moves', async () => { + const minted = await door('mint-credential', { connectionId: 'okta-gate' }); + expect(minted.status).toBe(200); + const { credentialId, token } = minted.body.data; + + for (const [caller, status, code] of [[MEMBER, 403, 'PERMISSION_DENIED'], [null, 401, 'UNAUTHENTICATED']] as const) { + session = caller; + const mint = await door('mint-credential', { connectionId: 'okta-gate-refused' }); + expect(mint.status, JSON.stringify(mint.body)).toBe(status); + expect(mint.body.error.code).toBe(code); + expect(mint.body.data).toBeUndefined(); + const revoke = await door('revoke-credential', { credentialId }); + expect(revoke.status, JSON.stringify(revoke.body)).toBe(status); + expect(revoke.body.error.code).toBe(code); + } + session = ADMIN; + + expect(await credentialRows('okta-gate-refused')).toEqual([]); + expect(Boolean((await credentialRows('okta-gate'))[0].active)).toBe(true); + expect(await auditRows(credentialId)).toHaveLength(1); // the mint's own, nothing more + expect((await provision(token, 'gate-30958')).status).toBe(201); + }, 30_000); +}); + +describe('[#30958] revoke: the bearer is refused from the next request on; rotate = mint + revoke', () => { + it('a revoked bearer answers 401, its rotation successor still provisions, and each revoke is audited once', async () => { + const old = (await door('mint-credential', { connectionId: 'okta-rotate' })).body.data; + const next = (await door('mint-credential', { connectionId: 'okta-rotate', label: 'rotation 2' })).body.data; + expect(old.credentialId).not.toBe(next.credentialId); + expect((await provision(old.token, 'rotate-before')).status).toBe(201); + + const revoked = await door('revoke-credential', { credentialId: old.credentialId }); + expect(revoked.status, JSON.stringify(revoked.body)).toBe(200); + expect(envelopeViolations(revoked.body)).toEqual([]); + expect(revoked.body.data).toEqual({ credentialId: old.credentialId, connectionId: 'okta-rotate', active: false }); + + const refused = await provision(old.token, 'rotate-after-old'); + expect(refused.status).toBe(401); + expect(String(((await refused.json()) as { status?: unknown }).status)).toBe('401'); + expect((await provision(next.token, 'rotate-after-new')).status).toBe(201); + + // The row is kept, switched off. + const rows = await credentialRows('okta-rotate'); + expect(rows).toHaveLength(2); + expect(Boolean(rows.find((r) => r.id === old.credentialId)!.active)).toBe(false); + expect(Boolean(rows.find((r) => r.id === next.credentialId)!.active)).toBe(true); + + const audit = await auditRows(old.credentialId); + expect(audit.map((r) => r.action).sort()).toEqual(['create', 'update']); + const revokeRow = audit.find((r) => r.action === 'update')!; + expect(revokeRow).toMatchObject({ user_id: ADMIN.user.id, actor: ADMIN.user.id, object_name: SCIM_CREDENTIAL_OBJECT }); + expect(JSON.parse(String(revokeRow.metadata))).toEqual({ event: 'scim_credential_revoked', connectionId: 'okta-rotate' }); + + // Already revoked: 409, nothing written, no second audit row. + const again = await door('revoke-credential', { credentialId: old.credentialId }); + expect(again.status).toBe(409); + expect(again.body.error.code).toBe('RESOURCE_CONFLICT'); + expect(await auditRows(old.credentialId)).toHaveLength(2); + + await expectSecretNowhereElse(old.token, old.credentialId); + await expectSecretNowhereElse(next.token, next.credentialId); + }, 30_000); + + it('an unknown credential is 404, a missing id 400, and revoke serves while SCIM is off', async () => { + const unknown = await door('revoke-credential', { credentialId: 'cred_missing' }); + expect(unknown.status).toBe(404); + expect(unknown.body.error.code).toBe('RESOURCE_NOT_FOUND'); + const missing = await door('revoke-credential', {}); + expect(missing.status).toBe(400); + expect(missing.body.error.code).toBe('INVALID_REQUEST'); + + const minted = (await door('mint-credential', { connectionId: 'okta-revoke-while-off' })).body.data; + const off = vi.spyOn(manager, 'isScimWired').mockReturnValue(false); + try { + const res = await door('revoke-credential', { credentialId: minted.credentialId }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + } finally { + off.mockRestore(); + } + expect((await provision(minted.token, 'revoked-while-off')).status).toBe(401); + }, 30_000); +}); From 1c6ee7f79e0c7e49bdcbf2c1019e8342c68b229c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:01:05 +0000 Subject: [PATCH 3/8] test(dogfood): sweep and drive the SCIM credential door on the composed runtime Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- ...admin-identity-audit-trail.dogfood.test.ts | 81 +++++++++++++++++++ ...min-route-nonadmin-refusal.dogfood.test.ts | 18 +++++ 2 files changed, 99 insertions(+) diff --git a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts index 3064f4ec3d4..0b10a3abed7 100644 --- a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts @@ -256,4 +256,85 @@ describe('#4940: what an admin identity operation leaves in sys_audit_log', () = expect(metadata).toMatchObject({ total: 1, created: 1, updated: 0, skipped: 0, errors: 0 }); expect(metadata.delivery).toMatchObject({ temporary: 1 }); }, 120_000); + + // ── W5 (#30958): the SCIM credential door, on the composed runtime ────── + // + // The platform admin mints a SCIM connection bearer, an identity provider + // provisions with it, the admin revokes it and the next provisioning call is + // refused. Then the secret is looked for everywhere this stack keeps or + // serves: the bearer must appear ONLY in the mint's own answer, and its + // keyed digest only in its own column at rest — never in a `sys_audit_log` + // row (the door's explicit rows AND plugin-audit's generic ones for the + // credential writes), never in `sys_activity`, never in the data API's list + // or export of the credential object. + + it('POST /admin/scim/mint-credential + revoke-credential: the bearer provisions, is refused once revoked, and is shown nowhere else', async () => { + const minted = await stack.apiAs(adminToken, 'POST', '/auth/admin/scim/mint-credential', { + connectionId: 'okta-dogfood-30958', + }); + expect(minted.status, await minted.clone().text()).toBe(200); + expect(minted.headers.get('cache-control')).toBe('no-store'); + const { token, credentialId } = (await minted.json()).data as { token: string; credentialId: string }; + + const provision = (localPart: string) => + stack.api('/auth/scim/v2/Users', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/scim+json' }, + body: JSON.stringify({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: `${localPart}@example.com`, + name: { givenName: localPart, familyName: 'Dogfood' }, + emails: [{ value: `${localPart}@example.com`, primary: true, type: 'work' }], + active: true, + }), + }); + const created = await provision('scim-provisioned-30958'); + expect(created.status, await created.clone().text()).toBe(201); + + const revoked = await stack.apiAs(adminToken, 'POST', '/auth/admin/scim/revoke-credential', { credentialId }); + expect(revoked.status, await revoked.clone().text()).toBe(200); + const refused = await provision('scim-after-revoke-30958'); + expect(refused.status, await refused.clone().text()).toBe(401); + + // The digest as stored, read below the engine (the engine never serves it). + const driver = ql.getDriver?.('sys_scim_connection_credential'); + expect(driver, 'no driver read for the credential object').toBeTruthy(); + const atRest = (await driver.find('sys_scim_connection_credential', { where: { id: credentialId } })) as any[]; + const digest = String(atRest[0]?.token_digest ?? ''); + expect(digest.length, 'the credential row holds no digest at rest').toBeGreaterThan(20); + expect(JSON.stringify(atRest[0])).not.toContain(token); + + // The door's explicit rows: one per mint and per revoke, naming the admin and the connection. + const explicit = await waitForRows( + async () => (await findRows(ql, 'sys_audit_log', { object_name: 'sys_scim_connection_credential', record_id: credentialId })) + .filter((r) => !isGeneric(r)), + (rows) => rows.length === 2, + 'the mint and revoke rows', + ); + expect(explicit.map((r) => JSON.parse(String(r.metadata)).event).sort()) + .toEqual(['scim_credential_minted', 'scim_credential_revoked']); + for (const row of explicit) { + expect(row.user_id).toBe(adminUserId); + expect(JSON.parse(String(row.metadata)).connectionId).toBe('okta-dogfood-30958'); + } + + // Nowhere else: every ledger row, the activity feed, and the data API's reads. + const ledgers = JSON.stringify([ + await findRows(ql, 'sys_audit_log', {}, 5000), + ...(ql.getSchema?.('sys_activity') ? [await findRows(ql, 'sys_activity', {}, 5000)] : []), + ]); + expect(ledgers).not.toContain(token); + expect(ledgers).not.toContain(digest); + const list = await stack.apiAs(adminToken, 'GET', '/data/sys_scim_connection_credential'); + const listed = await list.text(); + expect(list.status, listed).toBe(200); + expect(listed).toContain(credentialId); + const exported = await stack.apiAs(adminToken, 'GET', '/data/sys_scim_connection_credential/export'); + const exportedText = await exported.text(); + expect(exported.status, exportedText.slice(0, 300)).toBe(200); + for (const served of [listed, exportedText]) { + expect(served).not.toContain(token); + expect(served).not.toContain(digest); + } + }, 120_000); }); diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts index 9bd4194e091..7d4863de18e 100644 --- a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -283,6 +283,24 @@ function expectationsFor(targetUserId: string): Record bucket: 'objectstack-gate', note: 'a read: the gate runs first and the handler reads no body and no query parameter; the admin gets 200 { clientAddress, trustProxy }', }, + // ── #30958 — the SCIM connection-credential door (mint + revoke) ──────── + // + // Both mounts run `gateAdmin(c)` as their first statement, ahead of any body + // read, so anon 401 / member 403 hold on any body. The admin side on these + // payloads: SCIM is on in this boot (OS_SCIM_ENABLED, beforeAll), so the + // mint answers 200 with a fresh credential for a probe connection nothing + // provisions through (the sweep's own stack, discarded at teardown), and + // the revoke lands on 404 RESOURCE_NOT_FOUND for the unknown credential. + 'POST /api/v1/auth/admin/scim/mint-credential': { + bucket: 'objectstack-gate', + body: { connectionId: 'refusal-probe-scim' }, + note: 'the gate runs ahead of any body read; the admin gets 200 and a credential for the probe connection, since SCIM is on in this boot', + }, + 'POST /api/v1/auth/admin/scim/revoke-credential': { + bucket: 'objectstack-gate', + body: { credentialId: 'refusal-probe-credential' }, + note: 'the gate runs ahead of any body read; the admin passes it and lands on 404 RESOURCE_NOT_FOUND for the unknown credential', + }, // ── #9653: the /admin/sso/* bridges, gated ahead of their delegation ──── // // The ADR-0068 gate runs BEFORE the bridge re-dispatches into better-auth, From 84390e39013995f17241a63e5080df5a04fad646 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:01:50 +0000 Subject: [PATCH 4/8] docs(plugin-auth): SCIM connection credentials, and the changeset Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../30958-scim-connection-credential-door.md | 16 +++++++++ content/docs/permissions/authentication.mdx | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .changeset/30958-scim-connection-credential-door.md diff --git a/.changeset/30958-scim-connection-credential-door.md b/.changeset/30958-scim-connection-credential-door.md new file mode 100644 index 00000000000..bbc31d15e3a --- /dev/null +++ b/.changeset/30958-scim-connection-credential-door.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-auth': patch +--- + +feat(plugin-auth): a platform administrator can mint and revoke SCIM connection credentials + +Clause-②: yes (widening) +Metadata-protocol: none +Skills-docs: updated here — `content/docs/permissions/authentication.mdx` ("SCIM Connection Credentials" under Admin User Management); `skills/**` teaches no SCIM setup + +An identity provider (Okta, Entra ID) provisions users through `/api/v1/auth/scim/v2/*` with a bearer credential that this platform issues, but nothing could issue one: the mint existed with no caller, and the credential object refuses create on the data API, so no identity provider could connect. Two routes now do it, for the platform administrator only (the shared ADR-0068 gate: anyone else is refused `403 PERMISSION_DENIED`, `401 UNAUTHENTICATED` when signed out): + +- `POST /api/v1/auth/admin/scim/mint-credential` with `{ connectionId, label?, expiresAt?, provisioningDomainId?, organizationId? }` answers `{ credentialId, connectionId, token, expiresAt }`. The `token` is shown once, in that response only (`Cache-Control: no-store`); only its keyed one-way digest is stored, and it is not readable through the data API, the audit log or the logs. Refused `503 SERVICE_UNAVAILABLE` while SCIM is off. The credential row is owned by `organizationId` when given (it must exist), otherwise by the Default Organization. +- `POST /api/v1/auth/admin/scim/revoke-credential` with `{ credentialId }` switches the credential off, so the identity provider's next SCIM request with it answers `401`. `404 RESOURCE_NOT_FOUND` for an unknown credential, `409 RESOURCE_CONFLICT` for one already revoked. Served whether or not SCIM is on. + +Rotation is a second mint for the same `connectionId`, moving the identity provider to the new bearer, then revoking the old one. Each mint and each revoke writes one `sys_audit_log` row naming the administrator and the connection, never the credential. Nothing that existed changes: no route, response or stored row moves, and the credential object stays read-only on the data API. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index d860c9f363e..acc217dc4f4 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -1166,6 +1166,42 @@ rows that resolve to a temporary password (all of them under `temporary`, only the unreachable ones under `auto`) — reveals the generated passwords once with a download, splitting files above 500 rows into batches automatically. +#### SCIM Connection Credentials + +With SCIM enabled (`OS_SCIM_ENABLED=true`, or `plugins.scim`), an identity +provider such as Okta or Entra ID provisions users through +`/api/v1/auth/scim/v2/*` with a bearer credential the platform issues. Only a +platform administrator mints or revokes one, through the same gate as the +routes above; anyone else is refused `403 PERMISSION_DENIED` (`401` when +signed out). + +```bash +curl -X POST http://localhost:3000/api/v1/auth/admin/scim/mint-credential \ + -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_SESSION" \ + -d '{ "connectionId": "okta-prod", "label": "rotation 2026-Q4" }' +# → { success, data: { credentialId, connectionId, token, expiresAt } } +``` + +- `connectionId` (required) names the connection every provisioned resource + is scoped to. Optional: `label`, `expiresAt` (a future ISO date-time), + `provisioningDomainId`, and `organizationId` (the organization that owns the + credential row; the Default Organization when omitted). +- The `token` is shown **once**, in this response only (`Cache-Control: + no-store`). Only a keyed one-way digest of it is stored; it is not readable + through the data API, the audit log or the logs afterwards. Paste it into the + identity provider's SCIM settings as the bearer token. +- While SCIM is off the mint is refused `503 SERVICE_UNAVAILABLE`: a credential + nothing can verify is not issued. + +`POST /api/v1/auth/admin/scim/revoke-credential` with `{ "credentialId": "…" }` +switches that credential off: the identity provider's next SCIM request with it +is refused `401`. An unknown credential answers `404 RESOURCE_NOT_FOUND`, one +already revoked `409 RESOURCE_CONFLICT`. Revocation works whether or not SCIM is +on. To **rotate**, mint a second credential for the same `connectionId`, move +the identity provider to it, then revoke the old one; both authenticate until +you do. Each mint and each revoke writes one `sys_audit_log` row naming the +administrator and the connection, never the credential. + --- ## Client Integration From ac688f409b455a99dcd4b24726648d5bd5461334 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:10:02 +0000 Subject: [PATCH 5/8] test(dogfood): pin the SCIM credential export refusal the composed runtime answers Measured on the composed runtime: the export door answers 403 EXPORT_NOT_PERMITTED for the platform admin on sys_scim_connection_credential, so the pin asserts that refusal and that its body names neither the bearer nor its digest. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../dogfood/test/admin-identity-audit-trail.dogfood.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts index 0b10a3abed7..d8b5a29c003 100644 --- a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts @@ -329,9 +329,13 @@ describe('#4940: what an admin identity operation leaves in sys_audit_log', () = const listed = await list.text(); expect(list.status, listed).toBe(200); expect(listed).toContain(credentialId); + // Export is refused outright, even to the platform admin: no permission set + // grants `allowExport` on this engine-owned object (measured 403). Pinned so a + // future grant has to come back here and show the export serves no secret. const exported = await stack.apiAs(adminToken, 'GET', '/data/sys_scim_connection_credential/export'); const exportedText = await exported.text(); - expect(exported.status, exportedText.slice(0, 300)).toBe(200); + expect(exported.status, exportedText.slice(0, 300)).toBe(403); + expect(JSON.parse(exportedText).code).toBe('EXPORT_NOT_PERMITTED'); for (const served of [listed, exportedText]) { expect(served).not.toContain(token); expect(served).not.toContain(digest); From c8e10b4f9f242bb4d868ee08043005ec07cd69fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:29:06 +0000 Subject: [PATCH 6/8] fix(plugin-auth): the SCIM credential writes spell their system context, and the census counts them The door's audit insert and the revoke update now pass their system context as a readable literal, so the tenant-audit census reads both as decidably elevated instead of unreadable or context-free. The census artefacts are regenerated for the two new write call sites (261 -> 263) and the page's hand-written figures follow. The test file's log reader gets the parameter type tsconfig.test.json asks for. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../docs/permissions/tenant-audit-census.mdx | 40 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 22 +++++----- .../src/scim-connection-admin.test.ts | 4 +- .../plugin-auth/src/scim-connection-admin.ts | 2 +- .../src/scim-connection-service.ts | 6 ++- 5 files changed, 39 insertions(+), 35 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index ff22ac4cded..693db884367 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A +forwarding shim cannot, and **55 of the 263 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **261** | +| 175 write call sites | quoted in the merged changeset | **263** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **184 of 261** decidable, **77** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 144 decidably elevated, 0 decidably not, 109 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **186 of 263** decidable, **77** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 109 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -211,17 +211,17 @@ at any commit. Two structural facts do plausibly widen this reading against any hand or regex one, and both are counted in the generated tables below: the 62 sites reached -through an erased (`any`) receiver, and the 68 that name their object through a +through an erased (`any`) receiver, and the 69 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -144 of 261 (55%) as decidably elevated, with 109 more whose elevation is a +146 of 263 (56%) as decidably elevated, with 109 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 263`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,29 +233,29 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **261** | -| …whose object name is statically decidable | 184 | +| write call sites on the application surface | **263** | +| …whose object name is statically decidable | 186 | | …whose object name is chosen at run time | 77 | -| …against an object with tenancy ENABLED | 178 | +| …against an object with tenancy ENABLED | 180 | | …against an object that declares tenancy off | 6 | -| threading a tenant context | 198 | +| threading a tenant context | 200 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 55 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 144 | +| threading a decidably ELEVATED (`isSystem`) context | 146 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 109 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 199 | +| receiver carried a readable engine type | 201 | | receiver erased, placed by the object NAME | 52 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 116 | -| object name spelled through a `const` | 68 | +| object name spelled inline | 117 | +| object name spelled through a `const` | 69 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 70 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `ac688f409`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1037 | +| engine-shaped types recognised | 111 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 230 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index d05ca1acbbb..8f398e6f641 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 261 | -| Object name statically decidable | 184 | +| Write call sites | 263 | +| Object name statically decidable | 186 | | Object name chosen at run time | 77 | -| Against a tenancy-enabled object | 178 | +| Against a tenancy-enabled object | 180 | | Against an object declaring tenancy off | 6 | -| Threading a tenant context | 198 | +| Threading a tenant context | 200 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 55 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 144 | +| Threading a decidably elevated context | 146 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 109 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `ac688f409`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1037 | +| engine-shaped types recognised | 111 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 230 | ## Every site @@ -166,7 +166,9 @@ Measured on 2026-11-05 at `e40e04854`. | `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team_member` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_user_permission_set` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-auth/src/scim-connection-admin.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 | +| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `update` | `sys_scim_connection_credential` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 | | `packages/plugins/plugin-auth/src/sys-session-revoke.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 | diff --git a/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts b/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts index a7485d8e057..6b277cba08f 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts @@ -182,11 +182,11 @@ async function auditRows(credentialId: string): Promise fn.mock.calls), ...consoleSpies.flatMap((spy) => spy.mock.calls), ]; - return calls.map((args) => args.map((a) => (a instanceof Error ? `${a.message} ${a.stack}` : typeof a === 'string' ? a : JSON.stringify(a))).join(' ')).join('\n'); + return calls.map((args) => args.map((a: unknown) => (a instanceof Error ? `${a.message} ${a.stack}` : typeof a === 'string' ? a : JSON.stringify(a))).join(' ')).join('\n'); } /** The bearer and its digest appear in no audit row and no log line. */ diff --git a/packages/plugins/plugin-auth/src/scim-connection-admin.ts b/packages/plugins/plugin-auth/src/scim-connection-admin.ts index e6a77029702..86099885438 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-admin.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-admin.ts @@ -215,7 +215,7 @@ async function writeCredentialAudit( record_id: entry.credentialId, metadata: JSON.stringify(entry.metadata), }, - { context: SYSTEM_CTX } as never, + { context: SYSTEM_CTX }, ); } catch (error) { // The operation already happened (a minted bearer is already in the diff --git a/packages/plugins/plugin-auth/src/scim-connection-service.ts b/packages/plugins/plugin-auth/src/scim-connection-service.ts index b318b7b452e..026ec6e9e99 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-service.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-service.ts @@ -249,7 +249,7 @@ interface RevocationEngine { query: { where: Record; fields?: string[] }, options?: { context?: { isSystem?: boolean } }, ): Promise | null>; - update(object: string, data: Record): Promise; + update(object: string, data: Record, options?: { context?: { isSystem?: boolean } }): Promise; } /** What {@link revokeScimConnectionCredential} found and did. */ @@ -281,6 +281,8 @@ export async function revokeScimConnectionCredential( // Read `active` the way the verifier does: anything not affirmatively active is off. const active = (row as { active?: unknown }).active; if (active === false || active === 0 || active == null) return { outcome: 'already_revoked', credentialId: id, connectionId }; - await engine.update(SCIM_CREDENTIAL_OBJECT, { id, active: false }); + // The system context is spelled at the call, not only inherited from the + // caller's wrapped engine, so the write says what it is wherever it is read. + await engine.update(SCIM_CREDENTIAL_OBJECT, { id, active: false }, { context: { isSystem: true } }); return { outcome: 'revoked', credentialId: id, connectionId }; } From 677f474461d8885d95117cf5fba49cabfec6731d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:08:36 +0000 Subject: [PATCH 7/8] fix(plugin-auth): a SCIM connection id is at most 246 characters, leaving room for its audit actor A SCIM connection's own writes are audited as `svc:scim:` (#30960) in sys_audit_log.actor, which holds 255 characters. The mint door now refuses a longer id with the existing 400 INVALID_REQUEST, so a connection can never be minted whose every audit insert would fail. Pin: 246 characters mint, 247 are refused and nothing is stored. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../30958-scim-connection-credential-door.md | 2 +- content/docs/permissions/authentication.mdx | 3 ++- .../src/scim-connection-admin.test.ts | 20 ++++++++++++++++++- .../plugin-auth/src/scim-connection-admin.ts | 13 ++++++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.changeset/30958-scim-connection-credential-door.md b/.changeset/30958-scim-connection-credential-door.md index bbc31d15e3a..d0174f757fd 100644 --- a/.changeset/30958-scim-connection-credential-door.md +++ b/.changeset/30958-scim-connection-credential-door.md @@ -10,7 +10,7 @@ Skills-docs: updated here — `content/docs/permissions/authentication.mdx` ("SC An identity provider (Okta, Entra ID) provisions users through `/api/v1/auth/scim/v2/*` with a bearer credential that this platform issues, but nothing could issue one: the mint existed with no caller, and the credential object refuses create on the data API, so no identity provider could connect. Two routes now do it, for the platform administrator only (the shared ADR-0068 gate: anyone else is refused `403 PERMISSION_DENIED`, `401 UNAUTHENTICATED` when signed out): -- `POST /api/v1/auth/admin/scim/mint-credential` with `{ connectionId, label?, expiresAt?, provisioningDomainId?, organizationId? }` answers `{ credentialId, connectionId, token, expiresAt }`. The `token` is shown once, in that response only (`Cache-Control: no-store`); only its keyed one-way digest is stored, and it is not readable through the data API, the audit log or the logs. Refused `503 SERVICE_UNAVAILABLE` while SCIM is off. The credential row is owned by `organizationId` when given (it must exist), otherwise by the Default Organization. +- `POST /api/v1/auth/admin/scim/mint-credential` with `{ connectionId, label?, expiresAt?, provisioningDomainId?, organizationId? }` answers `{ credentialId, connectionId, token, expiresAt }`. `connectionId` is at most 246 characters, so the connection's audit actor `svc:scim:` fits `sys_audit_log.actor`. The `token` is shown once, in that response only (`Cache-Control: no-store`); only its keyed one-way digest is stored, and it is not readable through the data API, the audit log or the logs. Refused `503 SERVICE_UNAVAILABLE` while SCIM is off. The credential row is owned by `organizationId` when given (it must exist), otherwise by the Default Organization. - `POST /api/v1/auth/admin/scim/revoke-credential` with `{ credentialId }` switches the credential off, so the identity provider's next SCIM request with it answers `401`. `404 RESOURCE_NOT_FOUND` for an unknown credential, `409 RESOURCE_CONFLICT` for one already revoked. Served whether or not SCIM is on. Rotation is a second mint for the same `connectionId`, moving the identity provider to the new bearer, then revoking the old one. Each mint and each revoke writes one `sys_audit_log` row naming the administrator and the connection, never the credential. Nothing that existed changes: no route, response or stored row moves, and the credential object stays read-only on the data API. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index acc217dc4f4..ebbadfa9fb8 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -1183,7 +1183,8 @@ curl -X POST http://localhost:3000/api/v1/auth/admin/scim/mint-credential \ ``` - `connectionId` (required) names the connection every provisioned resource - is scoped to. Optional: `label`, `expiresAt` (a future ISO date-time), + is scoped to: at most 246 characters, so the connection's audit actor + `svc:scim:` fits the audit log's 255. Optional: `label`, `expiresAt` (a future ISO date-time), `provisioningDomainId`, and `organizationId` (the organization that owns the credential row; the Default Organization when omitted). - The `token` is shown **once**, in this response only (`Cache-Control: diff --git a/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts b/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts index 6b277cba08f..82694209a53 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-admin.test.ts @@ -264,7 +264,7 @@ describe('[#30958] mint: the platform admin gets a bearer the SCIM plugin accept { connectionId: '' }, { connectionId: ' padded ' }, { connectionId: 42 }, - { connectionId: 'x'.repeat(256) }, + { connectionId: 'x'.repeat(247) }, { connectionId: 'okta-bad-expiry', expiresAt: 'not-a-date' }, { connectionId: 'okta-bad-expiry', expiresAt: new Date(Date.now() - 60_000).toISOString() }, { connectionId: 'okta-bad-label', label: 7 }, @@ -277,6 +277,24 @@ describe('[#30958] mint: the platform admin gets a bearer the SCIM plugin accept expect(await credentialRows('okta-bad-label')).toEqual([]); }); + it('a connection id leaves room for the svc:scim: audit actor: 246 characters mint, 247 are refused', async () => { + // A SCIM connection's own writes are audited as `svc:scim:` + // (#30960) in `sys_audit_log.actor`, which holds 255 characters. + expect('svc:scim:'.length + 246).toBe(255); + const longest = 'c'.repeat(246); + const minted = await door('mint-credential', { connectionId: longest }); + expect(minted.status, JSON.stringify(minted.body)).toBe(200); + expect(minted.body.data.connectionId).toBe(longest); + expect(await credentialRows(longest)).toHaveLength(1); + + const tooLong = 'c'.repeat(247); + const refused = await door('mint-credential', { connectionId: tooLong }); + expect(refused.status).toBe(400); + expect(refused.body.error.code).toBe('INVALID_REQUEST'); + expect(refused.body.error.message).toContain('at most 246 characters'); + expect(await credentialRows(tooLong)).toEqual([]); + }); + it('a future expiry is stored and answered', async () => { const at = new Date(Date.now() + 86_400_000).toISOString(); const res = await door('mint-credential', { connectionId: 'okta-expiring', expiresAt: at }); diff --git a/packages/plugins/plugin-auth/src/scim-connection-admin.ts b/packages/plugins/plugin-auth/src/scim-connection-admin.ts index 86099885438..1c3797c0c7c 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-admin.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-admin.ts @@ -78,6 +78,15 @@ export const SCIM_REVOKE_CREDENTIAL_PATH = '/admin/scim/revoke-credential'; /** The longest value the credential row's text columns store. */ const MAX_TEXT = 255; +/** + * The longest connection id the door accepts: 246, not {@link MAX_TEXT}. + * A write made by a SCIM connection is audited with the service actor + * `svc:scim:` (#30960), and `sys_audit_log.actor` holds 255 + * characters, so a longer id would make that audit insert fail for every + * request the connection makes. 255 minus the 9-character prefix. + */ +const MAX_SCIM_CONNECTION_ID = MAX_TEXT - 'svc:scim:'.length; + /** Execution context for the store reads and the audit write. */ const SYSTEM_CTX = { isSystem: true } as const; @@ -248,11 +257,11 @@ export async function runMintScimCredential( const body = reading.body; const connectionId = body.connectionId; - if (typeof connectionId !== 'string' || connectionId.length === 0 || connectionId.trim() !== connectionId || connectionId.length > MAX_TEXT) { + if (typeof connectionId !== 'string' || connectionId.length === 0 || connectionId.trim() !== connectionId || connectionId.length > MAX_SCIM_CONNECTION_ID) { return fail( 400, 'INVALID_REQUEST', - `connectionId is required: the connection's identifier (e.g. "okta-prod"), a non-empty string of at most ${MAX_TEXT} characters with no surrounding whitespace`, + `connectionId is required: the connection's identifier (e.g. "okta-prod"), a non-empty string of at most ${MAX_SCIM_CONNECTION_ID} characters with no surrounding whitespace`, ); } const optional: Record<'label' | 'provisioningDomainId' | 'organizationId', string | undefined> = { From 79a87832eed77067bdd4e2ac3141d3ad38a52785 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:03:32 +0000 Subject: [PATCH 8/8] test(dogfood): the platform-admin /admin/ sweep admits the two SCIM credential mounts The full dogfood suite's platform-admin standing sweep derives every served /admin/ route and requires each to be classified. The SCIM mint and revoke mounts carry the ADR-0068 gate first, so they join ADMITTED with probe payloads that reach the handler: SCIM is on at this boot, so the mint answers 200, and the revoke of an unknown id answers 404. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude --- .../admin-platform-admin-standing.dogfood.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index c8cf384bce5..e22ad85ae5b 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -249,6 +249,14 @@ const ADMITTED = [ 'POST /api/v1/auth/admin/sso/register-saml', 'POST /api/v1/auth/admin/sso/request-domain-verification', 'POST /api/v1/auth/admin/sso/verify-domain', + // #30958 — the SCIM connection credential door: ObjectStack raw mounts whose + // FIRST statement is the ADR-0068 gate. SCIM is on at this boot + // (`OS_SCIM_ENABLED`), so the mint reaches a 200 and stores a credential; + // the revoke of an id that does not exist reaches the handler and answers + // 404. Their stored effects (the bearer provisions, is refused once revoked, + // and is audited) are pinned in `admin-identity-audit-trail.dogfood.test.ts`. + 'POST /api/v1/auth/admin/scim/mint-credential', + 'POST /api/v1/auth/admin/scim/revoke-credential', ] as const; /** @@ -731,6 +739,10 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout }, 'POST /api/v1/auth/admin/sso/request-domain-verification': { body: { providerId: 'standing-probe-oidc' } }, 'POST /api/v1/auth/admin/sso/verify-domain': { body: { providerId: 'standing-probe-oidc' } }, + // #30958. A connection id is the only required field; the revoke names a + // credential id that does not exist, so it moves nothing. + 'POST /api/v1/auth/admin/scim/mint-credential': { body: { connectionId: 'standing-probe-scim' } }, + 'POST /api/v1/auth/admin/scim/revoke-credential': { body: { credentialId: 'standing-probe-credential' } }, 'POST /api/v1/auth/admin/ban-user': { body: { userId: targetUserId, banReason: 'sweep' } }, 'POST /api/v1/auth/admin/unban-user': { body: { userId: targetUserId } }, 'POST /api/v1/auth/admin/impersonate-user': { body: { userId: targetUserId } },