diff --git a/.changeset/ui-view-environment-ownership.md b/.changeset/ui-view-environment-ownership.md new file mode 100644 index 0000000000..093d050823 --- /dev/null +++ b/.changeset/ui-view-environment-ownership.md @@ -0,0 +1,75 @@ +--- +"@objectstack/rest": minor +--- + +fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214) + +**Security floor.** This route was the one identity-touching route in +`RestServer`'s table that resolved no identity at all: it went from +`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous +caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero** +times — while the other 52 identity-touching routes answered 401 under an absent +context. + +Because the unscoped mount lets the REQUEST name its environment (bound +hostname, else the `X-Environment-Id` header), that made it a cross-environment +disclosure rather than a single-tenant one. Driven on the real route table with +a real `envRegistry` + `kernelManager`: an anonymous request naming another +environment received **that environment's** UI view — object label plus every +field's `name` / `label` / `type` / `required` / `readonly` — through **both** +naming channels, with the foreign kernel acquired. The route was additionally an +object-existence oracle for whatever environment was named, and an +**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but +silently fell through to the default environment and answered 200 with *that* +environment's view, so two 200s with different bytes distinguished a real +environment id from an invented one. + +Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was +explicitly measured **not** to be the repair — it stops the anonymous caller and +nothing else, because an authenticated caller could still name a foreign +environment and nothing downstream compared the environment that was *resolved* +with the environment the caller is *entitled to*. + +What the seam does now, in order: resolve the environment once through the +shared entry point; resolve identity **in that environment**; refuse anonymity; +then compare. The comparison reads `__authEnvironmentId` — an internal key +`computeExecCtx` now stamps on every context it produces, naming the environment +whose auth service actually validated the caller. It differs from the resolved +environment in exactly the branch that crosses: when the resolved environment's +kernel carries no `auth` service, the lookup falls back to the **default** +environment's, and a session minted there authenticated a request naming another +one. + +Both refusable shapes answer with the anonymous-deny envelope **verbatim** +(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller +naming a real foreign environment is already refused by the anonymous gate +(their credential is not valid there), so giving "you do not own this +environment" or "that environment id does not resolve" any *other* status would +rebuild the id oracle one layer up. One shape, byte for byte, for every way a +caller can fail to be entitled to the environment it named. The cost is +diagnosability: an operator whose environment genuinely lacks an `auth` service +sees the anonymous 401 rather than a wiring error. + +**Migration.** The published route changes from "anonymous read" to +"authenticated **and** ownership-checked", so a caller that relied on the old +behaviour breaks: + +- An **anonymous** consumer of `/ui/view/...` (for example a login screen + rendering a view before authentication) now receives 401. There is no opt-out; + the route is not on `isAuthGateAllowlisted` and was never a declared + control-plane exemption. +- A caller sending `X-Environment-Id` **while on a hostname bound to a different + environment** now receives 401 instead of being served the hostname's + environment. Drop the contradictory header; the bound hostname still decides. +- A caller sending an `X-Environment-Id` the registry cannot resolve now + receives 401 instead of the default environment's view. +- A deployment where an environment's kernel carries no `auth` service of its + own now refuses requests naming that environment, because the credential + would have been validated in the default environment instead. Wire the + environment's `auth` service. + +Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both +gated; naming an environment in the URL is no more of an entitlement than naming +it in a header. What the producer is *told* is unchanged — `getUiView` still +receives `{ object, type }` on the unscoped mount and the route-supplied +`environmentId` on the scoped one. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0d5e887c30..7b0e964043 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 68c870e32d..44f571232f 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ reachable: 19, blindSpot: 61, populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints', - controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 }, + // [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control + // exists to prove this census is still reading the file it thinks it is, and + // a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214 + // was supposed to cause — `registerUiEndpoints` was the ONE route in this + // file that resolved no identity, and it is now guarded. The move is +3 over + // the whole file (`occurrences` counts the bare term, comments included): + // one new call site — `if (this.enforceAuth(req, res, context)) return;`, + // 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an + // EXACT count rather than a range or a floor: a range would stop this row + // noticing the next move, which is the only thing it is for. + // + // ⚠️ The three sibling numbers were re-derived and did NOT move, which is + // what says this is a guard change and not a surface change: `population` + // 80, `reachable` 19, `private register*Endpoints(` 17 and + // `this.routeManager.register(` 80 are all unchanged — #13214 added no route + // and no registrar. `blindSpot` therefore stays 61 as well. + controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 }, note: 'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' + 'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' + diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index a295ed9938..3260e9262a 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -173,6 +173,15 @@ const ENTITLED = { isSystem: false, tenantId: 'org_census', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + // [#13214] The internal key `computeExecCtx` stamps on every context it + // produces, naming the environment whose auth service actually validated + // the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view + // site this census now counts — compares it against the environment the + // request resolved to, which under `makeServer` is `env_census`. + // `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic + // context has to model the key or it is a caller anchored NOWHERE, which + // that seam refuses. Every other site in this census ignores it. + __authEnvironmentId: 'env_census', }; const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] }; @@ -300,39 +309,57 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => { - expect(SITES.length).toBe(72); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89); + it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => { + // [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was + // the ONE metadata-touching route in the table that resolved no + // identity at all — the exception this census surfaced — and the + // 2026-08-30 ruling closed it. It joins as a BARE site behind the + // shared floor, which is the family the next two cases describe. + // + // ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is + // the point of counting both: one is the call site, the other two are + // prose mentions in the new doc-comments (the registrar's, recording + // that this route used to call `resolveExecCtx` zero times, and the + // ownership guard's, recording that adding `resolveExecCtx` + + // `enforceAuth` was measured NOT to be the repair). A mention count + // that tracked the site count exactly would be measuring one thing + // twice. + expect(SITES.length).toBe(73); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92); }); - it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => { + it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => { // 16 sites spell the catch on the invocation line; 4 more spell it on // the continuation line. A single-line grep sees 16 and the arithmetic // silently loses four sites. + // + // [#13214] The new site is BARE, and that is a decision the next case + // enforces: a locally-caught site sitting behind the shared floor would + // be the first of its kind and would break the structural claim below. const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch(')); expect(sameLine.length).toBe(16); expect(CAUGHT.length).toBe(20); - expect(BARE.length).toBe(52); + expect(BARE.length).toBe(53); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => { + it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => { // This inverts the reason the thread gave for doing the bare sites // first ("no local signal that a fault becomes an anonymous subject"). // The bare sites are bare BECAUSE the shared anonymous floor is the // next statement; the locally-caught ones carry a `.catch` because // they are NOT behind that floor and each must decide for itself. - expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52); + expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53); expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0); }); }); // --------------------------------------------------------------------------- -// 3. The 52 bare sites, driven +// 3. The 53 bare sites, driven // --------------------------------------------------------------------------- -describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => { - it('all 52 are reached by the mounted route table, so none is classified by inference', async () => { +describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => { + it('all 53 are reached by the mounted route table, so none is classified by inference', async () => { const reached = sitesOf(await sweep(undefined, 'FULL')); const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l)); // ⛔ A bare site that stopped being reachable must show up as a @@ -340,14 +367,14 @@ describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => { expect(unreached).toEqual([]); }, 120_000); - it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { + it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { const fault = await sweep(undefined, 'FULL'); const control = await sweep(ENTITLED, 'FULL'); const bareLines = new Set(BARE.map((s) => s.line)); const controlByRoute = new Map(control.map((r) => [r.route, r])); const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l))); - expect(rows.length).toBeGreaterThanOrEqual(52); + expect(rows.length).toBeGreaterThanOrEqual(53); for (const row of rows) { expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS); diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index ff0878826a..b5d75be389 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output // it, so it is an assembled field now and absent for the same reason // every other unset field is: this session carries no gate. '__kernel', + // [#13214] The SECOND post-assembly internal key, added by the + // 2026-08-30 security ruling and named here rather than left to a + // subset check — this pin exists precisely to make a key ARRIVING + // as loud as a key going missing, and this one arrived. + // + // It carries the environment whose auth service actually validated + // the caller, which is the left-hand side of the ownership + // comparison `enforceEnvironmentOwnership` makes at + // `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an + // authorization input, at exactly one reader inside `rest-server.ts` + // — ⛔ nothing downstream of this transport may branch on it, and + // it is deliberately NOT an `ExecutionContext` field because it + // describes how the context was OBTAINED, not what the principal + // may do. The assembled field set is unchanged; this sits beside it, + // in the same `as any` the class doc-comment already covers. + '__authEnvironmentId', ])); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 0920a4d93a..bc216e780d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1278,6 +1278,103 @@ export class RestServer { return false; } + /** + * [#13214] Refuse a request whose RESOLVED environment is not one the CALLER + * holds — the comparison this server did not have. + * + * ## The defect this closes, and why the anonymous gate alone did not + * + * `GET /api/v1/ui/view/:object/:type` mounts UNSCOPED as well as scoped, and + * on the unscoped mount the environment is named by the REQUEST: the bound + * hostname, else the `X-Environment-Id` header. Both were honoured with no + * identity resolved at all, so an ANONYMOUS caller received another + * environment's UI view — object label plus every field's name / label / + * type / required — and the route doubled as an object-existence oracle for + * whatever environment it named. Driven and reported on #13214 (PRs #13244, + * #13258). + * + * Adding `resolveExecCtx` + `enforceAuth` was measured NOT to be the repair + * (it was the rejected option B of the 2026-08-30 ruling): it stops the + * anonymous caller and nothing else, because an AUTHENTICATED caller could + * still name a foreign environment and nothing downstream ever compared the + * environment that was RESOLVED with the environment the caller is entitled + * to. That comparison is this method. + * + * ## What "entitled to" means here, mechanically + * + * There was no ownership predicate in this package to reuse — searched + * before writing one — and `ExecutionContext` carries no environment field, + * so the fact had to come from where identity is established. + * {@link computeExecCtx} validates the caller against an auth service it + * looks up in a KERNEL, and it records which environment that kernel belongs + * to on `__authEnvironmentId`. A credential is good for the environment + * whose auth service accepted it; if the request resolved to a different + * environment, the caller is not entitled to what is about to be served. + * + * That difference is not hypothetical — `computeExecCtx`'s second branch + * produces it: when the resolved environment's kernel carries no `auth` + * service, the lookup falls back to the DEFAULT environment's, and a session + * minted there then authenticates a request naming another environment. + * + * Two refusable shapes, both handled: + * + * 1. **Named but not served.** The caller named an environment through + * `X-Environment-Id` and the chain resolved a DIFFERENT one — which is + * what an unresolvable id does today: `resolveRequestEnvironmentId` + * swallows the `envRegistry.resolveById` miss and falls through to the + * default environment, answering 200 with THAT environment's view. Two + * 200s with different bytes is how a caller with no credential tells a + * real environment id from an invented one. Refused here rather than + * answered — the ruling's 「信号化拒绝」, and ⛔ never a silent fallback. + * 2. **Anchored elsewhere.** The credential was validated in an environment + * other than the one resolved. + * + * ## ⚠️ Why the refusal is the ANONYMOUS-DENY response, verbatim + * + * Deliberate, and the reason is the oracle rather than tidiness. A caller + * naming a REAL foreign environment already receives 401 from + * {@link enforceAuth} — their session is not valid in that environment, so + * no context resolves there. Answering the two cases above with anything + * else (403, or a 404 of this seam's own) would leave "this environment id + * exists" distinguishable from "it does not" by the status alone, which is + * the same oracle one layer up. One shape, byte-identical, for every way a + * caller can fail to be entitled to the environment it named. + * + * ⚠️ The cost is diagnosability, and it is named rather than discovered: an + * operator whose environment genuinely lacks an `auth` service sees the + * anonymous 401, not a wiring error. That is the same trade the sibling + * seams already make (`computeExecCtx` answers a faulting resolver and a + * genuinely anonymous caller identically, #12537). + * + * `undefined` and `'platform'` are NOT environments: a control-plane boot + * resolves no environment, so there is nothing to own and the anonymous + * floor above is the whole gate. A caller that NAMED one anyway (case 1) + * is still refused. + * + * @returns `true` when the response was sent and the caller must stop. + */ + private enforceEnvironmentOwnership( + req: any, + res: any, + environmentId: string | undefined, + context: any, + ): boolean { + // 1. Named through the header, but the chain served something else. + const named = this.extractProjectIdHeader(req); + const namedButNotServed = named !== undefined && named !== environmentId; + + // 2. The credential is anchored in a different environment. + const scopesAnEnvironment = environmentId !== undefined && environmentId !== 'platform'; + const anchoredElsewhere = + scopesAnEnvironment && (context as any)?.__authEnvironmentId !== environmentId; + + if (namedButNotServed || anchoredElsewhere) { + res.status(ANONYMOUS_DENY_STATUS).json(ANONYMOUS_DENY_BODY); + return true; + } + return false; + } + /** * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` / * `enable.apiMethods`) on the REST data surface — the *external* API boundary @@ -1873,9 +1970,16 @@ export class RestServer { // environmentId. let authService: any; let kernel: any; + // [#13214] WHICH environment's auth service actually validated this + // caller — the fact an ownership check needs and the one this method + // used to compute and drop. Three branches below can answer, and the + // SECOND of them answers for a DIFFERENT environment than the one the + // request resolved to; see `enforceEnvironmentOwnership`. + let authEnvironmentId: string | undefined; if (environmentId && environmentId !== 'platform' && this.kernelManager) { kernel = await this.kernelManager.getOrCreate(environmentId); authService = await kernel.getServiceAsync('auth').catch(() => undefined); + if (authService) authEnvironmentId = environmentId; } if (!authService && this.defaultEnvironmentIdProvider && this.kernelManager) { try { @@ -1883,6 +1987,13 @@ export class RestServer { if (def) { kernel = await this.kernelManager.getOrCreate(def); authService = await kernel.getServiceAsync('auth').catch(() => undefined); + // ⚠️ The CROSS-ENVIRONMENT branch. The request resolved to + // `environmentId`, but the credential is being checked + // against `def`'s auth service — so a session minted in the + // default environment authenticates a request naming + // another one. Recorded truthfully rather than as + // `environmentId`; that difference IS the ownership fact. + if (authService) authEnvironmentId = def; } } catch { /* fall through */ } } @@ -1891,6 +2002,10 @@ export class RestServer { // local kernel directly. if (!authService && this.authServiceProvider) { authService = await this.authServiceProvider(environmentId).catch(() => undefined); + // The provider is asked FOR this environment and answers for it + // (`rest-api-plugin` wires it to the lone local kernel), so the + // credential is anchored where the request resolved. + if (authService) authEnvironmentId = environmentId; } if (!authService) return undefined; // The auth service may be the AuthManager wrapper (which exposes @@ -2032,8 +2147,17 @@ export class RestServer { // requiresService capability gates (ADR-0057 D10). NOT an // authorization input — never read by RLS/permission logic, and // NOT an `ExecutionContext` field — hence the cast, which now - // covers this key alone. + // covers this key and `__authEnvironmentId` below. __kernel: kernel, + // [#13214] Internal: the environment whose auth service actually + // validated this caller — the left-hand side of the ownership + // comparison at the UI-view seam. ⚠️ Unlike `__kernel` this one IS + // an authorization input, at exactly one reader + // (`enforceEnvironmentOwnership`); it is deliberately NOT an + // `ExecutionContext` field, because it describes how the context + // was OBTAINED rather than what the principal may do, and nothing + // downstream of this transport may branch on it. + __authEnvironmentId: authEnvironmentId, } as any; // [#2408 / #3361] Open the per-request `Server-Timing` disclosure gate @@ -6908,6 +7032,32 @@ export class RestServer { /** * Register UI endpoints + * + * ## [#13214] This registrar's one route is identity- AND ownership-gated + * + * It used to be the single route in this server's table that resolved NO + * identity: it went straight from `resolveProtocol` to `getUiView`, so it + * answered 200 to an anonymous caller, byte-identically to an entitled one, + * having called `resolveExecCtx` zero times — while all 52 identity-touching + * siblings answered 401. Because the unscoped mount lets the REQUEST name + * its environment (bound hostname, else `X-Environment-Id`), that made + * another environment's object metadata anonymously readable and turned the + * route into an object-existence oracle for any environment a caller could + * name. + * + * Maintainer ruling, 2026-08-30 (option C): the seam must require that the + * RESOLVED environment belong to the caller — identity resolution PLUS an + * ownership check — on BOTH naming channels, and an `envRegistry.resolveById` + * validation failure must be a signalled refusal, ⛔ never a silent fallback + * to the default environment. ⛔ Anonymous-deny alone was explicitly the + * rejected option B. + * + * The order below is the ruling, step by step, and it is load-bearing: + * resolve the environment ONCE through the shared entry point, resolve + * identity IN that environment, refuse anonymity, then compare. Resolving + * identity before the environment is decided would authenticate the caller + * somewhere other than where the answer comes from, which is the very + * mismatch {@link enforceEnvironmentOwnership} exists to catch. */ private registerUiEndpoints(basePath: string): void { const uiPath = `${basePath}/ui`; @@ -6919,13 +7069,42 @@ export class RestServer { path: `${uiPath}/view/:object/:type`, handler: async (req: any, res: any) => { try { - const environmentId = isScoped ? req.params?.environmentId : undefined; + const routeEnvironmentId = isScoped ? req.params?.environmentId : undefined; + // [#13214] THE environment decision for this request, taken + // once through the shared entry point and then reused — so + // the identity below, the ownership comparison and the + // protocol that answers cannot be about three different + // environments. + const environmentId = await this.resolveRequestEnvironmentId(routeEnvironmentId, req); + // A BARE site, deliberately, and the census in + // `execctx-consumer-census.test.ts` is what makes that a + // decision rather than an omission: the 52 sites whose very + // next statement is the shared anonymous floor carry no + // local `.catch`, because `computeExecCtx` already converts + // every fault except an authz-store outage into `undefined` + // and re-raises that one — which this handler's own + // `try/catch` turns into the declared fault response. A + // local `.catch(rethrowAuthzStoreUnavailable)` here would be + // behaviourally identical and would make this the only site + // that is both locally caught AND behind the floor, which is + // exactly the split that census asserts does not exist. + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (this.enforceEnvironmentOwnership(req, res, environmentId, context)) return; const p = await this.resolveProtocol(environmentId, req); if (p.getUiView) { const view = await p.getUiView({ object: req.params.object, type: req.params.type as any, - ...(environmentId ? { environmentId } : {}), + // [#13214] `routeEnvironmentId`, NOT the resolved id. + // The gate above changed WHO may reach the producer; + // it deliberately did not change WHAT the producer is + // told. This key has only ever been present on the + // scoped mount, and the shipped `getUiView` declares + // `{ object, type }` alone — widening the argument on + // the unscoped mount would be an unrelated behaviour + // change riding on a security fix. + ...(routeEnvironmentId ? { environmentId: routeEnvironmentId } : {}), } as any); res.json(view); } else { diff --git a/packages/rest/src/ui-view-environment-ownership.test.ts b/packages/rest/src/ui-view-environment-ownership.test.ts new file mode 100644 index 0000000000..79eaa58c75 --- /dev/null +++ b/packages/rest/src/ui-view-environment-ownership.test.ts @@ -0,0 +1,332 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13214] The fact the ownership gate reads — pinned against its PRODUCER. + * + * ## Why this file has to exist separately + * + * `ui-view-route-tenancy.measurement.test.ts` and + * `ui-view-route-identity.measurement.test.ts` drive the repaired seam through + * an `instrument()` that replaces `resolveExecCtx` wholesale, so every + * execution context in those files is SYNTHETIC — including the + * `__authEnvironmentId` key that `enforceEnvironmentOwnership` compares. That + * makes them complete readings about the GATE and no reading at all about the + * FACT: if `computeExecCtx` stamped the key wrongly — or stamped the resolved + * environment unconditionally, which is the tempting shortcut — every + * assertion over there would stay green while the gate compared a value to + * itself and could never refuse anyone. + * + * ⭐ That is the shape of an assertion which survives its own defect. This file + * closes it by driving the UNSTUBBED method. + * + * ## What `__authEnvironmentId` means, and the branch that makes it non-trivial + * + * `computeExecCtx` resolves the request's environment, then looks up an `auth` + * service to validate the caller against. It has three ways to find one, and + * the SECOND does not belong to the resolved environment: + * + * 1. the resolved environment's own kernel → anchored there; + * 2. ⚠️ the DEFAULT environment's kernel, when (1) has no `auth` service + * → the credential is checked somewhere else entirely; + * 3. the single-kernel `authServiceProvider`, asked for this environment. + * + * Branch 2 is the whole reason an ownership comparison is needed rather than + * an anonymous check: a session minted in the default environment authenticates + * a request naming another one, and before the repair nothing anywhere noticed. + * §1 drives exactly that branch. + */ + +import { describe, it, expect } from 'vitest'; +import { ANONYMOUS_DENY_STATUS } from '@objectstack/core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import type { RestEnvRegistry, RestKernelManager } from './rest-server.js'; + +const BASE = '/api/v1'; +const UI_ROUTE = `GET ${BASE}/ui/view/:object/:type`; + +const ENV_A = 'env_alpha'; +const ENV_B = 'env_beta'; + +const SCHEMA_A = { + name: 'account', + label: 'Alpha Environment Accounts', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Alpha Account Name' }, + alpha_only_field: { name: 'alpha_only_field', type: 'text', label: 'Alpha Only' }, + }, +}; + +const SCHEMA_B = { + name: 'account', + label: 'Beta Environment Accounts', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Beta Account Name' }, + beta_only_field: { name: 'beta_only_field', type: 'text', label: 'Beta Only' }, + }, +}; + +function protocolFor(schema: unknown): any { + const engine = { registry: { getObject: (name: string) => (name === 'account' ? schema : null) } }; + return new ObjectStackProtocolImplementation(engine as any) as any; +} + +/** + * A minimal `objectql` the shared authz resolver can query without throwing. + * Every lookup answers EMPTY, so the resolved principal carries the session's + * user id and no roles — which is all these readings turn on. + */ +function emptyQl(): any { + const empty = async () => []; + return new Proxy({}, { + get: (_t, k: string) => (k === 'then' ? undefined : empty), + }); +} + +/** An auth service in the shape `computeExecCtx` normalises: `api.getSession`. */ +function authServiceFor(userId: string): any { + return { + api: { + getSession: async () => ({ user: { id: userId, email: `${userId}@example.test` }, session: { userId } }), + }, + }; +} + +interface KernelSpec { + /** `undefined` = this environment's kernel carries NO `auth` service. */ + authUserId?: string; + schema: unknown; +} + +function kernelManagerFor(spec: Record) { + const acquired: string[] = []; + const authLookups: string[] = []; + const kernelManager: RestKernelManager = { + async getOrCreate(environmentId: string) { + acquired.push(environmentId); + const entry = spec[environmentId]; + return { + async getServiceAsync(name: string): Promise { + if (name === 'auth') { + authLookups.push(environmentId); + if (!entry?.authUserId) throw new Error(`no auth service in ${environmentId}`); + return authServiceFor(entry.authUserId) as T; + } + if (name === 'objectql') return emptyQl() as T; + if (name === 'protocol') return protocolFor(entry?.schema ?? null) as T; + // `i18n`, `tenancy`, `settings`, … — absent, which every + // caller in `computeExecCtx` treats as best-effort. + throw new Error(`no ${name} service`); + }, + } as any; + }, + }; + return { kernelManager, acquired, authLookups }; +} + +const envRegistry: RestEnvRegistry = { + async resolveByHostname() { return null; }, + async resolveById(environmentId: string) { + return environmentId === ENV_A || environmentId === ENV_B ? { driver: environmentId } : null; + }, +}; + +type Handler = (req: any, res: any) => any; + +function recordingServer() { + const table = new Map(); + const on = (m: string) => (p: string, h: Handler) => { table.set(`${m} ${p}`, h); }; + return { + table, + get: on('GET'), post: on('POST'), put: on('PUT'), delete: on('DELETE'), patch: on('PATCH'), + use: () => {}, listen: async () => {}, close: async () => {}, + } as any; +} + +/** + * ⭐ The arity/position pin, carried over from the tenancy harness for its + * reason: a surplus argument still RUNS while shifting every later provider + * onto the wrong parameter, and `kernelManager` / `envRegistry` are positions 4 + * and 5 — the two everything here turns on. Typed as the constructor's own + * parameter list, so arity AND each position are checked by + * `tsconfig.test.json`, which compiles this file. + */ +function makeServer(kernelManager: RestKernelManager, defaultEnvironmentId: string | undefined) { + const server = recordingServer(); + const provider = async () => undefined as any; + const args: ConstructorParameters = [ + /* 1 server */ server, + /* 2 protocol (control plane) */ protocolFor(null), + /* 3 config */ { api: { enableProjectScoping: true, projectResolution: 'auto' } }, + /* 4 kernelManager */ kernelManager, + /* 5 envRegistry */ envRegistry, + /* 6 defaultEnvironmentIdProvider */ () => defaultEnvironmentId, + /* 7 authServiceProvider */ undefined, + /* 8 objectQLProvider */ provider, + /* 9 emailServiceProvider */ provider, + /* 10 sharingServiceProvider */ provider, + /* 11 reportsServiceProvider */ provider, + /* 12 approvalsServiceProvider */ provider, + /* 13 sharingRulesServiceProvider */ provider, + /* 14 i18nServiceProvider */ provider, + /* 15 analyticsServiceProvider */ provider, + /* 16 settingsServiceProvider */ provider, + /* 17 serviceExistsProvider */ () => false, + /* 18 securityServiceProvider */ provider, + /* 19 requestEnvResolver */ undefined, + /* 20 metadataServiceProvider */ provider, + ]; + const rs: any = new RestServer(...args); + return { rs, table: server.table as Map }; +} + +function request(environmentIdHeader?: string) { + return { + params: { object: 'account', type: 'list' }, + query: {}, body: {}, method: 'GET', + path: `${BASE}/ui/view/account/list`, + url: `${BASE}/ui/view/account/list`, + headers: { + host: 'unbound.example.test', + cookie: 'session=whatever', + ...(environmentIdHeader ? { 'x-environment-id': environmentIdHeader } : {}), + }, + } as any; +} + +// --------------------------------------------------------------------------- +// 1. The FACT — `computeExecCtx` records where the credential was validated +// --------------------------------------------------------------------------- + +describe('[#13214] §1 `__authEnvironmentId` names the environment that actually validated the caller', () => { + it("is the RESOLVED environment when that environment's own kernel carries the auth service", async () => { + const { kernelManager } = kernelManagerFor({ + [ENV_A]: { authUserId: 'u_alpha', schema: SCHEMA_A }, + [ENV_B]: { authUserId: 'u_beta', schema: SCHEMA_B }, + }); + const { rs } = makeServer(kernelManager, ENV_A); + + const ctx = await rs.resolveExecCtx(undefined, request(ENV_B)); + expect(ctx?.userId).toBe('u_beta'); + expect(ctx.__authEnvironmentId).toBe(ENV_B); + }, 120_000); + + it('⭐ is the DEFAULT environment — NOT the resolved one — when the resolved kernel has no auth service', async () => { + // ⚠️ THE branch the whole gate exists for. The request resolves to B, + // B's kernel has no `auth` service, so the credential is validated + // against A's. The caller IS authenticated — and authenticated + // somewhere other than where the answer would come from. + const { kernelManager, authLookups } = kernelManagerFor({ + [ENV_A]: { authUserId: 'u_alpha', schema: SCHEMA_A }, + [ENV_B]: { schema: SCHEMA_B }, // no auth service + }); + const { rs } = makeServer(kernelManager, ENV_A); + + const req = request(ENV_B); + // The environment really did resolve to B — stated as its own reading, + // so "anchored in A" cannot be read as "never resolved B". + expect(await rs.resolveRequestEnvironmentId(undefined, req)).toBe(ENV_B); + + const ctx = await rs.resolveExecCtx(undefined, req); + expect(ctx?.userId).toBe('u_alpha'); + // ⭐ Truthful, not convenient: it names A, so the comparison downstream + // can fail. Stamping the resolved environment here would make the gate + // compare a value to itself and refuse no one, with every gate-level + // test still green. + expect(ctx.__authEnvironmentId).toBe(ENV_A); + expect(ctx.__authEnvironmentId).not.toBe(ENV_B); + // The mechanism: B's auth lookup was attempted and failed, then A's ran. + expect(authLookups).toEqual([ENV_B, ENV_A]); + }, 120_000); + + it('with NO auth service anywhere there is no context at all, so there is nothing to anchor', async () => { + // The floor case, and the control for the two above: `undefined` is + // what an unauthenticated resolution yields, and `enforceAuth` turns + // that into the 401 — so `__authEnvironmentId` is only ever read on a + // context that exists. + const { kernelManager } = kernelManagerFor({ + [ENV_A]: { schema: SCHEMA_A }, // no auth service + [ENV_B]: { schema: SCHEMA_B }, // no auth service + }); + const { rs } = makeServer(kernelManager, ENV_A); + expect(await rs.resolveExecCtx(undefined, request(ENV_B))).toBeUndefined(); + }, 120_000); +}); + +// --------------------------------------------------------------------------- +// 2. The GATE, end to end, with NOTHING stubbed +// --------------------------------------------------------------------------- + +describe('[#13214] §2 the seam refuses a caller authenticated in another environment — unstubbed', () => { + async function drive(kernelManager: RestKernelManager, defaultEnvironmentId: string, header?: string) { + const { rs, table } = makeServer(kernelManager, defaultEnvironmentId); + rs.registerRoutes(); + const handler = table.get(UI_ROUTE); + if (!handler) throw new Error(`route not mounted: ${UI_ROUTE}`); + let status = 0; let body: any; let sent = false; + const res: any = { + status(c: number) { status = c; return res; }, + json(b: any) { body = b; sent = true; }, + send() { sent = true; }, header() { return res; }, setHeader() { return res; }, + end() { sent = true; }, write() { return true; }, type() { return res; }, + }; + await handler(request(header), res); + return { status: status || (sent ? 200 : 0), body }; + } + + it('⭐ a caller validated in A, naming B, is REFUSED — and receives none of B', async () => { + const wiring = kernelManagerFor({ + [ENV_A]: { authUserId: 'u_alpha', schema: SCHEMA_A }, + [ENV_B]: { schema: SCHEMA_B }, // no auth service → the crossing branch + }); + const refused = await drive(wiring.kernelManager, ENV_A, ENV_B); + + expect(refused.status).toBe(ANONYMOUS_DENY_STATUS); + expect(JSON.stringify(refused.body)).not.toContain('Beta'); + // ⛔ And NOT the default environment's view either — a refusal, not a + // redirection to whatever the caller does hold. + expect(JSON.stringify(refused.body)).not.toContain('Alpha'); + + // ⚠️ RECORDED, NOT REPAIRED — the ordering property the 2026-08-30 + // ruling names as input knowledge and puts out of this card's scope. + // The foreign environment's kernel IS materialised before the refusal, + // because identity resolution has to find an `auth` service and the + // deny sits after environment resolution. This is the unstubbed + // reading; the tenancy suite's `acquired` stays empty there only + // because it replaces `resolveExecCtx` wholesale. + expect(wiring.acquired).toContain(ENV_B); + expect(wiring.authLookups[0]).toBe(ENV_B); + }, 120_000); + + it('⭐ POSITIVE CONTROL — the same caller, naming their OWN environment, is served', async () => { + // Without this the case above is satisfied by a boot that refuses + // everything, which is a different defect wearing the same green. + const { kernelManager } = kernelManagerFor({ + [ENV_A]: { authUserId: 'u_alpha', schema: SCHEMA_A }, + [ENV_B]: { schema: SCHEMA_B }, + }); + const served = await drive(kernelManager, ENV_A, ENV_A); + + expect(served.status).toBe(200); + expect(served.body?.list?.label).toBe('Alpha Environment Accounts'); + expect((served.body.list.columns as any[]).map((c) => c.field)).toContain('alpha_only_field'); + }, 120_000); + + it('⭐ POSITIVE CONTROL — a caller validated IN B, naming B, is served B', async () => { + // The other direction: when B carries its own auth service the + // credential anchors there and the same URL is served. So the refusal + // above is about WHERE the caller was validated, not about B being + // unreachable through this route. + const { kernelManager } = kernelManagerFor({ + [ENV_A]: { authUserId: 'u_alpha', schema: SCHEMA_A }, + [ENV_B]: { authUserId: 'u_beta', schema: SCHEMA_B }, + }); + const served = await drive(kernelManager, ENV_A, ENV_B); + + expect(served.status).toBe(200); + expect(served.body?.list?.label).toBe('Beta Environment Accounts'); + expect((served.body.list.columns as any[]).map((c) => c.field)).toContain('beta_only_field'); + }, 120_000); +}); diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index f6f5af0187..57ebd43447 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -4,13 +4,19 @@ * [#13214] What `GET /api/v1/ui/view/:object/:type` decides about the caller — * at the REST seam AND downstream in `getUiView`. * - * ## What this file is + * ## ⭐ What this file is NOW: the single-tenant half of the regression pin * - * ⛔ A MEASUREMENT file. It repairs nothing, gates nothing and proposes - * nothing. Access-control behaviour is a human floor in this repo: if a - * reading below is a problem, the repair is a card of its own with a human - * decision on it. What is committed here is the instrument and its readings, - * so the next person does not have to re-derive them from grep. + * It was written as a MEASUREMENT file and every reading in §1-§2 pinned the + * UNGUARDED seam: 200 to an absent context, byte-identical to an entitled one, + * `resolveExecCtx` called zero times. The maintainer ruled the repair on + * 2026-08-30 (option C), and those readings have been INVERTED onto the + * repaired behaviour — ⛔ not deleted, ⛔ not softened. §3-§6 are unchanged: + * they are readings about the PRODUCER, about `isAuthGateAllowlisted` and about + * the ratchet's reach, and the repair moved none of them. + * + * ⚠️ The pre-repair readings were TRUE WHEN TAKEN and are kept in the comments + * beside their inverses, because an assertion with no history cannot tell a + * later reader whether the defect ever existed. * * ## The three questions, and why the second is the big one * @@ -155,11 +161,28 @@ async function call(handler: Handler, method: string, pattern: string, params: a return { status: status || (sent ? 200 : 0), code: body?.code ?? body?.error?.code ?? body?.error, body, threw }; } +/** + * The entitled caller this file drives with. + * + * ⚠️ `__authEnvironmentId` is not decoration and it is not a magic string: it is + * the internal key `computeExecCtx` stamps on every context it produces, naming + * the environment whose auth service actually validated the caller. + * `enforceEnvironmentOwnership` compares it against the environment the request + * resolved to, and `instrument()` below replaces `resolveExecCtx` wholesale, so + * a synthetic context has to MODEL that key or it is a caller anchored nowhere + * — which the repaired seam refuses, fail-closed and deliberately. + * + * `'env_13214'` is this harness's `defaultEnvironmentIdProvider` (see + * `makeServer`), i.e. exactly the environment every drive in this file resolves + * to. A caller entitled somewhere ELSE is the tenancy suite's subject, not + * this file's. + */ const ENTITLED = { userId: 'u_13214', isSystem: false, tenantId: 'org_13214', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + __authEnvironmentId: 'env_13214', }; /** Mount the REAL route table and drive one route under one wiring. */ @@ -246,21 +269,27 @@ function gatingProtocol() { // --------------------------------------------------------------------------- describe('[#13214] §1 the REST seam — absent context vs entitled context', () => { - it('answers 200 under BOTH, with byte-identical bodies, having asked for identity ZERO times', async () => { + it('refuses an ABSENT context with 401 and serves an ENTITLED one — having actually ASKED for identity', async () => { + // ⚠️ Pre-repair, this same drive answered 200 under BOTH, with + // byte-identical bodies and `execCtxCalls` at 0 on both legs. Those + // three observations were the finding; these are their inverses. const absent = await driveRoute(UI_ROUTE, undefined, realProtocol(), UI_PARAMS); const entitled = await driveRoute(UI_ROUTE, ENTITLED, realProtocol(), UI_PARAMS); - expect(absent.status).toBe(200); + expect(absent.status).toBe(ANONYMOUS_DENY_STATUS); + expect(absent.code).toBe(ANONYMOUS_DENY_CODE); expect(entitled.status).toBe(200); - // Not merely "both 200": the same bytes. A route that resolved identity - // and narrowed on it would answer 200 twice with DIFFERENT bodies. - expect(JSON.stringify(absent.body)).toBe(JSON.stringify(entitled.body)); - - // ⭐ The third, independent observation. `resolveExecCtx` is patched on - // the prototype for the WHOLE server, so this counts every site the - // driven request reached — not just this handler's. - expect(absent.execCtxCalls).toBe(0); - expect(entitled.execCtxCalls).toBe(0); + // Not merely "different statuses": the anonymous caller receives no + // view at all, so this is a refusal rather than a narrowed answer. + expect(JSON.stringify(absent.body)).not.toBe(JSON.stringify(entitled.body)); + expect((absent.body as any)?.list).toBeUndefined(); + expect((entitled.body as any)?.list?.columns?.length).toBeGreaterThan(0); + + // ⭐ The third, independent observation, inverted. `resolveExecCtx` is + // patched on the prototype for the WHOLE server, so this counts every + // site the driven request reached — not just this handler's. + expect(absent.execCtxCalls).toBeGreaterThan(0); + expect(entitled.execCtxCalls).toBeGreaterThan(0); }, 120_000); it('⭐ POSITIVE CONTROL — the same instrument, same boot, DOES refuse an absent context on a sibling route', async () => { @@ -280,12 +309,13 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () expect(entitled.execCtxCalls).toBeGreaterThan(0); }, 120_000); - it('the handler calls `resolveProtocol` and NOT `enforceAuth` — read off the mounted registrar, not off a grep of the file', () => { - // The source assertion is scoped to the registrar body so it cannot be - // satisfied by a neighbour's guard, which is the trap the card warns - // about: `enforceAuth` IS present in this file (52 times over) and - // `registerUiEndpoints` sits directly above `registerCrudEndpoints`, - // whose handlers all carry it. + it('the handler now calls `enforceAuth` — read off the mounted registrar, not off a grep of the file', () => { + // ⚠️ The two `false` assertions this case used to carry WERE the card's + // source-level finding. The scoping matters as much now as it did then, + // and for the same reason the card warns about: `enforceAuth` IS present + // in this file 52 times over, and `registerUiEndpoints` sits directly + // above `registerCrudEndpoints`, whose handlers all carry it — so a + // file-wide grep would have read as a pass before the repair too. const start = SOURCE.indexOf('private registerUiEndpoints('); const end = SOURCE.indexOf('private registerCrudEndpoints('); expect(start).toBeGreaterThan(0); @@ -294,11 +324,11 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () expect(body).toContain('this.resolveProtocol('); expect(body).toContain('p.getUiView('); - // ⛔ Reverse-checked zeros: the same two terms are counted over the - // WHOLE file below, so a zero here is "absent from this registrar", - // never "misspelled". - expect(body.includes('this.enforceAuth(')).toBe(false); - expect(body.includes('this.resolveExecCtx(')).toBe(false); + expect(body.includes('this.enforceAuth(')).toBe(true); + expect(body.includes('this.resolveExecCtx(')).toBe(true); + // ⛔ Reverse-checked: a `true` from a substring test is only worth + // something if the term is spelled the way the rest of the file spells + // it — 52 sibling sites say it is. expect(SOURCE.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(SOURCE.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); @@ -344,51 +374,89 @@ describe('[#13214] §2 the argument object — the producer cannot gate on what // --------------------------------------------------------------------------- describe('[#13214] §3 downstream — the REAL `getUiView`, driven', () => { - it('returns the SAME view to an absent and an entitled caller, field for field', async () => { - const absent = await driveRoute(UI_ROUTE, undefined, realProtocol(), UI_PARAMS); - const entitled = await driveRoute(UI_ROUTE, ENTITLED, realProtocol(), UI_PARAMS); - expect(absent.status).toBe(200); + // ⚠️ METHOD CHANGE, and it is the repair that forced it. Every case here + // used to compare an ABSENT caller against an ENTITLED one THROUGH the + // seam, because the seam served both. It no longer serves the absent one, + // so that comparison is not available and ⛔ has not been faked by relaxing + // an assertion. The producer question is unchanged and still answered — by + // calling the producer DIRECTLY, which is where "does it gate?" actually + // lives, plus a new reading the old method could not take: the producer is + // never REACHED at all when the seam refuses. + + it('⭐ the producer is never REACHED when the seam refuses — the guard sits before it, not inside it', async () => { + // The strongest form of "no metadata escapes": not that the response + // was empty, but that the thing which produces metadata was not called. + const rec = recordingProtocol(); + const absent = await driveRoute(UI_ROUTE, undefined, rec.protocol, UI_PARAMS); + expect(absent.status).toBe(ANONYMOUS_DENY_STATUS); + expect(rec.seen.length).toBe(0); + + // ⭐ CONTROL — the SAME recorder is reached by an entitled caller, so + // the zero above is a refusal and not a recorder that never records. + const rec2 = recordingProtocol(); + const entitled = await driveRoute(UI_ROUTE, ENTITLED, rec2.protocol, UI_PARAMS); expect(entitled.status).toBe(200); + expect(rec2.seen.length).toBe(1); + }, 120_000); - const columns = (b: any) => (b.list.columns as any[]).map((c) => c.field).sort(); - expect(columns(absent.body)).toEqual(columns(entitled.body)); + it('the producer applies NO authorization of its own — called directly, an identity in the argument changes nothing', async () => { + // ⚠️ This is #13214's originally-UNMEASURED half and the answer has not + // changed: the repair is at the seam, and the producer still gates + // nothing. Measured where it can still be measured — §2 and §4 supply + // the other half (the seam tells it nothing, and the instance is not + // per-request, so it could not gate even if it wanted to). + const producer = realProtocol(); + const bare = await producer.getUiView({ object: 'account', type: 'list' }); + const withIdentity = await producer.getUiView({ + object: 'account', type: 'list', context: ENTITLED, userId: ENTITLED.userId, + } as any); + expect(JSON.stringify(bare)).toBe(JSON.stringify(withIdentity)); // Freshness of the built artifact this reads (see the header note): // #5948 relocated `object` onto the CONTAINER. A `dist/` from before // that would put it on `list` instead and this would fail loudly rather // than reporting a stale producer's behaviour as current. - expect((absent.body as any).object).toBe('account'); - expect((absent.body as any).list.object).toBeUndefined(); + expect((bare as any).object).toBe('account'); + expect((bare as any).list.object).toBeUndefined(); }, 120_000); - it('⚠️ the one field it DOES drop is dropped by DECLARATION, not by caller — `hidden` goes for everyone, `salary` stays for everyone', async () => { + it('⚠️ the one field it DOES drop is dropped by DECLARATION, not by caller — `hidden` goes, `salary` stays', async () => { // This is the distinction the whole question turns on. An FLS-style - // narrowing would differ BETWEEN the two callers. This narrowing is - // identical for both, and keyed on a property of the schema. - const absent = await driveRoute(UI_ROUTE, undefined, realProtocol(), UI_PARAMS); - const entitled = await driveRoute(UI_ROUTE, ENTITLED, realProtocol(), UI_PARAMS); + // narrowing would differ BETWEEN callers; this one is keyed on a + // property of the schema and is identical however the producer is asked. + const served = await driveRoute(UI_ROUTE, ENTITLED, realProtocol(), UI_PARAMS); const cols = (b: any) => (b.list.columns as any[]).map((c) => c.field); + expect(cols(served.body)).not.toContain('secret'); + expect(cols(served.body)).toContain('salary'); - expect(cols(absent.body)).not.toContain('secret'); - expect(cols(entitled.body)).not.toContain('secret'); - expect(cols(absent.body)).toContain('salary'); - expect(cols(entitled.body)).toContain('salary'); + const direct: any = await realProtocol().getUiView({ + object: 'account', type: 'list', context: ENTITLED, + } as any); + expect((direct.list.columns as any[]).map((c) => c.field)).toEqual(cols(served.body)); }, 120_000); it('the form branch behaves the same way — this is not a list-only reading', async () => { + const served = await driveRoute(UI_ROUTE, ENTITLED, realProtocol(), { object: 'account', type: 'form' }); + expect(served.status).toBe(200); + // ...and it is refused for an anonymous caller on the same branch, so + // the repair is not list-only either. const absent = await driveRoute(UI_ROUTE, undefined, realProtocol(), { object: 'account', type: 'form' }); - const entitled = await driveRoute(UI_ROUTE, ENTITLED, realProtocol(), { object: 'account', type: 'form' }); - expect(absent.status).toBe(200); - expect(JSON.stringify(absent.body)).toBe(JSON.stringify(entitled.body)); + expect(absent.status).toBe(ANONYMOUS_DENY_STATUS); + expect((absent.body as any)?.form).toBeUndefined(); }, 120_000); it('⭐ POSITIVE CONTROL A — the instrument REPORTS a downstream refusal when the producer makes one', async () => { // The rival wiring. Same route, same boot, same driver: a producer that - // gates is visible as a refusal. So §3's "served identically" is a - // reading about the SHIPPED producer, not a property of this harness. - const absent = await driveRoute(UI_ROUTE, undefined, gatingProtocol(), UI_PARAMS); - expect(absent.status).toBe(403); - expect(absent.code).toBe('PERMISSION_DENIED'); + // gates is visible as a refusal, so §3's readings are about the SHIPPED + // producer and not a property of this harness. + // + // ⚠️ Driven with an ENTITLED caller now, and that is the point of the + // case rather than a workaround: the seam passes the gate, the producer + // still finds no identity in its argument (§2), and refuses. A 401 here + // would mean the seam refused and this control measured nothing. + const gated = await driveRoute(UI_ROUTE, ENTITLED, gatingProtocol(), UI_PARAMS); + expect(gated.status).toBe(403); + expect(gated.code).toBe('PERMISSION_DENIED'); }, 120_000); it('⭐ POSITIVE CONTROL B — the REAL producer is reachable and CAN answer something other than 200', async () => { diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index 6cade18564..5497997a3a 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -3,12 +3,23 @@ /** * [#13214] Does `GET /api/v1/ui/view/:object/:type` cross ENVIRONMENTS? * - * ## What this file is, and what it is not + * ## ⭐ What this file is NOW: the regression pin for the repair it measured * - * ⛔ A MEASUREMENT file. It repairs nothing, gates nothing, proposes nothing. - * `rest-server.ts` is byte-identical on this branch. Access-control behaviour - * is a human floor in this repo: if a reading below is a problem, the repair is - * a card of its own with a human decision on it. + * It was written as a MEASUREMENT file and it measured a live cross-environment + * disclosure: every assertion below pinned the LEAK as present. The maintainer + * ruled the repair on 2026-08-30 (option C — the seam must require that the + * resolved environment belong to the caller), and this file has been INVERTED + * onto the repaired behaviour rather than deleted or softened. ⛔ Nothing here + * was weakened into vagueness: each reading that said "B's view crosses to a + * caller with no claim on B" now says "it is refused", against the same + * instrument, the same fixtures and the same controls. + * + * A suite that measured a leak is the best available regression pin once + * flipped: it fails if the leak returns by ANY of the routes it drove. + * + * ⚠️ The 2026-08-29 readings recorded here were TRUE WHEN TAKEN. Where a case + * changed colour, the history stays in the comment — a reader who finds only + * the post-repair assertion cannot tell whether the leak ever existed. * * ## Why it exists separately from `ui-view-route-identity.measurement.test.ts` * @@ -300,6 +311,27 @@ const ENTITLED = { systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], }; +/** + * ⭐ An execution context ANCHORED in one environment — the shape the repaired + * seam reads, and the reason this helper exists rather than a bare `ENTITLED`. + * + * `instrument()` below replaces `resolveExecCtx` wholesale, so every context in + * this file is synthetic and must MODEL what the real producer emits. + * `computeExecCtx` records which environment's auth service actually validated + * the caller on `__authEnvironmentId`, and `enforceEnvironmentOwnership` + * compares that against the environment the request resolved to. A synthetic + * context that omits the key is a caller whose credential is anchored NOWHERE, + * which the seam refuses — fail-closed, and deliberately so. + * + * ⚠️ That the real producer sets this key truthfully — including the + * cross-environment fallback branch where it does NOT equal the resolved + * environment — is not assertable through a stub, so it is pinned separately + * against the unstubbed method in `ui-view-environment-ownership.test.ts`. + * Without that file every assertion here would be reading a fact this file + * supplies to itself. + */ +const entitledIn = (environmentId: string) => ({ ...ENTITLED, __authEnvironmentId: environmentId }); + interface DriveOptions { route?: string; params?: Record; @@ -400,7 +432,13 @@ describe('[#13214] §0 the instrument can tell environment A from environment B' it("C2 — with NO header and an UNBOUND hostname the route answers with environment A's view", async () => { // This is the "would otherwise resolve to A" baseline every §1/§2 // reading is measured against. It is driven, not assumed. - const observed = await drive({ host: HOST_NEUTRAL, ctx: undefined }); + // + // ⚠️ FLIPPED INPUT, not a flipped claim: the baseline used to be taken + // with `ctx: undefined`, because before the repair an anonymous caller + // was served. The environment resolution being measured is unchanged; + // reaching it now requires a caller entitled to the environment it + // resolves to, so the baseline is taken with one. + const observed = await drive({ host: HOST_NEUTRAL, ctx: entitledIn(ENV_A) }); expect(observed.status).toBe(200); expect(labelOf(observed.body)).toBe('Alpha Environment Accounts'); @@ -409,6 +447,14 @@ describe('[#13214] §0 the instrument can tell environment A from environment B' // ...and NOT the control-plane protocol, which is a third distinct // answer precisely so this cannot silently be that instead. expect(labelOf(observed.body)).not.toBe('Control Plane Accounts'); + + // ⭐ The same request with NO caller is refused — so C2's 200 is a + // statement about an entitled caller and not about the route being + // open. This is the anonymous floor the route did not have. + const anonymous = await drive({ host: HOST_NEUTRAL, ctx: undefined }); + expect(anonymous.status).toBe(ANONYMOUS_DENY_STATUS); + expect(anonymous.code).toBe(ANONYMOUS_DENY_CODE); + expect(labelOf(anonymous.body)).toBeUndefined(); }, 120_000); it("C3 — the SCOPED mount, where naming an environment is declared and URL-visible, DOES deliver environment B's view", async () => { @@ -419,21 +465,33 @@ describe('[#13214] §0 the instrument can tell environment A from environment B' route: UI_ROUTE_SCOPED, params: { environmentId: ENV_B, object: 'account', type: 'list' }, host: HOST_NEUTRAL, - ctx: undefined, + ctx: entitledIn(ENV_B), }); expect(observed.status).toBe(200); expect(labelOf(observed.body)).toBe('Beta Environment Accounts'); expect(columnsOf(observed.body)).toContain('beta_only_field'); expect(observed.acquired).toEqual([ENV_B]); + + // ⭐ And the scoped mount is gated too — naming an environment in the + // URL is no more of an entitlement than naming it in a header. A caller + // anchored in A is refused the same URL. + const foreign = await drive({ + route: UI_ROUTE_SCOPED, + params: { environmentId: ENV_B, object: 'account', type: 'list' }, + host: HOST_NEUTRAL, + ctx: entitledIn(ENV_A), + }); + expect(foreign.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(foreign.body)).toBeUndefined(); }, 120_000); it('C4 — the kernel-acquisition recorder is an observable, not a constant', async () => { - const a = await drive({ host: HOST_A, ctx: undefined }); + const a = await drive({ host: HOST_A, ctx: entitledIn(ENV_A) }); const b = await drive({ route: UI_ROUTE_SCOPED, params: { environmentId: ENV_B, object: 'account', type: 'list' }, - ctx: undefined, + ctx: entitledIn(ENV_B), }); expect(a.acquired).toEqual([ENV_A]); expect(b.acquired).toEqual([ENV_B]); @@ -447,61 +505,106 @@ describe('[#13214] §0 the instrument can tell environment A from environment B' // --------------------------------------------------------------------------- describe('[#13214] §1 channel: the `X-Environment-Id` header', () => { - it("⭐ an ANONYMOUS request naming environment B on an unscoped URL receives environment B's view", async () => { + it("⭐ an ANONYMOUS request naming environment B on an unscoped URL is REFUSED — it used to receive B's view", async () => { + // ⚠️ 2026-08-29, on the unrepaired seam, this same drive answered 200 + // with `Beta Environment Accounts`, B's kernel ACQUIRED, and + // `execCtxCalls` at 0. That was the finding. The assertions below are + // its inverse, one for one. const observed = await drive({ host: HOST_NEUTRAL, // resolves to nothing → would fall to A environmentIdHeader: ENV_B, // the only thing the caller supplies ctx: undefined, // ⭐ no execution context whatsoever }); - expect(observed.status).toBe(200); - expect(labelOf(observed.body)).toBe('Beta Environment Accounts'); - expect(columnsOf(observed.body)).toContain('beta_only_field'); - expect(columnsOf(observed.body)).not.toContain('alpha_only_field'); + expect(observed.status).toBe(ANONYMOUS_DENY_STATUS); + expect(observed.code).toBe(ANONYMOUS_DENY_CODE); - // Three independent observations, not one: - // - the answer is B's, not the A baseline C2 measured; - expect(labelOf(observed.body)).not.toBe('Alpha Environment Accounts'); - // - B's kernel was actually ACQUIRED by an anonymous request; - expect(observed.acquired).toEqual([ENV_B]); - // - and identity was never asked for, on any site the request reached. - expect(observed.execCtxCalls).toBe(0); + // Three independent observations, inverted one for one: + // - NO view of any environment is in the body — not B's, and not the + // A baseline either, so this is a refusal and not a redirection; + expect(labelOf(observed.body)).toBeUndefined(); + expect(columnsOf(observed.body)).toEqual([]); + expect(JSON.stringify(observed.body)).not.toContain('Beta'); + expect(JSON.stringify(observed.body)).not.toContain('Alpha'); + // - identity WAS asked for, which is the step the seam did not have; + expect(observed.execCtxCalls).toBeGreaterThan(0); + // - ⚠️ and NO kernel is acquired on this path — which is NOT the + // reading that was predicted before this ran, so it is written down + // the way it came out. The prediction was that B's kernel would + // still be ACQUIRED before the refusal, because the deny sits at the + // seam after environment resolution, exactly as it does on the + // guarded sibling route §5 measures. That prediction is right about + // PRODUCTION and wrong about this instrument, and the difference is + // the instrument itself: `instrument()` replaces `resolveExecCtx`, + // so the only `getOrCreate` caller left on this path is + // `resolveProtocol` — which the repair moved to AFTER the refusal. + // Unstubbed, `computeExecCtx` acquires the named environment's + // kernel to look for its `auth` service, and that is measured + // against the real method in `ui-view-environment-ownership.test.ts` + // (`authLookups` records B before the default). ⛔ So this zero is a + // property of the stub and must not be read as "the ordering + // property the ruling put out of scope has been repaired" — it has + // not been, and repairing it is a separate card. + expect(observed.acquired).toEqual([]); }, 120_000); - it('the answer does not depend on the caller — an ENTITLED caller naming B gets byte-identical bytes', async () => { - const anon = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); - const entitled = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: ENTITLED }); - expect(JSON.stringify(anon.body)).toBe(JSON.stringify(entitled.body)); - expect(entitled.execCtxCalls).toBe(0); + it('⭐ POSITIVE CONTROL — a caller ENTITLED TO B naming B is served, so the refusal above is a decision', async () => { + // Without this the whole section is satisfied by a route that refuses + // everyone, which would be a different defect wearing the same green. + const owner = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B) }); + expect(owner.status).toBe(200); + expect(labelOf(owner.body)).toBe('Beta Environment Accounts'); + expect(columnsOf(owner.body)).toContain('beta_only_field'); + expect(owner.acquired).toEqual([ENV_B]); + + // ...and the caller's ENTITLEMENT is what decides, not the mere + // presence of a session: an authenticated caller anchored in A, naming + // B, is refused. This is the case the rejected option B would have + // served — anonymous-deny alone never compares the two environments. + const outsider = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A) }); + expect(outsider.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(outsider.body)).toBeUndefined(); + expect(JSON.stringify(outsider.body)).not.toContain('Beta'); }, 120_000); - it('⭐ NEGATIVE CONTROL — a header naming an environment the registry does NOT know does not cross', async () => { - // So the crossing is not "any string in the header wins". The id is - // validated through `envRegistry.resolveById`, and an unknown one falls - // through to the default. This is what makes §4's precondition precise. + it('⭐ a header naming an environment the registry does NOT know is REFUSED, not answered with the default', async () => { + // ⚠️ This case inverted the hardest, and it is the ruling's 「信号化拒绝」. + // Before: an unknown id fell through `envRegistry.resolveById` to the + // DEFAULT environment and answered 200 with A's view — a validation + // failure served as a success. Now the caller named an environment the + // chain did not serve, and that is refused. const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: 'env_ghost_does_not_exist', - ctx: undefined, + ctx: entitledIn(ENV_A), // ⭐ a real caller, entitled to the default }); - expect(observed.status).toBe(200); - expect(labelOf(observed.body)).toBe('Alpha Environment Accounts'); - expect(observed.acquired).toEqual([ENV_A]); - // The registry WAS consulted — a green above is a decision, not a - // header the server never looked at. + expect(observed.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(observed.body)).toBeUndefined(); + // ⛔ Specifically NOT the default environment's view, which is the + // silent fallback the ruling forbids. + expect(labelOf(observed.body)).not.toBe('Alpha Environment Accounts'); + // The registry WAS consulted — the refusal is a decision, not a header + // the server never looked at. expect(observed.idLookups).toContain('env_ghost_does_not_exist'); + + // CONTROL: the same caller with no header is served the default + // environment, so the refusal is caused by the unresolvable NAME and + // not by this caller being unable to read anything. + const noHeader = await drive({ host: HOST_NEUTRAL, ctx: entitledIn(ENV_A) }); + expect(noHeader.status).toBe(200); + expect(labelOf(noHeader.body)).toBe('Alpha Environment Accounts'); }, 120_000); - it('the caller supplies nothing but the header — no cookie, no authorization, no session', async () => { + it('a caller supplying nothing but the header — no cookie, no authorization, no session — gets the anonymous refusal', async () => { const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined, headers: {}, // nothing added; the request carries `host` + the one header }); - expect(observed.status).toBe(200); - expect(labelOf(observed.body)).toBe('Beta Environment Accounts'); + expect(observed.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(observed.body)).toBeUndefined(); }, 120_000); }); @@ -511,49 +614,116 @@ describe('[#13214] §1 channel: the `X-Environment-Id` header', () => { // --------------------------------------------------------------------------- describe('[#13214] §2 channel: the request hostname', () => { - it("⭐ an ANONYMOUS request whose hostname is bound to environment B receives environment B's view", async () => { + it("⭐ an ANONYMOUS request whose hostname is bound to environment B is REFUSED — it used to receive B's view", async () => { + // ⚠️ The hostname channel had the LOWER bar of the two: a Host header is + // caller-controlled on any HTTP request and a tenant hostname is + // typically public. On the unrepaired seam this answered 200 with B's + // view, `execCtxCalls` at 0. Fixing only the header channel would have + // left this open, which is why the ruling names both. const observed = await drive({ host: HOST_B, ctx: undefined }); - expect(observed.status).toBe(200); - expect(labelOf(observed.body)).toBe('Beta Environment Accounts'); - expect(columnsOf(observed.body)).toContain('beta_only_field'); - expect(observed.acquired).toEqual([ENV_B]); - expect(observed.execCtxCalls).toBe(0); + expect(observed.status).toBe(ANONYMOUS_DENY_STATUS); + expect(observed.code).toBe(ANONYMOUS_DENY_CODE); + expect(labelOf(observed.body)).toBeUndefined(); + expect(JSON.stringify(observed.body)).not.toContain('Beta'); + expect(observed.execCtxCalls).toBeGreaterThan(0); + // The hostname WAS resolved — the refusal is not "the host was ignored". expect(observed.hostnameLookups).toContain(HOST_B); }, 120_000); + it('⭐ POSITIVE CONTROL — the hostname channel still SERVES the environment it is bound to, to a caller entitled there', async () => { + const owner = await drive({ host: HOST_B, ctx: entitledIn(ENV_B) }); + expect(owner.status).toBe(200); + expect(labelOf(owner.body)).toBe('Beta Environment Accounts'); + expect(owner.acquired).toEqual([ENV_B]); + + // ...and refuses a caller anchored in the OTHER environment reaching the + // same public hostname. Ownership, not reachability, is the gate. + const outsider = await drive({ host: HOST_B, ctx: entitledIn(ENV_A) }); + expect(outsider.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(outsider.body)).toBeUndefined(); + }, 120_000); + it('⭐ CONTROL — the same channel answers with A when the hostname is bound to A, so it is bidirectional', async () => { - const observed = await drive({ host: HOST_A, ctx: undefined }); + const observed = await drive({ host: HOST_A, ctx: entitledIn(ENV_A) }); expect(labelOf(observed.body)).toBe('Alpha Environment Accounts'); expect(observed.acquired).toEqual([ENV_A]); }, 120_000); it('⭐ NEGATIVE CONTROL — an unbound hostname does not cross; it falls to the default environment', async () => { - const observed = await drive({ host: 'unbound.example.test', ctx: undefined }); + const observed = await drive({ host: 'unbound.example.test', ctx: entitledIn(ENV_A) }); expect(labelOf(observed.body)).toBe('Alpha Environment Accounts'); expect(observed.acquired).toEqual([ENV_A]); expect(observed.hostnameLookups).toContain('unbound.example.test'); }, 120_000); - it('PRECEDENCE — hostname is consulted BEFORE the header, so a bound host wins over a header naming the other environment', async () => { - // Matters for §4: on a hostname-routed deployment the header is not an - // additional lever, and on a non-hostname deployment it is the lever. - const observed = await drive({ host: HOST_A, environmentIdHeader: ENV_B, ctx: undefined }); - expect(labelOf(observed.body)).toBe('Alpha Environment Accounts'); - expect(observed.acquired).toEqual([ENV_A]); - // The header was never even looked up, which is the mechanism. + it('PRECEDENCE — hostname is still consulted BEFORE the header; a request naming BOTH is now refused rather than silently served one of them', async () => { + // The mechanism is unchanged and still measured: a bound host wins and + // the header is never looked up. What changed is the ANSWER to a + // self-contradictory request. It used to be served environment A while + // the caller asked for B — an answer about an environment the caller + // did not name. Now a named environment that is not the one served is + // refused, which is the same rule that closes the id oracle in §4. + const observed = await drive({ host: HOST_A, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A) }); + expect(observed.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(observed.body)).toBeUndefined(); + // The header was never even looked up, which is the mechanism — the + // refusal comes from the seam's comparison, not from the registry. expect(observed.idLookups).not.toContain(ENV_B); + expect(observed.hostnameLookups).toContain(HOST_A); + + // CONTROL: drop the contradictory header and the same caller on the + // same host is served, so the refusal is caused by the contradiction. + const consistent = await drive({ host: HOST_A, ctx: entitledIn(ENV_A) }); + expect(consistent.status).toBe(200); + expect(labelOf(consistent.body)).toBe('Alpha Environment Accounts'); }, 120_000); }); // --------------------------------------------------------------------------- -// 3. BLAST RADIUS on the CROSS-ENVIRONMENT path — measured here, ⛔ not -// inherited from #13244's single-tenant result +// 3. WHAT THE ROUTE DISCLOSES — the inventory that was the blast radius +// +// ⚠️ These cases were written as the blast-radius reading for the CROSSING: an +// anonymous caller naming environment B, and what B handed it. The crossing is +// closed, so the same inventory is now taken on the OWNED path — the caller is +// entitled to B — because it is still the payload this route serves and a +// regression that re-opened the crossing would disclose exactly this. +// The FIRST case below is the crossing's own pin: a caller with no claim on B +// receives none of it. // --------------------------------------------------------------------------- -describe('[#13214] §3 what the crossed response actually contains', () => { +describe('[#13214] §3 what the response contains, on the owned path and on the refused one', () => { + it('⭐ THE PIN — a caller with no claim on environment B receives NONE of the inventory below', async () => { + // One assertion per disclosure the crossing used to carry, stated as an + // absence. This is the case that fails if any future change lets the + // route answer a non-owner, whatever else stays green. + const refused = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A) }); + const serialized = JSON.stringify(refused.body); + + expect(refused.status).toBe(ANONYMOUS_DENY_STATUS); + // The object label, and every field label/name B declares. + for (const disclosed of [ + 'Beta Environment Accounts', 'Beta Account Name', 'Beta Only', 'Beta Status', + 'Beta Secret', 'beta_only_field', 'beta_secret', + ]) { + expect(serialized.includes(disclosed), `refusal carried \`${disclosed}\``).toBe(false); + } + expect(labelOf(refused.body)).toBeUndefined(); + expect(columnsOf(refused.body)).toEqual([]); + + // ⭐ CONTROL — every one of those strings IS present when the OWNER + // asks, so the absences above are a refusal and not a fixture that + // stopped producing. + const owned = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B) }); + const ownedSerialized = JSON.stringify(owned.body); + expect(owned.status).toBe(200); + for (const disclosed of ['Beta Environment Accounts', 'Beta Account Name', 'Beta Only', 'beta_only_field']) { + expect(ownedSerialized.includes(disclosed), `owner did not receive \`${disclosed}\``).toBe(true); + } + }, 120_000); + it('the list body is exactly the view envelope — object, view type, label, columns, sort, searchable fields', async () => { - const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); + const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B) }); const body = observed.body; // ⭐ Pinned to the CROSSED body first, so this is an inventory of what @@ -573,7 +743,7 @@ describe('[#13214] §3 what the crossed response actually contains', () => { }, 120_000); it('⛔ NO record data crosses — the payload carries metadata only', async () => { - const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); + const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B) }); const serialized = JSON.stringify(observed.body); // Same pinning as above: this is a statement about B's payload. expect(labelOf(observed.body)).toBe('Beta Environment Accounts'); @@ -589,7 +759,7 @@ describe('[#13214] §3 what the crossed response actually contains', () => { it('the FORM branch crosses too, and carries the per-field required/readonly/type declarations', async () => { const observed = await drive({ - host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined, + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B), params: { object: 'account', type: 'form' }, }); expect(observed.status).toBe(200); @@ -633,7 +803,7 @@ describe('[#13214] §3 what the crossed response actually contains', () => { // the blast-radius reading for the crossing: what the disclosure this // file measures actually contains. A regression reachable only through // that chain would leave the producer-level pin green. - const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); + const observed = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B) }); const columns = columnsOf(observed.body); const labels = (observed.body.list.columns as any[]).map((c) => c.label); @@ -660,7 +830,7 @@ describe('[#13214] §3 what the crossed response actually contains', () => { // The name-agnostic form of the same statement, computed from the // fixture rather than from a copy of the producer's priority list: no - // column the crossed body emits is declared hidden. A tenth priority + // column the served body emits is declared hidden. A tenth priority // name added without the filter fails this even though nothing here // knows its spelling. const hiddenKeys = Object.keys(SCHEMA_B.fields) @@ -678,7 +848,7 @@ describe('[#13214] §3 what the crossed response actually contains', () => { // two branches now AGREE is the other half of the repair, and it is // what would say so if a future change moved only one of them. const form = await drive({ - host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined, + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B), params: { object: 'account', type: 'form' }, }); expect(formFieldsOf(form.body)).not.toContain('status'); @@ -686,21 +856,37 @@ describe('[#13214] §3 what the crossed response actually contains', () => { expect(formFieldsOf(form.body)).toContain('beta_only_field'); // control }, 120_000); - it('⚠️ the crossed route is also an OBJECT-EXISTENCE ORACLE for the named environment', async () => { - // A present object answers 200 and an absent one does not, so the same - // anonymous request distinguishes "environment B has an object called - // X" from "it does not" — a second reading, distinct from the payload. - const present = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); - const absent = await drive({ - host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined, + it('⭐ the OBJECT-EXISTENCE ORACLE is closed for a non-owner — present and absent objects answer identically', async () => { + // ⚠️ The second finding of the crossing, distinct from the payload: a + // present object answered 200 and an absent one did not, so an + // anonymous request could enumerate environment B's object namespace. + // The discriminator is gone for anyone without a claim on B — the two + // requests are refused with the SAME status and the SAME bytes. + const presentToOutsider = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A), + }); + const absentToOutsider = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A), params: { object: 'no_such_object_here', type: 'list' }, }); - expect(present.status).toBe(200); - // Pinned to B, so the oracle is a reading about the NAMED environment's - // object namespace rather than about the default one. - expect(labelOf(present.body)).toBe('Beta Environment Accounts'); - expect(absent.acquired).toEqual([ENV_B]); - expect(absent.status).not.toBe(200); + expect(presentToOutsider.status).toBe(ANONYMOUS_DENY_STATUS); + expect(absentToOutsider.status).toBe(presentToOutsider.status); + expect(JSON.stringify(absentToOutsider.body)).toBe(JSON.stringify(presentToOutsider.body)); + + // ⭐ CONTROL — the discriminator still EXISTS for the owner, so the + // equality above is the outsider being refused and not the route having + // lost the ability to tell the two objects apart. + const presentToOwner = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B), + }); + const absentToOwner = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B), + params: { object: 'no_such_object_here', type: 'list' }, + }); + expect(presentToOwner.status).toBe(200); + expect(labelOf(presentToOwner.body)).toBe('Beta Environment Accounts'); + expect(absentToOwner.status).not.toBe(200); + expect(absentToOwner.acquired).toEqual([ENV_B]); }, 120_000); }); @@ -715,22 +901,33 @@ describe('[#13214] §4 preconditions', () => { // sections: it reproduces the previous harness's wiring and shows the // header is inert there. So §1's crossing is a function of the tenancy // wiring, not of anything else this file changed. + // ⚠️ The header is dropped from this drive and that is deliberate. With + // no registry the chain resolves NOTHING, so a request naming an + // environment is now refused for naming one that was not served — a + // true reading, but a different one from the wiring fact this case is + // about. The header's own case is the last one in this section. const observed = await drive({ host: HOST_B, - environmentIdHeader: ENV_B, - ctx: undefined, + ctx: entitledIn('anything'), wiring: { defaultEnvironmentId: undefined }, }); expect(observed.status).toBe(200); expect(labelOf(observed.body)).toBe('Control Plane Accounts'); + + // ⭐ A control-plane boot resolves no environment, so there is nothing + // to own and the anonymous floor is the whole gate — which the route + // now has. Anonymous is refused here too. + const anonymous = await drive({ + host: HOST_B, ctx: undefined, wiring: { defaultEnvironmentId: undefined }, + }); + expect(anonymous.status).toBe(ANONYMOUS_DENY_STATUS); }, 120_000); it('an envRegistry WITHOUT a kernelManager does not cross either — the chain needs both', async () => { const shared = tenantWiring(); const observed = await drive({ host: HOST_B, - environmentIdHeader: ENV_B, - ctx: undefined, + ctx: entitledIn(ENV_A), wiring: { envRegistry: shared.envRegistry, defaultEnvironmentId: ENV_A }, }); // Env resolution is skipped entirely (the guard is `envRegistry && @@ -747,8 +944,7 @@ describe('[#13214] §4 preconditions', () => { const shared = tenantWiring(); const observed = await drive({ host: HOST_B, // bound to B - environmentIdHeader: ENV_B, // and naming B - ctx: undefined, + ctx: entitledIn(ENV_A), wiring: { envRegistry: shared.envRegistry, kernelManager: shared.kernelManager, @@ -757,13 +953,28 @@ describe('[#13214] §4 preconditions', () => { }, }); expect(labelOf(observed.body)).toBe('Alpha Environment Accounts'); + + // ...and the resolver's answer is what ownership is measured against: + // the same host, the same resolver, a caller anchored in B instead — B + // is not what was resolved, so it is refused. + const anchoredElsewhere = await drive({ + host: HOST_B, + ctx: entitledIn(ENV_B), + wiring: { + envRegistry: shared.envRegistry, + kernelManager: shared.kernelManager, + defaultEnvironmentId: ENV_A, + requestEnvResolver: { async resolveRequestEnvironmentId() { return ENV_A; } }, + }, + }); + expect(anchoredElsewhere.status).toBe(ANONYMOUS_DENY_STATUS); }, 120_000); it('⭐ CONTROL — the same injected resolver CAN send the request to B, so the reading above is the resolver deciding, not a dead channel', async () => { const shared = tenantWiring(); const observed = await drive({ host: HOST_NEUTRAL, - ctx: undefined, + ctx: entitledIn(ENV_B), wiring: { envRegistry: shared.envRegistry, kernelManager: shared.kernelManager, @@ -778,46 +989,88 @@ describe('[#13214] §4 preconditions', () => { // Named because it is the difference between "wiring a resolver closes // this" and "wiring a resolver closes this unless it throws". const shared = tenantWiring(); - const observed = await drive({ - host: HOST_NEUTRAL, - environmentIdHeader: ENV_B, - ctx: undefined, - wiring: { - envRegistry: shared.envRegistry, - kernelManager: shared.kernelManager, - defaultEnvironmentId: ENV_A, - requestEnvResolver: { async resolveRequestEnvironmentId() { throw new Error('resolver down'); } }, - }, + const degraded = { + envRegistry: shared.envRegistry, + kernelManager: shared.kernelManager, + defaultEnvironmentId: ENV_A, + requestEnvResolver: { async resolveRequestEnvironmentId() { throw new Error('resolver down'); } }, + }; + // The legacy chain is reached and the header still DECIDES the + // environment — that mechanism is unchanged and still measured. + const owner = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B), wiring: degraded, }); - expect(labelOf(observed.body)).toBe('Beta Environment Accounts'); + expect(labelOf(owner.body)).toBe('Beta Environment Accounts'); + + // ⭐ But a degraded resolver no longer degrades the GATE: the caller + // still has to own what the legacy chain resolved. Before the repair + // this drive with `ctx: undefined` answered 200 with B's view. + const outsider = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A), wiring: degraded, + }); + expect(outsider.status).toBe(ANONYMOUS_DENY_STATUS); + const anonymous = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined, wiring: degraded, + }); + expect(anonymous.status).toBe(ANONYMOUS_DENY_STATUS); + expect(labelOf(anonymous.body)).toBeUndefined(); }, 120_000); - it('⚠️ the route is also an ENVIRONMENT-ID ORACLE — a valid id and an invalid one get observably different answers', async () => { - // Bears directly on "what must an attacker already know". The id is - // validated, but the FAILURE is not signalled: an unknown id silently - // falls through to the default environment and answers 200 with THAT - // environment's view. So a caller with no credential can tell a real - // environment id from a made-up one by comparing two 200s — which is - // the difference between "must possess an id" and "can discover one". - const valid = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); - const invalid = await drive({ host: HOST_NEUTRAL, environmentIdHeader: 'env_not_real', ctx: undefined }); - - expect(valid.status).toBe(200); - expect(invalid.status).toBe(200); - // Same status, different bytes — the oracle. - expect(JSON.stringify(valid.body)).not.toBe(JSON.stringify(invalid.body)); - expect(labelOf(valid.body)).toBe('Beta Environment Accounts'); - expect(labelOf(invalid.body)).toBe('Alpha Environment Accounts'); - // ...and the same holds for the OTHER known environment, so the signal - // is "this id resolves" rather than "this id is B". - const otherValid = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_A, ctx: undefined }); - expect(labelOf(otherValid.body)).toBe('Alpha Environment Accounts'); - expect(otherValid.idLookups).toContain(ENV_A); + it('⭐ the ENVIRONMENT-ID ORACLE is closed — a valid id and an invalid one now get BYTE-IDENTICAL answers', async () => { + // ⚠️ This is the case that decided the refusal's SHAPE, and it is worth + // reading before changing either. + // + // Before: the id was validated through `envRegistry.resolveById`, but + // the failure was not signalled — an unknown id fell through to the + // DEFAULT environment and answered 200 with that environment's view. + // Two 200s with different bytes let a caller with no credential tell a + // real environment id from an invented one, which is the difference + // between "must possess an id" and "can discover one". + // + // ⭐ Closing it takes more than refusing: the two refusals have to be + // INDISTINGUISHABLE. A caller naming a real foreign environment is + // refused because their credential is not valid there; a caller naming + // an invented one is refused because the chain served something else. + // Two different reasons — one response, byte for byte. That is why + // `enforceEnvironmentOwnership` answers with the anonymous-deny + // envelope verbatim instead of minting a 403 or a 404 of its own: a + // distinct status for either reason would rebuild the oracle one layer + // up. + const anonValid = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: undefined }); + const anonInvalid = await drive({ host: HOST_NEUTRAL, environmentIdHeader: 'env_not_real', ctx: undefined }); + expect(anonValid.status).toBe(ANONYMOUS_DENY_STATUS); + expect(anonInvalid.status).toBe(anonValid.status); + expect(JSON.stringify(anonInvalid.body)).toBe(JSON.stringify(anonValid.body)); + + // ...and for an AUTHENTICATED caller who owns neither of the two ids in + // play. This leg is the one that would still leak if the refusals had + // been given different statuses. + const outsiderValid = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_A), + }); + const outsiderInvalid = await drive({ + host: HOST_NEUTRAL, environmentIdHeader: 'env_not_real', ctx: entitledIn(ENV_A), + }); + expect(outsiderValid.status).toBe(ANONYMOUS_DENY_STATUS); + expect(outsiderInvalid.status).toBe(outsiderValid.status); + expect(JSON.stringify(outsiderInvalid.body)).toBe(JSON.stringify(outsiderValid.body)); + // ⛔ And neither is the silent default-environment answer. + expect(labelOf(outsiderInvalid.body)).not.toBe('Alpha Environment Accounts'); + + // ⭐ CONTROL — the registry IS still consulted for the valid id, so the + // equality above is a refusal rather than a header nobody looked at; + // and the OWNER of a valid id still gets a distinguishable answer, so + // the route has not simply stopped resolving environments. + expect(outsiderValid.idLookups).toContain(ENV_B); + const ownerValid = await drive({ host: HOST_NEUTRAL, environmentIdHeader: ENV_B, ctx: entitledIn(ENV_B) }); + expect(ownerValid.status).toBe(200); + expect(JSON.stringify(ownerValid.body)).not.toBe(JSON.stringify(outsiderValid.body)); }, 120_000); - it('the unscoped mount passes `environmentId: undefined` — read off the registrar body, not off a grep of the file', () => { + it('⭐ the registrar body itself carries the three steps — read off the registrar, not off a grep of the file', () => { // The source half of the mechanism, scoped to the registrar so a - // neighbour's code cannot satisfy it. + // neighbour's code cannot satisfy it. ⚠️ Inverted: the two `false` + // assertions here were the card's source-level finding. const start = SOURCE.indexOf('private registerUiEndpoints('); const end = SOURCE.indexOf('private registerCrudEndpoints('); expect(start).toBeGreaterThan(0); @@ -825,15 +1078,29 @@ describe('[#13214] §4 preconditions', () => { const body = SOURCE.slice(start, end); expect(body).toContain("const isScoped = basePath.includes('/environments/:environmentId')"); - expect(body).toContain('const environmentId = isScoped ? req.params?.environmentId : undefined;'); + // The environment is decided ONCE, through the shared entry point, and + // that one answer is what identity, ownership and the protocol all use. + expect(body).toContain('this.resolveRequestEnvironmentId(routeEnvironmentId, req)'); expect(body).toContain('this.resolveProtocol(environmentId, req)'); - // ⛔ Reverse-checked zeros: the same two terms are counted over the - // WHOLE file, so a zero here is "absent from this registrar", never - // "misspelled". - expect(body.includes('this.enforceAuth(')).toBe(false); - expect(body.includes('this.resolveExecCtx(')).toBe(false); + // The three steps the seam did not have. + expect(body).toContain('this.resolveExecCtx(environmentId, req)'); + expect(body).toContain('this.enforceAuth(req, res, context)'); + expect(body).toContain('this.enforceEnvironmentOwnership(req, res, environmentId, context)'); + + // ⭐ ORDER, not just presence — anonymity is refused before ownership is + // compared, and both before any protocol is resolved. A guard that ran + // after the answer was produced would satisfy a presence check. + expect(body.indexOf('this.resolveExecCtx(')).toBeLessThan(body.indexOf('this.enforceAuth(')); + expect(body.indexOf('this.enforceAuth(')).toBeLessThan(body.indexOf('this.enforceEnvironmentOwnership(')); + expect(body.indexOf('this.enforceEnvironmentOwnership(')).toBeLessThan(body.indexOf('this.resolveProtocol(')); + + // ⛔ Reverse-checked: the same terms counted over the WHOLE file, so + // these readings are about this registrar and not about a misspelling. expect(SOURCE.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(SOURCE.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); + // ...and the ownership guard is NOT sprayed across the file — it is a + // single new call site plus its definition. + expect(SOURCE.split('this.enforceEnvironmentOwnership(').length - 1).toBe(1); }); });