Skip to content

Commit cc837db

Browse files
claude[bot]os-steveclaude
authored
fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type (#13625)
* fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type Maintainer ruling 2026-08-30 (option C): the seam must resolve identity AND compare the resolved environment against the one the caller is entitled to, on both naming channels (X-Environment-Id header and bound hostname), with a signalled refusal on an envRegistry.resolveById validation failure rather than a silent fallback to the default environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk * test(rest): re-anchor the census pins the seam repair moved - execctx-consumer-census: the UI-view route joins as BARE site 53 behind the shared anonymous floor (72->73 sites, 89->92 mentions, 52->53 bare); the seam drops its local .catch so it does not become the only site that is both locally caught AND behind the floor. - rest-exec-ctx-principal-kind: __authEnvironmentId named in the golden key set. - system-context-census: 5 doc anchors re-anchored by the gate's own --fix, pure line rot (+124 on every one). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk * test(qa): re-anchor the authz probe blind-spot enforceAuth control, 61 -> 64 The fourth count anchor this seam repair moves, and the one a package-scoped run cannot see: packages/qa/dogfood cites other packages' files and is not reached by `pnpm --filter @objectstack/rest ...` -- only the Dogfood Regression Gate runs it. Re-anchored, NOT relaxed: the control proves the census still reads the file it thinks it does, and a rising enforceAuth is exactly what this card causes. Measured +3 over the whole file (the derivation counts the bare term, comments included): one new call site (52 -> 53) plus two prose mentions. The sibling numbers were re-derived and did not move -- population 80, reachable 19, registrars 17, mounts 80 -- which is what says this is a guard change and not a surface change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --------- Co-authored-by: Claude <steve@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 789aa68 commit cc837db

9 files changed

Lines changed: 1173 additions & 193 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)
6+
7+
**Security floor.** This route was the one identity-touching route in
8+
`RestServer`'s table that resolved no identity at all: it went from
9+
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
10+
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
11+
times — while the other 52 identity-touching routes answered 401 under an absent
12+
context.
13+
14+
Because the unscoped mount lets the REQUEST name its environment (bound
15+
hostname, else the `X-Environment-Id` header), that made it a cross-environment
16+
disclosure rather than a single-tenant one. Driven on the real route table with
17+
a real `envRegistry` + `kernelManager`: an anonymous request naming another
18+
environment received **that environment's** UI view — object label plus every
19+
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
20+
naming channels, with the foreign kernel acquired. The route was additionally an
21+
object-existence oracle for whatever environment was named, and an
22+
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
23+
silently fell through to the default environment and answered 200 with *that*
24+
environment's view, so two 200s with different bytes distinguished a real
25+
environment id from an invented one.
26+
27+
Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
28+
explicitly measured **not** to be the repair — it stops the anonymous caller and
29+
nothing else, because an authenticated caller could still name a foreign
30+
environment and nothing downstream compared the environment that was *resolved*
31+
with the environment the caller is *entitled to*.
32+
33+
What the seam does now, in order: resolve the environment once through the
34+
shared entry point; resolve identity **in that environment**; refuse anonymity;
35+
then compare. The comparison reads `__authEnvironmentId` — an internal key
36+
`computeExecCtx` now stamps on every context it produces, naming the environment
37+
whose auth service actually validated the caller. It differs from the resolved
38+
environment in exactly the branch that crosses: when the resolved environment's
39+
kernel carries no `auth` service, the lookup falls back to the **default**
40+
environment's, and a session minted there authenticated a request naming another
41+
one.
42+
43+
Both refusable shapes answer with the anonymous-deny envelope **verbatim**
44+
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
45+
naming a real foreign environment is already refused by the anonymous gate
46+
(their credential is not valid there), so giving "you do not own this
47+
environment" or "that environment id does not resolve" any *other* status would
48+
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
49+
caller can fail to be entitled to the environment it named. The cost is
50+
diagnosability: an operator whose environment genuinely lacks an `auth` service
51+
sees the anonymous 401 rather than a wiring error.
52+
53+
**Migration.** The published route changes from "anonymous read" to
54+
"authenticated **and** ownership-checked", so a caller that relied on the old
55+
behaviour breaks:
56+
57+
- An **anonymous** consumer of `/ui/view/...` (for example a login screen
58+
rendering a view before authentication) now receives 401. There is no opt-out;
59+
the route is not on `isAuthGateAllowlisted` and was never a declared
60+
control-plane exemption.
61+
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
62+
environment** now receives 401 instead of being served the hostname's
63+
environment. Drop the contradictory header; the bound hostname still decides.
64+
- A caller sending an `X-Environment-Id` the registry cannot resolve now
65+
receives 401 instead of the default environment's view.
66+
- A deployment where an environment's kernel carries no `auth` service of its
67+
own now refuses requests naming that environment, because the credential
68+
would have been validated in the default environment instead. Wire the
69+
environment's `auth` service.
70+
71+
Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
72+
gated; naming an environment in the URL is no more of an entitlement than naming
73+
it in a header. What the producer is *told* is unchanged — `getUiView` still
74+
receives `{ object, type }` on the unscoped mount and the route-supplied
75+
`environmentId` on the scoped one.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 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` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
161-
| 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` |
161+
| 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` |
162162
| 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` |
163163
| 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` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |

packages/qa/dogfood/test/authz-probe-blind-spot.census.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
231231
reachable: 19,
232232
blindSpot: 61,
233233
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
234-
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
234+
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
235+
// exists to prove this census is still reading the file it thinks it is, and
236+
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
237+
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
238+
// file that resolved no identity, and it is now guarded. The move is +3 over
239+
// the whole file (`occurrences` counts the bare term, comments included):
240+
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
241+
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
242+
// EXACT count rather than a range or a floor: a range would stop this row
243+
// noticing the next move, which is the only thing it is for.
244+
//
245+
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
246+
// what says this is a guard change and not a surface change: `population`
247+
// 80, `reachable` 19, `private register*Endpoints(` 17 and
248+
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
249+
// and no registrar. `blindSpot` therefore stays 61 as well.
250+
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
235251
note:
236252
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
237253
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +

packages/rest/src/execctx-consumer-census.test.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ const ENTITLED = {
173173
isSystem: false,
174174
tenantId: 'org_census',
175175
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
176+
// [#13214] The internal key `computeExecCtx` stamps on every context it
177+
// produces, naming the environment whose auth service actually validated
178+
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
179+
// site this census now counts — compares it against the environment the
180+
// request resolved to, which under `makeServer` is `env_census`.
181+
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
182+
// context has to model the key or it is a caller anchored NOWHERE, which
183+
// that seam refuses. Every other site in this census ignores it.
184+
__authEnvironmentId: 'env_census',
176185
};
177186
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };
178187

@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
300309
// ---------------------------------------------------------------------------
301310

302311
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
303-
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
304-
expect(SITES.length).toBe(72);
305-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
312+
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
313+
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
314+
// the ONE metadata-touching route in the table that resolved no
315+
// identity at all — the exception this census surfaced — and the
316+
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
317+
// shared floor, which is the family the next two cases describe.
318+
//
319+
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
320+
// the point of counting both: one is the call site, the other two are
321+
// prose mentions in the new doc-comments (the registrar's, recording
322+
// that this route used to call `resolveExecCtx` zero times, and the
323+
// ownership guard's, recording that adding `resolveExecCtx` +
324+
// `enforceAuth` was measured NOT to be the repair). A mention count
325+
// that tracked the site count exactly would be measuring one thing
326+
// twice.
327+
expect(SITES.length).toBe(73);
328+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
306329
});
307330

308-
it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
331+
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
309332
// 16 sites spell the catch on the invocation line; 4 more spell it on
310333
// the continuation line. A single-line grep sees 16 and the arithmetic
311334
// silently loses four sites.
335+
//
336+
// [#13214] The new site is BARE, and that is a decision the next case
337+
// enforces: a locally-caught site sitting behind the shared floor would
338+
// be the first of its kind and would break the structural claim below.
312339
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
313340
expect(sameLine.length).toBe(16);
314341
expect(CAUGHT.length).toBe(20);
315-
expect(BARE.length).toBe(52);
342+
expect(BARE.length).toBe(53);
316343
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
317344
});
318345

319-
it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
346+
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
320347
// This inverts the reason the thread gave for doing the bare sites
321348
// first ("no local signal that a fault becomes an anonymous subject").
322349
// The bare sites are bare BECAUSE the shared anonymous floor is the
323350
// next statement; the locally-caught ones carry a `.catch` because
324351
// they are NOT behind that floor and each must decide for itself.
325-
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
352+
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
326353
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
327354
});
328355
});
329356

330357
// ---------------------------------------------------------------------------
331-
// 3. The 52 bare sites, driven
358+
// 3. The 53 bare sites, driven
332359
// ---------------------------------------------------------------------------
333360

334-
describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
335-
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
361+
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
362+
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
336363
const reached = sitesOf(await sweep(undefined, 'FULL'));
337364
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
338365
// ⛔ A bare site that stopped being reachable must show up as a
339366
// shrinking census, never as a row silently inherited from a neighbour.
340367
expect(unreached).toEqual([]);
341368
}, 120_000);
342369

343-
it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
370+
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
344371
const fault = await sweep(undefined, 'FULL');
345372
const control = await sweep(ENTITLED, 'FULL');
346373
const bareLines = new Set(BARE.map((s) => s.line));
347374
const controlByRoute = new Map(control.map((r) => [r.route, r]));
348375

349376
const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
350-
expect(rows.length).toBeGreaterThanOrEqual(52);
377+
expect(rows.length).toBeGreaterThanOrEqual(53);
351378

352379
for (const row of rows) {
353380
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);

packages/rest/src/rest-exec-ctx-principal-kind.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
293293
// it, so it is an assembled field now and absent for the same reason
294294
// every other unset field is: this session carries no gate.
295295
'__kernel',
296+
// [#13214] The SECOND post-assembly internal key, added by the
297+
// 2026-08-30 security ruling and named here rather than left to a
298+
// subset check — this pin exists precisely to make a key ARRIVING
299+
// as loud as a key going missing, and this one arrived.
300+
//
301+
// It carries the environment whose auth service actually validated
302+
// the caller, which is the left-hand side of the ownership
303+
// comparison `enforceEnvironmentOwnership` makes at
304+
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
305+
// authorization input, at exactly one reader inside `rest-server.ts`
306+
// — ⛔ nothing downstream of this transport may branch on it, and
307+
// it is deliberately NOT an `ExecutionContext` field because it
308+
// describes how the context was OBTAINED, not what the principal
309+
// may do. The assembled field set is unchanged; this sits beside it,
310+
// in the same `as any` the class doc-comment already covers.
311+
'__authEnvironmentId',
296312
]));
297313
});
298314

0 commit comments

Comments
 (0)