Skip to content

Commit 566e155

Browse files
committed
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
1 parent 7bda2b7 commit 566e155

3 files changed

Lines changed: 153 additions & 8 deletions

File tree

packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,36 @@ describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE
218218
return { status: res.statusCode as number, body: res.body };
219219
}
220220

221-
it('on the wire (real route, real `errorResponseBase`): 503 with a declared `SERVICE_UNAVAILABLE` envelope, never a served 2xx', async () => {
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');
222249
const handlers = await mountOn(kernelWith('factory-throws'));
223-
const { status, body } = await drive(handlers['POST /api/v1/keys'], { headers: { 'x-api-key': RAW_EXMEMBER }, body: { name: 'k' }, query: {} });
250+
const { status, body } = await drive(handlers[AUTOMATION], withKey);
224251
expect(status).toBe(503);
225252
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
226253
expect(body?.success).toBe(false);
@@ -229,6 +256,13 @@ describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE
229256
expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE');
230257
});
231258

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+
232266
it('CONTROL on the wire: with tenancy never registered the same door serves — the no-tenancy composition is untouched', async () => {
233267
const handlers = await mountOn(kernelWith('unregistered'));
234268
const { status } = await drive(handlers['GET /api/v1/health'], { headers: {} });

packages/runtime/src/http-dispatcher.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
import {
44
ObjectKernel, getEnv, evaluateAuthGate, isAuthGateAllowlisted,
5+
// [#13906 decision 1 A] The identity step's net re-raises ONLY the loud
6+
// authz-store outage (the `.catch` shape `@objectstack/core` prescribes for
7+
// every seam between `resolveAuthzContext` and a door), and the tenancy
8+
// read is classified by the REGISTRY's own "never registered" brand.
9+
rethrowAuthzStoreUnavailable, isServiceNotRegisteredError,
510
} from '@objectstack/core';
611
import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage } from '@objectstack/types';
712
import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
@@ -535,7 +540,20 @@ export class HttpDispatcher {
535540
// ctx.userId/roles/permissions/tenantId via opCtx.context.
536541
try {
537542
context.executionContext = await this.timedResolveExecutionContext({
538-
getService: (n: string) => this.resolveService(this.requestKernel(context), n, context.environmentId),
543+
// [#13906 decision 1 A] `resolveService` is a capability PROBE:
544+
// its fallback chain absorbs every rejection at every step and
545+
// answers `undefined`, which is the right shape for "is this
546+
// optional service installed" and the wrong shape for the ONE
547+
// authorization INPUT the resolver reads through this facade.
548+
// For `tenancy` the resolver must see the registry's CLASSIFIED
549+
// rejection — branded "never registered" (absorbed, the
550+
// supported no-tenancy composition) versus unbranded
551+
// "registered and failed to build" (re-raised, so the door
552+
// answers 503 instead of admitting on a posture it could not
553+
// read). Every other name keeps the probe.
554+
getService: (n: string) => n === 'tenancy'
555+
? this.resolveServiceOrLoud(this.requestKernel(context), n, context.environmentId)
556+
: this.resolveService(this.requestKernel(context), n, context.environmentId),
539557
// Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
540558
// `resolveService('objectql', envId)` factory can return a different
541559
// instance that doesn't see THIS env's rows (the gotcha
@@ -564,7 +582,17 @@ export class HttpDispatcher {
564582
// (the scoped prefix is stripped only by the caller, later).
565583
acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath),
566584
});
567-
} catch {
585+
} catch (err) {
586+
// [#13906 decision 1 A / #13279] The ONE fault that must stay loud:
587+
// an authorization input that exists and could not be read (a
588+
// failed permission-store read, a `tenancy` service that is
589+
// registered and failed to build). Swallowing it here answered an
590+
// outage as an anonymous request — a 401 byte-identical to a caller
591+
// with no credential, which is the "changed disguise" the shared
592+
// `rethrowAuthzStoreUnavailable` exists to end. It re-raises that
593+
// class by brand and returns `undefined` for everything else, so
594+
// every other fault still degrades exactly as before:
595+
rethrowAuthzStoreUnavailable(err);
568596
// anonymous request — leave executionContext undefined
569597
}
570598
}
@@ -2120,6 +2148,52 @@ export class HttpDispatcher {
21202148
return services[name];
21212149
}
21222150

2151+
/**
2152+
* [#13906 decision 1 A] Resolve a service whose ABSENCE is a supported
2153+
* composition but whose FAILURE is an outage — today only the `tenancy`
2154+
* read the identity step feeds `resolveExecutionContext`.
2155+
*
2156+
* `resolveService` above is a capability probe: every step of its chain
2157+
* absorbs every rejection and falls through, so a factory that threw and a
2158+
* name nothing registered both come back as `undefined`. That collapse is
2159+
* the defect #13906 repaired one seam over (`rest-server.ts`), and the
2160+
* classification here is the same one, taken from the REGISTRY rather than
2161+
* from message text (#13905):
2162+
*
2163+
* - branded "never registered" → `undefined`, quiet;
2164+
* - every other rejection (a factory that threw, a scoped registration
2165+
* resolved without a scope id, a circular service dependency) →
2166+
* re-raised unbranded, for the resolver to answer as
2167+
* `AuthzStoreUnavailableError` (503).
2168+
*
2169+
* The chain order is `resolveService`'s (scoped lookup on the host kernel
2170+
* first, then the request's own kernel), so WHICH registry answers is
2171+
* unchanged; only what a rejection MEANS is. A host with no async accessor
2172+
* (`KernelBase`-shaped, e.g. `LiteKernel`) supports no service factories,
2173+
* so "not registered" is the only fault it can report — it keeps the quiet
2174+
* probe, which is the same classification rather than a second collapse.
2175+
*/
2176+
private async resolveServiceOrLoud(kernel: any, name: string, scopeId?: string): Promise<any> {
2177+
const classified = async (read: () => Promise<any>): Promise<{ found: boolean; value?: any }> => {
2178+
try {
2179+
const svc = await read();
2180+
return svc != null ? { found: true, value: svc } : { found: false };
2181+
} catch (err) {
2182+
if (isServiceNotRegisteredError(err)) return { found: false };
2183+
throw err;
2184+
}
2185+
};
2186+
if (scopeId && typeof this.defaultKernel.getServiceAsync === 'function') {
2187+
const scoped = await classified(() => this.defaultKernel.getServiceAsync(name, scopeId));
2188+
if (scoped.found) return scoped.value;
2189+
}
2190+
if (typeof kernel?.getServiceAsync === 'function') {
2191+
const own = await classified(() => kernel.getServiceAsync(name));
2192+
return own.found ? own.value : undefined;
2193+
}
2194+
return this.resolveService(kernel, name, scopeId);
2195+
}
2196+
21232197
/**
21242198
* Get the ObjectQL service which provides access to SchemaRegistry.
21252199
* Tries multiple access patterns since kernel structure varies.

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@
1515
* synthesis live in ONE place now (`@objectstack/core`), shared with the REST
1616
* server, so the two entry points can never drift on authorization again.
1717
*
18-
* Always resolves — never throws. Anonymous requests yield
19-
* `{ isSystem: false, positions: [], permissions: [] }`.
18+
* Resolves for every request it can ANSWER — anonymous requests yield the
19+
* guest envelope (`{ isSystem: false, positions: [], permissions: [] }`) —
20+
* and throws `AuthzStoreUnavailableError` (503) for the one class of fault
21+
* that leaves the answer undetermined: an authorization INPUT that exists and
22+
* could not be read (a permission-store read that failed, #13279; a `tenancy`
23+
* service that is registered and failed to build, #13906 decision 1 A). The
24+
* dispatcher's net (`HttpDispatcher.resolveRequestScope`) re-raises exactly
25+
* that class and degrades everything else to anonymous, as before.
2026
*/
2127

2228
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -30,6 +36,13 @@ import {
3036
assembleExecutionContextOrGuest,
3137
type EntryLocalization,
3238
effectiveTenancyPosture,
39+
// [#13906 decision 1 A] The loud answer for an authorization input that
40+
// exists and could not be read, and the REGISTRY's own "never registered"
41+
// brand that lets the tenancy seam absorb the supported no-tenancy
42+
// composition while every other rejection stays loud. Never message text
43+
// (#13905).
44+
AuthzStoreUnavailableError,
45+
isServiceNotRegisteredError,
3346
} from '@objectstack/core';
3447

3548
/**
@@ -168,11 +181,35 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
168181
// Layer 0 wall, so admission and the wall can never disagree. Deliberately not
169182
// `OS_TENANCY_POSTURE`: that is what the operator ASKED for, and under
170183
// ADR-0093 D4/D5 a requested-but-unenforceable wall resolves to `single`.
171-
// Absent service ⇒ undefined ⇒ no posture-conditional refusal.
184+
//
185+
// [#13906 decision 1 A, at this door] `undefined` is not a neutral value
186+
// here: `resolveAuthzContext` gates BOTH posture-conditional API-key
187+
// refusals (`organization_required`, `organization_membership_ended`) on a
188+
// PRESENT posture, so "no posture" means "no wall". The seam used to resolve
189+
// EVERY rejection to that — and a `tenancy` service that was registered and
190+
// FAILED to build read as a deployment with no tenancy at all. Two facts,
191+
// told apart by the registry's own brand, the way `rest-server.ts` already
192+
// does on both of its wirings:
193+
//
194+
// - never registered → branded → `undefined`, quiet. The supported
195+
// no-tenancy composition: no wall exists, and refusing there would break
196+
// every single-organization embedder.
197+
// - registered and FAILED to build → unbranded → re-raised as the same loud
198+
// answer a failed permission-store read gives (#13279): the posture is an
199+
// authorization INPUT, so admission was never decided. A posture that
200+
// could not be READ is not a posture that is ABSENT.
201+
//
202+
// A facade that resolves `undefined` instead of rejecting (the dispatcher's
203+
// capability PROBE, `resolveService`) still reads as absent — which is why
204+
// `HttpDispatcher.resolveRequestScope` hands THIS read the classified
205+
// rejection rather than the probe's collapsed answer.
172206
let tenancyPosture;
173207
try {
174208
tenancyPosture = effectiveTenancyPosture(await opts.getService('tenancy'));
175-
} catch {
209+
} catch (err) {
210+
if (!isServiceNotRegisteredError(err)) {
211+
throw new AuthzStoreUnavailableError('tenancy', err);
212+
}
176213
tenancyPosture = undefined;
177214
}
178215

0 commit comments

Comments
 (0)