Skip to content

Commit daafae6

Browse files
fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at install-local and on every MCP stdio call (#23455, #23456) (#23508)
Fixes #23455 Fixes #23456 Clause-②: no ## What changed This PR covers the family card #23455 (one card for the family) and #23456. It closes the seams of ruling C′ on #23287 (`6159450584`, maintainer 「同意」) that PR #23440 did not reach. It then makes the owner-posture input required, so the compiler names any seam that forgets it. The ruling, quoted: - Item 1: "a held user's API key is refused at every seam with the same code". - Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key. `MFA_REQUIRED` has scope `account` and holds the key. `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` have scope `credential` and do not. ### 1. The marketplace install-local resolver (`@objectstack/cloud-connection`, member 1) `resolveInstallPrincipal` does two new things: - It hands the shared resolver `userAuthGates: userAuthGateSourceOf(authService)`. It reaches the auth service through `ctx.getService('auth')`, read per request, the way its sibling seams do. - It asks `evaluateAuthGate` about the resolver's own posture (`authz.authGate`), instead of the session user it used to capture. So all five doors refuse a held owner's key through the existing `refuseHeldSession`: `403`, the posture's `code` and the session's `message`, before any effect. A held session is refused exactly as before. ### 2. The MCP stdio face, asked on every call (`@objectstack/mcp`, member 2, #23456) Measured on `origin/main`: - The stdio face already re-resolves its identity per principal-bound call. `resolvePrincipal` calls `resolveStdioExecutionContext` from every data-bridge verb and from the record resource (ADR-0101 D1, the same schedule as revocation and #15348's tenancy posture). - So a per-call refusal is structurally possible, and that is what is built. A start-only refusal is not used. What changed: - `resolveStdioExecutionContext` takes a REQUIRED `userAuthGates` parameter. `resolveStdioUserAuthGates(ctx)` reads it per call, beside `resolveStdioTenancyPosture`. The function now assembles `authGate: authz.authGate` instead of `authGate: undefined`. - `resolvePrincipal` asks `evaluateAuthGate` on the context and refuses a held owner's call before any read or write. The refusal is a thrown envelope: `code` `MFA_REQUIRED`, `status` 403, and the session's remediation `message`. That is what REST's `enforceAuth` and the dispatcher answer, in the shape this transport's other refusals take (`recordNotFoundError`). - A held key still STARTS the transport, and one `warn` line names the hold and the remedy. The key is valid and nothing revokes it. A refusal to start would outlast the remediation until someone restarted the process, which item 4 rules out. - So a posture that appears mid-session refuses the next call, and one that clears serves the next call, with no restart. ### 3. `resolveApiKeyPrincipal` (`@objectstack/core`, member 3): wired, not retired, on an unmeasured premise The ruling's premise for retirement is that no non-test caller exists here or in `objectstack-ai/objectui` or `objectstack-ai/cloud`. | Repo | Reading | |:--|:--| | this repo, at `fc4aff4a17` | `git grep resolveApiKeyPrincipal` finds the definition, its tests and three re-exports (core's `security/index.ts`, runtime's `security/api-key.ts` and `security/index.ts`), plus prose. No non-test call. | | objectui `main` at `52d61943c0` | Fetched into a private ref and `git grep`ped there: zero hits for `resolveApiKeyPrincipal`. Control: the same grep for `resolveAuthzContext` returns 8 lines (comments and docs), so the grep reads that tree. | | cloud | **NOT MEASURED.** This session has no read access: `add_repo` answered "you don't have access", and a REST read answered 403. No published cloud package exists on npm to read instead. `/search/*` was not used. | Since a live cloud caller could not be ruled out, the ruling's prohibition branch was taken: no retirement, and no silent fail-open. - `resolveApiKeyPrincipal` takes a REQUIRED `userAuthGates`. - A `held` verdict collapses to no principal (fail closed). - Its docblock states the cost: a principal-or-nothing answer cannot carry the posture's code, so a request-answering host should read `resolveApiKeyAdmission` and refuse `held` with `gate.code`. Open question 1 asks the seat to retire it once cloud is read. ### 4. The class fix: the owner-posture input is REQUIRED (`@objectstack/core`) This is PR #23440's seat record item 6: - `ResolveAuthzInput.userAuthGates` changes from `?: UserAuthGateSource` to `: UserAuthGateSource | undefined`. - On `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, `userAuthGates` is now required. - `tenancyPosture` is required with it, because a required parameter cannot follow an optional one. `undefined` is a value a caller passes on purpose. The docblocks say where it is legitimate: a kernel with no `auth` service. They also say where it is not: a seam that could reach one. **Premise measured: it holds.** Every in-tree production caller names its source without new plumbing. | Caller | Source | |:--|:--| | REST `computeExecCtx`, runtime `resolveExecutionContext`, sharing resolver, datasource admin, settings resolver, storage door gate reader | `userAuthGateSourceOf(…)` already (PR #23440) | | cloud-connection `resolveInstallPrincipal` | new, `ctx.getService('auth')` | | MCP `resolveStdioExecutionContext` (start probe and per call) | new, `ctx.getService('auth')` | | storage `buildFileReadAuthorizer` | new, `registry.getService('auth')` through a shared `resolveGateAuthService` | | core `resolveAuthzContext` and `resolveApiKeyPrincipal` | pass their own required input through | The compiler's enumeration matches the `git grep` census: after the change, every package holding a call site type-checks (see Verification). **Storage, measured.** The authorizer's call is an explicit pass-through, not a second gate: - Every storage route is mounted through `mountFamilyRoute`. Its door asks `buildAuthGateSessionReader`, which already refuses a held owner's key on the same request. - `composeStorageRoutes` binds the reader and the authorizer from the same `auth` service. - So the authorizer now resolves a held key truthfully, as a held principal, and does not judge it again. **Test and kit callers.** - 141 object literals in tests and the sharing testkit gained an explicit `userAuthGates: undefined`, and 20 positional test calls gained explicit `undefined` arguments. They present no key, or test something other than the owner's posture, so their subjects are unchanged. - The dogfood helper `resolveAuthzFor` (`armed.ts`) documents itself as resolving "the SAME way the REST entry point does", so it gets the real source from the stack's `auth` service. ## Pins (three facts per seam, PR #23440's shape) | Seam | Pin | Held (`MFA_REQUIRED`) | Credential scope only | Posture clears | |:--|:--|:--|:--|:--| | install-local, all five doors | `cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts` | `403`, `{ code: 'MFA_REQUIRED', message }` on every door, zero effects, ledger untouched | served (install and list) | `403`, then served; key row unchanged | | MCP stdio, per call | `mcp/src/stdio-api-key-account-posture.pin.test.ts` (real kernel, real core chain) | start succeeds with one warning; query, get, create and the record resource reject `{ code: 'MFA_REQUIRED', status: 403, message }`; nothing written; a real `StdioServerTransport` `tools/call` answers `isError` with the remediation message | served | same live session: a posture that appears refuses the next call, one that clears serves the next call | | core | `core/src/security/api-key-account-posture.test.ts` | `resolveApiKeyPrincipal` answers no principal | answers the owner | answers the owner again | The compile-level pin is in the same core file. Four `@ts-expect-error` lines cover each omitted input: the `ResolveAuthzInput` key, the admission with two and with three positional arguments, and the principal with four. Explicit `undefined` controls sit beside them. They live in a function that is never invoked and are read by `check:test-typecheck`'s tsc program, where an unused directive is TS2578. ### Negative control (reverse verification), on the committed head `a15aecc0aa` Each leg went through `scripts/ablation-replace.mjs`. The anchor hit exactly once, and the blob changed. A restore trap covered all four paths. After each leg the blob equals HEAD and `git diff HEAD` is empty. The cloud-connection and MCP subjects are imported from `src`, and the compile pins read `src`, so no dist leg applies. | Leg | Mutation | Result | |:--|:--|:--| | A | install-local resolver passes `userAuthGates: undefined` | `2 failed / 1 passed`: the held and clears cases are red, the credential-scope control is green | | B | stdio per-call refusal disabled (the `if (gate)` guard ANDed with a never-true clock comparison) | `2 failed / 1 passed`, same split | | C1 | `ResolveAuthzInput.userAuthGates?` optional again | `check:test-typecheck` red: one unledgered type error in `api-key-account-posture.test.ts` (the unused directive) | | C2 | `resolveApiKeyAdmission`'s `userAuthGates?` optional again | `check:test-typecheck` red, same file | ## Verification Every reading below is on head `fc4aff4a17`. That is the branch after `origin/main` `394f65e891` was merged in, with `pnpm install --frozen-lockfile` and the closure rebuilt. - **Full suites** (each package's own `test` script, `--maxWorkers=2`), all exit 0: - core 98 files / 2441 tests; - cloud-connection 46 / 555; - mcp 37 / 420; - service-storage 56 / 987; - plugin-sharing 45 / 1058; - rest 283 / 5852 passed, 326 skipped. - **Touched test files in the other caller packages**, all exit 0: - plugin-security 2 files / 120 tests; - plugin-auth 1 / 17; - cli integration layer (`security-catalog-echo-env-kernel.integration.test.ts`, the one cli file in the diff) 1 / 9; - dogfood 15 / 163: the 12 touched dogfood tests, plus the three suites that call the edited `resolveAuthzFor` helper. - **Typecheck** (`pnpm --filter … run typecheck`, including each package's `check:test-typecheck` ledger) exits 0 for all 13 packages that hold a call site: core, mcp, cloud-connection, service-storage, rest, runtime, plugin-sharing, plugin-security, plugin-auth, service-datasource, service-settings, dogfood and cli. - **ESLint, narrowed:** `eslint --no-inline-config` over the 37 changed `.ts` files reports 0 errors and 0 warnings. - **Gates:** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 76 commands from `fc4aff4a17`. All 76 ran, each recording its exit code before any pipe. - `--ran`: `76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN`. - One gate needed a second run. `check:dual-build-cjs-loads` first refused with exit 3 (PREREQUISITE NOT MET: eight packages outside this diff had no `dist/`). After those were built, it exited 0 (110 entry points across 68 packages load). The reconciled list carries the re-run. - `check:adr-0087-registration` verified the changeset's `runtime-interface-only` operand. - **Size:** 38 files, +1055 / −171 = 1226 changed lines against the merge base, under the 3,000-line threshold. ## Acceptance notes - **The stdio client reads the message, not the code.** The data tools render a thrown error with `errorResult(messageOf(err))`, and the record resource writes `{ error: message }`. Both are shared by the HTTP and stdio faces, and both flatten EVERY coded refusal on this transport to its message, `RECORD_NOT_FOUND` included. #15942 adopted envelope rendering (`errorResultFromThrown`) for the action tools only. The stdio refusal itself carries `code` and `status`, and the client receives the session's remediation message verbatim. Reported as an out-of-scope finding, and see Open question 2. The HTTP MCP face is unaffected: the dispatcher refuses the whole request with `403 MFA_REQUIRED` before the MCP server sees it. - **The stdio face's principal-free calls are as before.** `list_objects`, `describe_object`, prompts and resource listings resolve no principal (the ADR-0101 boundary: the identity binds row data). So a held key reaches them exactly as a revoked key does today. Carrier: none. - **Stale chain prose outside this diff:** - ADR-0101 names `resolveApiKeyPrincipal` as part of the stdio verify chain (Tier H, not edited). - `qa/dogfood/test/authz-conformance.matrix.ts`'s stdio row does the same. The chain is `resolveAuthzContext` → `resolveApiKeyAdmission`. This PR's own docblock in `mcp/src/plugin.ts` is corrected. - **The changeset's remedy is one prose line, not a FROM → TO block.** Measured with `check:adr-0087-registration`: a detectable FROM → TO for a required-parameter change is refused under every honest disposition. - `no-migration-prescription` and `runtime-interface-only` inherit the prescription refusal. - `type-surface-only` needs a base type that was erased. - `runtime-export-retired` needs a retired export. So the remedy is one instruction line, and the marker is `runtime-interface-only` on `resolve-authz-context.ts#ResolveAuthzInput`. The gate verified that marker. - **`@objectstack/runtime` is graded `minor` with no source change.** Its entry re-exports `resolveApiKeyPrincipal` explicitly, and the rest through `export *`, so its importers see the new required inputs. - **No governed surface** is in the file list: no `.claude/**`, `docs/adr/**`, `skills/**`, `AGENTS.md` or workflows. ## Open questions 1. **Retire `resolveApiKeyPrincipal` once cloud is read?** The ruling's premise could not be measured on cloud from this session. - A. The seat reads cloud. On zero callers, a follow-up retires it under `runtime-export-retired @objectstack/core#resolveApiKeyPrincipal`, and `@objectstack/runtime`'s two explicit re-exports go with it. - B. Keep it wired as here. - Recommendation: **A**. The collapse cannot carry the posture's code (item 1), it has no in-tree caller, and the startup axis retires zero-pull exports at once. B stays correct and fail-closed in the meantime. 2. **The stdio code on the wire.** - A. Leave the shared tool layer as it is: every coded refusal is presented as its message on both faces. - B. A separate card renders coded bridge refusals through `errorResultFromThrown` on the data tools and the record resource. That makes the code machine-readable on both faces for every refusal: a widening of what a client reads, with tests that pin today's text to re-judge. - Recommendation: **B, as its own card**. It reaches far beyond this family. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96a7f43 commit daafae6

38 files changed

Lines changed: 1055 additions & 171 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/cloud-connection': minor
5+
'@objectstack/mcp': minor
6+
'@objectstack/service-storage': patch
7+
---
8+
9+
fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call
10+
11+
Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal.
12+
13+
<!-- adr-0087: not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput) An authentication-policy change at runtime TypeScript call sites, not a metadata change. No authorable spec key, stored shape or metadata surface moves, so `objectstack migrate meta` has nothing to rewrite. The input that becomes required lives on a runtime interface and on two runtime functions of `@objectstack/core` (`resolveApiKeyAdmission`, `resolveApiKeyPrincipal`), none of them a metadata surface; the affected party is a TypeScript caller, and the compiler names its call site. The packages publish (not unpublished), no ADR-0087 id covers this and none is added (not registered or already-registered). -->
14+
15+
**BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes.
16+
17+
Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not.
18+
19+
- **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there.
20+
- **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart.
21+
- **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change.
22+
- **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`.
23+
- **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire.
24+
25+
What changes for you:
26+
27+
- **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued.
28+
- **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both.
29+
- **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered).

‎packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ async function boot(kind: Kind, dbFile: string, rootDir: string, app: any): Prom
9898
/** Read `echo_doc` as `userId`, with the context the request path builds. */
9999
async function readAs(ql: any, userId: string, org: string): Promise<'granted' | 'refused'> {
100100
const authz = await resolveAuthzContext({
101+
userAuthGates: undefined,
101102
ql,
102103
headers: {},
103104
getSession: () => ({ user: { id: userId }, session: { userId, activeOrganizationId: org } }),
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23455 — ruling C′ items 1 and 4] The install-local doors judge an API key
5+
* by the answer the shared key-admission seam gives for its OWNER.
6+
*
7+
* ## What was open
8+
*
9+
* `MarketplaceInstallLocalPlugin.resolveInstallPrincipal` called
10+
* `resolveAuthzContext` with no owner-posture source and asked the ADR-0069
11+
* gate about the session user alone. An API key admits its owner without a
12+
* session, so a key whose owner owes MFA enrolment carried no posture here and
13+
* all five doors served it, while REST and every face ruling C′ built refuse
14+
* the same key with `403 MFA_REQUIRED`.
15+
*
16+
* ## What is pinned, through the booted plugin's own doors
17+
*
18+
* - a key whose owner holds `MFA_REQUIRED` (scope `account`) → all five doors
19+
* answer `403 MFA_REQUIRED` with the session's message, before any effect;
20+
* - a key whose owner holds only `PASSWORD_RESET_REQUIRED` (scope
21+
* `credential`) → served;
22+
* - the refusal is not a revocation: the same key is served again once the
23+
* posture clears, with nothing rewritten.
24+
*
25+
* The harness is `cloud-connection-auth-gate.pin.test.ts`'s install-local
26+
* half, with a data engine that knows the key and an auth service that answers
27+
* a per-user posture (`resolveUserAuthGates`, the computation plugin-auth's
28+
* `AuthManager` exposes).
29+
*/
30+
31+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
32+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
33+
import { join } from 'node:path';
34+
import { tmpdir } from 'node:os';
35+
import { hashApiKey } from '@objectstack/core';
36+
37+
const seedCalls: unknown[] = [];
38+
vi.mock('@objectstack/runtime', () => ({
39+
SeedLoaderService: class {
40+
async load(request: unknown) {
41+
seedCalls.push(request);
42+
return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
43+
}
44+
async buildDependencyGraph(objectNames: string[]) {
45+
return {
46+
nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })),
47+
insertOrder: objectNames,
48+
circularDependencies: [],
49+
};
50+
}
51+
},
52+
recordSeedOutcome: vi.fn(),
53+
}));
54+
55+
import { assertEngineDeleteDispatch } from '@objectstack/metadata-core';
56+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
57+
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
58+
59+
const IL = '/api/v1/marketplace/install-local';
60+
const KEY = 'osk_install_local_account_posture_pin';
61+
const OWNER = 'u_key_owner';
62+
63+
const MFA_REQUIRED = {
64+
code: 'MFA_REQUIRED',
65+
message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.',
66+
};
67+
const PASSWORD_RESET_REQUIRED = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' };
68+
69+
type Posture = { code: string; message: string };
70+
71+
const APP = {
72+
id: 'com.acme.keyheld',
73+
namespace: 'keyheld',
74+
version: '1.0.0',
75+
objects: [{ name: 'widget', fields: { code: { type: 'text' } } }],
76+
data: [{ object: 'widget', externalId: 'code', records: [{ code: 'a' }, { code: 'b' }] }],
77+
};
78+
const LEDGER_FILE = 'com.acme.keyheld.json';
79+
80+
/** Equality and `$in` — the two shapes the shared resolver issues — and nothing else, loudly. */
81+
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
82+
for (const [field, cond] of Object.entries((where ?? {}) as Record<string, unknown>)) {
83+
if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`);
84+
if (cond !== null && typeof cond === 'object') {
85+
const ops = Object.keys(cond as object);
86+
if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as { $in?: unknown }).$in)) {
87+
throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`);
88+
}
89+
if (!(cond as { $in: unknown[] }).$in.includes(row[field])) return false;
90+
continue;
91+
}
92+
if (row[field] !== cond) return false;
93+
}
94+
return true;
95+
}
96+
97+
/** The key's owner holds the authoring capability, so a refusal below is the gate's alone. */
98+
function grantRows(): Record<string, Array<Record<string, unknown>>> {
99+
return {
100+
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]' }],
101+
sys_user: [{ id: OWNER, email: 'owner@acme.test' }],
102+
sys_member: [],
103+
sys_user_position: [],
104+
sys_position: [],
105+
sys_metadata_activation: [],
106+
sys_position_permission_set: [],
107+
sys_user_permission_set: [
108+
{ id: 'ups1', user_id: OWNER, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null },
109+
],
110+
sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }],
111+
};
112+
}
113+
114+
type Handler = (c: any) => Promise<any>;
115+
116+
/**
117+
* Boot the plugin with an auth service whose per-user posture is read from
118+
* `postures` on every ask, so a test can move it while the doors are live.
119+
*/
120+
async function mountInstallLocal(storageDir: string, postures: Record<string, Posture[]>) {
121+
const register = vi.fn(async () => undefined);
122+
const syncSchemas = vi.fn(async () => undefined);
123+
const widgets = [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }];
124+
const engineDelete = vi.fn(async (object: string, options?: any) => {
125+
const dispatch = assertEngineDeleteDispatch(options);
126+
if (object !== 'widget' || dispatch.kind !== 'by-id') return false;
127+
const at = widgets.findIndex((w) => w.id === dispatch.id);
128+
if (at < 0) return false;
129+
widgets.splice(at, 1);
130+
return true;
131+
});
132+
const routes = new Map<string, Handler>();
133+
const rawApp = {
134+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
135+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
136+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
137+
};
138+
const hooks = new Map<string, any>();
139+
const rows = grantRows();
140+
const keyRowBefore = JSON.stringify(rows.sys_api_key);
141+
const services: Record<string, any> = {
142+
manifest: { register },
143+
auth: {
144+
// No session: every caller below presents the key alone.
145+
api: { getSession: async () => null },
146+
resolveUserAuthGates: async (userId: string) => postures[userId] ?? [],
147+
},
148+
objectql: bindCatalogFromTables({
149+
syncSchemas,
150+
find: async (object: string, q: any = {}) => {
151+
if (object === 'widget') return widgets.map((w) => ({ ...w }));
152+
const matched = (rows[object] ?? []).filter((row) => matchesWhere(row, q?.where));
153+
return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched;
154+
},
155+
delete: engineDelete,
156+
}, rows),
157+
metadata: { getObject: async () => ({ name: 'widget', fields: {} }) },
158+
};
159+
const ctx: any = {
160+
hook: (e: string, h: any) => hooks.set(e, h),
161+
getService: (name: string) => {
162+
if (name === 'http-server') return { getRawApp: () => rawApp };
163+
const svc = services[name];
164+
if (svc === undefined) throw new Error(`no ${name}`);
165+
return svc;
166+
},
167+
registerService: () => undefined,
168+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
169+
};
170+
await new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }).start(ctx);
171+
await hooks.get('kernel:ready')?.();
172+
// `kernel:ready` rehydrates the ledger, so every counter starts AFTER it.
173+
register.mockClear();
174+
syncSchemas.mockClear();
175+
engineDelete.mockClear();
176+
seedCalls.length = 0;
177+
return {
178+
routes,
179+
effects: () => register.mock.calls.length + syncSchemas.mock.calls.length + seedCalls.length + engineDelete.mock.calls.length,
180+
keyRowUnchanged: () => JSON.stringify(rows.sys_api_key) === keyRowBefore,
181+
};
182+
}
183+
184+
function withKey(body: unknown = {}, manifestId?: string) {
185+
const headers = new Headers({ 'x-api-key': KEY });
186+
const url = `http://localhost${IL}`;
187+
return {
188+
req: {
189+
url,
190+
raw: new Request(url, { headers }),
191+
header: (n: string) => headers.get(n) ?? undefined,
192+
json: async () => body,
193+
param: () => manifestId,
194+
},
195+
json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }),
196+
};
197+
}
198+
199+
/** The five doors, addressed with what each needs to act. */
200+
const IL_DOORS = [
201+
{ route: `POST ${IL}`, body: { manifest: APP } },
202+
{ route: `GET ${IL}`, body: {} },
203+
{ route: `DELETE ${IL}/:manifestId`, body: {} },
204+
{ route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} },
205+
{ route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} },
206+
] as const;
207+
208+
let dir: string;
209+
beforeEach(() => {
210+
dir = mkdtempSync(join(tmpdir(), 'il-key-posture-pin-'));
211+
seedCalls.length = 0;
212+
});
213+
afterEach(() => {
214+
rmSync(dir, { recursive: true, force: true });
215+
vi.restoreAllMocks();
216+
});
217+
218+
describe('install-local doors — a key answers as its owner\'s session does (#23455, ruling C′)', () => {
219+
it('REFUSES a key whose owner holds MFA_REQUIRED on all five doors, with the session\'s 403 code and message, before any effect', async () => {
220+
const postures: Record<string, Posture[]> = {};
221+
const m = await mountInstallLocal(dir, postures);
222+
// Installed by the same key while its owner is not held, so the
223+
// follow-on doors have a target.
224+
const seeded = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP }));
225+
expect(seeded.status, JSON.stringify(seeded.payload)).toBe(200);
226+
expect(existsSync(join(dir, LEDGER_FILE))).toBe(true);
227+
const effectsBefore = m.effects();
228+
229+
postures[OWNER] = [MFA_REQUIRED];
230+
for (const door of IL_DOORS) {
231+
const res = await m.routes.get(door.route)!(withKey(door.body, APP.id));
232+
expect(res.status, door.route).toBe(403);
233+
expect(res.payload, door.route).toEqual({ success: false, error: { code: MFA_REQUIRED.code, message: MFA_REQUIRED.message } });
234+
}
235+
expect(m.effects(), 'a held owner\'s key caused an effect').toBe(effectsBefore);
236+
expect(existsSync(join(dir, LEDGER_FILE)), 'a held owner\'s key reached the uninstall').toBe(true);
237+
});
238+
239+
it('SERVES a key whose owner holds only a credential-scope posture', async () => {
240+
const m = await mountInstallLocal(dir, { [OWNER]: [PASSWORD_RESET_REQUIRED] });
241+
const installed = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP }));
242+
expect(installed.status, JSON.stringify(installed.payload)).toBe(200);
243+
expect(installed.payload.success).toBe(true);
244+
expect(m.effects()).toBeGreaterThan(0);
245+
const listed = await m.routes.get(`GET ${IL}`)!(withKey());
246+
expect(listed.status).toBe(200);
247+
expect(listed.payload.success).toBe(true);
248+
});
249+
250+
it('the refusal is not a revocation: the same key is served once the posture clears', async () => {
251+
const postures: Record<string, Posture[]> = { [OWNER]: [MFA_REQUIRED] };
252+
const m = await mountInstallLocal(dir, postures);
253+
const held = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP }));
254+
expect(held.status).toBe(403);
255+
expect(held.payload?.error?.code).toBe('MFA_REQUIRED');
256+
expect(m.effects()).toBe(0);
257+
258+
// The owner enrols; nothing about the key is rewritten or reissued.
259+
postures[OWNER] = [];
260+
const again = await m.routes.get(`POST ${IL}`)!(withKey({ manifest: APP }));
261+
expect(again.status, JSON.stringify(again.payload)).toBe(200);
262+
expect(again.payload.success).toBe(true);
263+
expect(existsSync(join(dir, LEDGER_FILE))).toBe(true);
264+
expect(m.keyRowUnchanged(), 'the hold wrote to the key row').toBe(true);
265+
});
266+
});

0 commit comments

Comments
 (0)