From 0c304d7d6e67acbe7b5f81cb4069abc2fa92fbff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:58:34 +0000 Subject: [PATCH 1/3] fix(core,cloud-connection,mcp,service-storage)!: every key-admitting seam names its owner-posture source (#23455, #23456) [WIP] Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- ...atalog-echo-env-kernel.integration.test.ts | 1 + .../src/marketplace-install-local-plugin.ts | 83 ++++++++----- .../security/admin-standing-surface.test.ts | 3 + .../security/api-key-account-posture.test.ts | 53 ++++++++- packages/core/src/security/api-key.test.ts | 40 +++---- packages/core/src/security/api-key.ts | 66 ++++++++--- .../security/authz-store-unavailable.test.ts | 23 ++-- ...resolve-authz-context.guest-grants.test.ts | 2 +- ...uthz-context.platform-admin-config.test.ts | 2 + ...-authz-context.request-grants-memo.test.ts | 60 ++++++---- .../security/resolve-authz-context.test.ts | 101 ++++++++++------ .../src/security/resolve-authz-context.ts | 18 ++- packages/mcp/src/plugin.ts | 112 ++++++++++++++++-- .../src/session-grants-resolved-once.test.ts | 1 + .../src/explain-enforce-parity.test.ts | 1 + .../explain-removed-member-principal.test.ts | 1 + .../src/exec-context-seam.testkit.ts | 3 + packages/qa/dogfood/test/armed.ts | 5 +- ...ts-parent-rls-count-parity.dogfood.test.ts | 1 + ...ments-unscoped-delete-gate.dogfood.test.ts | 2 + ...audit-log-audit-capability.dogfood.test.ts | 1 + .../authored-row-write-scope.dogfood.test.ts | 1 + .../test/bulk-widener-probe.dogfood.test.ts | 1 + ...t-attachment-comment-gates.dogfood.test.ts | 1 + .../test/delegation-of-duty.dogfood.test.ts | 2 + ...erated-phantom-share-grant.dogfood.test.ts | 1 + ...organization-write-refusal.dogfood.test.ts | 1 + ...lic-read-write-write-floor.dogfood.test.ts | 1 + ...ner-anchor-and-bulk-writes.dogfood.test.ts | 1 + ...ite-unreadable-not-matched.dogfood.test.ts | 1 + ...cctx-authz-input-seam-reachability.test.ts | 2 + .../src/meta-type-read-capability.test.ts | 1 + .../src/meta-type-write-capability.test.ts | 1 + .../remote-tables-twin.equivalence.test.ts | 1 + .../src/storage-service-plugin.ts | 48 ++++++-- 35 files changed, 471 insertions(+), 171 deletions(-) diff --git a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts index 9213ccaea82..b544ea7c4e9 100644 --- a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -98,6 +98,7 @@ async function boot(kind: Kind, dbFile: string, rootDir: string, app: any): Prom /** Read `echo_doc` as `userId`, with the context the request path builds. */ async function readAs(ql: any, userId: string, org: string): Promise<'granted' | 'refused'> { const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: () => ({ user: { id: userId }, session: { userId, activeOrganizationId: org } }), diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 9951d96475d..57fb34f24f1 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -113,9 +113,13 @@ import { resolveAuthzContext, isAuthzStoreUnavailableError, // ADR-0069 — the authentication-policy gate's ONE decision, asked of the - // session `resolveInstallPrincipal` reads (see `refuseHeldSession`). + // posture `resolveInstallPrincipal`'s shared resolver decided (see + // `refuseHeldSession`). evaluateAuthGate, type AuthGate, + // [#23455] The auth service's per-user posture computation, bound for the + // shared key-admission seam. + userAuthGateSourceOf, // [#16013] The ADMISSION seam's posture derivation, in ONE call — the same // symbol `packages/rest`'s seam now imports, for the same reason. It reads // the posture IN FORCE off the kernel's `tenancy` service and carries @@ -176,7 +180,7 @@ import { import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; -import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { IAuthService, IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { DeletePackageRequest, UninstallCleanupOutcome, @@ -536,23 +540,26 @@ interface InstallPrincipal { /** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */ tenancyPosture?: TenancyPosture; /** - * ADR-0069: the authentication-policy posture of the session this - * admission resolved, present only for a session the policy holds. - * `refuseHeldSession` refuses on it. + * ADR-0069: the authentication-policy posture of the principal this + * admission resolved, present only for one the policy holds — a held + * session, or (#23455, ruling C′ item 1) an API key whose owner holds an + * `account`-scope posture. `refuseHeldSession` refuses on it. */ authGate?: AuthGate; } /** - * ADR-0069: the refusal every install-local door gives a session the - * authentication policy HOLDS (an expired password, enforced MFA not yet - * enrolled), right after the 401 and before the caller's capabilities are - * consulted or the ledger is read. Such a session is gated out of every - * protected resource until it remediates, so it gets REST's answer: `403` - * with the gate's own `code` and `message`, in this plugin's envelope. The - * consult itself happens once, in `resolveInstallPrincipal`; this only - * answers on it, in one place, so a door added later cannot copy a weaker - * answer. `null` when the session is not held. + * ADR-0069: the refusal every install-local door gives a principal the + * authentication policy HOLDS (a session with an expired password, enforced + * MFA not yet enrolled; an API key whose owner owes that enrolment), right + * after the 401 and before the caller's capabilities are consulted or the + * ledger is read. Such a principal is gated out of every protected resource + * until it remediates, so it gets REST's answer: `403` with the gate's own + * `code` and `message`, in this plugin's envelope. The consult itself happens + * once, in `resolveInstallPrincipal`; this only answers on it, in one place, + * so a door added later cannot copy a weaker answer. Nothing is written, so a + * held key is served again the moment its owner remediates (ruling C′ item + * 4). `null` when the principal is not held. */ function refuseHeldSession(c: any, principal: InstallPrincipal): Response | null { if (!principal.authGate) return null; @@ -3319,13 +3326,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { let ql: IObjectQLEngine | undefined; try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } - // The session user the bridge below read, kept so the ADR-0069 gate - // is asked about the SAME session the resolver admitted: one read, - // both decisions. The resolver calls the bridge only when no API - // key admitted the caller, so a key caller leaves it unset and no - // policy holds it. - let sessionUser: unknown; - // The better-auth session bridge — resolved lazily and defensively, // exactly as the previous implementation did, then handed to the // shared resolver instead of being trusted on its own. @@ -3338,9 +3338,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } // [#22258] The in-process session-read rule, as in // `resolveActiveOrgId` above. - const session = await api?.getSession?.(inProcessSessionReadInput(h)); - sessionUser = session?.user; - return session; + return await api?.getSession?.(inProcessSessionReadInput(h)); } catch { return undefined; } @@ -3350,15 +3348,38 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // THIS door before the resolver runs. Omitting it is what left // both posture-conditional API-key refusals unreachable here. const tenancyPosture = await this.resolveAdmissionTenancyPosture(ctx); - const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); + // [#23455 · ruling C′ item 3] The auth service's per-user posture + // computation, for the shared key-admission seam: a key whose OWNER + // holds an `account`-scope posture resolves as a held principal + // carrying it (`authz.authGate`), exactly as it does on REST. Read + // per request like the two above; a lookup that faults is a kernel + // with no posture to ask. + let authService: IAuthService | undefined; + try { + authService = ctx.getService('auth'); + } catch { + authService = undefined; + } + const authz = await resolveAuthzContext({ + ql, + headers, + getSession, + tenancyPosture, + userAuthGates: userAuthGateSourceOf(authService), + }); if (!authz.userId) return null; // ADR-0069: the ONE consult of the authentication-policy gate for - // all five doors, on the session read above (the posture rides its - // user, computed by plugin-auth's `customSession` enrichment only - // while a gate feature is active). No request path is passed, so - // the allow-list can grant no exemption (an empty path earns none, - // #7898); no install-local route is a remediation or bootstrap route. - const authGate = evaluateAuthGate(sessionUser, ''); + // all five doors. [#23455] It asks the shared resolver's posture + // (`authz.authGate`), decided for whichever credential admitted the + // caller: a held session's own, or the account-scope posture of an + // API key's owner. It used to ask the session user alone, so a key + // never carried a posture here and a held owner's key was served. + // A key whose owner holds only a `credential`-scope posture + // (password expiry, a forced reset) carries none, and is served. No + // request path is passed, so the allow-list can grant no exemption + // (an empty path earns none, #7898); no install-local route is a + // remediation or bootstrap route. + const authGate = evaluateAuthGate(authz, ''); return { userId: String(authz.userId), systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [], diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index c4459c44ca8..f23bb23581a 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -319,6 +319,7 @@ async function observe(variant: keyof typeof VARIANTS): Promise { const { tables, org, platformAdminEmails } = VARIANTS[variant]!; await withPlatformAdminEmails(platformAdminEmails, () => resolveAuthzContext({ + userAuthGates: undefined, ql: makeRecordingQl(tables, seen), headers: headers(), getSession: sessionFor('usr_1', org), @@ -366,6 +367,7 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re it('reaches the platform-admin derivation — otherwise the observation proves nothing', async () => { const ctx = await withPlatformAdminEmails(undefined, () => resolveAuthzContext({ + userAuthGates: undefined, ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()), headers: headers(), getSession: sessionFor('usr_1', 'org_1'), @@ -399,6 +401,7 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re const v = VARIANTS['config-anchored platform admin']!; const ctx = await withPlatformAdminEmails(v.platformAdminEmails, () => resolveAuthzContext({ + userAuthGates: undefined, ql: makeRecordingQl(v.tables, new Map()), headers: headers(), getSession: sessionFor('usr_1', v.org), diff --git a/packages/core/src/security/api-key-account-posture.test.ts b/packages/core/src/security/api-key-account-posture.test.ts index aa84bee2578..ec4380244fb 100644 --- a/packages/core/src/security/api-key-account-posture.test.ts +++ b/packages/core/src/security/api-key-account-posture.test.ts @@ -20,12 +20,16 @@ * - a posture computation that rejects is the branded outage, never an * admission; * - the tenancy refusal still answers first, as the generic 401; - * - the key's own organization is what the owner is asked about. + * - the key's own organization is what the owner is asked about; + * - [#23455] `resolveApiKeyPrincipal` never answers a held owner's key with + * a principal; + * - [#23455] the posture source is a REQUIRED input of all three entries, so + * a seam that forgets it does not compile (compile-level pins below). */ import { describe, it, expect, vi } from 'vitest'; import { resolveAuthzContext } from './resolve-authz-context.js'; -import { hashApiKey, resolveApiKeyAdmission } from './api-key.js'; +import { hashApiKey, resolveApiKeyAdmission, resolveApiKeyPrincipal } from './api-key.js'; import { accountScopeAuthGate, authGateScope, @@ -170,9 +174,49 @@ describe('resolveApiKeyAdmission — the held arm (ruling C′ item 3)', () => { expect(err.code).toBe('SERVICE_UNAVAILABLE'); }); - it('without a posture source the owner is not asked (an unwired caller keeps the old answer)', async () => { + it('an explicit `undefined` source (a kernel with no auth service) leaves the owner unasked', async () => { const { ql } = makeQl(); - expect((await resolveApiKeyAdmission(ql, keyHeaders(), Date.now())).outcome).toBe('admitted'); + expect((await resolveApiKeyAdmission(ql, keyHeaders(), Date.now(), undefined, undefined)).outcome).toBe('admitted'); + }); +}); + +describe('[#23455] resolveApiKeyPrincipal — never a principal for a held owner\'s key', () => { + it('collapses `held` to no principal, and still admits a key whose owner is not held', async () => { + const { ql } = makeQl(); + const { table, source } = postureSource({ [OWNER]: [MFA_REQUIRED] }); + expect(await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source)).toBeUndefined(); + // A credential-scope posture holds password sign-in alone. + table[OWNER] = [PASSWORD_RESET_REQUIRED]; + expect((await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source))?.userId).toBe(OWNER); + // Not a revocation: the posture clears and the same key answers again. + table[OWNER] = []; + expect((await resolveApiKeyPrincipal(ql, keyHeaders(), Date.now(), undefined, source))?.userId).toBe(OWNER); + }); +}); + +describe('[#23455] the posture source is a REQUIRED input at every key-admitting entry', () => { + /** + * Compile-level pins. `compileOnly` is never invoked: its lines are read by + * the package's test tsc program (`check:test-typecheck`), where an + * `@ts-expect-error` that stops erroring is itself an error (TS2578). So a + * change that makes the input optional again turns that gate red here, + * naming this file — the omission compiling silently is the defect. + */ + it('omitting it does not compile; an explicit `undefined` does', () => { + const compileOnly = (ql: unknown, headers: Headers): void => { + // @ts-expect-error — `userAuthGates` is a required key of `ResolveAuthzInput`. + void resolveAuthzContext({ ql, headers }); + // @ts-expect-error — `tenancyPosture` and `userAuthGates` are required parameters. + void resolveApiKeyAdmission(ql, headers, Date.now()); + // @ts-expect-error — `userAuthGates` is a required parameter. + void resolveApiKeyAdmission(ql, headers, Date.now(), undefined); + // @ts-expect-error — `userAuthGates` is a required parameter. + void resolveApiKeyPrincipal(ql, headers, Date.now(), undefined); + void resolveAuthzContext({ ql, headers, userAuthGates: undefined }); + void resolveApiKeyAdmission(ql, headers, Date.now(), undefined, undefined); + void resolveApiKeyPrincipal(ql, headers, Date.now(), undefined, undefined); + }; + expect(typeof compileOnly).toBe('function'); }); }); @@ -244,6 +288,7 @@ describe('resolveAuthzContext — a held key is a held principal, judged by the it('the session arm carries the session\'s own posture, every scope included', async () => { const { ql } = makeQl(); const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ cookie: 'session=held' }), getSession: async () => ({ user: { id: COOKIE_USER, authGate: PASSWORD_EXPIRED } }), diff --git a/packages/core/src/security/api-key.test.ts b/packages/core/src/security/api-key.test.ts index 5ebcb905080..4768287be31 100644 --- a/packages/core/src/security/api-key.test.ts +++ b/packages/core/src/security/api-key.test.ts @@ -62,7 +62,7 @@ describe('core api-key primitives', () => { it('resolveApiKeyPrincipal resolves a Bearer osk_ key (remote MCP client path)', async () => { const raw = 'osk_' + 'b'.repeat(24); const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` }); + const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` }, undefined, undefined, undefined); expect(p?.userId).toBe('u1'); }); @@ -84,36 +84,36 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => { // value the mint path really produces. { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org1', scopes: '["read"]', expires_at: FUTURE }, ]); - const p = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }); + const p = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, undefined, undefined, undefined); expect(p).toEqual({ userId: 'u1', tenantId: 'org1', scopes: ['read'] }); }); it('resolves via Authorization: ApiKey', async () => { const raw = 'osk_valid'; const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const p = await resolveApiKeyPrincipal(ql, { authorization: `ApiKey ${raw}` }); + const p = await resolveApiKeyPrincipal(ql, { authorization: `ApiKey ${raw}` }, undefined, undefined, undefined); expect(p?.userId).toBe('u1'); }); it('returns undefined for no key / revoked / expired / unknown / owner-less', async () => { const raw = 'osk_x'; const base = (extra: any) => makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1', ...extra }]); - expect(await resolveApiKeyPrincipal(base({}), {})).toBeUndefined(); // no key header - expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: true, user_id: 'u1' }]), { 'x-api-key': raw })).toBeUndefined(); - expect(await resolveApiKeyPrincipal(base({ expires_at: PAST }), { 'x-api-key': raw })).toBeUndefined(); - expect(await resolveApiKeyPrincipal(base({}), { 'x-api-key': 'osk_wrong' })).toBeUndefined(); - expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: false }]), { 'x-api-key': raw })).toBeUndefined(); // no user_id + expect(await resolveApiKeyPrincipal(base({}), {}, undefined, undefined, undefined)).toBeUndefined(); // no key header + expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: true, user_id: 'u1' }]), { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(base({ expires_at: PAST }), { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(base({}), { 'x-api-key': 'osk_wrong' }, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal(makeQl([{ key: hashApiKey(raw), revoked: false }]), { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); // no user_id }); it('never matches a plaintext-stored key (hash lookup only)', async () => { const raw = 'osk_plain'; const ql = makeQl([{ key: raw, revoked: false, user_id: 'u1' }]); - expect(await resolveApiKeyPrincipal(ql, { 'x-api-key': raw })).toBeUndefined(); + expect(await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, undefined, undefined, undefined)).toBeUndefined(); }); it('fail-closed when ql is missing/unusable', async () => { - expect(await resolveApiKeyPrincipal(undefined, { 'x-api-key': 'osk_x' })).toBeUndefined(); - expect(await resolveApiKeyPrincipal({}, { 'x-api-key': 'osk_x' })).toBeUndefined(); + expect(await resolveApiKeyPrincipal(undefined, { 'x-api-key': 'osk_x' }, undefined, undefined, undefined)).toBeUndefined(); + expect(await resolveApiKeyPrincipal({}, { 'x-api-key': 'osk_x' }, undefined, undefined, undefined)).toBeUndefined(); }); }); @@ -140,7 +140,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }, ]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, undefined, undefined, undefined); expect(admission.outcome).toBe('admitted'); expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); }); @@ -157,7 +157,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', organization_id: 'org_a', organizationId: 'org_a' }, ]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, undefined, undefined, undefined); // `single` (the default posture here) admits an org-less key, so this // asserts the SPELLING, not the refusal. expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBeUndefined(); @@ -165,7 +165,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { it('admits an org-less key under `single` — there is no wall to fail', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single'); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single', undefined); expect(admission.outcome).toBe('admitted'); }); @@ -179,13 +179,13 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { */ it('admits an org-less key under `group` — it already works there', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'group'); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'group', undefined); expect(admission.outcome).toBe('admitted'); }); it('REFUSES an org-less key under `isolated` — the posture where it is provably dead', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined); expect(admission.outcome).toBe('refused'); expect(admission.outcome === 'refused' && admission.reason).toBe('organization_required'); // The message is the operator-facing half of "loud at call time": it must @@ -218,7 +218,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { */ it('admits an org-less key when the posture is unknown (no tenancy service)', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), undefined); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), undefined, undefined); expect(admission.outcome).toBe('admitted'); }); @@ -226,7 +226,7 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }, ]); - const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined); expect(admission.outcome).toBe('admitted'); expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); }); @@ -238,13 +238,13 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { */ it('resolveApiKeyPrincipal collapses a refusal to undefined (fail-closed for old callers)', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const principal = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); + const principal = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, Date.now(), 'isolated', undefined); expect(principal).toBeUndefined(); }); it('an absent key is `none`, never a refusal', async () => { const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); - const admission = await resolveApiKeyAdmission(ql, {}, Date.now(), 'isolated'); + const admission = await resolveApiKeyAdmission(ql, {}, Date.now(), 'isolated', undefined); expect(admission.outcome).toBe('none'); }); }); diff --git a/packages/core/src/security/api-key.ts b/packages/core/src/security/api-key.ts index 8f6d0a1daa7..757ef16d3e9 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -248,23 +248,42 @@ export function effectiveTenancyPosture( } /** - * Verify an inbound API key against `sys_api_key` and resolve its principal. - * This is the ONE verify path shared by the dispatcher/MCP and REST surfaces. + * Verify an inbound API key against `sys_api_key` and resolve its principal — + * the verdict of {@link resolveApiKeyAdmission}, collapsed to a principal. * * Fail-closed: returns `undefined` for a missing key, an unusable data engine, - * a lookup error, or a key that is unknown / revoked / expired / owner-less. + * a lookup error, a key that is unknown / revoked / expired / owner-less, a key + * the tenancy posture refuses, and a key whose OWNER holds an `account`-scope + * ADR-0069 posture (`held`). * - * @param ql A data engine with `find(object, { where, limit, context })`. - * @param headers Request headers (Web `Headers` or a plain object). - * @param nowMs Clock for expiry checks (injectable for tests). + * ⚠️ The collapse is lossy by construction: a principal-or-nothing answer cannot + * carry the posture's code, so a host that admits keys through this function + * answers a held owner's key as it answers no key at all, never with the `403` + * and code ruling C′ item 1 gives every other seam. A transport that answers + * requests reads {@link resolveApiKeyAdmission} (or `resolveAuthzContext`) and + * refuses `held` with `gate.code`. + * + * [#23455] `userAuthGates` is REQUIRED, for the reason it is on + * {@link resolveApiKeyAdmission}: while this function took no posture source + * at all, any host admitting keys through it admitted a held owner's key. + * `tenancyPosture` is required with it, because a required parameter cannot + * follow an optional one; `undefined` is a legitimate value for both and is + * passed on purpose. + * + * @param ql A data engine with `find(object, { where, limit, context })`. + * @param headers Request headers (Web `Headers` or a plain object). + * @param nowMs Clock for expiry checks (injectable for tests). + * @param tenancyPosture The deployment's effective tenancy posture, or `undefined` when no `tenancy` service is registered. + * @param userAuthGates `userAuthGateSourceOf(authService)`, or `undefined` when the kernel has no auth service. */ export async function resolveApiKeyPrincipal( ql: any, headers: any, nowMs: number = Date.now(), - tenancyPosture?: TenancyPosture, + tenancyPosture: TenancyPosture | undefined, + userAuthGates: UserAuthGateSource | undefined, ): Promise { - const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture); + const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture, userAuthGates); return admission.outcome === 'admitted' ? admission.principal : undefined; } @@ -272,28 +291,41 @@ export async function resolveApiKeyPrincipal( * [#8287] The full verdict behind {@link resolveApiKeyPrincipal} — same lookup, * but it distinguishes a POSTURE REFUSAL from "no principal". * - * `resolveApiKeyPrincipal` collapses `refused` into `undefined` so every - * existing caller keeps working and keeps failing closed; a caller that can - * report WHY (the shared `resolveAuthzContext`) uses this instead. + * `resolveApiKeyPrincipal` collapses `refused` and `held` into `undefined`, so + * a caller of it fails closed; a caller that can report WHY (the shared + * `resolveAuthzContext`) uses this instead. * * The only refusal decided here is the org-less one, because it needs nothing * but the row and the posture. The ex-member refusal needs the caller's * membership set and is decided in `resolveAuthzContext`, where that set is * already resolved. + * + * [#23455] Every parameter after `nowMs` is REQUIRED, and `undefined` is a + * value passed on purpose — see `userAuthGates` below. */ export async function resolveApiKeyAdmission( ql: any, headers: any, nowMs: number = Date.now(), - tenancyPosture?: TenancyPosture, + /** + * The deployment's effective tenancy posture (`effectiveTenancyPosture`), + * or `undefined` when no `tenancy` service is registered. Required because + * `userAuthGates` after it is: a required parameter cannot follow an + * optional one. + */ + tenancyPosture: TenancyPosture | undefined, /** * [#23287, ruling C′ item 3] The auth service's per-user posture - * computation (`userAuthGateSourceOf`). OMITTING it leaves the key's owner - * unasked — the behaviour before the ruling — so an unwired caller is never - * made worse, only less strict. A kernel without `plugin-auth` has no policy - * to hold anyone with, and supplies nothing. + * computation (`userAuthGateSourceOf(authService)`). + * + * [#23455] REQUIRED. While it was optional, a caller that never named a + * source compiled and admitted a held owner's key — the fail-open three + * seams carried after the ruling. `undefined` is the value a kernel + * without `plugin-auth` passes (no policy can hold anyone there); ⛔ never + * the value a caller that can reach the `auth` service passes to quiet the + * compiler. */ - userAuthGates?: UserAuthGateSource, + userAuthGates: UserAuthGateSource | undefined, ): Promise { const apiKey = extractApiKey(headers); if (!apiKey) return { outcome: 'none' }; diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index a1b35734797..38280b01eac 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -63,7 +63,7 @@ const settle = async (p: Promise) => describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is not', () => { it('a permission-store OUTAGE refuses, with the branded error', async () => { - const r = await settle(resolveAuthzContext({ ql: qlDown(), headers: {}, ...SESSION })); + const r = await settle(resolveAuthzContext({ userAuthGates: undefined, ql: qlDown(), headers: {}, ...SESSION })); expect(r.ok).toBe(false); expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true); // The wire vocabulary an outage selects — an EXISTING ADR-0112 member. @@ -77,13 +77,13 @@ describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is it('⭐ THE TWIN: a reachable, genuinely EMPTY store still resolves to zero capabilities', async () => { // The assertion that keeps the fix honest. If this ever throws, the repair // has stopped distinguishing the two facts and merely moved the lie. - const ctx = await resolveAuthzContext({ ql: qlEmpty(), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toEqual([]); }); it('⭐ CONTROL: a healthy store still resolves the capability it grants', async () => { - const ctx = await resolveAuthzContext({ ql: qlHealthy(), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toContain('studio.access'); }); @@ -91,15 +91,15 @@ describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is it('an ABSENT engine is not a failed read — it still resolves, as it always did', async () => { // `tryFind`'s `!ql` guard. An embedder that never wired a data plane must // keep resolving; only a read that was ISSUED and THREW is loud. - const ctx = await resolveAuthzContext({ ql: undefined, headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: undefined, headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toEqual([]); - const noFind = await resolveAuthzContext({ ql: {} as any, headers: {}, ...SESSION }); + const noFind = await resolveAuthzContext({ userAuthGates: undefined, ql: {} as any, headers: {}, ...SESSION }); expect(noFind.userId).toBe(USER); }); it('an ANONYMOUS request never reaches the store, so an outage cannot make it loud', async () => { - const ctx = await resolveAuthzContext({ ql: qlDown(), headers: {} }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlDown(), headers: {} }); expect(ctx.userId).toBeUndefined(); expect(ctx.systemPermissions).toEqual([]); }); @@ -380,7 +380,7 @@ const OUTAGE_SHAPES: ReadonlyArray unknown describe('[#13279 option A] ⭐ THE OUTAGE DIRECTION — a read failure that is not an unprovisioned table stays LOUD', () => { it.each(OUTAGE_SHAPES)('%s ⇒ AuthzStoreUnavailableError (503 SERVICE_UNAVAILABLE)', async (_label, make) => { - const r = await settle(resolveAuthzContext({ ql: qlThrowing(make), headers: {}, ...SESSION })); + const r = await settle(resolveAuthzContext({ userAuthGates: undefined, ql: qlThrowing(make), headers: {}, ...SESSION })); expect(r.ok).toBe(false); expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true); expect((r as any).e.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); @@ -397,7 +397,7 @@ describe('[#13279 option A] ⭐ THE OUTAGE DIRECTION — a read failure that is return []; }, }; - const r = await settle(resolveAuthzContext({ ql, headers: {}, ...SESSION })); + const r = await settle(resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, ...SESSION })); expect(r.ok).toBe(false); expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true); expect((r as any).e.object).toBe('sys_user_permission_set'); @@ -428,7 +428,7 @@ describe('[#13279 option A] ⭐ THE UNPROVISIONED DIRECTION — a never-provisio it.each(UNPROVISIONED_SHAPES)( '%s ⇒ zero capabilities, because that is the TRUE answer and not a fabrication', async (_label, make) => { - const ctx = await resolveAuthzContext({ ql: qlThrowing(make), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: qlThrowing(make), headers: {}, ...SESSION }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toEqual([]); expect(ctx.permissions).toEqual([]); @@ -447,12 +447,13 @@ describe('[#13279 option A] ⭐ THE UNPROVISIONED DIRECTION — a never-provisio // is granted — so they must be the same envelope, byte for byte. Anything // less means the quiet branch is quietly different. const unprovisioned = await resolveAuthzContext({ + userAuthGates: undefined, ql: qlThrowing((o) => new Error(`no such table: ${o}`)), headers: {}, ...SESSION, }); - const empty = await resolveAuthzContext({ ql: qlEmpty(), headers: {}, ...SESSION }); + const empty = await resolveAuthzContext({ userAuthGates: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); expect(JSON.stringify(unprovisioned)).toBe(JSON.stringify(empty)); // CONTROL against a vacuous comparison: a HEALTHY store differs from both. - const healthy = await resolveAuthzContext({ ql: qlHealthy(), headers: {}, ...SESSION }); + const healthy = await resolveAuthzContext({ userAuthGates: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(empty)); }); diff --git a/packages/core/src/security/resolve-authz-context.guest-grants.test.ts b/packages/core/src/security/resolve-authz-context.guest-grants.test.ts index 9cb4a0f69c0..6d01ab1d62a 100644 --- a/packages/core/src/security/resolve-authz-context.guest-grants.test.ts +++ b/packages/core/src/security/resolve-authz-context.guest-grants.test.ts @@ -77,7 +77,7 @@ function fakeEngine(opts: FakeOptions = {}) { } const anonymous = (engine: unknown, extra: Record = {}) => - resolveAuthzContext({ ql: engine, headers: new Headers(), getSession: async () => undefined, ...extra }); + resolveAuthzContext({ userAuthGates: undefined, ql: engine, headers: new Headers(), getSession: async () => undefined, ...extra }); describe('ADR-0138 D3 — a bound set reaches the guest entry, in the derived organization', () => { it('carries the bound set and the one organization (D3 item 1, D4 item 1)', async () => { diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index 16b409d85d8..8d1003c187e 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -214,6 +214,7 @@ describe('[#11663 L2] acceptance criterion — the configured, VERIFIED account' declare('a@b.c'); const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'a@b.c', email_verified: true })); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers(), getSession: async () => ({ user: { id: 'usr_1', email: 'a@b.c' }, session: {} }), @@ -311,6 +312,7 @@ describe('⭐ [#11663 L2 pin P1] the derivation reads the STORED row, never the declare('a@b.c'); const ql = makeQl(configOnlyTables({ id: 'usr_1', email: 'impostor@corp.example', email_verified: true })); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers(), getSession: async () => ({ user: { id: 'usr_1', email: 'a@b.c' }, session: {} }), diff --git a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts index f82e86d7d49..f1923d088d5 100644 --- a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -206,7 +206,7 @@ function makeQl(tables: Record, opts: { epoch?: boolean; middlewa */ async function armedQl(tables: Record): Promise { const ql = makeQl(tables); - await resolveAuthzContext({ ql, headers: {} }); + await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {} }); expect(ql.calls.length).toBe(0); expect(ql.middlewareCount()).toBe(1); return ql; @@ -379,6 +379,7 @@ async function resolveBoth(c: CallerClass) { const memoQl = await armedQl(makeTables()); const withMemo = await resolveAuthzContext({ + userAuthGates: undefined, ql: memoQl, headers: c.headers ?? {}, getSession: sessionReadThatResolves(memoQl, c.fixture), @@ -388,6 +389,7 @@ async function resolveBoth(c: CallerClass) { const baselineQl = makeQl(makeTables()); const baseline = await resolveAuthzContext({ + userAuthGates: undefined, ql: baselineQl, headers: c.headers ?? {}, getSession: sessionReadOnly(c.fixture), @@ -424,20 +426,20 @@ describe('request-scoped grants memo — the round-trip count', () => { expect(oneResolution).toBe(6); const memoQl = await armedQl(makeTables()); - const withMemo = await resolveAuthzContext({ ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); + const withMemo = await resolveAuthzContext({ userAuthGates: undefined, ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); expect(memoQl.calls.length).toBe(oneResolution); expect(multiset(memoQl.calls)).toEqual(multiset(standaloneQl.calls)); // An engine without the write-epoch seam: the memo declines, and the // request makes the two resolutions it made before the memo existed. const noSeamQl = makeQl(makeTables(), { epoch: false }); - const noSeam = await resolveAuthzContext({ ql: noSeamQl, headers: {}, getSession: sessionReadThatResolves(noSeamQl, member), nowMs: T0 }); + const noSeam = await resolveAuthzContext({ userAuthGates: undefined, ql: noSeamQl, headers: {}, getSession: sessionReadThatResolves(noSeamQl, member), nowMs: T0 }); expect(noSeamQl.calls.length).toBe(2 * oneResolution); expect(noSeam).toEqual(withMemo); // …and one that cannot register a middleware (no write observer) declines too. const noMiddlewareQl = makeQl(makeTables(), { middleware: false }); - const noMiddleware = await resolveAuthzContext({ ql: noMiddlewareQl, headers: {}, getSession: sessionReadThatResolves(noMiddlewareQl, member), nowMs: T0 }); + const noMiddleware = await resolveAuthzContext({ userAuthGates: undefined, ql: noMiddlewareQl, headers: {}, getSession: sessionReadThatResolves(noMiddlewareQl, member), nowMs: T0 }); expect(noMiddlewareQl.calls.length).toBe(2 * oneResolution); expect(noMiddleware).toEqual(withMemo); }); @@ -445,11 +447,11 @@ describe('request-scoped grants memo — the round-trip count', () => { it('the request that registers the engine\'s write observer stores nothing for it; the next one is served', async () => { const ql = makeQl(makeTables()); expect(ql.middlewareCount()).toBe(0); - const first = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); + const first = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); expect(ql.middlewareCount()).toBe(1); expect(ql.calls.length).toBe(12); const before = ql.calls.length; - const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); + const second = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); expect(ql.calls.length - before).toBe(6); expect(ql.middlewareCount()).toBe(1); expect(second).toEqual(first); @@ -474,14 +476,14 @@ describe('request-scoped grants memo — isolation', () => { return payload; }; - const pOwner = resolveAuthzContext({ ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, owner), ownerRead), nowMs: T0, tenancyPosture: 'isolated' }); - const pMember = resolveAuthzContext({ ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, member), memberRead), nowMs: T0, tenancyPosture: 'isolated' }); + const pOwner = resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, owner), ownerRead), nowMs: T0, tenancyPosture: 'isolated' }); + const pMember = resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, member), memberRead), nowMs: T0, tenancyPosture: 'isolated' }); await Promise.all([ownerRead.promise, memberRead.promise]); release.resolve(); const [ownerCtx, memberCtx] = await Promise.all([pOwner, pMember]); - const baselineOwner = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(owner), nowMs: T0, tenancyPosture: 'isolated' }); - const baselineMember = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baselineOwner = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(owner), nowMs: T0, tenancyPosture: 'isolated' }); + const baselineMember = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ownerCtx).toEqual(baselineOwner); expect(memberCtx).toEqual(baselineMember); expect(ownerCtx.userId).toBe('u_owner'); @@ -497,14 +499,14 @@ describe('request-scoped grants memo — isolation', () => { const ql = await armedQl(tables); const member = sessionOf('u_member', 'member@x.com', 'org_a'); - const first = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + const first = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(first.positions).toContain('auditor'); // Revoked with NO epoch bump and outside the chain: what retires the first // request's resolution is the end of that request, and nothing else. tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); const before = ql.calls.length; - const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + const second = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(second.positions).not.toContain('auditor'); expect(second.permissions).not.toContain('read_all'); expect(ql.calls.length - before).toBe(6); @@ -518,6 +520,7 @@ describe('request-scoped grants memo — isolation', () => { const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: async () => { @@ -540,6 +543,7 @@ describe('request-scoped grants memo — isolation', () => { const qlB = await armedQl(makeTables()); const member = sessionOf('u_member', 'member@x.com', 'org_a'); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: qlA, headers: {}, getSession: sessionReadThatResolves(qlB, member), @@ -548,7 +552,7 @@ describe('request-scoped grants memo — isolation', () => { }); expect(qlB.calls.length).toBe(6); expect(qlA.calls.length).toBe(6); - const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -556,12 +560,14 @@ describe('request-scoped grants memo — isolation', () => { const ql = await armedQl(makeTables()); const member = sessionOf('u_member', 'member@x.com', 'org_a'); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: async () => { // The inner call resolves twice-over-once in ITS OWN scope: its session // read's resolution serves its step 2 (6 reads), and that scope closes. const inner = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), @@ -576,7 +582,7 @@ describe('request-scoped grants memo — isolation', () => { }); // Inner 8, outer step 2 afresh 8. expect(ql.calls.length).toBe(12); - const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); }); @@ -586,7 +592,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const postWriteBaseline = async (nowMs = T0) => { const after = makeTables(); after.sys_user_position = withoutMembersAuditor(after.sys_user_position); - return resolveAuthzContext({ ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs, tenancyPosture: 'isolated' }); + return resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs, tenancyPosture: 'isolated' }); }; it('a revocation that bumped the epoch BEFORE the first resolution opened and lands before step 2', async () => { @@ -601,6 +607,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re }); const read = sessionReadThatResolves(ql, member); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: async () => { @@ -630,7 +637,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re await landing.promise; tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); }); - const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ql.calls.length).toBe(12); // Not landed yet: a fresh read still sees the row, and so did step 2. expect(ctx.positions).toContain('auditor'); @@ -643,6 +650,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const ql = await armedQl(tables); const read = sessionReadThatResolves(ql, member); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: async () => { @@ -664,6 +672,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const ql = await armedQl(makeTables()); const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: async () => { @@ -686,7 +695,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re if (!started && object === 'sys_metadata_activation') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } return find(object, opts); }; - await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); await w; expect(ql.calls.length).toBe(12); }); @@ -694,10 +703,10 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re it('a step-2 clock later than the first resolution\'s, inside its validity window, is served', async () => { // temp_role is valid until T0 + 1 day: T0 + 1 hour is inside the window. const ql = await armedQl(makeTables()); - const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); expect(ql.calls.length).toBe(6); expect(ctx.positions).toContain('temp_role'); - const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -705,16 +714,16 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re // The session read resolves at T0 (temp_role active until T0 + 1 day); // step 2 asks at T0 + 2 days, past that boundary. const ql = await armedQl(makeTables()); - const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx.positions).not.toContain('temp_role'); expect(ctx.permissions).not.toContain('temp_tools'); expect(ql.calls.length).toBe(12); - const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); // A step-2 clock EARLIER than the resolution it would be served. const ql2 = await armedQl(makeTables()); - await resolveAuthzContext({ ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); + await resolveAuthzContext({ userAuthGates: undefined, ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); expect(ql2.calls.length).toBe(12); }); @@ -722,6 +731,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const ql = await armedQl(makeTables()); const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: async () => { @@ -745,9 +755,9 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re return find(object, opts); }; const sink: { payload?: any; grants?: UserAuthzGrants } = {}; - const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); expect(sink.grants).toBeUndefined(); - const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + const baseline = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); }); @@ -757,7 +767,7 @@ describe('request-scoped grants memo — served values are clones', () => { const ql = await armedQl(makeTables()); const sink: { payload?: any; grants?: UserAuthzGrants } = {}; const member = sessionOf('u_member', 'member@x.com', 'org_a'); - const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); expect(ql.calls.length).toBe(6); expect(ctx.positions).toEqual(sink.payload.user.positions); expect(ctx.positions).not.toBe(sink.payload.user.positions); diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index f61ef17cc3a..86a1e023ebf 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -85,7 +85,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.positions).toContain('contributor'); }); @@ -96,7 +96,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('org_owner'); }); @@ -110,7 +110,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('contributor_ps'); expect(ctx.systemPermissions).toContain('cap_x'); }); @@ -123,7 +123,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.positions).toContain('platform_admin'); }); @@ -135,7 +135,7 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'o1' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).not.toContain('platform_admin'); }); @@ -146,12 +146,12 @@ describe('resolveAuthzContext — single source of truth', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('ai_seat'); }); it('anonymous (no session, no api key) → empty context', async () => { - const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); expect(ctx.userId).toBeUndefined(); expect(ctx.positions).toEqual([]); expect(ctx.permissions).toEqual([]); @@ -193,7 +193,7 @@ describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', ( sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.email).toBe('ada@x.com'); expect(ctx.permissions).toContain('ai_seat'); expect(ql.counts.sys_user).toBe(1); @@ -721,7 +721,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); expect(ctx.positions).toContain('contributor'); // null bounds = unbounded, unchanged }); @@ -733,7 +733,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_from: FUTURE }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -748,7 +748,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).toContain('stand_in'); expect(ctx.positions).not.toContain('boundary'); }); @@ -763,7 +763,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { ], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.permissions).not.toContain('admin_full_access'); expect(ctx.positions).not.toContain('platform_admin'); }); @@ -775,7 +775,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: 'not-a-date' }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -806,7 +806,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); // The half that was already correct. expect(ctx.accessible_org_ids).toEqual([]); // The half this card fixes — it used to carry `org_member`. @@ -824,7 +824,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('org_admin'); expect(ctx.accessible_org_ids).toEqual([]); }); @@ -846,6 +846,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_permission_set: [], }; const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1', { org: 'o2' }), nowMs: NOW, }); expect(ctx.positions).toContain('org_member'); // the in-window row still grants @@ -864,7 +865,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).toContain('org_owner'); expect(ctx.positions).toContain('org_member'); expect(ctx.accessible_org_ids).toEqual(['o1']); @@ -900,6 +901,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { it('escalation, BEFORE: an ACTIVE owner membership still resolves TENANT_ADMIN', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(orgOwnerPositionTables({})), headers: H(), getSession: session('esc', { org: 'o1' }), nowMs: NOW, }); @@ -910,6 +912,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { it('escalation, AFTER: a LAPSED owner membership resolves MEMBER, not TENANT_ADMIN', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(orgOwnerPositionTables({ valid_until: PAST })), headers: H(), getSession: session('esc', { org: 'o1' }), nowMs: NOW, }); @@ -934,7 +937,7 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user_permission_set: [{ user_id: 'esc2', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }], sys_permission_set: [{ id: 'psO', name: 'organization_admin' }], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('esc2', { org: 'o1' }), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('esc2', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('org_owner'); // the role projection is closed expect(ctx.permissions).toContain('organization_admin'); // the grant row is not expect(ctx.posture).toBe('TENANT_ADMIN'); @@ -949,7 +952,7 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1' }], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); // holding an explicit position must NOT cost the baseline anchor expect(ctx.positions).toContain('contributor'); expect(ctx.positions).toContain('everyone'); @@ -957,7 +960,7 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { it('anonymous resolution never gains `everyone`', async () => { const ql = makeQl({}); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: async () => undefined }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: async () => undefined }); expect(ctx.positions).not.toContain('everyone'); expect(ctx.userId).toBeUndefined(); }); @@ -1029,7 +1032,7 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { it('resolves the principal × grants → posture matrix', async () => { const actual: Record = {}; for (const [name, fx] of Object.entries(FIXTURES)) { - const ctx = await resolveAuthzContext({ ql: fx.ql, headers: H(), getSession: fx.getSession }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: fx.ql, headers: H(), getSession: fx.getSession }); actual[name] = ctx.posture; } expect(actual).toEqual(EXPECTED_POSTURE); @@ -1038,7 +1041,7 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { it('posture is strictly nested: PLATFORM_ADMIN > TENANT_ADMIN > MEMBER', async () => { const rank = async (name: string) => { const fx = FIXTURES[name]; - const ctx = await resolveAuthzContext({ ql: fx.ql, headers: H(), getSession: fx.getSession }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: fx.ql, headers: H(), getSession: fx.getSession }); return POSTURE_RANK[ctx.posture!]; }; expect(await rank('platform_admin')).toBeGreaterThan(await rank('tenant_admin')); @@ -1062,12 +1065,12 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { { id: 'psO', name: 'organization_admin' }, ], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('both', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('both', { org: 'o1' }) }); expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); it('anonymous principal carries no posture rung', async () => { - const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl({}), headers: H(), getSession: async () => undefined }); expect(ctx.posture).toBeUndefined(); }); }); @@ -1107,7 +1110,7 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }; - const viaHttp = await resolveAuthzContext({ ql: makeQl(tables), headers: H(), getSession: session('u1') }); + const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1') }); const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1'); expect([...viaUser.positions].sort()).toEqual([...viaHttp.positions].sort()); expect([...viaUser.permissions].sort()).toEqual([...viaHttp.permissions].sort()); @@ -1188,7 +1191,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { it('adopts the key organization as the request tenant when membership holds', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_a', role: 'member' }])); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBe('u1'); expect(ctx.tenantId).toBe('org_a'); expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0); @@ -1206,7 +1209,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('refuses a key whose owner is no longer a member of its organization', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }])); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(ctx.tenantId).toBeUndefined(); expect(ctx.permissions).toEqual([]); @@ -1217,14 +1220,14 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { const ql = makeQl(tables([ { user_id: 'u1', organization_id: 'org_a', role: 'member', valid_until: '2000-01-01T00:00:00Z' }, ])); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']); }); it('the same key under `group` is refused too — the wall is membership-derived there as well', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }])); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'group' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'group' }); expect(ctx.userId).toBeUndefined(); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']); }); @@ -1236,7 +1239,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('does NOT apply the membership check under `single`', async () => { const ql = makeQl(tables([])); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'single' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'single' }); expect(ctx.userId).toBe('u1'); expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0); }); @@ -1256,6 +1259,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { sys_user_permission_set: [], }); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: keyHeaders(), getSession: session('u1', { org: 'org_a' }), @@ -1280,7 +1284,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('[#14273] a post-grant refusal answers an envelope with no `authRefusal` — the reason is server-side only', async () => { const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }])); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(Object.prototype.hasOwnProperty.call(ctx, 'authRefusal')).toBe(false); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']); @@ -1294,7 +1298,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(Object.prototype.hasOwnProperty.call(ctx, 'authRefusal')).toBe(false); expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_required']); @@ -1309,7 +1313,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { return inner.find(object, opts); }, }; - await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + await resolveAuthzContext({ userAuthGates: undefined, ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); // One read for `accessible_org_ids`, one for the fellow-org peer list that // an ACTIVE tenant already triggered before this change. The membership // assertion adds neither. @@ -1360,6 +1364,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('a DEACTIVATED position stops granting its permission sets', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(withActive(false)), headers: H(), getSession: session('u1'), @@ -1373,6 +1378,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { // permission-set names (position names are commonly reused as set names), // so a name left standing would resolve the same grant one layer down. const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(withActive(false)), headers: H(), getSession: session('u1'), @@ -1384,6 +1390,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('an ACTIVE position still grants (the flag is not a blanket revocation)', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(withActive(true)), headers: H(), getSession: session('u1'), @@ -1395,6 +1402,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('an ABSENT ledger row grants — deployed positions are not mass-revoked', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(withActive(undefined)), headers: H(), getSession: session('u1'), @@ -1405,12 +1413,14 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { it('the 0/1 storage shape deactivates too — what the primary driver returns', async () => { const off = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(withActive(0)), headers: H(), getSession: session('u1'), }); expect(off.permissions).not.toContain('contributor_ps'); const on = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(withActive(1)), headers: H(), getSession: session('u1'), @@ -1428,7 +1438,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { // there is no flag to read, so nothing may be inferred from its absence. sys_metadata_activation: [off('position', 'something_else')], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('org_owner'); }); @@ -1455,7 +1465,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { { id: 'ps2', name: 'reviewer_ps' }, ], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('contributor_ps'); expect(ctx.permissions).toContain('reviewer_ps'); expect(ctx.positions).not.toContain('contributor'); @@ -1478,7 +1488,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { }], sys_metadata_activation: [off('permission', 'crm_full')], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.systemPermissions).not.toContain('cap_x'); expect(ctx.tabPermissions?.crm).toBeUndefined(); @@ -1497,7 +1507,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { }], sys_metadata_activation: [off('permission', 'admin_full_access')], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); // Dropped BEFORE the derivation, so the posture cannot be read off a set // that no longer grants — the whole point of filtering at §6b rather than // after it. @@ -1522,7 +1532,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { ], sys_metadata_activation: [off('permission', 'crm_full'), off('permission', 'crm_read', true)], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.permissions).toContain('crm_read'); }); @@ -1540,7 +1550,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], sys_metadata_activation: [off('position', 'contributor')], }); - const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('crm_full'); expect(ctx.positions).not.toContain('contributor'); }); @@ -1579,6 +1589,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { // The rows' flags are carried into the ledger by the upgrade ceremony // (`convertDeactivatedCatalogRows`); the resolver never falls back to them. const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl({ ...withActive(undefined), sys_position: [{ id: 'r1', name: 'contributor', active: false }], @@ -1732,6 +1743,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('CONTROL · a BACKED claim is adopted untouched — the guard is not a blanket', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_beta'), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1742,6 +1754,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('THE SUBJECT: an UNBACKED claim resolves with NO active organization', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.tenantId).toBeUndefined(); @@ -1758,6 +1771,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('B, not A: the PRINCIPAL survives — still authenticated, still a member where it really is', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1778,6 +1792,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('the envelope carries no residue of the rejected claim — the fellow-org peer list is not the left org\'s', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.org_user_ids).toEqual(['u_ex']); @@ -1787,6 +1802,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('the same claim under `group` is dropped too — the wall is membership-derived there as well', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'group', }); expect(ctx.userId).toBe('u_ex'); @@ -1802,6 +1818,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('does NOT apply under `single` — no wall, nothing to justify a claim against', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'single', }); expect(ctx.tenantId).toBe('org_alpha'); @@ -1816,6 +1833,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('does NOT apply when the transport supplies no posture', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), }); expect(ctx.tenantId).toBe('org_alpha'); @@ -1825,6 +1843,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('a session with NO claim at all is not a drop — nothing was claimed', async () => { const ql = makeQl(tables()); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', null), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1838,6 +1857,7 @@ describe('[#15409] a session organization claim that no membership backs', () => sys_member: [{ user_id: 'u_ex', organization_id: 'org_alpha', role: 'member', valid_until: '2000-01-01T00:00:00Z' }], }); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha'), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe('u_ex'); @@ -1853,6 +1873,7 @@ describe('[#15409] a session organization claim that no membership backs', () => it('says it OUT LOUD, once, naming session / principal / organization / reason — and ⛔ never the token', async () => { const ql = makeQl(tables()); await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: H(), getSession: sessionAs('u_ex', 'org_alpha', 'ses_victim'), tenancyPosture: 'isolated', }); const lines = dropLines(); @@ -1878,6 +1899,7 @@ describe('[#15409] a session organization claim that no membership backs', () => sys_api_key: [{ id: 'key_ex', key: hashApiKey(raw), revoked: false, user_id: 'u_ex', active_organization_id: 'org_alpha' }], }); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: { 'x-api-key': raw }, tenancyPosture: 'isolated', }); expect(ctx.userId).toBeUndefined(); @@ -2087,6 +2109,7 @@ describe('[#20515] with no active organization, only global grants apply', () => for (const userId of ['u_ex', 'u_gone']) { it(`${userId}: the claim is dropped AND the left organization's grant no longer applies`, async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(tables()), headers: H(), getSession: namingAlpha(userId), tenancyPosture: 'isolated', }); expect(ctx.userId).toBe(userId); @@ -2098,6 +2121,7 @@ describe('[#20515] with no active organization, only global grants apply', () => it('CONTROL · a current member with the same grant and that organization active keeps it', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_member'), tenancyPosture: 'isolated', }); expect(ctx.tenantId).toBe(ALPHA); @@ -2107,6 +2131,7 @@ describe('[#20515] with no active organization, only global grants apply', () => it('a GLOBAL grant is unchanged: the removed member still holds it with no active organization', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_global_ex'), tenancyPosture: 'isolated', }); expect(ctx.tenantId).toBeUndefined(); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 3590c6ab680..7f22889cbe4 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -192,11 +192,21 @@ export interface ResolveAuthzInput { * `account`-scope posture ({@link ResolvedAuthzContext.authGate}). * * Supplied by the transport, like {@link tenancyPosture}, because this - * resolver is kernel-agnostic. OMITTING it leaves a key's owner unasked — - * the behaviour before the ruling — so an unwired caller is never made - * worse, only less strict. + * resolver is kernel-agnostic. + * + * [#23455] REQUIRED, and `undefined` is a VALUE a caller passes on purpose. + * While it was optional, a seam that never named its source compiled, and + * every API key it admitted skipped its owner's posture: a held owner's key + * was served at three seams after ruling C′ refused it everywhere else. The + * key is now present in every call, so the compiler names a new seam that + * forgets it. Pass `userAuthGateSourceOf(authService)` wherever the kernel's + * `auth` service is reachable (it answers `undefined` itself for a service + * with no posture computation). Pass a bare `undefined` only where no auth + * service exists at all — a kernel composed without `plugin-auth` has no + * policy to hold anyone with — ⛔ never to quiet the compiler at a seam that + * could reach one. */ - userAuthGates?: UserAuthGateSource; + userAuthGates: UserAuthGateSource | undefined; } /** diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index cd63b42fb96..4cd2e734612 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -15,13 +15,19 @@ import { // deliberately different shape, not a copy of that classification. classifyAdmissionTenancyPosture, effectiveTenancyPosture, + // [#23455 · ruling C′] The ADR-0069 gate's ONE decision, and the binding of + // the auth service's per-user posture computation the key-admission seam asks. + evaluateAuthGate, + userAuthGateSourceOf, + type AuthGate, type EntryLocalization, type TenancyPostureSource, + type UserAuthGateSource, } from '@objectstack/core'; import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { TenancyPosture } from '@objectstack/spec/security'; -import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import type { IAIService, IAuthService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import { PACKAGE_VERSION, DEFAULT_SERVER_VERSION } from './package-version.js'; import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js'; @@ -124,10 +130,53 @@ async function resolveStdioTenancyPosture(ctx: PluginContext): Promise('auth'); + } catch { + authService = undefined; + } + return userAuthGateSourceOf(authService); +} + +/** The status the REST and dispatcher faces answer a held principal with. */ +const AUTH_GATE_REFUSAL_STATUS = 403; + +/** + * [#23455 · ruling C′ items 1 and 4] The refusal a stdio call gets when the + * key's OWNER holds an `account`-scope ADR-0069 posture: the envelope REST's + * `enforceAuth` and the dispatcher answer the same key with — `403`, the + * posture's `code` (`MFA_REQUIRED`) and the session's remediation `message` — + * as a thrown ADR-0112 envelope, the shape this door's other refusals take + * (`recordNotFoundError`). + * + * ⛔ Not a revocation: nothing is written, the key stays valid, and the next + * call after the owner remediates is served. + */ +function stdioAuthGateRefusal(gate: AuthGate): Error & { code: string; status: number } { + const err = new Error(gate.message) as Error & { code: string; status: number }; + err.code = gate.code; + err.status = AUTH_GATE_REFUSAL_STATUS; + return err; +} + /** * Resolve `OS_MCP_STDIO_API_KEY` into an {@link ExecutionContext} through the * SAME `@objectstack/core` verify + authorization chain the HTTP and REST - * surfaces use (`resolveApiKeyPrincipal` → `resolveAuthzContext`), so a stdio + * surfaces use (`resolveAuthzContext` → `resolveApiKeyAdmission`), so a stdio * call is scoped exactly like the same identity over REST (RLS / FLS / tenant). * * [#7279] Assembled by the SHARED `assembleExecutionContext` rather than by @@ -162,14 +211,29 @@ async function resolveStdioTenancyPosture(ctx: PluginContext): Promise Promise }, apiKey: string, localization: EntryLocalization | undefined, tenancyPosture: TenancyPosture | undefined, + userAuthGates: UserAuthGateSource | undefined, ): Promise { - const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey }, tenancyPosture }); + const authz = await resolveAuthzContext({ + ql, + headers: { 'x-api-key': apiKey }, + tenancyPosture, + userAuthGates, + }); return assembleExecutionContext({ authz, // OAuth access tokens are honoured on the `/mcp` HTTP door alone @@ -199,11 +263,13 @@ async function resolveStdioExecutionContext( // session token that surface was designed around is a product decision // nobody has made — not a refactor. accessToken: undefined, - // [ADR-0069] No authentication-policy gate: that posture is resolved from a - // better-auth SESSION (expired password / enforced MFA), and this face has - // none. REST carries it because `enforceAuth` reads it off the envelope ten - // lines away; nothing on the stdio path reads it. - authGate: undefined, + // [ADR-0069 · #23455, ruling C′ item 3] The posture the shared resolver + // decided for this key: its OWNER's `account`-scope posture (enforced MFA + // not yet enrolled), or nothing. A `credential`-scope posture (password + // expiry, a forced reset) holds password sign-in alone and never appears + // here for a key. `resolvePrincipal` refuses a context carrying it on + // every call, exactly as REST's `enforceAuth` reads it off the envelope. + authGate: authz.authGate, }); } @@ -421,11 +487,14 @@ export class MCPServerPlugin implements Plugin { // below. A `tenancy` service that is REGISTERED AND BROKEN raises // `AuthzStoreUnavailableError` out of this line — loud, and it stops the // boot rather than starting a door whose admission was never decided. + // [#23455] …and so is the owner's posture: a key whose owner holds an + // `account`-scope posture resolves here as a HELD identity. const initial = await resolveStdioExecutionContext( ql, apiKey, undefined, await resolveStdioTenancyPosture(ctx), + resolveStdioUserAuthGates(ctx), ); if (!initial) { throw new Error( @@ -433,6 +502,21 @@ export class MCPServerPlugin implements Plugin { 'Refusing to start stdio (ADR-0101).', ); } + // [#23455 · ruling C′ item 4] A HELD identity still starts. The key is + // valid and nothing revokes it; what holds it is the owner's standing, + // which can clear at any moment, and the per-call refusal below is what + // serves the next call once it does. Refusing to start instead would turn + // the hold into an outage that outlives the remediation until someone + // restarts the process. Said once, at the start, naming the remedy. + const initialGate = evaluateAuthGate(initial, ''); + if (initialGate) { + ctx.logger.warn( + `[MCP] The OS_MCP_STDIO_API_KEY identity ${initial.userId} is held by an authentication policy ` + + `(${initialGate.code}): every data call on this stdio transport is refused with ${initialGate.code} ` + + 'until the key\'s owner remediates, and served again on the next call after that, with no restart. ' + + `Remedy: ${initialGate.message}`, + ); + } const scopedQl = ql; // ── Localization, resolved ONCE for the life of the transport (#7279) ── // (once the settings bind window has closed — see the #11622 clause below) @@ -560,14 +644,26 @@ export class MCPServerPlugin implements Plugin { // host registers the provider after the kernel starts. const storedHashDigest = (): StoredHashDigest | undefined => typeof scopedQl.getKeyedDigest === 'function' ? scopedQl.getKeyedDigest() : undefined; + // [#23455 · ruling C′ items 1 and 4] The owner's posture is asked on the + // same schedule, and a held owner's call is REFUSED here, before any read + // or write, with the envelope REST answers the same key with (`403`, the + // posture's code, the session's remediation message). Per call rather + // than once at start: a posture that appears mid-session refuses the + // next call, and one that clears serves the next call — the refusal is + // not a revocation, so nothing is written and nothing needs a restart. const resolvePrincipal = async (): Promise => { const ec = await resolveStdioExecutionContext( scopedQl, apiKey, await localizationForRead(), await resolveStdioTenancyPosture(ctx), + resolveStdioUserAuthGates(ctx), ); if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)'); + // No request path: no stdio call is a remediation or bootstrap route, + // and an empty path earns no allow-list exemption (#7898). + const gate = evaluateAuthGate(ec, ''); + if (gate) throw stdioAuthGateRefusal(gate); return ec; }; if (metadataService) { diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts index d19a6df066c..0457e616291 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -234,6 +234,7 @@ async function resolveRequest(a: Arranged, bearer: string | null, tenancyPosture const headers = new Headers(bearer ? { authorization: `Bearer ${bearer}` } : {}); return a.probe.measure(() => resolveAuthzContext({ + userAuthGates: undefined, ql: a.engine, headers, getSession: (h: any) => auth.api.getSession({ headers: h }), diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index b2f54e68fc7..aea282a98ca 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -481,6 +481,7 @@ async function bootPrincipal(source: PostureSource) { const enforced = async (userId: string) => { const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql: engine, headers: {}, getSession: async () => ({ diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index ab25ba84650..1aabc2263e9 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -204,6 +204,7 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { /** Enforcement's context for `userId`, whose session claims `org_alpha`: the real resolver and assembler. */ const enforced = async (userId: string) => { const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql: engine, headers: {}, getSession: async () => ({ diff --git a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts index c3b09efa62e..376659dbf29 100644 --- a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts +++ b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts @@ -124,6 +124,9 @@ export async function bootRequestContext( }); const authz = await resolveAuthzContext({ + // [#23455] This kit composes no auth service and presents no API key, so + // no owner posture can be asked: `undefined`, passed on purpose. + userAuthGates: undefined, ql, headers: new Headers(), // [#15349] The posture a transport supplies. Threaded rather than omitted diff --git a/packages/qa/dogfood/test/armed.ts b/packages/qa/dogfood/test/armed.ts index e3c5fcc590c..bb6fb83c7f3 100644 --- a/packages/qa/dogfood/test/armed.ts +++ b/packages/qa/dogfood/test/armed.ts @@ -79,7 +79,7 @@ // disarm and #8049's `passwordHistoryCount: 0` disarm. A guard for vacuous // assertions that was never shown to fail would be the joke it exists to stop. -import { resolveAuthzContext } from '@objectstack/core'; +import { resolveAuthzContext, userAuthGateSourceOf } from '@objectstack/core'; import type { VerifyStack } from '@objectstack/verify'; /** The system read context every probe uses — probes observe, never mutate. */ @@ -216,6 +216,9 @@ export async function resolveAuthzFor(stack: VerifyStack, token: string): Promis ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), + // [#23455] The owner-posture source REST names, from the same `auth` + // service: the key-admission seam asks it for an API key's owner. + userAuthGates: userAuthGateSourceOf(authService), }); return { userId: ctx?.userId, diff --git a/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts b/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts index dceac1819d4..fa3205baca7 100644 --- a/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts @@ -65,6 +65,7 @@ describe('sys_attachment reads inherit parent RLS — the COUNT too (#9483)', () if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts index cf672abbae9..5057852daa5 100644 --- a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts @@ -82,6 +82,7 @@ describe('sys_attachment delete gate under an unscoped multi-delete (#9483)', () let api: any = authService?.api; if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ authorization: `Bearer ${token}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), @@ -276,6 +277,7 @@ describe('sys_attachment unscoped multi-delete is refused on its SHAPE, not on e let api: any = authService?.api; if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ authorization: `Bearer ${token}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index ece97fc5786..2795e9badb9 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -158,6 +158,7 @@ describe('[#21260] the ledger audit capability exempts its holder from the paren const auth = await stack.kernel.getServiceAsync('auth'); const api = auth?.api ?? (typeof auth?.getApi === 'function' ? await auth.getApi() : undefined); const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ authorization: `Bearer ${token[who]}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index 05582b0aa81..721d76295ef 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -192,6 +192,7 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 2f6053e5831..4cf0389930f 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -149,6 +149,7 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts index eb928317827..e455a1c58ff 100644 --- a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts @@ -135,6 +135,7 @@ describe('controlled_by_parent parent gates: sys_attachment and sys_comment judg let api: any = authService?.api; if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const context = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ authorization: `Bearer ${token}` }), getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts index f77aeb41cf1..d5dfab4b238 100644 --- a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts @@ -128,6 +128,7 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { // ── RESOLUTION path (L1 validity filter on the delegated grant) ──────────── it('the delegate RESOLVES the delegated position while inside the window', async () => { const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: sessionFor(delegateId), nowMs: Date.now(), }); expect(ctx.positions, 'delegate holds the delegated position during the window').toContain('vacation_approver'); @@ -136,6 +137,7 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { it('the delegated position STOPS resolving at valid_until — access dies, no cleanup job', async () => { const afterExpiry = Date.parse(validUntil) + 1000; const ctx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: {}, getSession: sessionFor(delegateId), nowMs: afterExpiry, }); expect(ctx.positions, 'the delegated grant is gone the instant its window closes').not.toContain('vacation_approver'); diff --git a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts index 9c84b03b622..40e25e4db0f 100644 --- a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts @@ -184,6 +184,7 @@ describe('[#8119] federated phantom anchor: single-record gates + share posture' // one however the store looks afterwards. adminToken = await stack.signIn(); adminCtx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ authorization: `Bearer ${adminToken}` }), getSession: async (h: unknown) => { diff --git a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts index 8fc0beb0ea5..b716bc2246f 100644 --- a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts @@ -79,6 +79,7 @@ describe('[ADR-0123 D2 / #8208] a tenant-scoped write with no active organizatio adminToken = await stack.signIn(); const adminCtx = await resolveAuthzContext({ + userAuthGates: undefined, ql, headers: new Headers({ authorization: `Bearer ${adminToken}` }), getSession: async (h: unknown) => { diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index cf471402372..ad75465561e 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -188,6 +188,7 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index d78bc2a21d7..edb390a1427 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -69,6 +69,7 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const headers = new Headers({ authorization: `Bearer ${token}` }); return resolveAuthzContext({ + userAuthGates: undefined, ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index f057cadfa14..df4c3feab83 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -172,6 +172,7 @@ async function callerContext(b: Booted): Promise { if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); const headers = new Headers({ authorization: `Bearer ${b.token}` }); return resolveAuthzContext({ + userAuthGates: undefined, ql: b.ql, headers, getSession: async (h: any) => api?.getSession?.({ headers: h }), diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index 0ba8b5c493f..14292597b0f 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -571,6 +571,7 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post try { const headers = new Headers({ 'x-api-key': RAW_EXMEMBER_KEY }); const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql: qlWith({ memberships: MEMBER_ROWS }), headers, getSession: async () => undefined, @@ -609,6 +610,7 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post try { const headers = new Headers({ 'x-api-key': RAW_EXMEMBER_KEY }); const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql: qlWith({ memberships: MEMBER_ROWS }), headers, getSession: async () => undefined, diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index 7877b481885..38514a68b8e 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -388,6 +388,7 @@ const authService = { async function resolveFor(credential: string) { const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql: grantsEngine as any, headers: new Headers({ authorization: credential }), getSession: async (h: any) => authService.api.getSession({ headers: h }), diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index 19460cb3915..b9e3ee85610 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -343,6 +343,7 @@ const authService = { async function resolveFor(credential: string) { const authz = await resolveAuthzContext({ + userAuthGates: undefined, ql: grantsEngine as any, headers: new Headers({ authorization: credential }), getSession: async (h: any) => authService.api.getSession({ headers: h }), diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index e3bc01b2ff0..bb2112bdb26 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -276,6 +276,7 @@ function mountBoth() { } } const authz = await resolveAuthzContext({ + userAuthGates: undefined, // [#9593] The SAME engine the admin spelling resolves `objectql` to, // stated rather than omitted. It used to be `undefined` here, which was // right while only a session mattered; now that one spelling reads diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 6a5717cf23f..26ce5268fd2 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -1158,6 +1158,25 @@ function buildAuthSessionResolver( }; } +/** The slice of the kernel's `auth` service the storage family's two posture readers use. */ +type GateAuthService = { + isAuthGateActive?(): boolean; + resolveUserAuthGates?(userId: string, options?: { organizationId?: string }): Promise; +}; + +/** + * The kernel's `auth` service, or `undefined` when none is registered (a + * kernel composed without `plugin-auth`). One lookup for both posture readers: + * the family door's gate reader and the download authorizer's resolver. + */ +function resolveGateAuthService(registry: StorageGateRegistry): GateAuthService | undefined { + try { + return registry.getService('auth') ?? undefined; + } catch { + return undefined; + } +} + /** * ADR-0069 — read a request's session user for the authentication-policy gate * the storage family's door asks (`registerStorageFamily`). `undefined` when @@ -1190,16 +1209,7 @@ function buildAuthGateSessionReader( registry: StorageGateRegistry, engine: IDataEngine | null, ): StorageGatedSessionReader | undefined { - type GateAuthService = { - isAuthGateActive?(): boolean; - resolveUserAuthGates?(userId: string, options?: { organizationId?: string }): Promise; - }; - let authService: GateAuthService | undefined; - try { - authService = registry.getService('auth'); - } catch { - return undefined; - } + const authService = resolveGateAuthService(registry); if (!authService) return undefined; const getSession = buildGetSession(registry); if (!getSession) return undefined; @@ -1380,7 +1390,23 @@ function buildFileReadAuthorizer( // fix. Raised INSIDE this `try`, so an outage takes the relay (commit 6a180e42d) in // the `catch` below rather than a new net. const tenancyPosture = await resolveAdmissionTenancyPosture(registry); - const authz = await resolveAuthzContext({ ql: engine, headers, getSession, tenancyPosture }); + // [#23455] The auth service's per-user posture computation, named here + // as every key-admitting seam names it. This authorizer is not a second + // ADR-0069 gate: every route that reaches it is mounted behind the + // family's door (`mountFamilyRoute` in `storage-routes.ts`), whose gate + // reader (`buildAuthGateSessionReader`) has already refused a held + // owner's key on this request with the posture's `403`, and both are + // bound by `composeStorageRoutes` from the same `auth` service. Passing + // the source keeps the resolved context truthful (a held key resolves + // as a held principal carrying `authGate`) instead of reading a held + // key as a plain admission here. + const authz = await resolveAuthzContext({ + ql: engine, + headers, + getSession, + tenancyPosture, + userAuthGates: userAuthGateSourceOf(resolveGateAuthService(registry)), + }); if (!authz.userId) return 'unauthenticated'; // Uploader / owner may always download. From d13192ee84d50493f8f38dfa0d41806e80ad258f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:11:58 +0000 Subject: [PATCH 2/3] test(cloud-connection,mcp,core): pin the held owner's key at install-local and per stdio call (#23455, #23456) [WIP] Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- ...-local-api-key-account-posture.pin.test.ts | 266 ++++++++++++++++ .../stdio-api-key-account-posture.pin.test.ts | 289 ++++++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts create mode 100644 packages/mcp/src/stdio-api-key-account-posture.pin.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts new file mode 100644 index 00000000000..b9cf74e512a --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23455 — ruling C′ items 1 and 4] The install-local doors judge an API key + * by the answer the shared key-admission seam gives for its OWNER. + * + * ## What was open + * + * `MarketplaceInstallLocalPlugin.resolveInstallPrincipal` called + * `resolveAuthzContext` with no owner-posture source and asked the ADR-0069 + * gate about the session user alone. An API key admits its owner without a + * session, so a key whose owner owes MFA enrolment carried no posture here and + * all five doors served it, while REST and every face ruling C′ built refuse + * the same key with `403 MFA_REQUIRED`. + * + * ## What is pinned, through the booted plugin's own doors + * + * - a key whose owner holds `MFA_REQUIRED` (scope `account`) → all five doors + * answer `403 MFA_REQUIRED` with the session's message, before any effect; + * - a key whose owner holds only `PASSWORD_RESET_REQUIRED` (scope + * `credential`) → served; + * - the refusal is not a revocation: the same key is served again once the + * posture clears, with nothing rewritten. + * + * The harness is `cloud-connection-auth-gate.pin.test.ts`'s install-local + * half, with a data engine that knows the key and an auth service that answers + * a per-user posture (`resolveUserAuthGates`, the computation plugin-auth's + * `AuthManager` exposes). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { hashApiKey } from '@objectstack/core'; + +const seedCalls: unknown[] = []; +vi.mock('@objectstack/runtime', () => ({ + SeedLoaderService: class { + async load(request: unknown) { + seedCalls.push(request); + return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + } + async buildDependencyGraph(objectNames: string[]) { + return { + nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })), + insertOrder: objectNames, + circularDependencies: [], + }; + } + }, + recordSeedOutcome: vi.fn(), +})); + +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; + +const IL = '/api/v1/marketplace/install-local'; +const KEY = 'osk_install_local_account_posture_pin'; +const OWNER = 'u_key_owner'; + +const MFA_REQUIRED = { + code: 'MFA_REQUIRED', + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', +}; +const PASSWORD_RESET_REQUIRED = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' }; + +type Posture = { code: string; message: string }; + +const APP = { + id: 'com.acme.keyheld', + namespace: 'keyheld', + version: '1.0.0', + objects: [{ name: 'widget', fields: { code: { type: 'text' } } }], + data: [{ object: 'widget', externalId: 'code', records: [{ code: 'a' }, { code: 'b' }] }], +}; +const LEDGER_FILE = 'com.acme.keyheld.json'; + +/** Equality and `$in` — the two shapes the shared resolver issues — and nothing else, loudly. */ +function matchesWhere(row: Record, where: unknown): boolean { + for (const [field, cond] of Object.entries((where ?? {}) as Record)) { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as { $in?: unknown }).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + if (!(cond as { $in: unknown[] }).$in.includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +/** The key's owner holds the authoring capability, so a refusal below is the gate's alone. */ +function grantRows(): Record>> { + return { + sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]' }], + sys_user: [{ id: OWNER, email: 'owner@acme.test' }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_metadata_activation: [], + sys_position_permission_set: [], + sys_user_permission_set: [ + { id: 'ups1', user_id: OWNER, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null }, + ], + sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], + }; +} + +type Handler = (c: any) => Promise; + +/** + * Boot the plugin with an auth service whose per-user posture is read from + * `postures` on every ask, so a test can move it while the doors are live. + */ +async function mountInstallLocal(storageDir: string, postures: Record) { + const register = vi.fn(async () => undefined); + const syncSchemas = vi.fn(async () => undefined); + const widgets = [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }]; + const engineDelete = vi.fn(async (object: string, options?: any) => { + const dispatch = assertEngineDeleteDispatch(options); + if (object !== 'widget' || dispatch.kind !== 'by-id') return false; + const at = widgets.findIndex((w) => w.id === dispatch.id); + if (at < 0) return false; + widgets.splice(at, 1); + return true; + }); + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + const hooks = new Map(); + const rows = grantRows(); + const keyRowBefore = JSON.stringify(rows.sys_api_key); + const services: Record = { + manifest: { register }, + auth: { + // No session: every caller below presents the key alone. + api: { getSession: async () => null }, + resolveUserAuthGates: async (userId: string) => postures[userId] ?? [], + }, + objectql: bindCatalogFromTables({ + syncSchemas, + find: async (object: string, q: any = {}) => { + if (object === 'widget') return widgets.map((w) => ({ ...w })); + const matched = (rows[object] ?? []).filter((row) => matchesWhere(row, q?.where)); + return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; + }, + delete: engineDelete, + }, rows), + metadata: { getObject: async () => ({ name: 'widget', fields: {} }) }, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + registerService: () => undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + await new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }).start(ctx); + await hooks.get('kernel:ready')?.(); + // `kernel:ready` rehydrates the ledger, so every counter starts AFTER it. + register.mockClear(); + syncSchemas.mockClear(); + engineDelete.mockClear(); + seedCalls.length = 0; + return { + routes, + effects: () => register.mock.calls.length + syncSchemas.mock.calls.length + seedCalls.length + engineDelete.mock.calls.length, + keyRowUnchanged: () => JSON.stringify(rows.sys_api_key) === keyRowBefore, + }; +} + +function withKey(body: unknown = {}, manifestId?: string) { + const headers = new Headers({ 'x-api-key': KEY }); + const url = `http://localhost${IL}`; + return { + req: { + url, + raw: new Request(url, { headers }), + header: (n: string) => headers.get(n) ?? undefined, + json: async () => body, + param: () => manifestId, + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +/** The five doors, addressed with what each needs to act. */ +const IL_DOORS = [ + { route: `POST ${IL}`, body: { manifest: APP } }, + { route: `GET ${IL}`, body: {} }, + { route: `DELETE ${IL}/:manifestId`, body: {} }, + { route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} }, + { route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} }, +] as const; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'il-key-posture-pin-')); + seedCalls.length = 0; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('install-local doors — a key answers as its owner\'s session does (#23455, ruling C′)', () => { + it('REFUSES a key whose owner holds MFA_REQUIRED on all five doors, with the session\'s 403 code and message, before any effect', async () => { + const postures: Record = {}; + const m = await mountInstallLocal(dir, postures); + // Installed by the same key while its owner is not held, so the + // follow-on doors have a target. + const seeded = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP })); + expect(seeded.status, JSON.stringify(seeded.payload)).toBe(200); + expect(existsSync(join(dir, LEDGER_FILE))).toBe(true); + const effectsBefore = m.effects(); + + postures[OWNER] = [MFA_REQUIRED]; + for (const door of IL_DOORS) { + const res = await m.routes.get(door.route)!(withKey(door.body, APP.id)); + expect(res.status, door.route).toBe(403); + expect(res.payload, door.route).toEqual({ success: false, error: { code: MFA_REQUIRED.code, message: MFA_REQUIRED.message } }); + } + expect(m.effects(), 'a held owner\'s key caused an effect').toBe(effectsBefore); + expect(existsSync(join(dir, LEDGER_FILE)), 'a held owner\'s key reached the uninstall').toBe(true); + }); + + it('SERVES a key whose owner holds only a credential-scope posture', async () => { + const m = await mountInstallLocal(dir, { [OWNER]: [PASSWORD_RESET_REQUIRED] }); + const installed = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP })); + expect(installed.status, JSON.stringify(installed.payload)).toBe(200); + expect(installed.payload.success).toBe(true); + expect(m.effects()).toBeGreaterThan(0); + const listed = await m.routes.get(`GET ${IL}`)!(withKey()); + expect(listed.status).toBe(200); + expect(listed.payload.success).toBe(true); + }); + + it('the refusal is not a revocation: the same key is served once the posture clears', async () => { + const postures: Record = { [OWNER]: [MFA_REQUIRED] }; + const m = await mountInstallLocal(dir, postures); + const held = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP })); + expect(held.status).toBe(403); + expect(held.payload?.error?.code).toBe('MFA_REQUIRED'); + expect(m.effects()).toBe(0); + + // The owner enrols; nothing about the key is rewritten or reissued. + postures[OWNER] = []; + const again = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP })); + expect(again.status, JSON.stringify(again.payload)).toBe(200); + expect(again.payload.success).toBe(true); + expect(existsSync(join(dir, LEDGER_FILE))).toBe(true); + expect(m.keyRowUnchanged(), 'the hold wrote to the key row').toBe(true); + }); +}); diff --git a/packages/mcp/src/stdio-api-key-account-posture.pin.test.ts b/packages/mcp/src/stdio-api-key-account-posture.pin.test.ts new file mode 100644 index 00000000000..3b796459c08 --- /dev/null +++ b/packages/mcp/src/stdio-api-key-account-posture.pin.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23456 — ruling C′ items 1 and 4] The MCP stdio face asks the key OWNER's + * posture on EVERY call of a live session, and refuses a held owner's call + * with the envelope REST answers the same key with. + * + * ## What was open + * + * `resolveStdioExecutionContext` resolved `OS_MCP_STDIO_API_KEY` through + * `resolveAuthzContext` with no owner-posture source and assembled + * `authGate: undefined`, so a key whose owner owes MFA enrolment started the + * transport and was served on every call, while REST and every face ruling C′ + * built refuse the same key with `403 MFA_REQUIRED`. + * + * ## Why per call, and why the transport still starts + * + * Item 4: the refusal is not a revocation, and the key resumes the moment the + * owner remediates. A start-only refusal could neither see a posture that + * appears mid-session nor let the key resume without a restart. So a held + * identity STARTS (it is a valid key; a warning names the hold once), and each + * principal-bound call re-asks the owner's posture — the schedule ADR-0101 D1 + * already gives revocation on this door. + * + * ## What is pinned + * + * - a key whose owner holds `MFA_REQUIRED` (scope `account`) → every + * principal-bound call (the data tools' bridge and the record resource) + * rejects with `code` `MFA_REQUIRED`, `status` 403 and the session's + * message, before any read or write; over the real stdio wire a + * `tools/call` answers a tool error carrying that remediation message; + * - a key whose owner holds only `PASSWORD_RESET_REQUIRED` (scope + * `credential`) → served; + * - the same session: a posture that APPEARS refuses the next call, and a + * posture that CLEARS serves the next call, with no restart. + * + * A REAL `ObjectKernel` holds the services and `@objectstack/core` is not + * mocked: the real verify → authorize chain runs. The engine double carries + * only `find` and `insert`, the two verbs this door's calls reach here. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ObjectKernel, hashApiKey } from '@objectstack/core'; +import { MCPServerPlugin } from './plugin.js'; +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge } from './mcp-http-tools.js'; + +const OBJECT = 'crm_note'; +const RAW_KEY = 'osk_23456_stdio_account_posture'; +const OWNER = 'u_key_owner'; + +const MFA_REQUIRED = { + code: 'MFA_REQUIRED', + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', +}; +const PASSWORD_RESET_REQUIRED = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' }; + +type Posture = { code: string; message: string }; + +/** Equality and `$in` — the two shapes the shared resolver issues — and nothing else, loudly. */ +function matchesWhere(row: Record, where: unknown): boolean { + for (const [field, cond] of Object.entries((where ?? {}) as Record)) { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as { $in?: unknown }).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + if (!(cond as { $in: unknown[] }).$in.includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +function makeFixture() { + const notes: Array> = [{ id: 'n1', title: 'first note' }]; + const tables: Record>> = { + sys_api_key: [{ id: 'key_owner', key: hashApiKey(RAW_KEY), user_id: OWNER, revoked: false, scopes: '[]' }], + sys_user: [{ id: OWNER, email: 'owner@example.com' }], + sys_member: [], + sys_user_permission_set: [{ user_id: OWNER, permission_set_id: 'ps_shared' }], + sys_permission_set: [{ id: 'ps_shared', name: 'shared_access', system_permissions: ['studio.access'] }], + }; + const keyRowBefore = JSON.stringify(tables.sys_api_key); + let seq = 0; + const engine = { + async find(object: string, query: any = {}) { + if (object === OBJECT) return notes.filter((row) => matchesWhere(row, query?.where)).map((row) => ({ ...row })); + const matched = (tables[object] ?? []).filter((row) => matchesWhere(row, query?.where)); + return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; + }, + async insert(object: string, data: any) { + if (object !== OBJECT) throw new Error(`fixture: no write table for '${object}'`); + const row = { id: `n${++seq + 1}`, ...data }; + notes.push(row); + return { ...row }; + }, + }; + return { engine, notes, keyRowUnchanged: () => JSON.stringify(tables.sys_api_key) === keyRowBefore }; +} + +/** A REAL kernel holding the engine, a metadata double and an auth service whose posture the test moves. */ +function makeHost(postures: Record) { + const fixture = makeFixture(); + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); + kernel.registerService('objectql', fixture.engine); + kernel.registerService('metadata', { + listObjects: vi.fn(async () => []), + // No declaration ⇒ the ADR-0049 exposure gate falls open, the state a bare + // kernel is in; what is measured here is the posture, nothing else. + getObject: vi.fn(async () => null), + get: vi.fn(async () => null), + list: vi.fn(async () => []), + exists: vi.fn(async () => false), + getRegisteredTypes: vi.fn(async () => ['object']), + register: vi.fn(), + unregister: vi.fn(), + }); + kernel.registerService('auth', { + // The computation plugin-auth's `AuthManager` exposes, read on every ask. + resolveUserAuthGates: async (userId: string) => postures[userId] ?? [], + }); + let runtime: MCPServerRuntime | undefined; + const ctx = { + registerService: vi.fn((name: string, service: unknown) => { + if (name === 'mcp') runtime = service as MCPServerRuntime; + kernel.registerService(name, service); + }), + getService: vi.fn((name: string): T => kernel.getService(name)), + replaceService: vi.fn(), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(async () => {}), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(() => kernel), + }; + return { fixture, ctx, runtime: () => runtime }; +} + +interface Started { + bridge: McpDataBridge; + getRecord: (object: string, id: string) => Promise | null>; + runtime: MCPServerRuntime; +} + +/** + * Boot the plugin on the stdio path. The data tools are registered for real on + * the long-lived server (`bridgeDataTools` is spied, not replaced); only the + * transport's own `start()` is stubbed, since it would claim this process's + * stdin/stdout, and the prompt bridge, which reads skill metadata this file + * does not exercise. + */ +async function startStdio(host: ReturnType): Promise { + process.env.OS_MCP_STDIO_API_KEY = RAW_KEY; + let getRecord: Started['getRecord'] | undefined; + const realBridgeResources = MCPServerRuntime.prototype.bridgeResources; + const dataTools = vi.spyOn(MCPServerRuntime.prototype, 'bridgeDataTools'); + const spies = [ + vi.spyOn(MCPServerRuntime.prototype, 'bridgeResources').mockImplementation(function (this: MCPServerRuntime, meta, reader) { + getRecord = reader as Started['getRecord']; + return realBridgeResources.call(this, meta, reader); + }), + vi.spyOn(MCPServerRuntime.prototype, 'bridgePrompts').mockImplementation(async () => {}), + vi.spyOn(MCPServerRuntime.prototype, 'start').mockImplementation(async () => {}), + ]; + try { + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(host.ctx as never); + await plugin.start(host.ctx as never); + } finally { + for (const s of spies) s.mockRestore(); + } + const bridge = dataTools.mock.calls[0]?.[0] as McpDataBridge | undefined; + dataTools.mockRestore(); + const runtime = host.runtime(); + if (!bridge || !getRecord || !runtime) throw new Error('stdio start registered no principal-bound surface'); + return { bridge, getRecord, runtime }; +} + +/** The envelope a refused call rejects with, read off the thrown value. */ +async function refusalOf(call: Promise): Promise<{ code?: unknown; status?: unknown; message?: unknown }> { + const err = await call.then(() => undefined, (e: unknown) => e); + expect(err, 'the call was served').toBeInstanceOf(Error); + const e = err as { code?: unknown; status?: unknown; message?: unknown }; + return { code: e.code, status: e.status, message: e.message }; +} + +const HELD_ENVELOPE = { code: 'MFA_REQUIRED', status: 403, message: MFA_REQUIRED.message }; + +/** One `tools/call` over a real `StdioServerTransport` attached to the long-lived server. */ +async function callToolOverStdio(runtime: MCPServerRuntime, name: string, args: Record) { + const serverStdin = new PassThrough(); + const serverStdout = new PassThrough(); + const transport = new StdioServerTransport(serverStdin, serverStdout); + await runtime.server.connect(transport); + const frames: Array<{ id?: number; result?: any; error?: any }> = []; + let buffered = ''; + serverStdout.on('data', (chunk: Buffer | string) => { + buffered += String(chunk); + let nl = buffered.indexOf('\n'); + while (nl >= 0) { + const line = buffered.slice(0, nl).trim(); + buffered = buffered.slice(nl + 1); + nl = buffered.indexOf('\n'); + if (!line) continue; + try { frames.push(JSON.parse(line)); } catch { /* not a frame */ } + } + }); + const answer = (id: number) => new Promise<{ result?: any; error?: any }>((resolve, reject) => { + const started = Date.now(); + const poll = () => { + const frame = frames.find((f) => f.id === id); + if (frame) return resolve(frame); + if (Date.now() - started > 5_000) return reject(new Error(`stdio: no answer to id ${id} within 5s`)); + setTimeout(poll, 5); + }; + poll(); + }); + const send = (frame: Record) => serverStdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`); + try { + send({ id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'pin', version: '0.0.0' } } }); + await answer(1); + send({ method: 'notifications/initialized' }); + send({ id: 2, method: 'tools/call', params: { name, arguments: args } }); + return await answer(2); + } finally { + await transport.close().catch(() => {}); + } +} + +const ORIGINAL_ENV = { ...process.env }; +beforeEach(() => { process.env = { ...ORIGINAL_ENV }; }); +afterEach(() => { process.env = { ...ORIGINAL_ENV }; vi.restoreAllMocks(); }); + +describe('MCP stdio — a key answers as its owner\'s session does, on every call (#23456, ruling C′)', () => { + it('REFUSES every principal-bound call of a held owner\'s key with 403 MFA_REQUIRED and the session\'s message, before any read or write', async () => { + const host = makeHost({ [OWNER]: [MFA_REQUIRED] }); + // A valid key whose owner is held still STARTS, and says so once. + const { bridge, getRecord, runtime } = await startStdio(host); + const warned = host.ctx.logger.warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('held by an authentication policy')); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain('MFA_REQUIRED'); + + expect(await refusalOf(bridge.query(OBJECT, {}))).toEqual(HELD_ENVELOPE); + expect(await refusalOf(bridge.get(OBJECT, 'n1'))).toEqual(HELD_ENVELOPE); + expect(await refusalOf(bridge.create(OBJECT, { title: 'held write' }))).toEqual(HELD_ENVELOPE); + expect(await refusalOf(getRecord(OBJECT, 'n1'))).toEqual(HELD_ENVELOPE); + expect(host.fixture.notes.filter((n) => n.title === 'held write'), 'a held owner\'s write landed').toHaveLength(0); + + // What a stdio client sees: a tool error carrying the remediation message. + const answered = await callToolOverStdio(runtime, 'query_records', { objectName: OBJECT }); + expect(answered.error).toBeUndefined(); + expect(answered.result?.isError).toBe(true); + expect(answered.result?.content?.[0]?.text).toBe(MFA_REQUIRED.message); + }); + + it('SERVES a key whose owner holds only a credential-scope posture', async () => { + const host = makeHost({ [OWNER]: [PASSWORD_RESET_REQUIRED] }); + const { bridge, getRecord } = await startStdio(host); + expect(host.ctx.logger.warn.mock.calls.map((c) => String(c[0])).some((l) => l.includes('held by an authentication policy'))).toBe(false); + const read = (await bridge.query(OBJECT, {})) as { records: Array> }; + expect(read.records.map((r) => r.id)).toEqual(['n1']); + expect(await getRecord(OBJECT, 'n1')).toMatchObject({ id: 'n1' }); + await bridge.create(OBJECT, { title: 'served write' }); + expect(host.fixture.notes.filter((n) => n.title === 'served write')).toHaveLength(1); + }); + + it('the same live session: a posture that APPEARS refuses the next call, and one that CLEARS serves the next call — no restart, nothing revoked', async () => { + const postures: Record = {}; + const host = makeHost(postures); + const { bridge, getRecord } = await startStdio(host); + expect(((await bridge.query(OBJECT, {})) as { records: unknown[] }).records).toHaveLength(1); + + // The deployment starts enforcing MFA and the owner has not enrolled. + postures[OWNER] = [MFA_REQUIRED]; + expect(await refusalOf(bridge.query(OBJECT, {}))).toEqual(HELD_ENVELOPE); + expect(await refusalOf(getRecord(OBJECT, 'n1'))).toEqual(HELD_ENVELOPE); + + // The owner enrols; nothing about the key is rewritten or reissued. + postures[OWNER] = []; + expect(((await bridge.query(OBJECT, {})) as { records: unknown[] }).records).toHaveLength(1); + expect(await getRecord(OBJECT, 'n1')).toMatchObject({ id: 'n1' }); + expect(host.fixture.keyRowUnchanged(), 'the hold wrote to the key row').toBe(true); + }); +}); From a15aecc0aab4519b0cd110e7181b8dc5b2ff0438 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:25:53 +0000 Subject: [PATCH 3/3] chore(changeset): the owner-posture input is required; two seams refuse a held owner's key (#23455, #23456) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- .../23455-key-admission-posture-required.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/23455-key-admission-posture-required.md diff --git a/.changeset/23455-key-admission-posture-required.md b/.changeset/23455-key-admission-posture-required.md new file mode 100644 index 00000000000..62d11cff01f --- /dev/null +++ b/.changeset/23455-key-admission-posture-required.md @@ -0,0 +1,29 @@ +--- +'@objectstack/core': minor +'@objectstack/runtime': minor +'@objectstack/cloud-connection': minor +'@objectstack/mcp': minor +'@objectstack/service-storage': patch +--- + +fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call + +Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal. + + + +**BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes. + +Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not. + +- **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there. +- **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart. +- **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change. +- **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`. +- **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire. + +What changes for you: + +- **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued. +- **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both. +- **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered).