Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/23217-s2-rest-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/rest': patch
---

REST's request identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition

Clause-②: no

- `RestServer` resolves the caller of every data, metadata and package route by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'reread-when-active'`, `credentials: 'api-key-and-session'`, `sessionApi: 'required'`, `session: 'request'`, its settings lookup for the localization, its own request-locale parse, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The environment and auth-service resolution, the header normalisation, the data engine, the tenancy wiring, the per-request memo, the `Server-Timing` disclosure and the fail-closed `catch` stay at the door.
- The answers are the ones the door gave before: an anonymous caller gets `401` with the shared body; a session the authentication policy holds gets `403` with its code and message while a gate feature is active; an API key is judged by its own owner's posture; an unreadable permission store, tenancy service or active gate re-read gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code.
- One ordering moved, and it shows only when two faults coincide on the single-kernel wiring. The data engine is now resolved before the auth service's session read is asked for. If the configured engine provider fails AND the auth service exposes no session read (or its `getApi()` rejects), the answer is now `503 SERVICE_UNAVAILABLE` (the engine outage) instead of `401`. With a working auth service, the engine outage was already `503`.
- Nothing to do for a consumer: no export, route, configuration key or response shape changed.
9 changes: 6 additions & 3 deletions packages/core/src/security/authz-store-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url));
* throw reaches the transport's error mapping unaided.
*/
const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
'packages/rest/src/rest-server.ts': 'guarded',
// The one request-to-ExecutionContext composition (ruling record 6227928430,
// letter A). It catches nothing around the resolver: every door that calls
// it — the runtime dispatcher's `resolve-execution-context.ts` first —
// it — the runtime dispatcher's `resolve-execution-context.ts` first, REST's
// `rest-server.ts` second, whose fail-closed `catch` re-raises the outage —
// receives the outage and answers it at its own net.
'packages/core/src/security/request-execution-context.ts': 'propagates',
'packages/mcp/src/plugin.ts': 'propagates',
Expand Down Expand Up @@ -299,7 +299,10 @@ describe('[#13279] every transport that authorizes through resolveAuthzContext',
// below would be comparing two empty sets and passing.
const found = discoverTransports();
expect(found.length).toBeGreaterThanOrEqual(8);
expect(found).toContain('packages/rest/src/rest-server.ts');
// A transport this repo is known to hold. [#23217 slice 2] It named
// `rest-server.ts` until that door moved onto the one composition, which
// is the call site REST's answers now come from.
expect(found).toContain('packages/core/src/security/request-execution-context.ts');
}, SCAN_BUDGET_MS);

it('⭐ SET EQUALITY: the ledger names exactly the transports source contains', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,27 @@ describe('[#15021] §2 the subject — an allow-listed path inside the fail-clos
it('MEASURED: the throw is upstream of the path — `computeExecCtx` never reads `req.path` for the gate decision', () => {
// Structural corroboration of the drive above, derived from today's source
// rather than quoted from the card. `isAuthGateAllowlisted` is imported by
// this module, but the gate block that raises does not consult it.
const start = SOURCE.indexOf('if (gateActive) {');
// this module, but the gate decision that raises does not consult it.
//
// SUPERSEDED READING, quoted. [#23217 slice 2, ruling record 6227928430
// letter A] the gate block this sliced left `rest-server.ts` for the ONE
// request-to-context composition (`resolveRequestExecutionContext`,
// @objectstack/core), so its anchors are gone from this file:
// const start = SOURCE.indexOf('if (gateActive) {');
// const block = SOURCE.slice(start, SOURCE.indexOf('normalizeAuthGate(gatedSession?.user)', start));
// expect(block).toContain("throw new AuthzStoreUnavailableError('auth_gate', err);");
// ⭐ RE-AIMED to the door's half of the same fact: the decision is made
// from what the door hands the composition, and that call names the gate
// choice and hands over no path. The raise itself is pinned where it lives
// (`packages/core/src/security/request-execution-context.test.ts`, choice
// 2), and the drives above still read it on this door's wire.
const start = SOURCE.indexOf('const base = await resolveRequestExecutionContext({');
expect(start).toBeGreaterThan(-1);
const block = SOURCE.slice(start, SOURCE.indexOf('normalizeAuthGate(gatedSession?.user)', start));
expect(block).toContain("throw new AuthzStoreUnavailableError('auth_gate', err);");
expect(block).not.toContain('isAuthGateAllowlisted');
expect(block).not.toContain('req.path');
const call = SOURCE.slice(start, SOURCE.indexOf('\n });\n', start));
expect(call).toContain("authGate: 'reread-when-active',");
expect(call).not.toContain('isAuthGateAllowlisted');
expect(call).not.toContain('req.path');
expect(call).not.toMatch(/\bpath\b/);
});
});

Expand Down
62 changes: 44 additions & 18 deletions packages/rest/src/execctx-authz-input-seam-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,30 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
// REACHES the shared classification, and that neither grew a `catch` of its
// own again. The discriminator is still the REGISTRY's brand, never message
// text (#13905); it is asserted at its new home.
expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/);
expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/);
//
// SUPERSEDED PINS, quoted for the same reason. [#23217 slice 2, ruling
// record 6227928430 letter A] the door calls the ONE request-to-context
// composition (`resolveRequestExecutionContext`, @objectstack/core), which
// runs the classification itself on the thunk the door hands it, so the
// two `await` sites these matched are gone from this file:
// expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => kernel\.getServiceAsync\('tenancy'\)/);
// expect(body).toMatch(/tenancyPosture = await classifyAdmissionTenancyPosture\(\s*\n?\s*\(\) => this\.tenancyServiceProvider!\(environmentId\)/);
// ⭐ RE-AIMED again, and the subject still has not moved: BOTH wirings
// reach the shared classification. Each branch now builds the thunk, and
// the thunk is what the composition is handed; that the composition
// classifies whatever it is handed is pinned in
// `packages/core/src/security/request-execution-context.test.ts` (its
// tenancy cases), and §2/§3 below still drive the wire answer end to end.
expect(body).toMatch(/tenancy = \(\) => kernel\.getServiceAsync\('tenancy'\)/);
expect(body).toMatch(/tenancy = \(\) => this\.tenancyServiceProvider!\(environmentId\)/);
expect(body).toMatch(/resolveRequestExecutionContext\(\{[\s\S]*?\n\s*tenancy,\n/);
// ⛔ NARROWNESS CONTROL for the fold: the seam region itself holds NO
// `catch`. A local `catch` reappearing here is exactly the silent-`catch`
// degradation #13906 decision 1 option A forbids, and it would be invisible
// to the two delegation pins above.
// to the delegation pins above.
const tenancySeam = body.slice(
body.indexOf('let tenancyPosture;'),
body.indexOf('const authz = await resolveAuthzContext('),
body.indexOf('let tenancy: TenancyServiceResolver | undefined;'),
body.indexOf('const base = await resolveRequestExecutionContext('),
);
expect(tenancySeam.length).toBeGreaterThan(0);
expect(tenancySeam).not.toMatch(/catch/);
Expand All @@ -212,7 +227,7 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
// a `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync`, and
// without this guard the dereference would raise an unbranded `TypeError`
// and turn that host shape into a 503.
expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/);
expect(body).toMatch(/let tenancy: TenancyServiceResolver \| undefined;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/);
// [#15256 / 1A] …and the OTHER wiring now has a leg of its own, with the
// same classification. Before this card the `if` above was the whole
// block, so on the single-kernel wiring `tenancyPosture` stayed the
Expand Down Expand Up @@ -255,18 +270,29 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
// the comment-only swallow that made a FAILED re-read of an ACTIVE gate
// indistinguishable from gate-off. That swallow is gone.
expect(body).not.toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/);
expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/);
// The re-read is loud, and it is the RAW api call — ⛔ not the swallowing
// `getSession` closure, which would re-collapse the very same two facts.
// [#22258] Its argument is the in-process session-read rule's input (a
// cookie request reads without renewal); the call itself is still the raw,
// throwing one this pin exists for.
expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/);
expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/);
// ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host
// whose probe faults never declared a gate. If this ever flips, the repair
// has widened past the window the ruling named.
expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/);
// SUPERSEDED PINS, quoted. [#23217 slice 2, ruling record 6227928430
// letter A] the gate decision left this file for the ONE request-to-context
// composition, so the door's own copy of it these matched is gone:
// expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/);
// expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/);
// expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/);
// expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/);
// ⭐ RE-AIMED to what stays this door's: it declares the decision the
// ruling made for it, `'reread-when-active'` (an active gate's re-read is
// the raw, throwing read and fails as `AuthzStoreUnavailableError('auth_gate')`;
// a probe that throws reads as inactive; a key keeps its own posture), and
// it holds no second copy of that decision. The decision's own window, both
// legs, is pinned where it lives (`packages/core/src/security/request-execution-context.test.ts`,
// choice 2), and §4 below still drives every leg through this door's wire.
expect(body).toMatch(/resolveRequestExecutionContext\(\{[\s\S]*?\n\s*authGate: 'reread-when-active',\n/);
// ⛔ NARROWNESS CONTROL for the move: no local gate probe or re-read came
// back beside the call. A copy here would drift from the composition's
// window exactly as the copies this card retired did. Read on the CODE
// lines only: the door's comments still name the probe to explain the
// parameter.
const code = body.split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
expect(code).toMatch(/authGate: 'reread-when-active'/);
expect(code).not.toMatch(/isAuthGateActive|gatedSession|getSession\(/);
});

it('KNOWN NEGATIVE: the engine seam #13476 repaired is NOT flagged as absorb', () => {
Expand Down
107 changes: 102 additions & 5 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
// the protocol actually receives.

import { describe, it, expect, vi } from 'vitest';
import { hashApiKey } from '@objectstack/core';
import { hashApiKey, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability';
import { RestServer } from './rest-server';
import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js';
Expand Down Expand Up @@ -127,7 +127,7 @@ function makeRes() {
* ExecutionContext the transport handed it — the instrumentation point of the
* issue, moved into a test.
*/
function boot() {
function boot(auth: unknown = makeAuth()) {
const seen: any[] = [];
const protocol: any = {
getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }),
Expand All @@ -140,7 +140,7 @@ function boot() {
undefined, // kernelManager
undefined, // envRegistry
undefined, // defaultEnvironmentIdProvider
async () => makeAuth(), // authServiceProvider
async () => auth, // authServiceProvider
async () => makeQl(), // objectQLProvider
);
rest.registerRoutes();
Expand All @@ -150,8 +150,8 @@ function boot() {
}

/** Drive `GET /api/v1/data/task` with the given request headers. */
async function request(headers: Record<string, string>) {
const { route, seen, protocol } = boot();
async function request(headers: Record<string, string>, auth?: unknown) {
const { route, seen, protocol } = boot(auth);
const out = makeRes();
await route!.handler({
method: 'GET',
Expand Down Expand Up @@ -391,3 +391,100 @@ describe('#7280 — the ADR-0069 gate posture is an ASSEMBLED field on this face
expect(protocol.findData).toHaveBeenCalled();
});
});

/**
* [ruling record 6227928430, letter A item 2] REST's door, proven on the one
* request-to-`ExecutionContext` composition by this pin plus its existing ones.
*
* `computeExecCtx` resolves through `resolveRequestExecutionContext`
* (`@objectstack/core`), with REST's differences stated as that function's
* named parameters. The two answers a door owes before any other are pinned
* here on the real data route and read at the protocol, the engine's door:
*
* - ADR-0096: a request that resolves no principal never reaches the engine.
* REST answers it at its anonymous floor, with the shared body.
* - ADR-0069: a session the authentication policy holds is refused with its
* posture. REST reads the session's posture again only while the auth
* service says a gate is active, a key is judged by its own owner's posture
* whatever cookie rides beside it, and a re-read that fails is the outage,
* never an admission.
*
* Every row was green on the door's hand-written composition and is green on
* the shared one.
*/
describe('[ruling 6227928430 item 2] REST\'s door: the ADR-0096 principal-less refusal and the ADR-0069 gate on the data route', () => {
const HELD = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' };

/** `makeAuth`'s sessions, with the gate feature on or off, and a re-read that may fail. */
const authWith = (opts: { gateActive: boolean; rereadFails?: boolean }) => {
const sessions = makeAuth();
let reads = 0;
return {
isAuthGateActive: () => opts.gateActive,
api: {
getSession: async (input: { headers: any }) => {
// The identity read is the request's first; the gate's re-read is its second.
if (opts.rereadFails && ++reads > 1) throw new Error('session backend unavailable');
return sessions.api.getSession(input);
},
},
};
};

const ROWS: Array<{
name: string;
headers: Record<string, string>;
gate: { gateActive: boolean; rereadFails?: boolean };
status: number;
body?: unknown;
code?: string;
principal?: string;
}> = [
{
name: 'ADR-0096: no credential is refused at the anonymous floor with the shared body, and the engine is never asked',
headers: {}, gate: { gateActive: true },
status: ANONYMOUS_DENY_STATUS, body: ANONYMOUS_DENY_BODY,
},
{
name: 'ADR-0069: a held session while a gate is active is refused 403 with its posture, and the engine is never asked',
headers: { cookie: 'member-gated' }, gate: { gateActive: true },
status: 403, body: { error: HELD },
},
{
name: 'ADR-0069: the same held session while no gate is active is admitted, and the engine is asked as that principal',
headers: { cookie: 'member-gated' }, gate: { gateActive: false },
status: 200, principal: 'member1',
},
{
name: 'ADR-0069: an API key beside the held cookie answers for its own owner, never for the cookie',
headers: { cookie: 'member-gated', 'x-api-key': RAW_API_KEY }, gate: { gateActive: true },
status: 200, principal: 'member1',
},
{
name: 'ADR-0069: an active gate whose re-read fails is the 503 outage, and the engine is never asked',
headers: { cookie: 'member' }, gate: { gateActive: true, rereadFails: true },
status: 503, code: 'SERVICE_UNAVAILABLE',
},
{
name: 'CONTROL: an ungated session while a gate is active is admitted, and the engine is asked as that principal',
headers: { cookie: 'member' }, gate: { gateActive: true },
status: 200, principal: 'member1',
},
];

it.each(ROWS)('$name', async (row) => {
const { ctx, out, protocol } = await request(row.headers, authWith(row.gate));

expect(out.getStatus()).toBe(row.status);
if (row.body !== undefined) expect(out.getJson()).toEqual(row.body);
// The data route answers a thrown outage in its flat dialect: `code` beside `error`.
if (row.code !== undefined) expect(out.getJson()?.code).toBe(row.code);
if (row.principal === undefined) {
expect(protocol.findData).not.toHaveBeenCalled();
return;
}
expect(protocol.findData).toHaveBeenCalledTimes(1);
expect(ctx?.userId).toBe(row.principal);
expect('authGate' in ctx).toBe(false);
});
});
Loading
Loading