Skip to content

Commit 401e50a

Browse files
fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door (#15909)
* test(runtime): pin the tenancy-posture seam's two facts at the runtime door (red against main) Pins for #13906 decision 1 option A on the runtime resolver and the dispatcher wiring: a tenancy service that is registered and fails to build must raise AuthzStoreUnavailableError (503), and a tenancy service that was never registered must keep resolving quietly with no posture. Source untouched in this commit so the red run measures origin/main bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(runtime): un-nest a comment delimiter in the door pin header Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door resolveExecutionContext swallowed every rejection of the tenancy read into "no posture", and no posture skips both posture-conditional API-key refusals — so a tenancy service that was registered and failed to build read as a deployment with no wall. Apply #13906 decision 1 option A the way rest-server.ts already does: absorb only the registry's branded "never registered" rejection; re-raise everything else as AuthzStoreUnavailableError (503 SERVICE_UNAVAILABLE). Two nets between the resolver and the transport envelope are told the same thing: the dispatcher's service facade hands the resolver the classified rejection for 'tenancy' (resolveService is a capability probe that collapsed it to undefined), and resolveRequestScope's catch re-raises only the branded outage via rethrowAuthzStoreUnavailable, degrading everything else to anonymous as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(kernel): the identity-step sample no longer claims resolveExecutionContext always resolves It rejects with AuthzStoreUnavailableError (503) when an authorization input exists and could not be read — false since #13279 for a failed permission-store read, and now also for a tenancy service that is registered and failed to build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e581457 commit 401e50a

6 files changed

Lines changed: 558 additions & 8 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The runtime dispatcher door no longer admits a request on a tenancy posture it could not read.
6+
7+
`resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants.
8+
9+
The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text:
10+
11+
- **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders.
12+
- **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent.
13+
14+
Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request.

content/docs/protocol/kernel/index.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,11 @@ that order.
293293

294294
// 1. Identity — resolveExecutionContext() reads the better-auth session (or
295295
// API key), aggregates positions/permission sets/RLS membership, and layers
296-
// locale + timezone on top. It always resolves; anonymous yields
297-
// `{ isSystem: false, positions: [], permissions: [] }`.
296+
// locale + timezone on top. Anonymous yields
297+
// `{ isSystem: false, positions: [], permissions: [] }`. It rejects with
298+
// AuthzStoreUnavailableError (503) only when an authorization input exists
299+
// and could not be read — a failed permission-store read, or a tenancy
300+
// service that is registered and failed to build.
298301
const context = await resolveExecutionContext({ getService, getQl, request });
299302
// → ExecutionContext { userId, tenantId, locale, timezone, positions,
300303
// permissions, isSystem, ... }
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13906 decision 1 option A — at the RUNTIME door, measured end to end]
5+
*
6+
* The runtime resolver (`security/resolve-execution-context.ts`) has ONE
7+
* production caller: `HttpDispatcher.resolveRequestScope`, reached from
8+
* `dispatch()` and from the declarative-endpoint fallback. Between the
9+
* resolver's tenancy read and the transport's error envelope sat THREE nets,
10+
* every one of them collapsing "the posture could not be READ" into "there is
11+
* no posture":
12+
*
13+
* 1. the resolver's own bare `catch { tenancyPosture = undefined }`;
14+
* 2. the dispatcher's `getService` facade — `resolveService`, a capability
15+
* PROBE whose fallback chain absorbs every rejection at every step and
16+
* hands back `undefined`, so the resolver's catch was never even reached;
17+
* 3. `resolveRequestScope`'s bare `catch` ("anonymous request") around the
18+
* whole identity step.
19+
*
20+
* With all three in place a tenancy service that was REGISTERED AND FAILED TO
21+
* BUILD read as "no wall": both posture-conditional API-key refusals were
22+
* skipped and an ex-member's org-stamped key was admitted with full grants.
23+
* The REST seam (`rest-server.ts`) already answers this class with
24+
* `AuthzStoreUnavailableError` (503); this file pins the same answer on the
25+
* runtime door, against a REAL `ObjectKernel` so the rejections under test are
26+
* the registry's own (#13905: branded on "never registered", unbranded on
27+
* "registered and could not be built").
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
32+
import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core';
33+
import { ApiErrorSchema, BaseResponseSchema } from '@objectstack/spec/api';
34+
35+
import { HttpDispatcher } from './http-dispatcher.js';
36+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
37+
import { hashApiKey } from './security/api-key.js';
38+
39+
const FUTURE = '2999-01-01T00:00:00Z';
40+
const RAW_EXMEMBER = 'osk_exmember_dispatcher_door';
41+
42+
function qlWith() {
43+
const tables: Record<string, any[]> = {
44+
// Stamped org_A; the owner's ONLY current membership is org_B.
45+
sys_api_key: [
46+
{ id: 'k_ex', key: hashApiKey(RAW_EXMEMBER), revoked: false, user_id: 'u_exmember', active_organization_id: 'org_A', expires_at: FUTURE },
47+
],
48+
sys_member: [{ user_id: 'u_exmember', organization_id: 'org_B' }],
49+
sys_user_permission_set: [], sys_permission_set: [],
50+
sys_position: [], sys_position_permission_set: [], sys_user_position: [],
51+
};
52+
return {
53+
async find(object: string, opts: any) {
54+
const rows = tables[object] ?? [];
55+
const where = opts?.where ?? {};
56+
const matched = rows.filter((row) => {
57+
for (const [k, v] of Object.entries(where)) {
58+
if (v !== null && typeof v === 'object') {
59+
if (Array.isArray((v as any).$in) && !(v as any).$in.includes(row[k])) return false;
60+
continue;
61+
}
62+
if ((v ?? null) !== (row[k] ?? null)) return false;
63+
}
64+
return true;
65+
});
66+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
67+
},
68+
};
69+
}
70+
71+
type Tenancy = 'healthy-isolated' | 'factory-throws' | 'unregistered';
72+
73+
/** A REAL kernel, as the host would hand the dispatcher. */
74+
function kernelWith(tenancy: Tenancy): ObjectKernel {
75+
// `gracefulShutdown: false` — a fixture kernel must not hook the test
76+
// runner's process signals.
77+
const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any);
78+
kernel.registerService('objectql', qlWith());
79+
if (tenancy === 'healthy-isolated') {
80+
kernel.registerService('tenancy', { posture: 'isolated' });
81+
} else if (tenancy === 'factory-throws') {
82+
// The REAL failure class: the registry's own unbranded rejection.
83+
kernel.registerServiceFactory('tenancy', () => {
84+
throw new Error('tenancy backend unavailable');
85+
});
86+
}
87+
// 'unregistered' → nothing: the branded not-registered rejection.
88+
return kernel;
89+
}
90+
91+
function dispatcherOn(kernel: ObjectKernel) {
92+
return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
93+
}
94+
95+
/** The context shape the plugin hands `dispatch()`: `{ request }`, nothing resolved yet. */
96+
function requestWith(headers: Record<string, string>): any {
97+
return { request: { headers } };
98+
}
99+
100+
/** Settle to the rejection, or to `undefined` when the call RESOLVED. */
101+
const rejectionOf = (p: Promise<unknown>) => p.then(() => undefined, (e) => e);
102+
103+
// ---------------------------------------------------------------------------
104+
// §1 — the identity step (`resolveRequestScope`): what the door DERIVES
105+
// ---------------------------------------------------------------------------
106+
107+
describe('[#13906 / 1A] HttpDispatcher.resolveRequestScope — the tenancy posture seam on the dispatcher wiring', () => {
108+
it('POSITIVE CONTROL: a healthy `isolated` tenancy service reaches the resolver on THIS wiring — the ex-member key is refused (guest)', async () => {
109+
const context = requestWith({ 'x-api-key': RAW_EXMEMBER });
110+
await dispatcherOn(kernelWith('healthy-isolated')).resolveRequestScope(context, '/data/task');
111+
// The membership refusal fires and the request resolves as a GUEST —
112+
// this is what distinguishes "the refusal was skipped" (next test)
113+
// from "the refusal never applied to this fixture".
114+
expect(context.executionContext).toBeDefined();
115+
expect(context.executionContext.userId).toBeUndefined();
116+
});
117+
118+
it('REPAIRED: tenancy REGISTERED AND FAILING (factory throws) → the identity step raises AuthzStoreUnavailableError (503) — no longer an admitted principal', async () => {
119+
// SUPERSEDED PIN, quoted — what origin/main answered on this wiring:
120+
// await dispatcher.resolveRequestScope(context, '/data/task'); // resolved
121+
// expect(context.executionContext.userId).toBe('u_exmember'); // admitted, full grants
122+
// `resolveService` absorbed the factory's rejection into `undefined`,
123+
// the resolver read that as "no posture", and the Layer 0 refusal
124+
// never ran.
125+
const context = requestWith({ 'x-api-key': RAW_EXMEMBER });
126+
const err: any = await rejectionOf(dispatcherOn(kernelWith('factory-throws')).resolveRequestScope(context, '/data/task'));
127+
expect(err, 'the identity step RESOLVED — the failed build read as "no wall"').toBeDefined();
128+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
129+
expect(err.code).toBe('SERVICE_UNAVAILABLE');
130+
expect(err.status).toBe(503);
131+
expect(err.object).toBe('tenancy');
132+
// Nothing was written on the context — an outage leaves no principal behind.
133+
expect(context.executionContext).toBeUndefined();
134+
});
135+
136+
it('SUPPORTED, unchanged: tenancy NEVER registered → quiet `undefined` posture, the key is admitted (the no-tenancy composition)', async () => {
137+
// No wall exists here, and an org-stamped key working is by design.
138+
// This is the composition a careless repair breaks; it must be
139+
// byte-for-byte what it was.
140+
const context = requestWith({ 'x-api-key': RAW_EXMEMBER });
141+
await dispatcherOn(kernelWith('unregistered')).resolveRequestScope(context, '/data/task');
142+
expect(context.executionContext.userId).toBe('u_exmember');
143+
expect(context.executionContext.tenantId).toBe('org_A');
144+
});
145+
146+
it('THE COLLAPSE IS ENDED: "registered and failed" and "never registered" no longer answer alike on this wiring', async () => {
147+
const failedCtx = requestWith({ 'x-api-key': RAW_EXMEMBER });
148+
const failed: any = await rejectionOf(dispatcherOn(kernelWith('factory-throws')).resolveRequestScope(failedCtx, '/data/task'));
149+
const absentCtx = requestWith({ 'x-api-key': RAW_EXMEMBER });
150+
await dispatcherOn(kernelWith('unregistered')).resolveRequestScope(absentCtx, '/data/task');
151+
expect(isAuthzStoreUnavailableError(failed)).toBe(true);
152+
expect(absentCtx.executionContext.userId).toBe('u_exmember');
153+
});
154+
155+
it('every OTHER fault of the identity step still degrades to anonymous — only the branded outage is re-raised', async () => {
156+
// The net around the identity step keeps its fail-closed shape for
157+
// everything except the one class the ruling requires to stay loud:
158+
// an engine whose `find` throws a plain Error is not an authz-store
159+
// outage (the API-key lookup fails closed to "no key"), so the request
160+
// resolves as a guest exactly as before.
161+
const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any);
162+
kernel.registerService('objectql', { find: async () => { throw new Error('plain engine fault'); } });
163+
kernel.registerService('tenancy', { posture: 'isolated' });
164+
const context = requestWith({ 'x-api-key': RAW_EXMEMBER });
165+
await dispatcherOn(kernel).resolveRequestScope(context, '/data/task');
166+
expect(context.executionContext).toBeDefined();
167+
expect(context.executionContext.userId).toBeUndefined();
168+
});
169+
});
170+
171+
// ---------------------------------------------------------------------------
172+
// §2 — the door: the outage LEAVES `dispatch()` and reaches the envelope
173+
// ---------------------------------------------------------------------------
174+
175+
describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE_UNAVAILABLE', () => {
176+
it('`dispatch()` re-raises the outage — no net inside the pipeline turns it back into an anonymous 200/401', async () => {
177+
const err: any = await rejectionOf(
178+
dispatcherOn(kernelWith('factory-throws')).dispatch('GET', '/data/task', undefined, {}, requestWith({ 'x-api-key': RAW_EXMEMBER })),
179+
);
180+
expect(err, '`dispatch()` RESOLVED — the outage was absorbed inside the pipeline').toBeDefined();
181+
expect(err.code).toBe('SERVICE_UNAVAILABLE');
182+
expect(err.status).toBe(503);
183+
});
184+
185+
/** A fake `IHttpServer` recording the handlers the plugin mounts. */
186+
function makeFakeServer() {
187+
const handlers: Record<string, (req: any, res: any) => any> = {};
188+
const rec = (verb: string) => (path: string, handler: any) => { handlers[`${verb} ${path}`] = handler; };
189+
return {
190+
handlers,
191+
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
192+
};
193+
}
194+
195+
async function mountOn(kernel: ObjectKernel) {
196+
const { server, handlers } = makeFakeServer();
197+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
198+
await plugin.start?.({
199+
getKernel: () => kernel,
200+
getService: (n: string) => (n === 'http.server' ? server : undefined),
201+
environmentId: undefined,
202+
logger: { info() {}, warn() {}, error() {}, debug() {} },
203+
hook: () => {}, on: () => {},
204+
} as any);
205+
return handlers;
206+
}
207+
208+
async function drive(handler: (req: any, res: any) => any, req: any) {
209+
expect(handler, 'route must be mounted').toBeTypeOf('function');
210+
const res: any = {
211+
statusCode: undefined, body: undefined,
212+
status(c: number) { res.statusCode = c; return res; },
213+
header() { return res; },
214+
json(b: any) { res.body = b; return res; },
215+
end() { return res; },
216+
};
217+
await handler(req, res);
218+
return { status: res.statusCode as number, body: res.body };
219+
}
220+
221+
// The wire route is `GET /automation` with the ex-member key, chosen by
222+
// MEASUREMENT on the unrepaired tree so that each tenancy state answers
223+
// differently and no domain-side 503 is in the way (`POST /keys` answers
224+
// its own `503 Data service not available` against a fixture engine
225+
// with no `insert`, so it cannot pin this seam):
226+
//
227+
// tenancy service | before (origin/main) | after
228+
// ------------------|----------------------|-------
229+
// healthy isolated | 401 UNAUTHENTICATED | 401 — the membership refusal, unchanged
230+
// never registered | 501 NOT_IMPLEMENTED | 501 — admitted, then "no automation service", unchanged
231+
// registered+FAILED | 501 NOT_IMPLEMENTED | 503 SERVICE_UNAVAILABLE
232+
//
233+
// The 501 on the failed leg is the defect on the wire: byte-for-byte the
234+
// "never registered" answer, i.e. the ex-member was ADMITTED.
235+
const AUTOMATION = 'GET /api/v1/automation';
236+
const withKey = { headers: { 'x-api-key': RAW_EXMEMBER }, query: {} };
237+
238+
it('POSITIVE CONTROL on the wire: healthy `isolated` tenancy → the ex-member key is refused on the anonymous floor (401)', async () => {
239+
const handlers = await mountOn(kernelWith('healthy-isolated'));
240+
const { status, body } = await drive(handlers[AUTOMATION], withKey);
241+
expect(status).toBe(401);
242+
expect(body?.error?.code).toBe('UNAUTHENTICATED');
243+
});
244+
245+
it('REPAIRED on the wire (real route, real `errorResponseBase`): registered and FAILING → 503 with a declared `SERVICE_UNAVAILABLE` envelope', async () => {
246+
// SUPERSEDED PIN, quoted — measured on origin/main:
247+
// expect(status).toBe(501);
248+
// expect(body?.error?.code).toBe('NOT_IMPLEMENTED');
249+
const handlers = await mountOn(kernelWith('factory-throws'));
250+
const { status, body } = await drive(handlers[AUTOMATION], withKey);
251+
expect(status).toBe(503);
252+
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
253+
expect(body?.success).toBe(false);
254+
const parsed = ApiErrorSchema.safeParse(body?.error);
255+
expect(parsed.error?.issues ?? []).toEqual([]);
256+
expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE');
257+
});
258+
259+
it('THE COLLAPSE IS ENDED on the wire: never registered keeps its 501 (admitted, no automation service) — only the FAILED leg moved', async () => {
260+
const handlers = await mountOn(kernelWith('unregistered'));
261+
const { status, body } = await drive(handlers[AUTOMATION], withKey);
262+
expect(status).toBe(501);
263+
expect(body?.error?.code).toBe('NOT_IMPLEMENTED');
264+
});
265+
266+
it('CONTROL on the wire: with tenancy never registered the same door serves — the no-tenancy composition is untouched', async () => {
267+
const handlers = await mountOn(kernelWith('unregistered'));
268+
const { status } = await drive(handlers['GET /api/v1/health'], { headers: {} });
269+
expect(status).toBe(200);
270+
});
271+
});

0 commit comments

Comments
 (0)