From 237187ec6ea75a4448097ba4fc21ddb0c90bc608 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 14:59:23 +0000 Subject: [PATCH 1/7] fix(identity): mint API keys against the minter's active organization (#8287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys_api_key` carried no organization, so under `OS_TENANCY_POSTURE=isolated` a minted key authenticated a user with no active organization and the Layer 0 wall (`organization_id = activeOrganizationId`) could match nothing: every org-scoped read answered `200` with `total 0` while the console went on offering minting. The column was absent by an inherited rule, not by oversight — `resolveInjectedSystemColumns` skips `managedBy: 'better-auth'` objects, and `sys_api_key` carries that flag even though better-auth's `apiKey` plugin is not loaded and the table is hand-rolled ObjectStack. - declare `active_organization_id` on `sys_api_key` (+ index, list columns) - register it as an ADR-0105 D7 managed extension field, and correct that registry's long-standing drift (its comment said every column here is an extension field; the set listed one) - mint (`POST /keys`) inherits the caller's active organization, re-checks membership against `sys_member` at mint time, and refuses under a walled posture rather than handing back a key that cannot read - the verifier reads ONE spelling (PD #12), refuses an org-less key under `isolated`, and the shared resolver fails an ex-member's key closed using the membership set it had already read — zero extra queries The column is deliberately NOT named `organization_id`: that name would make `sys_api_key` itself org-walled, hiding pre-existing org-less rows from their own owners while they keep authenticating under `group`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- packages/core/package.json | 1 + packages/core/src/security/api-key.test.ts | 123 +++++++++++++++- packages/core/src/security/api-key.ts | 134 ++++++++++++++++-- .../security/resolve-authz-context.test.ts | 130 +++++++++++++++++ .../src/security/resolve-authz-context.ts | 69 ++++++++- .../apps/translations/en.objects.generated.ts | 4 + .../translations/es-ES.objects.generated.ts | 4 + .../translations/ja-JP.objects.generated.ts | 4 + .../translations/zh-CN.objects.generated.ts | 4 + .../src/identity/sys-api-key.object.ts | 55 ++++++- .../src/managed-extension-fields.ts | 36 ++++- .../plugin-security/src/tenant-layer.test.ts | 57 ++++++++ packages/runtime/src/domains/keys.ts | 94 +++++++++++- .../runtime/src/http-dispatcher.keys.test.ts | 124 ++++++++++++++++ .../resolve-execution-context.test.ts | 23 ++- pnpm-lock.yaml | 3 + 16 files changed, 840 insertions(+), 25 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 4a3f226623..187e3a74ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "zod": "^4.4.3" }, "keywords": [ diff --git a/packages/core/src/security/api-key.test.ts b/packages/core/src/security/api-key.test.ts index 83063853be..cbbac26909 100644 --- a/packages/core/src/security/api-key.test.ts +++ b/packages/core/src/security/api-key.test.ts @@ -9,6 +9,7 @@ import { parseScopes, isExpired, resolveApiKeyPrincipal, + resolveApiKeyAdmission, } from './api-key.js'; /** In-memory sys_api_key store exposing the `find` shape the verifier uses. */ @@ -69,7 +70,11 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => { it('resolves a valid key to its principal (x-api-key)', async () => { const raw = 'osk_valid'; const ql = makeQl([ - { key: hashApiKey(raw), revoked: false, user_id: 'u1', organization_id: 'org1', scopes: '["read"]', expires_at: FUTURE }, + // [#8287] Re-spelled from `organization_id`: this fixture merely USED the + // alias the verifier no longer reads, and its assertion — a valid key + // resolves to owner + tenant + scopes — is unchanged and still reads a + // 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 }); expect(p).toEqual({ userId: 'u1', tenantId: 'org1', scopes: ['read'] }); @@ -103,3 +108,119 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => { expect(await resolveApiKeyPrincipal({}, { 'x-api-key': 'osk_x' })).toBeUndefined(); }); }); + +// ── [#8287] Organization on the key ──────────────────────────────────────── + +/** + * The card: under `OS_TENANCY_POSTURE=isolated` a minted key read NOTHING — + * `200 + total 0` on every org-scoped object — because `sys_api_key` carried no + * organization at all, and the `isolated` Layer 0 wall is + * `organization_id = activeOrganizationId`. With no active organization, no row + * can match. These tests pin both halves of the fix: the principal now carries + * the organization the key was minted against, and a key that carries none is + * REFUSED under the one posture where it is provably dead, instead of + * authenticating into a silent-empty. + */ +describe('resolveApiKeyAdmission — organization (#8287)', () => { + const raw = 'osk_org_probe'; + const withPosture = async (posture: string | undefined, fn: () => Promise): Promise => { + const previous = process.env.OS_TENANCY_POSTURE; + if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = posture; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + } + }; + + it('reads the organization off the row and carries it as tenantId', async () => { + 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 }); + expect(admission.outcome).toBe('admitted'); + expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); + }); + + /** + * The canonical-spelling pin (PD #12). The verifier used to read + * `row.organization_id ?? row.organizationId` — a consumer-side alias chain + * for a producer that did not exist. The mint path now writes exactly one + * spelling, so the verifier reads exactly one: a row carrying only the OLD + * names resolves to NO organization, which is the honest answer for a row no + * mint path ever wrote. + */ + it('reads ONE spelling — the retired organization_id aliases do not resolve', async () => { + 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 }); + // `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(); + }); + + 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 withPosture('single', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + expect(admission.outcome).toBe('admitted'); + }); + + /** + * `group`'s wall is `organization_id IN accessible_org_ids`, and that set is + * derived from the owner's `sys_member` rows INDEPENDENTLY of the active + * organization — so an org-less key already reads the union of its owner's + * organizations there. Refusing it would break working deployments for no + * security gain, which is why the refusal is posture-conditional rather than + * "no org ⇒ no key". + */ + 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 withPosture('group', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + 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 withPosture('isolated', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + 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 + // name the posture and the remedy, not merely deny. + expect(admission.outcome === 'refused' && admission.message).toMatch(/isolated/); + }); + + it('the legacy `multi` spelling refuses too (it normalizes to `isolated`)', async () => { + const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]); + const admission = await withPosture('multi', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + expect(admission.outcome).toBe('refused'); + }); + + it('an ORG-STAMPED key is admitted under `isolated` — the fix, not just the refusal', async () => { + const ql = makeQl([ + { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }, + ]); + const admission = await withPosture('isolated', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + expect(admission.outcome).toBe('admitted'); + expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); + }); + + /** + * A refusal must stay distinguishable from "no key" — that distinction is + * the whole point of the admission type. `resolveApiKeyPrincipal` collapses + * both to `undefined` so every pre-existing caller keeps failing closed. + */ + 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 withPosture('isolated', () => resolveApiKeyPrincipal(ql, { 'x-api-key': raw })); + 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 withPosture('isolated', () => resolveApiKeyAdmission(ql, {})); + expect(admission.outcome).toBe('none'); + }); +}); diff --git a/packages/core/src/security/api-key.ts b/packages/core/src/security/api-key.ts index fa0f327eed..054c96494a 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -23,6 +23,10 @@ import { createHash, randomBytes } from 'node:crypto'; +import { postureEnforcesWall, postureUsesUnionScope } from '@objectstack/spec/security'; +import type { TenancyPosture } from '@objectstack/spec/security'; +import { resolveTenancyPosture } from '@objectstack/types'; + /** Default visible prefix for generated keys (helps users identify a key). */ export const API_KEY_PREFIX = 'osk_'; @@ -127,10 +131,60 @@ export function isExpired(value: unknown, nowMs: number): boolean { /** The principal resolved from a valid `sys_api_key`. */ export interface ApiKeyPrincipal { userId: string; + /** + * The organization this key authenticates INTO — read from the row's + * `active_organization_id` and adopted by `resolveAuthzContext` as the + * request's active organization (`ExecutionContext.tenantId`), which is what + * lets the ADR-0105 Layer 0 wall match. `undefined` for a key minted before + * #8287, or one minted under the `single` posture where there is no + * organization to inherit. + */ tenantId?: string; scopes: string[]; } +/** + * [#8287] Why a key was refused. Distinct from "no key present" and from "this + * key is unknown/revoked/expired": a refusal means the credential is real and + * intact but cannot be admitted under this deployment's tenancy posture. + */ +export type ApiKeyRefusalReason = 'organization_required' | 'organization_membership_ended'; + +/** + * The verdict on an inbound API key. Three outcomes, deliberately distinct: + * + * - `none` — no key header, or a key that is unknown / revoked / expired / + * owner-less. Indistinguishable by design (never tell a prober which), and + * the caller MAY fall through to the session path exactly as before. + * - `admitted` — a usable principal. + * - `refused` — a real, intact key the posture cannot admit. The caller must + * NOT fall through to the session path: falling through would be more + * permissive than today's behaviour (an API key already outranks a session), + * and the whole point of the refusal is that it is LOUD at call time. + */ +export type ApiKeyAdmission = + | { outcome: 'none' } + | { outcome: 'admitted'; principal: ApiKeyPrincipal } + | { outcome: 'refused'; reason: ApiKeyRefusalReason; message: string }; + +/** + * Read the deployment's tenancy posture, fail-closed. + * + * `resolveTenancyPosture` THROWS on an unrecognized `OS_TENANCY_POSTURE` — by + * design, so a typo cannot silently drop the organization wall. The CLI's boot + * gate refuses to serve in that state, so this branch is unreachable on a + * running deployment; if it is ever reached anyway, treat the posture as + * `isolated` (the strictest) rather than letting an unreadable posture become + * the reason a credential is admitted. + */ +export function currentTenancyPosture(): TenancyPosture { + try { + return resolveTenancyPosture(); + } catch { + return 'isolated'; + } +} + /** * 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. @@ -147,9 +201,31 @@ export async function resolveApiKeyPrincipal( headers: any, nowMs: number = Date.now(), ): Promise { + const admission = await resolveApiKeyAdmission(ql, headers, nowMs); + return admission.outcome === 'admitted' ? admission.principal : undefined; +} + +/** + * [#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. + * + * 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. + */ +export async function resolveApiKeyAdmission( + ql: any, + headers: any, + nowMs: number = Date.now(), +): Promise { const apiKey = extractApiKey(headers); - if (!apiKey) return undefined; - if (!ql || typeof ql.find !== 'function') return undefined; + if (!apiKey) return { outcome: 'none' }; + if (!ql || typeof ql.find !== 'function') return { outcome: 'none' }; // Match by the indexed at-rest hash only — never query by the raw key. let rows: any; @@ -160,22 +236,62 @@ export async function resolveApiKeyPrincipal( context: { isSystem: true }, }); } catch { - return undefined; + return { outcome: 'none' }; } if (rows && (rows as any).value) rows = (rows as any).value; const row = Array.isArray(rows) ? rows[0] : undefined; - if (!row || row.revoked === true) return undefined; + if (!row || row.revoked === true) return { outcome: 'none' }; const expiresAt = row.expires_at ?? row.expiresAt; - if (isExpired(expiresAt, nowMs)) return undefined; + if (isExpired(expiresAt, nowMs)) return { outcome: 'none' }; const userId = row.user_id ?? row.userId; - if (!userId || typeof userId !== 'string') return undefined; + if (!userId || typeof userId !== 'string') return { outcome: 'none' }; + + // [#8287 / PD #12] ONE spelling. The producer (`runtime` `/keys`) writes + // `active_organization_id` and nothing else, so this reads that column and + // nothing else. The `row.organization_id ?? row.organizationId` chain that + // stood here was a consumer-side tolerance for a producer that did not exist + // yet — it read a column no mint path ever wrote, which is why the ruling's + // "key auth establishes that organization" clause was already implemented + // and still measured as inert. + const tenantId = typeof row.active_organization_id === 'string' && row.active_organization_id + ? row.active_organization_id + : undefined; + + // [#8287] Posture-conditional refusal for a key that carries no organization. + // + // ⛔ Never backfilled — inferring the org from the owner's CURRENT membership + // would silently upgrade a credential minted under a different promise. + // Refusal is scoped to the one posture where such a key is provably dead: + // + // - `single` — no wall exists; an org-less key works, and always did. + // - `group` — the wall is `organization_id IN accessible_org_ids`, and + // that set derives from the owner's `sys_member` rows + // INDEPENDENTLY of the active organization, so an org-less + // key already reads the union of its owner's orgs. Refusing + // would break working deployments for no security gain. + // - `isolated` — the wall is `organization_id = activeOrganizationId`; with + // no active organization NOTHING can match, which is exactly + // the `200 + total 0` this card was filed for. Refuse, so the + // failure is loud at call time instead of silently empty. + if (!tenantId) { + const posture = currentTenancyPosture(); + if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) { + return { + outcome: 'refused', + reason: 'organization_required', + message: + 'This API key carries no organization and cannot be used under the `isolated` tenancy ' + + 'posture, where every organization-scoped read is walled to an active organization. ' + + 'Mint a replacement key — new keys inherit the minter’s active organization.', + }; + } + } return { - userId, - tenantId: row.organization_id ?? row.organizationId ?? undefined, - scopes: parseScopes(row.scopes), + outcome: 'admitted', + principal: { userId, tenantId, scopes: parseScopes(row.scopes) }, }; } diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 82f672c585..63c84a5ed4 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js'; import { POSTURE_RANK } from './posture-ladder.js'; +import { hashApiKey } from './api-key.js'; import type { AuthzPosture } from '@objectstack/spec/security'; /** @@ -466,3 +467,132 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( }); }); + +// ── [#8287] API-key organization admission ───────────────────────────────── + +/** + * The two refusals that need the resolver rather than the verifier: one + * because it needs the caller's membership set (resolved here, once), one + * because the refusal must not silently fall through to the session path. + */ +describe('resolveAuthzContext — API-key organization (#8287)', () => { + const raw = 'osk_ctx_probe'; + const keyHeaders = () => ({ 'x-api-key': raw }); + const withPosture = async (posture: string, fn: () => Promise): Promise => { + const previous = process.env.OS_TENANCY_POSTURE; + process.env.OS_TENANCY_POSTURE = posture; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + } + }; + const tables = (member: any[]) => ({ + sys_api_key: [{ key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }], + sys_user: [{ id: 'u1', email: 'ada@x.com' }], + sys_member: member, + sys_user_position: [], + sys_user_permission_set: [], + }); + + 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 withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + expect(ctx.userId).toBe('u1'); + expect(ctx.tenantId).toBe('org_a'); + expect(ctx.authRefusal).toBeUndefined(); + }); + + /** + * Fail-closed at VERIFY time, not revoke-on-event. Membership ends through + * better-auth org endpoints, SCIM deprovisioning, a direct `sys_member` + * delete, or an ADR-0091 window simply lapsing — a revoke-on-removal hook + * must catch every one of those or it silently misses. + * + * The result is NO PRINCIPAL, deliberately: degrading to a user-only + * principal would answer 200 with zero rows, which is the silent-empty class + * this card exists to remove. + */ + 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 withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + expect(ctx.userId).toBeUndefined(); + expect(ctx.tenantId).toBeUndefined(); + expect(ctx.permissions).toEqual([]); + expect(ctx.authRefusal?.reason).toBe('organization_membership_ended'); + }); + + it('refuses when the membership row exists but its ADR-0091 window has lapsed', async () => { + const ql = makeQl(tables([ + { user_id: 'u1', organization_id: 'org_a', role: 'member', valid_until: '2000-01-01T00:00:00Z' }, + ])); + const ctx = await withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + expect(ctx.userId).toBeUndefined(); + expect(ctx.authRefusal?.reason).toBe('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 withPosture('group', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + expect(ctx.userId).toBeUndefined(); + expect(ctx.authRefusal?.reason).toBe('organization_membership_ended'); + }); + + /** + * Under `single` there is no organization boundary to cross, and a + * deployment with no membership rows at all would otherwise have every + * stamped key refused. + */ + it('does NOT apply the membership check under `single`', async () => { + const ql = makeQl(tables([])); + const ctx = await withPosture('single', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + expect(ctx.userId).toBe('u1'); + expect(ctx.authRefusal).toBeUndefined(); + }); + + /** + * A refused key must NOT fall through to the session path. Falling through + * would be MORE permissive than the behaviour this replaced — an API key + * already outranks a session — and a refusal that quietly becomes a session + * login is not a refusal. + */ + it('a refused org-less key does not fall through to the session', async () => { + const ql = makeQl({ + sys_api_key: [{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }], + sys_user: [{ id: 'u1' }], + sys_member: [{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }], + sys_user_position: [], + sys_user_permission_set: [], + }); + const ctx = await withPosture('isolated', () => resolveAuthzContext({ + ql, + headers: keyHeaders(), + getSession: session('u1', { org: 'org_a' }), + })); + expect(ctx.userId).toBeUndefined(); + expect(ctx.authRefusal?.reason).toBe('organization_required'); + }); + + /** + * The membership check is a set test on data the resolver has ALREADY read + * to build `accessible_org_ids` — it must not add a query. Counting reads is + * how that stays true: a later refactor that re-reads `sys_member` for this + * check turns a free assertion into a per-request cost, silently. + */ + it('costs zero additional queries (sys_member is read once)', async () => { + let memberReads = 0; + const inner = makeQl(tables([{ user_id: 'u1', organization_id: 'org_a', role: 'member' }])); + const ql = { + async find(object: string, opts: any) { + if (object === 'sys_member') memberReads += 1; + return inner.find(object, opts); + }, + }; + await withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + // 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. + expect(memberReads).toBe(2); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index a155caa65c..8008d06579 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -32,8 +32,10 @@ import { ORGANIZATION_ADMIN_GRANTS, } from '@objectstack/spec'; import type { AuthzPosture } from '@objectstack/spec/security'; +import { postureEnforcesWall } from '@objectstack/spec/security'; -import { resolveApiKeyPrincipal } from './api-key.js'; +import { resolveApiKeyAdmission, currentTenancyPosture } from './api-key.js'; +import type { ApiKeyRefusalReason } from './api-key.js'; import { isGrantActive } from './grant-validity.js'; import { derivePosture } from './posture-ladder.js'; @@ -67,6 +69,21 @@ export interface ResolvedAuthzContext { * anonymous requests carry no rung. */ posture?: AuthzPosture; + /** + * [#8287] Set when an inbound API key was REFUSED — a real, intact + * credential this deployment's tenancy posture cannot admit. The context is + * otherwise EMPTY (no `userId`), so every transport already fails it closed + * to 401 with no change; this field only lets a transport that wants to say + * WHY do so, instead of answering the operator with a bare "unauthenticated" + * for a key they can see is neither revoked nor expired. + * + * ⚠️ `reason` is NOT an `error.code`. The wire vocabulary is closed + * (ADR-0112: `StandardErrorCode ∪ ERROR_CODE_LEDGER`, both in `packages/spec`) + * and a refused credential's standard member is `UNAUTHENTICATED`. This is a + * diagnostic discriminator for the message, deliberately lowercase so it can + * never be mistaken for one. + */ + authRefusal?: { reason: ApiKeyRefusalReason; message: string }; } export interface ResolveAuthzInput { @@ -117,7 +134,16 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise = { label: "Owner", help: "User who owns this API key" }, + active_organization_id: { + label: "Active Organization", + help: "Organization this key authenticates into — inherited from the minter at creation and established as the request’s active organization" + }, scopes: { label: "Scopes", help: "JSON array of permission scopes" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index dc91ac30a5..f06f58de20 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -942,6 +942,10 @@ export const esESObjects: NonNullable = { label: "Propietario", help: "Usuario que posee esta clave de API." }, + active_organization_id: { + label: "Active Organization", + help: "Organization this key authenticates into — inherited from the minter at creation and established as the request’s active organization" + }, scopes: { label: "Ámbitos", help: "Matriz JSON de ámbitos de permisos." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 9fbeb2b593..7118ff72f8 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -942,6 +942,10 @@ export const jaJPObjects: NonNullable = { label: "所有者", help: "この API キーを所有するユーザー" }, + active_organization_id: { + label: "Active Organization", + help: "Organization this key authenticates into — inherited from the minter at creation and established as the request’s active organization" + }, scopes: { label: "スコープ", help: "権限スコープの JSON 配列" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ce644b40e4..a82b32cff6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -942,6 +942,10 @@ export const zhCNObjects: NonNullable = { label: "所有者", help: "拥有该 API 密钥的用户" }, + active_organization_id: { + label: "Active Organization", + help: "Organization this key authenticates into — inherited from the minter at creation and established as the request’s active organization" + }, scopes: { label: "范围", help: "权限范围的 JSON 数组" diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index f92733085c..fa64630168 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -94,7 +94,10 @@ export const SysApiKey = ObjectSchema.create({ name: 'mine', label: 'My Keys', data: { provider: 'object', object: 'sys_api_key' }, - columns: ['name', 'prefix', 'expires_at', 'last_used_at', 'revoked'], + // [#8287] `active_organization_id` is shown here deliberately: the card's + // complaint was a valid-looking credential whose reach the owner could + // not see. The column IS the reach, so the "My Keys" list states it. + columns: ['name', 'prefix', 'active_organization_id', 'expires_at', 'last_used_at', 'revoked'], filter: [ { field: 'user_id', operator: 'equals', value: '{current_user_id}' }, ], @@ -126,7 +129,7 @@ export const SysApiKey = ObjectSchema.create({ name: 'all_keys', label: 'All', data: { provider: 'object', object: 'sys_api_key' }, - columns: ['name', 'prefix', 'user_id', 'expires_at', 'last_used_at', 'revoked'], + columns: ['name', 'prefix', 'user_id', 'active_organization_id', 'expires_at', 'last_used_at', 'revoked'], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, @@ -168,6 +171,49 @@ export const SysApiKey = ObjectSchema.create({ }), // ── Access ─────────────────────────────────────────────────── + // + // [#8287] The organization this key authenticates INTO. Set once, on the + // mint path, from the minter's active organization (inherited — there is + // deliberately no org parameter and no cross-org key); the verifier reads + // it back and `resolveAuthzContext` establishes it as the request's active + // organization, which is what lets the ADR-0105 Layer 0 wall match. Before + // this column a minted key carried no organization at all, so under the + // `isolated` posture (`organization_id = activeOrganizationId`) no row + // could ever match and the whole key surface read nothing while the console + // went on offering minting. + // + // ⚠️ The NAME is load-bearing, and it is `active_organization_id` — the + // `sys_session.active_organization_id` spelling — NOT `organization_id`. + // Two reasons, one semantic and one measured: + // + // - Semantic: this value is not "the organization that owns this row", it + // is "the organization this credential makes ACTIVE". A session carries + // exactly the same fact under exactly this name, and both are read into + // `ExecutionContext.tenantId` by the one shared resolver. One concept, + // one name (ADR-0089). + // - Measured: `objectHasOrgIdField` (plugin-security `security-plugin.ts`) + // tests for the literal `organization_id`, and `computeTenantLayer0Filter` + // (`tenant-layer.ts`) exempts objects without it. Naming this column + // `organization_id` would therefore make `sys_api_key` ITSELF org-walled, + // and both walled postures exclude NULL: every pre-existing org-less row + // would vanish from the console's "My Keys" list FOR ITS OWN OWNER, and + // under `group` those rows still authenticate — a live credential its + // owner can no longer see or revoke. New keys would fare little better: + // a key minted in org A disappears from its owner's list whenever they + // switch to org B. `sys_api_key` is an owner-scoped credential table + // like `sys_user` / `sys_session` / `sys_account`; it is scoped by the + // Layer 1 `sys_api_key_self` policy (`user_id == current_user.id`), and + // keeping it that way is what stops this fix from creating a fresh + // instance of the very silent-empty class it exists to remove. + active_organization_id: Field.lookup('sys_organization', { + label: 'Active Organization', + required: false, + readonly: true, + description: + 'Organization this key authenticates into — inherited from the minter at creation and established as the request’s active organization', + group: 'Access', + }), + scopes: Field.textarea({ label: 'Scopes', required: false, @@ -251,6 +297,11 @@ export const SysApiKey = ObjectSchema.create({ { fields: ['user_id'] }, { fields: ['prefix'] }, { fields: ['revoked'] }, + // [#8287] Not for the verify path — that matches the unique `key` hash and + // reads the organization off the row it already has. This serves the + // administrative direction ("which keys authenticate into this org?"), + // which is the query a tenant admin runs when a membership ends. + { fields: ['active_organization_id'] }, ], enable: { diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index 1d236a1506..346fe46646 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -80,13 +80,37 @@ export const MANAGED_EXTENSION_FIELDS: Readonly { expect(andComposeLayers(null, null)).toBeNull(); }); }); + +// ── [#8287] sys_api_key must stay OUT of the wall ────────────────────────── + +/** + * The console list-behaviour pin for #8287, taken against the REAL field set + * rather than a hand-written `objectHasOrgIdField` boolean — the two can drift, + * and this is precisely the pair that must not. + * + * #8287 gave `sys_api_key` a column recording the organization a key + * authenticates into. The column is deliberately named + * `active_organization_id` (the `sys_session` spelling), NOT `organization_id`, + * and THIS is the behaviour that naming choice protects: + * + * `security-plugin.ts` answers `objectHasOrgIdField` by testing the registered + * field set for the literal `organization_id`, and `computeTenantLayer0Filter` + * exempts an object without it. Had the column been called `organization_id`, + * `sys_api_key` would itself have become org-walled — and BOTH walled postures + * exclude NULL. Every pre-existing org-less key row would then have vanished + * from the console's "My Keys" list FOR ITS OWN OWNER, while under `group` + * continuing to authenticate: a live credential its owner can neither see nor + * revoke. That is a NEW instance of the silent-empty class #8287 exists to + * remove, so it is pinned here rather than left to a comment. + */ +describe('sys_api_key is not org-walled (#8287)', () => { + const apiKeyFields = new Set(Object.keys(SysApiKey.fields ?? {})); + + it('declares the organization stamp under the non-walling name', () => { + expect(apiKeyFields.has('active_organization_id')).toBe(true); + expect(apiKeyFields.has('organization_id')).toBe(false); + }); + + for (const tenancyPosture of ['single', 'group', 'isolated'] as const) { + it(`${tenancyPosture}: Layer 0 contributes nothing, so a key row stays visible to its owner`, () => { + const filter = computeTenantLayer0Filter({ + ...base, + tenancyPosture, + // Exactly what security-plugin.ts computes from the registered fields. + objectHasOrgIdField: apiKeyFields.has('organization_id'), + }); + expect(filter).toBeNull(); + }); + } + + /** + * The counterfactual, so this suite fails if the exemption is ever the thing + * that breaks rather than the name: under the walled postures an object that + * DOES carry `organization_id` is walled, and the wall excludes NULL rows. + */ + it('counterfactual: the same postures DO wall an object that carries organization_id', () => { + expect(computeTenantLayer0Filter({ ...base, tenancyPosture: 'isolated', objectHasOrgIdField: true })) + .toEqual({ organization_id: 'org-a' }); + expect(computeTenantLayer0Filter({ ...base, tenancyPosture: 'group', objectHasOrgIdField: true })) + .toEqual({ organization_id: { $in: ['org-a', 'org-b'] } }); + }); +}); diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index 397c3a5021..56c4938795 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -22,8 +22,19 @@ * - The row is written with an elevated `{ isSystem: true }` context * because `sys_api_key` is protection-locked; safe because the row's * contents are fully server-controlled (user_id pinned to caller). + * - [#8287] `active_organization_id` is INHERITED from the caller's active + * organization and is likewise never read from the body. There is + * deliberately no org parameter and no cross-org key in v1: a caller cannot + * mint a credential for an organization other than the one they are + * currently working in, so minting can never be a lateral-movement step. + * Inheritance alone is not trusted — the caller's membership in that + * organization is re-checked here, against `sys_member`, at mint time. */ +import { isGrantActive } from '@objectstack/core'; +import { postureEnforcesWall } from '@objectstack/spec/security'; +import { resolveTenancyPosture } from '@objectstack/types'; + import { generateApiKey } from '../security/api-key.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -82,11 +93,85 @@ export async function handleKeysRequest( return { handled: true, response: deps.error('Data service not available', 503) }; } + // ── [#8287] Resolve the organization this key will authenticate into. ── + // + // INHERITED from the caller's active organization (`ExecutionContext + // .tenantId`, which the one shared resolver fills from the session's + // `activeOrganizationId` or from the minting key's own stamp). Never a + // body parameter: see the header. + const activeOrganizationId = typeof ec.tenantId === 'string' && ec.tenantId.trim() + ? ec.tenantId.trim() + : undefined; + + let tenancyPosture; + try { + tenancyPosture = resolveTenancyPosture(); + } catch { + // Unrecognized OS_TENANCY_POSTURE — the CLI's boot gate refuses to + // serve in that state, so this is unreachable on a running deployment. + // Treat it as walled rather than letting an unreadable posture be the + // reason a key is minted without an organization. + tenancyPosture = 'isolated' as const; + } + const walled = postureEnforcesWall(tenancyPosture); + + if (walled && !activeOrganizationId) { + // Refuse rather than mint. Under a walled posture an org-less key + // reads nothing (`isolated`) or reads by a rule that has nothing to do + // with what the caller asked for (`group`) — and handing back a + // valid-looking secret that cannot do its job is the exact defect this + // change removes. Fail at mint time, where the caller is a human at a + // console who can act on it. + return { + handled: true, + response: deps.error( + 'Cannot create an API key without an active organization: this deployment runs a walled ' + + `tenancy posture ('${tenancyPosture}') in which every organization-scoped read requires one. ` + + 'Select an organization and try again.', + 400, + ), + }; + } + + if (activeOrganizationId) { + // Membership check at mint time (the ruling's second clause). The + // inherited value comes from the caller's own context, so this is not + // guarding against a forged parameter — it guards against minting a + // long-lived credential off a STALE context: a session whose active + // organization outlived the membership that justified it. ADR-0091 + // validity windows are honoured, so a lapsed membership does not mint + // either. + let memberRows: any; + try { + memberRows = await ql.find('sys_member', { + where: { user_id: ec.userId, organization_id: activeOrganizationId }, + limit: 1, + context: { isSystem: true }, + }); + } catch { + // Fail closed: an unreadable membership table is not evidence of + // membership. + return { handled: true, response: deps.error('Failed to create API key', 500) }; + } + if (memberRows && (memberRows as any).value) memberRows = (memberRows as any).value; + const member = Array.isArray(memberRows) ? memberRows[0] : undefined; + if (!member || !isGrantActive(member, Date.now())) { + return { + handled: true, + response: deps.error( + 'Cannot create an API key for an organization you are not a member of.', + 403, + ), + }; + } + } + // Generate AFTER validation so we never mint on a rejected request. const generated = generateApiKey(); // Server-controlled row. user_id is pinned to the caller; only the hash - // is persisted. NOTHING from the body can set key/id/user_id/revoked. + // is persisted. NOTHING from the body can set key/id/user_id/revoked/ + // active_organization_id. const row: Record = { name, key: generated.hash, @@ -95,6 +180,7 @@ export async function handleKeysRequest( revoked: false, }; if (expiresAt) row.expires_at = expiresAt; + if (activeOrganizationId) row.active_organization_id = activeOrganizationId; let inserted: any; try { @@ -117,6 +203,12 @@ export async function handleKeysRequest( name, prefix: generated.prefix, key: generated.raw, + // [#8287] Echo the organization the key is pinned to. The + // card's complaint was a credential whose reach the caller + // could not see; the mint response is the first and best + // place to state it, and it is the only moment the caller + // is definitely looking. + ...(activeOrganizationId ? { active_organization_id: activeOrganizationId } : {}), ...(expiresAt ? { expires_at: expiresAt } : {}), }, }, diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index 3fe150b6be..29dbbae68d 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -168,3 +168,127 @@ describe('HttpDispatcher.handleKeys (POST /keys — key generation)', () => { expect(resolved.userId).toBeUndefined(); }); }); + +// ── [#8287] The key inherits the minter's active organization ────────────── + +/** + * Kernel whose ObjectQL also serves `sys_member`, so the mint-time membership + * check has something real to read. + */ +function makeOrgKernel(members: any[]) { + const rows: any[] = []; + const ql = { + insert: async (_obj: string, data: any) => { + const id = `key_${rows.length + 1}`; + rows.push({ id, ...data }); + return { id }; + }, + find: async (obj: string, opts: any) => { + const where = opts?.where ?? {}; + const table = obj === 'sys_api_key' ? rows : obj === 'sys_member' ? members : []; + return table.filter((r: any) => Object.entries(where).every(([k, v]) => r[k] === v)); + }, + update: async () => ({}), + delete: async () => ({}), + }; + const kernel: any = { + getService: (n: string) => (n === 'objectql' ? ql : undefined), + getServiceAsync: async (n: string) => (n === 'objectql' ? ql : undefined), + }; + return { kernel, rows }; +} + +const orgCtx = (tenantId?: string) => ({ + request: { headers: {} }, + response: {}, + environmentId: undefined, + executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [], tenantId }, +}); + +const withPosture = async (posture: string, fn: () => Promise): Promise => { + const previous = process.env.OS_TENANCY_POSTURE; + process.env.OS_TENANCY_POSTURE = posture; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + } +}; + +describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => { + it('stamps the minter’s active organization on the row and echoes it once', async () => { + const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }]); + const res = await withPosture('isolated', () => + dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + + expect(res.response.status).toBe(201); + expect(rows[0].active_organization_id).toBe('org_a'); + expect(res.response.body.data.active_organization_id).toBe('org_a'); + }); + + /** + * ⛔ No org parameter, no cross-org keys in v1. The organization is + * INHERITED — a body that names another organization is ignored exactly the + * way a body naming another `user_id` already is, so minting can never be a + * lateral-movement step. + */ + it('ignores an organization supplied in the body (inherited, never parameterized)', async () => { + const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }]); + const res = await withPosture('isolated', () => dispatcher(kernel).handleKeys( + 'POST', + { name: 'agent', organization_id: 'org_evil', active_organization_id: 'org_evil', organizationId: 'org_evil' }, + orgCtx('org_a'), + )); + + expect(res.response.status).toBe(201); + expect(rows[0].active_organization_id).toBe('org_a'); + }); + + it('refuses when the caller is not a member of their own active organization', async () => { + const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_other', role: 'owner' }]); + const res = await withPosture('isolated', () => + dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + + expect(res.response.status).toBe(403); + // Nothing was minted — a refused mint must not leave a credential behind. + expect(rows).toHaveLength(0); + }); + + it('refuses when the membership row exists but its ADR-0091 window has lapsed', async () => { + const { kernel, rows } = makeOrgKernel([ + { user_id: 'u1', organization_id: 'org_a', role: 'owner', valid_until: '2000-01-01T00:00:00Z' }, + ]); + const res = await withPosture('isolated', () => + dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + + expect(res.response.status).toBe(403); + expect(rows).toHaveLength(0); + }); + + /** + * The card's own defect, caught one step earlier: under a walled posture a + * key with no organization reads nothing, so handing back a valid-looking + * secret is the dishonest half. Refuse at mint time, where the caller is a + * human at a console who can act on it. + */ + it('refuses to mint an org-less key under a walled posture', async () => { + const { kernel, rows } = makeOrgKernel([]); + const res = await withPosture('isolated', () => + dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); + + expect(res.response.status).toBe(400); + expect(rows).toHaveLength(0); + }); + + it('still mints an org-less key under `single` — there is no organization to inherit', async () => { + const { kernel, rows } = makeOrgKernel([]); + const res = await withPosture('single', () => + dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); + + expect(res.response.status).toBe(201); + expect(rows).toHaveLength(1); + expect(rows[0].active_organization_id).toBeUndefined(); + expect(res.response.body.data.active_organization_id).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 521bd92919..ccbae72664 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -108,7 +108,12 @@ describe('resolveExecutionContext — API key verify path', () => { expect(ctx.permissions).toContain('data:write'); }); - it('carries an organization_id through to tenantId when present', async () => { + // [#8287] Re-spelled from `organization_id` — and this is the end-to-end pin + // for the ruling's third clause ("key authentication establishes that + // organization as the request's active organization"). The clause was always + // implemented; before #8287 it read a column no mint path ever wrote, which + // is why the card measured the whole key surface as inert. + it('carries active_organization_id through to tenantId when present', async () => { const raw = 'osk_org'; const rows = [ { @@ -116,7 +121,7 @@ describe('resolveExecutionContext — API key verify path', () => { key: hashApiKey(raw), revoked: false, user_id: 'u1', - organization_id: 'org1', + active_organization_id: 'org1', }, ]; const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); @@ -286,7 +291,12 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 const RAW = 'osk_admin'; function makeAuthQl(extraGrants = []) { const tables = { - sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', organization_id: 'orgA', expires_at: FUTURE }], + // [#8287] Re-spelled from `organization_id`: these fixtures use the key as a + // VEHICLE to authenticate u1 into orgA, and `active_organization_id` is the + // column the verifier reads for exactly that. The assertions below depend on + // the resulting tenantId being 'orgA' (the org-scoped grant must be filtered + // out), so this keeps them meaningful rather than merely green. + sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', active_organization_id: 'orgA', expires_at: FUTURE }], sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }], sys_user_permission_set: [ { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, @@ -349,7 +359,12 @@ describe('resolveExecutionContext — posture plumbing (#2947)', () => { const RAW = 'osk_posture'; function makeQlFor(permissionSets: any[], userPermSets: any[]) { const tables: Record = { - sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', organization_id: 'orgA', expires_at: FUTURE }], + // [#8287] Re-spelled from `organization_id`: these fixtures use the key as a + // VEHICLE to authenticate u1 into orgA, and `active_organization_id` is the + // column the verifier reads for exactly that. The assertions below depend on + // the resulting tenantId being 'orgA' (the org-scoped grant must be filtered + // out), so this keeps them meaningful rather than merely green. + sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', active_organization_id: 'orgA', expires_at: FUTURE }], sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'member' }], sys_user_permission_set: userPermSets, sys_permission_set: permissionSets, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b67c7965b5..eb62f9295a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../spec + '@objectstack/types': + specifier: workspace:* + version: link:../types zod: specifier: ^4.4.3 version: 4.4.3 From 78c1a820aa6864c66f618a77e367e39e8ce08ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:15:29 +0000 Subject: [PATCH 2/7] test(core): resolve @objectstack/types to source in typecheck and tests (#8287) Adding the dependency made `check:test-source-alias` and `check:type-source-resolution` both fire: a unit test or typecheck that reads a sibling's built `dist/` is a verdict about a build, not about the code in this checkout, and the dangerous direction is the one that PASSES. Anchored regex aliases (array form) so the bare entry cannot swallow the `/node` subpath. Also adds the changeset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- .changeset/api-key-carries-organization.md | 74 ++++++++++++++++++++++ packages/core/tsconfig.json | 9 +++ packages/core/vitest.config.ts | 14 ++++ 3 files changed, 97 insertions(+) create mode 100644 .changeset/api-key-carries-organization.md diff --git a/.changeset/api-key-carries-organization.md b/.changeset/api-key-carries-organization.md new file mode 100644 index 0000000000..2f130459b5 --- /dev/null +++ b/.changeset/api-key-carries-organization.md @@ -0,0 +1,74 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": minor +"@objectstack/core": minor +"@objectstack/runtime": minor +--- + +feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287) + + + +On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could +read **nothing at all**. `sys_api_key` carried no organization column, so key +authentication established a user but no active organization — and the +`isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with +no active organization matches no row. Every organization-scoped read answered +`200` with `total 0` while the console went on offering minting, so a tenant +admin could mint a valid-looking secret and discover only at call time that it +read nothing. (There was no cross-tenant leak — the failure was in the other +direction.) + +**The column was absent by an inherited rule, not by oversight.** +`resolveInjectedSystemColumns` injects `organization_id` into every registered +object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that +flag — even though better-auth's `apiKey` plugin is not loaded and the table is +hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7 +extension-field registration to stay consistent. The read side, by contrast, +was **already wired**: `resolveApiKeyPrincipal` already read an organization +into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a +column no mint path ever wrote. + +**What changes** + +- `sys_api_key` declares `active_organization_id` (+ index, and the column is + shown in the "My Keys" and "All" list views, because the card's complaint was + a credential whose reach its owner could not see). +- `POST /api/v1/keys` **inherits** the caller's active organization — there is + deliberately no org parameter and no cross-org key — and **re-checks the + caller's `sys_member` membership at mint time**, honouring ADR-0091 validity + windows. Under a walled posture it refuses (400) rather than minting a key + with no organization, and refuses (403) for an organization the caller is not + a member of. The mint response echoes the organization the key is pinned to. +- The verifier reads **one spelling** (Prime Directive #12): the + `row.organization_id ?? row.organizationId` chain it used to carry was a + consumer-side tolerance for a producer that did not exist. +- An **ex-member's key fails closed at verify time** — no principal, not a + degrade to a user-only principal, which would resurrect the same + `200 + total 0` silent-empty. Checked at verify rather than by revoking on + membership loss, because membership ends through many paths (better-auth org + endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and + a hook must catch every one or it silently misses. It costs **zero extra + queries**: the resolver has already read `sys_member` for this user. +- **Pre-existing org-less keys are never backfilled** — that would silently + upgrade credentials minted under a different promise. They keep working under + `single` (no wall) and under `group` (whose wall derives from the owner's + memberships independently of the active organization, so they already work + there), and are **refused under `isolated`**, where they are provably dead + today. + +**The column is deliberately named `active_organization_id`, not +`organization_id`** — the `sys_session` spelling, for the same concept: the +organization a credential makes *active*. `objectHasOrgIdField` tests for the +literal `organization_id`, and Layer 0 exempts objects without it, so the other +name would have made `sys_api_key` itself org-walled. Both walled postures +exclude NULL, so every pre-existing org-less row would have vanished from its +**own owner's** "My Keys" list while, under `group`, continuing to +authenticate — a live credential nobody could see or revoke, which is a fresh +instance of the very class this change removes. diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 2131d6aa2b..11bf66703c 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,6 +3,15 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", + // [#8287] `@objectstack/types` is resolved to its SOURCE, not its built + // `dist/*.d.ts`. `pnpm check:type-source-resolution` requires this of every + // cross-package type import: a typecheck that reads a stale sibling `dist` + // is a verdict about a build, not about the code in this checkout — and the + // dangerous direction is the one that PASSES. + "paths": { + "@objectstack/types": ["../types/src/index.ts"], + "@objectstack/types/node": ["../types/src/node.ts"] + }, "types": ["node"] }, "include": ["src/**/*"], diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 782c6a40a4..4f0c40ce1f 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,10 +1,24 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import path from 'node:path'; + import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', + // [#8287] Resolve `@objectstack/types` to its SOURCE. `resolveTenancyPosture` + // reads `OS_TENANCY_POSTURE` live, and the api-key admission tests drive it + // per-case; against a stale sibling `dist` these tests would be a verdict + // about a build rather than about the checkout (`pnpm check:test-source-alias`). + // + // ANCHORED regexes, array form: a bare string `find` matches by PREFIX, so + // with a FILE replacement it would also swallow the `/node` subpath and + // resolve it to the garbage path `…/types/src/index.ts/node`. + alias: [ + { find: /^@objectstack\/types$/, replacement: path.resolve(__dirname, '../types/src/index.ts') }, + { find: /^@objectstack\/types\/node$/, replacement: path.resolve(__dirname, '../types/src/node.ts') }, + ], }, }); From ef36911d30be5b173f06133c100e25c34eb33783 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:47:17 +0000 Subject: [PATCH 3/7] fix(identity): take the tenancy posture from the `tenancy` service, not the env (#8287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the red `Build Core` at 2b993c9d2 and, underneath it, a correctness bug the build failure exposed. The build break: `check:type-source-resolution` requires a cross-package type import to resolve to SOURCE, so adding `@objectstack/types` to `core` forced a `paths` rule — which collides with core's `rootDir: "./src"` under the tsup DTS build (TS6059). That gate's own header documents this exact cost. The bug it exposed is the more important half. `resolveTenancyPosture()` reads `OS_TENANCY_POSTURE`, which is what the operator ASKED for — not what is ENFORCED. Under ADR-0093 D4/D5 a deployment requesting `isolated` without the enterprise organizations runtime resolves to `single` and runs with no wall at all, so the env-reading version would have refused org-less API keys on a deployment that has no organization boundary to enforce. The posture is now an explicit input, resolved from the kernel's `tenancy` service — the same source plugin-security reconciles before handing a posture to the Layer 0 wall, so admission and the wall can never disagree. `core` drops the `@objectstack/types` dependency entirely, and both gates go quiet on their own rather than by registry widening. An ABSENT posture disables the two posture-conditional refusals, leaving behaviour exactly as before: that is a question about the deployment, not about the credential, so an unwired transport is never made worse — only less strict. Wired here: the runtime dispatcher/MCP path and the REST data API, which are the surfaces the card measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- packages/core/package.json | 1 - packages/core/src/security/api-key.test.ts | 57 +++++++++++------ packages/core/src/security/api-key.ts | 63 +++++++++++++------ packages/core/src/security/index.ts | 5 ++ .../security/resolve-authz-context.test.ts | 27 +++----- .../src/security/resolve-authz-context.ts | 22 +++++-- packages/rest/src/rest-server.ts | 13 +++- packages/runtime/src/domains/keys.ts | 23 ++++--- .../runtime/src/http-dispatcher.keys.test.ts | 53 +++++++--------- .../src/security/resolve-execution-context.ts | 21 ++++++- pnpm-lock.yaml | 3 - 11 files changed, 179 insertions(+), 109 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 187e3a74ac..4a3f226623 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,7 +32,6 @@ }, "dependencies": { "@objectstack/spec": "workspace:*", - "@objectstack/types": "workspace:*", "zod": "^4.4.3" }, "keywords": [ diff --git a/packages/core/src/security/api-key.test.ts b/packages/core/src/security/api-key.test.ts index cbbac26909..0302338595 100644 --- a/packages/core/src/security/api-key.test.ts +++ b/packages/core/src/security/api-key.test.ts @@ -10,6 +10,7 @@ import { isExpired, resolveApiKeyPrincipal, resolveApiKeyAdmission, + effectiveTenancyPosture, } from './api-key.js'; /** In-memory sys_api_key store exposing the `find` shape the verifier uses. */ @@ -123,17 +124,10 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => { */ describe('resolveApiKeyAdmission — organization (#8287)', () => { const raw = 'osk_org_probe'; - const withPosture = async (posture: string | undefined, fn: () => Promise): Promise => { - const previous = process.env.OS_TENANCY_POSTURE; - if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = posture; - try { - return await fn(); - } finally { - if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = previous; - } - }; + // The posture is an INPUT now, resolved by the transport from the kernel's + // `tenancy` service — never read from the environment here. `effectiveTenancyPosture` + // is what performs that reconciliation; these tests exercise both it and the + // admission behaviour it feeds. it('reads the organization off the row and carries it as tenantId', async () => { const ql = makeQl([ @@ -164,7 +158,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 withPosture('single', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single'); expect(admission.outcome).toBe('admitted'); }); @@ -178,13 +172,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 withPosture('group', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'group'); 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 withPosture('isolated', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); 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 @@ -192,17 +186,40 @@ describe('resolveApiKeyAdmission — organization (#8287)', () => { expect(admission.outcome === 'refused' && admission.message).toMatch(/isolated/); }); - it('the legacy `multi` spelling refuses too (it normalizes to `isolated`)', async () => { + /** + * The posture must be the ENFORCED one, not the requested one. ADR-0093 D4/D5: + * a deployment that asks for `isolated` without the enterprise organizations + * runtime runs with NO wall, and `tenancy.isolationActive` is how the service + * says so. Reading `OS_TENANCY_POSTURE` instead would refuse org-less keys on + * a deployment that has no wall at all. + */ + it('effectiveTenancyPosture reads the ENFORCED posture from the tenancy service', () => { + expect(effectiveTenancyPosture({ posture: 'isolated' })).toBe('isolated'); + expect(effectiveTenancyPosture({ posture: 'multi' })).toBe('isolated'); // legacy alias + expect(effectiveTenancyPosture({ posture: 'group' })).toBe('group'); + // No posture field: fall back to the boolean the service exposes. + expect(effectiveTenancyPosture({ isolationActive: true })).toBe('isolated'); + expect(effectiveTenancyPosture({ isolationActive: false })).toBe('single'); + // No service at all ⇒ undefined ⇒ callers apply no posture-conditional refusal. + expect(effectiveTenancyPosture(undefined)).toBeUndefined(); + }); + + /** + * An unknown posture must NOT refuse. This is a question about the DEPLOYMENT, + * not about the credential: refusing here would break every org-less key on a + * `single` deployment whose transport has not been wired. + */ + 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 withPosture('multi', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); - expect(admission.outcome).toBe('refused'); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), undefined); + expect(admission.outcome).toBe('admitted'); }); it('an ORG-STAMPED key is admitted under `isolated` — the fix, not just the refusal', async () => { const ql = makeQl([ { key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }, ]); - const admission = await withPosture('isolated', () => resolveApiKeyAdmission(ql, { 'x-api-key': raw })); + const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); expect(admission.outcome).toBe('admitted'); expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a'); }); @@ -214,13 +231,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 withPosture('isolated', () => resolveApiKeyPrincipal(ql, { 'x-api-key': raw })); + const principal = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); 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 withPosture('isolated', () => resolveApiKeyAdmission(ql, {})); + const admission = await resolveApiKeyAdmission(ql, {}, Date.now(), 'isolated'); expect(admission.outcome).toBe('none'); }); }); diff --git a/packages/core/src/security/api-key.ts b/packages/core/src/security/api-key.ts index 054c96494a..e54987a730 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -23,9 +23,8 @@ import { createHash, randomBytes } from 'node:crypto'; -import { postureEnforcesWall, postureUsesUnionScope } from '@objectstack/spec/security'; +import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from '@objectstack/spec/security'; import type { TenancyPosture } from '@objectstack/spec/security'; -import { resolveTenancyPosture } from '@objectstack/types'; /** Default visible prefix for generated keys (helps users identify a key). */ export const API_KEY_PREFIX = 'osk_'; @@ -168,21 +167,39 @@ export type ApiKeyAdmission = | { outcome: 'refused'; reason: ApiKeyRefusalReason; message: string }; /** - * Read the deployment's tenancy posture, fail-closed. + * The shape of the kernel's `tenancy` service this module reads a posture from. + * Structural on purpose: `@objectstack/core` must not depend on the plugin that + * provides it, and an embedding without that plugin simply supplies nothing. + */ +export interface TenancyPostureSource { + posture?: string; + isolationActive?: boolean; +} + +/** + * [#8287] Resolve the EFFECTIVE tenancy posture from the kernel's `tenancy` + * service — the same reconciliation `plugin-security` performs before handing a + * posture to `computeTenantLayer0Filter`, so the wall and the API-key admission + * can never disagree about which posture is in force. + * + * ⚠️ Deliberately NOT `resolveTenancyPosture()` from `@objectstack/types`, which + * reads `OS_TENANCY_POSTURE` directly. That answers what the operator ASKED + * for, not what is ENFORCED: under ADR-0093 D4/D5 a deployment that requests + * `isolated` without the enterprise `@objectstack/organizations` runtime + * resolves to `single` and runs with NO organization wall. Reading the env + * there would refuse org-less API keys on a deployment whose wall is not even + * active — breaking working automation to enforce a boundary that does not + * exist. The `tenancy` service is the one place that already knows the + * difference. * - * `resolveTenancyPosture` THROWS on an unrecognized `OS_TENANCY_POSTURE` — by - * design, so a typo cannot silently drop the organization wall. The CLI's boot - * gate refuses to serve in that state, so this branch is unreachable on a - * running deployment; if it is ever reached anyway, treat the posture as - * `isolated` (the strictest) rather than letting an unreadable posture become - * the reason a credential is admitted. + * Returns `undefined` when no service is available, which callers must treat as + * "no posture-conditional refusal" — see {@link resolveApiKeyAdmission}. */ -export function currentTenancyPosture(): TenancyPosture { - try { - return resolveTenancyPosture(); - } catch { - return 'isolated'; - } +export function effectiveTenancyPosture( + tenancy: TenancyPostureSource | undefined | null, +): TenancyPosture | undefined { + if (!tenancy) return undefined; + return normalizeTenancyPosture(tenancy.posture) ?? (tenancy.isolationActive ? 'isolated' : 'single'); } /** @@ -200,8 +217,9 @@ export async function resolveApiKeyPrincipal( ql: any, headers: any, nowMs: number = Date.now(), + tenancyPosture?: TenancyPosture, ): Promise { - const admission = await resolveApiKeyAdmission(ql, headers, nowMs); + const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture); return admission.outcome === 'admitted' ? admission.principal : undefined; } @@ -222,6 +240,7 @@ export async function resolveApiKeyAdmission( ql: any, headers: any, nowMs: number = Date.now(), + tenancyPosture?: TenancyPosture, ): Promise { const apiKey = extractApiKey(headers); if (!apiKey) return { outcome: 'none' }; @@ -275,8 +294,16 @@ export async function resolveApiKeyAdmission( // no active organization NOTHING can match, which is exactly // the `200 + total 0` this card was filed for. Refuse, so the // failure is loud at call time instead of silently empty. - if (!tenantId) { - const posture = currentTenancyPosture(); + // + // ⚠️ An ABSENT posture means "the caller could not tell us which posture is in + // force", and the answer to that is to admit — i.e. today's behaviour. Not + // fail-closed, deliberately, and this is the one place in this module where + // that is the right call: refusing on an unknown posture would break every + // org-less key on every `single` deployment whose transport has not been + // wired, to enforce a wall that may not exist. Fail-closed belongs on + // questions about THIS credential; this is a question about the deployment. + if (!tenantId && tenancyPosture) { + const posture = tenancyPosture; if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) { return { outcome: 'refused', diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index d2bd7b1a29..89b829e10c 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -76,8 +76,13 @@ export { parseScopes, isExpired, resolveApiKeyPrincipal, + resolveApiKeyAdmission, + effectiveTenancyPosture, type GeneratedApiKey, type ApiKeyPrincipal, + type ApiKeyAdmission, + type ApiKeyRefusalReason, + type TenancyPostureSource, } from './api-key.js'; export { diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 63c84a5ed4..df9ae31430 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -478,16 +478,6 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( describe('resolveAuthzContext — API-key organization (#8287)', () => { const raw = 'osk_ctx_probe'; const keyHeaders = () => ({ 'x-api-key': raw }); - const withPosture = async (posture: string, fn: () => Promise): Promise => { - const previous = process.env.OS_TENANCY_POSTURE; - process.env.OS_TENANCY_POSTURE = posture; - try { - return await fn(); - } finally { - if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = previous; - } - }; const tables = (member: any[]) => ({ sys_api_key: [{ key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' }], sys_user: [{ id: 'u1', email: 'ada@x.com' }], @@ -498,7 +488,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 withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBe('u1'); expect(ctx.tenantId).toBe('org_a'); expect(ctx.authRefusal).toBeUndefined(); @@ -516,7 +506,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 withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(ctx.tenantId).toBeUndefined(); expect(ctx.permissions).toEqual([]); @@ -527,14 +517,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 withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); expect(ctx.userId).toBeUndefined(); expect(ctx.authRefusal?.reason).toBe('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 withPosture('group', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'group' }); expect(ctx.userId).toBeUndefined(); expect(ctx.authRefusal?.reason).toBe('organization_membership_ended'); }); @@ -546,7 +536,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { */ it('does NOT apply the membership check under `single`', async () => { const ql = makeQl(tables([])); - const ctx = await withPosture('single', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'single' }); expect(ctx.userId).toBe('u1'); expect(ctx.authRefusal).toBeUndefined(); }); @@ -565,11 +555,12 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { sys_user_position: [], sys_user_permission_set: [], }); - const ctx = await withPosture('isolated', () => resolveAuthzContext({ + const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), getSession: session('u1', { org: 'org_a' }), - })); + tenancyPosture: 'isolated', + }); expect(ctx.userId).toBeUndefined(); expect(ctx.authRefusal?.reason).toBe('organization_required'); }); @@ -589,7 +580,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { return inner.find(object, opts); }, }; - await withPosture('isolated', () => resolveAuthzContext({ ql, headers: keyHeaders() })); + await resolveAuthzContext({ 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. diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 8008d06579..b67e8b8b7b 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -31,10 +31,10 @@ import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS, } from '@objectstack/spec'; -import type { AuthzPosture } from '@objectstack/spec/security'; +import type { AuthzPosture, TenancyPosture } from '@objectstack/spec/security'; import { postureEnforcesWall } from '@objectstack/spec/security'; -import { resolveApiKeyAdmission, currentTenancyPosture } from './api-key.js'; +import { resolveApiKeyAdmission } from './api-key.js'; import type { ApiKeyRefusalReason } from './api-key.js'; import { isGrantActive } from './grant-validity.js'; import { derivePosture } from './posture-ladder.js'; @@ -99,6 +99,18 @@ export interface ResolveAuthzInput { getSession?: (headers: any) => Promise | any; /** Clock injection for API-key expiry (tests). */ nowMs?: number; + /** + * [#8287] The deployment's EFFECTIVE tenancy posture, as resolved from the + * kernel's `tenancy` service (`effectiveTenancyPosture`) — never from + * `OS_TENANCY_POSTURE`, which reports what was requested rather than what is + * enforced (ADR-0093 D4/D5). + * + * Supplied by the transport because this resolver is deliberately + * kernel-agnostic. OMITTING it disables the two posture-conditional API-key + * refusals and leaves behaviour exactly as it was — so an unwired caller is + * never made WORSE, only less strict. + */ + tenancyPosture?: TenancyPosture; } function safeJsonParse(s: string, fallback: T): T { @@ -134,7 +146,7 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise { try { return await api.getSession({ headers: h }); } catch { return undefined; } }; - const authz = await resolveAuthzContext({ ql, headers, getSession }); + // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` + // service — the same source plugin-security reconciles for the Layer 0 + // wall, so API-key admission and the wall agree. Absent ⇒ undefined ⇒ + // no posture-conditional refusal (behaviour unchanged). + let tenancyPosture; + try { + tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); + } catch { + tenancyPosture = undefined; + } + const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); // [#6216] The anonymous contract IS the shared assembler's default // entry: no resolved principal → no context → 401. Taken early here // only so an anonymous request does not pay for the localization and diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index 56c4938795..a3b0b5fd7f 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -31,9 +31,8 @@ * organization is re-checked here, against `sys_member`, at mint time. */ -import { isGrantActive } from '@objectstack/core'; +import { isGrantActive, effectiveTenancyPosture } from '@objectstack/core'; import { postureEnforcesWall } from '@objectstack/spec/security'; -import { resolveTenancyPosture } from '@objectstack/types'; import { generateApiKey } from '../security/api-key.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -103,17 +102,21 @@ export async function handleKeysRequest( ? ec.tenantId.trim() : undefined; + // The EFFECTIVE posture, from the kernel's `tenancy` service — what is + // ENFORCED, not what `OS_TENANCY_POSTURE` requested (ADR-0093 D4/D5: a + // requested-but-unenforceable wall resolves to `single`). An absent service + // means we cannot tell, and the honest answer to that at MINT time is to + // mint: refusing would block key creation on a deployment that may have no + // wall at all. let tenancyPosture; try { - tenancyPosture = resolveTenancyPosture(); + tenancyPosture = effectiveTenancyPosture( + await deps.resolveService(context, 'tenancy' as any, context.environmentId), + ); } catch { - // Unrecognized OS_TENANCY_POSTURE — the CLI's boot gate refuses to - // serve in that state, so this is unreachable on a running deployment. - // Treat it as walled rather than letting an unreadable posture be the - // reason a key is minted without an organization. - tenancyPosture = 'isolated' as const; + tenancyPosture = undefined; } - const walled = postureEnforcesWall(tenancyPosture); + const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false; if (walled && !activeOrganizationId) { // Refuse rather than mint. Under a walled posture an org-less key @@ -126,7 +129,7 @@ export async function handleKeysRequest( handled: true, response: deps.error( 'Cannot create an API key without an active organization: this deployment runs a walled ' - + `tenancy posture ('${tenancyPosture}') in which every organization-scoped read requires one. ` + + `tenancy posture ('${String(tenancyPosture)}') in which every organization-scoped read requires one. ` + 'Select an organization and try again.', 400, ), diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index 29dbbae68d..c0f67487e7 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -175,7 +175,7 @@ describe('HttpDispatcher.handleKeys (POST /keys — key generation)', () => { * Kernel whose ObjectQL also serves `sys_member`, so the mint-time membership * check has something real to read. */ -function makeOrgKernel(members: any[]) { +function makeOrgKernel(members: any[], posture?: string) { const rows: any[] = []; const ql = { insert: async (_obj: string, data: any) => { @@ -191,9 +191,14 @@ function makeOrgKernel(members: any[]) { update: async () => ({}), delete: async () => ({}), }; + // [#8287] The mint path resolves the EFFECTIVE posture from the kernel's + // `tenancy` service (ADR-0093 D4/D5), never from OS_TENANCY_POSTURE — a + // requested-but-unenforceable wall resolves to `single` there, and minting + // must follow what is ENFORCED. + const tenancy = posture ? { posture } : undefined; const kernel: any = { - getService: (n: string) => (n === 'objectql' ? ql : undefined), - getServiceAsync: async (n: string) => (n === 'objectql' ? ql : undefined), + getService: (n: string) => (n === 'objectql' ? ql : n === 'tenancy' ? tenancy : undefined), + getServiceAsync: async (n: string) => (n === 'objectql' ? ql : n === 'tenancy' ? tenancy : undefined), }; return { kernel, rows }; } @@ -205,22 +210,10 @@ const orgCtx = (tenantId?: string) => ({ executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [], tenantId }, }); -const withPosture = async (posture: string, fn: () => Promise): Promise => { - const previous = process.env.OS_TENANCY_POSTURE; - process.env.OS_TENANCY_POSTURE = posture; - try { - return await fn(); - } finally { - if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = previous; - } -}; - describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => { it('stamps the minter’s active organization on the row and echoes it once', async () => { - const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }]); - const res = await withPosture('isolated', () => - dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }], 'isolated'); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a')); expect(res.response.status).toBe(201); expect(rows[0].active_organization_id).toBe('org_a'); @@ -234,21 +227,20 @@ describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => * lateral-movement step. */ it('ignores an organization supplied in the body (inherited, never parameterized)', async () => { - const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }]); - const res = await withPosture('isolated', () => dispatcher(kernel).handleKeys( + const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }], 'isolated'); + const res = await dispatcher(kernel).handleKeys( 'POST', { name: 'agent', organization_id: 'org_evil', active_organization_id: 'org_evil', organizationId: 'org_evil' }, orgCtx('org_a'), - )); + ); expect(res.response.status).toBe(201); expect(rows[0].active_organization_id).toBe('org_a'); }); it('refuses when the caller is not a member of their own active organization', async () => { - const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_other', role: 'owner' }]); - const res = await withPosture('isolated', () => - dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_other', role: 'owner' }], 'isolated'); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a')); expect(res.response.status).toBe(403); // Nothing was minted — a refused mint must not leave a credential behind. @@ -258,9 +250,8 @@ describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => it('refuses when the membership row exists but its ADR-0091 window has lapsed', async () => { const { kernel, rows } = makeOrgKernel([ { user_id: 'u1', organization_id: 'org_a', role: 'owner', valid_until: '2000-01-01T00:00:00Z' }, - ]); - const res = await withPosture('isolated', () => - dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + ], 'isolated'); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a')); expect(res.response.status).toBe(403); expect(rows).toHaveLength(0); @@ -273,18 +264,16 @@ describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => * human at a console who can act on it. */ it('refuses to mint an org-less key under a walled posture', async () => { - const { kernel, rows } = makeOrgKernel([]); - const res = await withPosture('isolated', () => - dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); + const { kernel, rows } = makeOrgKernel([], 'isolated'); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined)); expect(res.response.status).toBe(400); expect(rows).toHaveLength(0); }); it('still mints an org-less key under `single` — there is no organization to inherit', async () => { - const { kernel, rows } = makeOrgKernel([]); - const res = await withPosture('single', () => - dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); + const { kernel, rows } = makeOrgKernel([], 'single'); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined)); expect(res.response.status).toBe(201); expect(rows).toHaveLength(1); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 3506b002c7..74946d7268 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -29,6 +29,7 @@ import { resolveLocalizationContext, assembleExecutionContextOrGuest, type EntryLocalization, + effectiveTenancyPosture, } from '@objectstack/core'; /** @@ -162,7 +163,25 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise undefined : getSession; - const authz = await resolveAuthzContext({ ql, headers, getSession: getSessionForProvenance }); + // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` service — + // the same source `plugin-security` reconciles before handing a posture to the + // Layer 0 wall, so admission and the wall can never disagree. Deliberately not + // `OS_TENANCY_POSTURE`: that is what the operator ASKED for, and under + // ADR-0093 D4/D5 a requested-but-unenforceable wall resolves to `single`. + // Absent service ⇒ undefined ⇒ no posture-conditional refusal. + let tenancyPosture; + try { + tenancyPosture = effectiveTenancyPosture(await opts.getService('tenancy')); + } catch { + tenancyPosture = undefined; + } + + const authz = await resolveAuthzContext({ + ql, + headers, + getSession: getSessionForProvenance, + tenancyPosture, + }); // [#6216 — maintainer ruling 2026-08-08, Option A] The ExecutionContext // ASSEMBLY now lives in ONE place too (`assembleExecutionContext*`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7754f2eef..92a8930575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,9 +804,6 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../spec - '@objectstack/types': - specifier: workspace:* - version: link:../types zod: specifier: ^4.4.3 version: 4.4.3 From 339a1b6ad64a04b74da71e084163e9052e5e826e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:07:07 +0000 Subject: [PATCH 4/7] chore(core): drop the dead @objectstack/types source-resolution config (#8287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tsconfig `paths` and vitest `alias` entries were added to satisfy check:type-source-resolution / check:test-source-alias when core briefly depended on @objectstack/types. That dependency is gone — the tenancy posture now arrives from the kernel's `tenancy` service — so both entries resolve nothing, and their comments describe a `resolveTenancyPosture` call that no longer exists. Left in place they would mislead the next author and re-arm the TS6059 rootDir collision the moment anyone re-added the import. Both gates stay green without them, because the predicate is the IMPORT, not the file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- packages/core/tsconfig.json | 9 --------- packages/core/vitest.config.ts | 14 -------------- 2 files changed, 23 deletions(-) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 11bf66703c..2131d6aa2b 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,15 +3,6 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - // [#8287] `@objectstack/types` is resolved to its SOURCE, not its built - // `dist/*.d.ts`. `pnpm check:type-source-resolution` requires this of every - // cross-package type import: a typecheck that reads a stale sibling `dist` - // is a verdict about a build, not about the code in this checkout — and the - // dangerous direction is the one that PASSES. - "paths": { - "@objectstack/types": ["../types/src/index.ts"], - "@objectstack/types/node": ["../types/src/node.ts"] - }, "types": ["node"] }, "include": ["src/**/*"], diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 4f0c40ce1f..782c6a40a4 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,24 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import path from 'node:path'; - import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', - // [#8287] Resolve `@objectstack/types` to its SOURCE. `resolveTenancyPosture` - // reads `OS_TENANCY_POSTURE` live, and the api-key admission tests drive it - // per-case; against a stale sibling `dist` these tests would be a verdict - // about a build rather than about the checkout (`pnpm check:test-source-alias`). - // - // ANCHORED regexes, array form: a bare string `find` matches by PREFIX, so - // with a FILE replacement it would also swallow the `/node` subpath and - // resolve it to the garbage path `…/types/src/index.ts/node`. - alias: [ - { find: /^@objectstack\/types$/, replacement: path.resolve(__dirname, '../types/src/index.ts') }, - { find: /^@objectstack\/types\/node$/, replacement: path.resolve(__dirname, '../types/src/node.ts') }, - ], }, }); From c64aadb7c515802cacc2280c5b6ab16cc43ecc12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:23:20 +0000 Subject: [PATCH 5/7] test(runtime): pin the new #8287 key-mint engine double to ObjectQL's dispatch predicates (#8287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:engine-double-contract` went red at 339a1b6ad: the organization-inheritance suite added a second engine double to http-dispatcher.keys.test.ts, taking the file from 1 unguarded double to 2 on both the delete and the update slice while the shrink-only baseline records 1. The baseline is NOT raised — that is an explicit gate-weakening action and the gate's own message rules it out ("pin the new one rather than raising it"). The new `makeOrgKernel` double now routes both write verbs through assertEngineUpdateDispatch / assertEngineDeleteDispatch from @objectstack/metadata-core, so it cannot be looser than ObjectQL itself. The file's pre-existing makeKernel double keeps its measured DEBT entry untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- .../runtime/src/http-dispatcher.keys.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index c0f67487e7..629c0e135a 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + import { HttpDispatcher } from './http-dispatcher.js'; import { resolveExecutionContext } from './security/resolve-execution-context.js'; import { hashApiKey } from './security/api-key.js'; @@ -188,8 +190,21 @@ function makeOrgKernel(members: any[], posture?: string) { const table = obj === 'sys_api_key' ? rows : obj === 'sys_member' ? members : []; return table.filter((r: any) => Object.entries(where).every(([k, v]) => r[k] === v)); }, - update: async () => ({}), - delete: async () => ({}), + // The write verbs route through `ObjectQL`'s OWN dispatch predicates rather + // than a hand-written approximation of them (`check:engine-double-contract`, + // #4434/#5480). A double that imports the producer's decision cannot be + // looser than the producer — which is what keeps a green suite from meaning + // nothing on the day one of these stops being dormant. Only this second + // double is pinned; the file's pre-existing `makeKernel` double is the + // shrink-only baseline's measured DEBT entry and is left exactly as it was. + update: async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }, + delete: async (_obj: string, options?: any) => { + assertEngineDeleteDispatch(options); + return {}; + }, }; // [#8287] The mint path resolves the EFFECTIVE posture from the kernel's // `tenancy` service (ADR-0093 D4/D5), never from OS_TENANCY_POSTURE — a From 824eacdf8dfeb7dded7fe97dc02319093c915599 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:37:39 +0000 Subject: [PATCH 6/7] test(runtime): type the new #8287 key-mint suite against HttpDispatcherResult (#8287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-check-debt --re-measure` went red at 339a1b6ad: @objectstack/runtime's TEST_DEBT records 227 raw tsc errors and the tree measured 235 (+8). All eight are TS18048 'res.response' is possibly 'undefined' in the organization-inheritance suite this PR added — `HttpDispatcherResult.response` is optional, and the runtime test layer is hidden from tsc at the package level, so `pnpm test` going green said nothing about them. The ledger is NOT raised — it is a shrink-only ratchet and raising it is on the maintainer's floor. A `responseOf()` helper narrows once and throws a named error when a dispatcher answers no response at all, so the failure stays distinguishable from a wrong status. Scoped to the new suite: the older suite's nine reads are the file's share of the frozen number, and pressing that down is a separate improvement to bank, not a rider on this repair. Re-measured: runtime back to exactly 227. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- .../runtime/src/http-dispatcher.keys.test.ts | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index 629c0e135a..e67b7bb66d 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { HttpDispatcher } from './http-dispatcher.js'; +import { HttpDispatcher, type HttpDispatcherResult } from './http-dispatcher.js'; import { resolveExecutionContext } from './security/resolve-execution-context.js'; import { hashApiKey } from './security/api-key.js'; @@ -225,14 +225,37 @@ const orgCtx = (tenantId?: string) => ({ executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [], tenantId }, }); +/** + * `HttpDispatcherResult.response` is OPTIONAL, so every read of it is a + * `possibly undefined` in a type-checked program — and this package's test + * layer IS type-checked, by `check:type-check-debt --re-measure` against a + * shrink-only ledger, even though `pnpm test` never sees it. + * + * Narrow once, and narrow LOUDLY. `expect(res.response).toBeDefined()` would + * satisfy a reader and narrow nothing (vitest's matchers are not assertion + * signatures), and a `!` would silence the compiler while leaving the failure + * to surface as `undefined is not an object` three lines later. A dispatcher + * that answered no response at all is a different defect from one that + * answered the wrong status; this keeps them distinguishable. + * + * Scoped to the #8287 suite below on purpose: the older suite's reads are the + * file's share of the frozen TEST_DEBT number, and pressing that number down is + * a separate improvement to bank, not a rider on this repair. + */ +function responseOf(res: HttpDispatcherResult): NonNullable { + const { response } = res; + if (!response) throw new Error('handleKeys answered no response at all'); + return response; +} + describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => { it('stamps the minter’s active organization on the row and echoes it once', async () => { const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }], 'isolated'); - const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a')); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); - expect(res.response.status).toBe(201); + expect(res.status).toBe(201); expect(rows[0].active_organization_id).toBe('org_a'); - expect(res.response.body.data.active_organization_id).toBe('org_a'); + expect(res.body.data.active_organization_id).toBe('org_a'); }); /** @@ -243,21 +266,21 @@ describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => */ it('ignores an organization supplied in the body (inherited, never parameterized)', async () => { const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }], 'isolated'); - const res = await dispatcher(kernel).handleKeys( + const res = responseOf(await dispatcher(kernel).handleKeys( 'POST', { name: 'agent', organization_id: 'org_evil', active_organization_id: 'org_evil', organizationId: 'org_evil' }, orgCtx('org_a'), - ); + )); - expect(res.response.status).toBe(201); + expect(res.status).toBe(201); expect(rows[0].active_organization_id).toBe('org_a'); }); it('refuses when the caller is not a member of their own active organization', async () => { const { kernel, rows } = makeOrgKernel([{ user_id: 'u1', organization_id: 'org_other', role: 'owner' }], 'isolated'); - const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a')); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); - expect(res.response.status).toBe(403); + expect(res.status).toBe(403); // Nothing was minted — a refused mint must not leave a credential behind. expect(rows).toHaveLength(0); }); @@ -266,9 +289,9 @@ describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => const { kernel, rows } = makeOrgKernel([ { user_id: 'u1', organization_id: 'org_a', role: 'owner', valid_until: '2000-01-01T00:00:00Z' }, ], 'isolated'); - const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a')); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); - expect(res.response.status).toBe(403); + expect(res.status).toBe(403); expect(rows).toHaveLength(0); }); @@ -280,19 +303,19 @@ describe('HttpDispatcher.handleKeys — organization inheritance (#8287)', () => */ it('refuses to mint an org-less key under a walled posture', async () => { const { kernel, rows } = makeOrgKernel([], 'isolated'); - const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined)); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); - expect(res.response.status).toBe(400); + expect(res.status).toBe(400); expect(rows).toHaveLength(0); }); it('still mints an org-less key under `single` — there is no organization to inherit', async () => { const { kernel, rows } = makeOrgKernel([], 'single'); - const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined)); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); - expect(res.response.status).toBe(201); + expect(res.status).toBe(201); expect(rows).toHaveLength(1); expect(rows[0].active_organization_id).toBeUndefined(); - expect(res.response.body.data.active_organization_id).toBeUndefined(); + expect(res.body.data.active_organization_id).toBeUndefined(); }); }); From db2bd675b8e571b2df62f302484c4ce0124bd524 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:17:58 +0000 Subject: [PATCH 7/7] test(runtime): the new #8287 key-mint matcher refuses combinators instead of misreading them (#8287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:where-matcher-conformance` went red once the engine-double gate stopped aborting the ESLint job ahead of it: `makeOrgKernel`'s find matcher is an `Object.entries(where).every(...)` body with no combinator branch, so it read `$or`/`$and` as an ordinary FIELD NAME, compared `row.$or` (undefined) against the array, matched nothing, and would have handed a suite an empty result set with nothing erroring — shape (b) in that gate's header. Fixed by refusal, not by implementing the combinator: the `makeKernel` matcher 160 lines above in this same file already refuses with this exact message, so this keeps one convention in one file, and refusal is what 140 of the 233 discovered matchers already do. The baseline is NOT touched — third shrink-only ratchet on this branch, same rule. Gate now reports 233/233 conforming, 141 by refusing (+1, exactly this matcher). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --- packages/runtime/src/http-dispatcher.keys.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index e67b7bb66d..bc46747a87 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -185,10 +185,21 @@ function makeOrgKernel(members: any[], posture?: string) { rows.push({ id, ...data }); return { id }; }, + // REFUSES combinators rather than answering them wrong, exactly as the + // `makeKernel` matcher above does (`check:where-matcher-conformance`, + // #8494). Without the throw, `Object.entries` reads `$or`/`$and` as an + // ordinary FIELD NAME, compares `row.$or` (undefined) against the array, + // matches nothing, and hands the suite an empty result set with nothing + // erroring — a test that passes while asserting on a query the double + // never ran. Refusal is the cheap correct answer for a double that only + // ever sees scalar equality: it cannot go green on the wrong query. find: async (obj: string, opts: any) => { const where = opts?.where ?? {}; const table = obj === 'sys_api_key' ? rows : obj === 'sys_member' ? members : []; - return table.filter((r: any) => Object.entries(where).every(([k, v]) => r[k] === v)); + return table.filter((r: any) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + })); }, // The write verbs route through `ObjectQL`'s OWN dispatch predicates rather // than a hand-written approximation of them (`check:engine-double-contract`,