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/src/security/api-key.test.ts b/packages/core/src/security/api-key.test.ts index 83063853be..0302338595 100644 --- a/packages/core/src/security/api-key.test.ts +++ b/packages/core/src/security/api-key.test.ts @@ -9,6 +9,8 @@ import { parseScopes, isExpired, resolveApiKeyPrincipal, + resolveApiKeyAdmission, + effectiveTenancyPosture, } from './api-key.js'; /** In-memory sys_api_key store exposing the `find` shape the verifier uses. */ @@ -69,7 +71,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 +109,135 @@ 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'; + // 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([ + { 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 resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single'); + 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 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 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 + // name the posture and the remedy, not merely deny. + expect(admission.outcome === 'refused' && admission.message).toMatch(/isolated/); + }); + + /** + * 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 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 resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated'); + 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 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 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 fa0f327eed..e54987a730 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -23,6 +23,9 @@ import { createHash, randomBytes } from 'node:crypto'; +import { postureEnforcesWall, postureUsesUnionScope, normalizeTenancyPosture } from '@objectstack/spec/security'; +import type { TenancyPosture } from '@objectstack/spec/security'; + /** Default visible prefix for generated keys (helps users identify a key). */ export const API_KEY_PREFIX = 'osk_'; @@ -127,10 +130,78 @@ 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 }; + +/** + * 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. + * + * Returns `undefined` when no service is available, which callers must treat as + * "no posture-conditional refusal" — see {@link resolveApiKeyAdmission}. + */ +export function effectiveTenancyPosture( + tenancy: TenancyPostureSource | undefined | null, +): TenancyPosture | undefined { + if (!tenancy) return undefined; + return normalizeTenancyPosture(tenancy.posture) ?? (tenancy.isolationActive ? 'isolated' : 'single'); +} + /** * 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. @@ -146,10 +217,34 @@ export async function resolveApiKeyPrincipal( ql: any, headers: any, nowMs: number = Date.now(), + tenancyPosture?: TenancyPosture, ): Promise { + const admission = await resolveApiKeyAdmission(ql, headers, nowMs, tenancyPosture); + 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(), + tenancyPosture?: TenancyPosture, +): 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 +255,70 @@ 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. + // + // ⚠️ 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', + 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/index.ts b/packages/core/src/security/index.ts index a9afe5e46c..59f148f28a 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 0d22b1ef01..11eb159ed6 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'; /** @@ -467,6 +468,126 @@ 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 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 resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + 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 resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' }); + 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 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 resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'group' }); + 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 resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'single' }); + 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 resolveAuthzContext({ + ql, + headers: keyHeaders(), + getSession: session('u1', { org: 'org_a' }), + tenancyPosture: 'isolated', + }); + 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 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. + expect(memberReads).toBe(2); + }); +}); + /** * [#8613 / ADR-0049] `sys_permission_set.active` and `sys_position.active` — * enforce-or-remove, enforced. diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 86821d0da6..903d3e6347 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -31,9 +31,11 @@ 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 { resolveApiKeyPrincipal } 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'; import { isRowActive } from './row-active.js'; @@ -68,6 +70,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 { @@ -83,6 +100,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 { @@ -118,7 +147,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/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8a1819ce29..1df6f1e0d8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2,6 +2,7 @@ import { IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted, + effectiveTenancyPosture, assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one @@ -2887,7 +2888,17 @@ export class RestServer { const getSession = async (h: any) => { 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 397c3a5021..a3b0b5fd7f 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -22,8 +22,18 @@ * - 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, effectiveTenancyPosture } from '@objectstack/core'; +import { postureEnforcesWall } from '@objectstack/spec/security'; + 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 +92,89 @@ 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; + + // 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 = effectiveTenancyPosture( + await deps.resolveService(context, 'tenancy' as any, context.environmentId), + ); + } catch { + tenancyPosture = undefined; + } + const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false; + + 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 ('${String(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 +183,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 +206,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..bc46747a87 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest'; -import { HttpDispatcher } from './http-dispatcher.js'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +import { HttpDispatcher, type HttpDispatcherResult } from './http-dispatcher.js'; import { resolveExecutionContext } from './security/resolve-execution-context.js'; import { hashApiKey } from './security/api-key.js'; @@ -168,3 +170,163 @@ 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[], posture?: string) { + const rows: any[] = []; + const ql = { + insert: async (_obj: string, data: any) => { + const id = `key_${rows.length + 1}`; + 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]) => { + 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`, + // #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 + // 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 : n === 'tenancy' ? tenancy : undefined), + getServiceAsync: async (n: string) => (n === 'objectql' ? ql : n === 'tenancy' ? tenancy : undefined), + }; + return { kernel, rows }; +} + +const orgCtx = (tenantId?: string) => ({ + request: { headers: {} }, + response: {}, + environmentId: undefined, + 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 = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + + expect(res.status).toBe(201); + expect(rows[0].active_organization_id).toBe('org_a'); + expect(res.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' }], 'isolated'); + 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.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 = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + + expect(res.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' }, + ], 'isolated'); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx('org_a'))); + + expect(res.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([], 'isolated'); + const res = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); + + 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 = responseOf(await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, orgCtx(undefined))); + + expect(res.status).toBe(201); + expect(rows).toHaveLength(1); + expect(rows[0].active_organization_id).toBeUndefined(); + expect(res.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/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*`,