Skip to content

Commit 1361a5a

Browse files
fix(rest)!: compare the credential's environment with the resolved environment in enforceAuth (#25565)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao2dkxxuFkMApsJLTFUkjKef)_ Clause-②: yes (narrowing) Metadata-protocol: none ## What changes `enforceAuth`, the shared gate every REST data-plane route calls, now compares the environment whose `auth` service validated the credential with the environment the request resolved to, and answers a mismatch with the existing anonymous-deny response (same status, same body). - `computeExecCtx` already stamped `__authEnvironmentId` (the validating environment). It now also stamps `__environmentId` (the resolved environment) beside it. Both are internal keys, not `ExecutionContext` fields. - A module-private predicate, `credentialAnchoredElsewhere`, makes the comparison. `undefined` and `'platform'` are not compared (a control-plane request has nothing to own), and a context that `computeExecCtx` did not produce carries neither key and is not judged. - `GET /ui/view/:object/:type` keeps its own `enforceEnvironmentOwnership` call unchanged. Its anchored-elsewhere half now duplicates the shared gate, but its named-but-not-served half (an `X-Environment-Id` the chain did not serve) has no other home, so the call stays. Only its docblock changed. Why: ADR-0006 makes auth per-environment. `computeExecCtx` can validate a caller against the default environment's `auth` service when the target environment's kernel carries none, and until now only the UI-view route compared the two environments. ## New pins `rest-scoped-route-environment-ownership.test.ts` drives the unstubbed identity resolution with two environments and checks five route families (crud read and write, data-action clone, batch `createMany`, record sharing), each on the scoped and the unscoped mount: - a credential validated in a different environment than the target answers the anonymous-deny status and body, and nothing downstream (protocol or sharing service) is called; - positive controls: the target environment's own credential, and the default environment's credential aimed at the default environment, are served and reach the downstream call exactly once. The file also pins that the context names both environments. Its refusal cases fail on `main` (19d3720) and pass with this change; its 20 controls pass on both. ## Who sees a difference Only a multi-environment host whose per-environment kernels carry no `auth` service while the default environment's does. In this repository nothing registers both `kernel-manager` and `default-project`, so in-repo compositions answer exactly as before. Single-kernel deployments are untouched: the one auth service answers for the one environment. ## Tests On head 985e41f (the fix), plus the census re-anchor on 1fcfb16: - `pnpm --filter @objectstack/rest test`: Test Files 317 passed (317), Tests 6379 passed / 326 skipped. - `pnpm --filter @objectstack/rest typecheck`: exit 0. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. `request-execution-context.conformance.test.ts`: 13/13 (the new internal key is set aside by name beside the other two). - `rest-exec-ctx-principal-kind.test.ts`: the post-assembly key-set pin names `__environmentId`. - Dogfood battery (`pnpm --filter @objectstack/dogfood test`, full, on 985e41f, lock VERDICT command-exit 1): Test Files 290 passed / 1 failed / 3 skipped (294), Tests 2499 passed / 1 failed / 18 skipped. The one failure was a consequence of this change: `authz-probe-blind-spot.test.ts` counts the bare term `enforceAuth` in `rest-server.ts`, comments included, and this diff adds four prose mentions (111 to 115, no call site). Re-anchored in the census with its provenance on 1fcfb16; that file reruns 47/47. Declared narrowing: the full battery was not rerun for the test-only commit. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on 1fcfb16: 73 derived, 73 run, all exit 0, reconciled with `--ran` (exit 0). 69 ran on 985e41f; the 4 the census commit added (`spec` `check:empty-state`, `check:liveness`, `check:strictness-ledger`, `check:variant-docs`) and the ratchet and text families (`check:type-check-debt`, `check:type-check-coverage`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check:test-source-alias`) ran on 1fcfb16. Two needed prerequisites on the first pass: `check-plugin-teardown-shape --self-test` (exit 3: pinned fixture commit absent from the shallow clone; fetched, exit 0) and `check:dual-build-cjs-loads` (exit 3: needs a full declaration build; rebuilt, exit 0). ## Acceptance notes - The changeset carries the BREAKING banner, the remedy line and the ADR-0087 `not-required` disposition. - Kept as a draft for the maintainer's approval: this is a security boundary. --- _Generated by [Claude Code](https://claude.ai/code/session_017KrjEWoY6mS9sWygvs6Qxg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 025f6de commit 1361a5a

6 files changed

Lines changed: 375 additions & 12 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: a credential is honoured only in the environment whose auth service validated it, on every route behind the shared auth gate
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A request-time authentication check in the REST transport: no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and the refusal reuses the existing anonymous-deny response. There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the REST transport (ADR-0006: auth is per-environment).
13+
14+
- **Before.** When the environment a request resolved to had no `auth` service of its own, the REST server validated the caller against the default environment's `auth` service and served the request. Only `GET /ui/view/:object/:type` compared the two environments.
15+
- **Now.** `enforceAuth`, the shared gate every data-plane route calls, compares the environment that validated the credential with the environment the request resolved to, and refuses a mismatch with the same `401` and body an unauthenticated request receives. This covers crud, data-action, import/export, batch, sharing, approvals, analytics, search, security and the other routes behind that gate, on both the scoped (`/environments/:environmentId/...`) and the unscoped mount.
16+
- **Unchanged.** A credential validated in the environment it targets is served exactly as before, and so is every request on a single-kernel deployment, where the one auth service answers for the one environment. Control-plane requests, which resolve no environment, are not compared.
17+
- **Who sees it.** Only a multi-environment host whose per-environment kernels carry no `auth` service while the default environment's does. Such a request now answers `401`.
18+
19+
**The remedy.** Give each environment's kernel its own `auth` service, and sign in to the environment you call.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,14 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
944944
// `registerMetadataEndpoints`, so it was a blind spot; its handler opened
945945
// with `enforceAuth`, so that control drops by one. The registrar survives
946946
// for the document, so `private register*Endpoints(` stays 16.
947+
// [credential-environment comparison in `enforceAuth`] `enforceAuth`
948+
// 111 -> 115, and nothing else on this row moved. ⛔ RE-ANCHORED, not
949+
// relaxed: four PROSE mentions and no call site. They are the docblock of
950+
// the new `credentialAnchoredElsewhere` predicate (`rest-server.ts:1783`),
951+
// the anchored-elsewhere case in `enforceEnvironmentOwnership`'s docblock
952+
// (`:2545`) and the two inline comments on the internal context keys in
953+
// `computeExecCtx` (`:3444`, `:3454`). `enforceAuth`'s own body gained a
954+
// comparison in lines that do not spell the term.
947955
controls: {
948956
'private register*Endpoints(': 16,
949957
'this.routeManager.register(': 62,
@@ -983,7 +991,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
983991
'registerPerItemRoute(': 8,
984992
'const registerPerItemRoute =': 1,
985993
'forwarder slice: this.routeManager.register(': 1,
986-
enforceAuth: 111,
994+
enforceAuth: 115,
987995
},
988996
note:
989997
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,14 +309,20 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
309309
// It carries the environment whose auth service actually validated
310310
// the caller, which is the left-hand side of the ownership
311311
// comparison `enforceEnvironmentOwnership` makes at
312-
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
313-
// authorization input, at exactly one reader inside `rest-server.ts`
314-
// — ⛔ nothing downstream of this transport may branch on it, and
312+
// `GET /ui/view/:object/:type` and `enforceAuth` makes on every route
313+
// behind it. ⚠️ Unlike `__kernel` it IS an authorization input, read
314+
// only inside `rest-server.ts` — ⛔ nothing downstream of this transport may branch on it, and
315315
// it is deliberately NOT an `ExecutionContext` field because it
316316
// describes how the context was OBTAINED, not what the principal
317317
// may do. The assembled field set is unchanged; this sits beside it,
318318
// in the same `as any` the class doc-comment already covers.
319319
'__authEnvironmentId',
320+
// The THIRD post-assembly internal key, the right-hand side of the
321+
// same comparison: the environment the request RESOLVED to. Read by
322+
// `enforceAuth` on every route behind it (`credentialAnchoredElsewhere`
323+
// in `rest-server.ts`), which refuses a credential validated in a
324+
// different environment. Same status as `__authEnvironmentId`.
325+
'__environmentId',
320326
]));
321327
});
322328

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The credential-environment comparison on EVERY route behind `enforceAuth`,
5+
* driven through the UNSTUBBED identity resolution.
6+
*
7+
* ## The fact this file is about
8+
*
9+
* `computeExecCtx` validates a caller against an `auth` service it finds in a
10+
* kernel, and it has a branch that finds one in the DEFAULT environment's
11+
* kernel when the resolved environment's kernel carries none. A session
12+
* minted in the default environment then authenticates a request aimed at
13+
* another environment. The context records which environment actually
14+
* validated the caller (`__authEnvironmentId`) and which environment the
15+
* request resolved to (`__environmentId`); `enforceAuth` — the one shared
16+
* entry point of the data plane — compares the two and refuses a mismatch
17+
* with the anonymous-deny response, byte for byte, so "valid elsewhere" is
18+
* indistinguishable from "not authenticated".
19+
*
20+
* `ui-view-environment-ownership.test.ts` pins the producer of
21+
* `__authEnvironmentId` and the UI-view seam; this file pins the rest of the
22+
* data plane: crud, data-action, batch and sharing, on both the scoped mount
23+
* (`/environments/:environmentId/...`) and the unscoped one (header-named).
24+
*
25+
* Each refusal carries two halves: the status and body, AND the downstream
26+
* (protocol or sharing service) was never reached. Each has a positive
27+
* control — the same wiring with the target environment carrying its own
28+
* `auth` service, which must be served — so the refusal cannot be satisfied
29+
* by a server that refuses everyone.
30+
*/
31+
32+
import { describe, it, expect } from 'vitest';
33+
import { ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
34+
import { RestServer } from './rest-server.js';
35+
import type { RestEnvRegistry, RestKernelManager } from './rest-server.js';
36+
37+
const BASE = '/api/v1';
38+
const SCOPED = `${BASE}/environments/:environmentId`;
39+
40+
const ENV_A = 'env_alpha'; // the default environment
41+
const ENV_B = 'env_beta';
42+
43+
interface Reached { env: string; method: string }
44+
45+
/**
46+
* A protocol that serves every call it is asked by recording it — the
47+
* downstream half of each pin. `getMetaItem` answers no item, so the object
48+
* carries no exposure policy and `enforceApiAccess` lets the call through.
49+
*/
50+
function recordingProtocol(env: string, reached: Reached[]): any {
51+
const record = (method: string, answer: unknown) => async () => {
52+
reached.push({ env, method });
53+
return answer;
54+
};
55+
return {
56+
getMetaItem: async () => undefined,
57+
getMetaItems: async () => ({ items: [] }),
58+
findData: record('findData', { records: [{ id: `${env}_row` }], total: 1 }),
59+
getData: record('getData', { record: { id: `${env}_row` } }),
60+
createData: record('createData', { id: `${env}_new` }),
61+
cloneData: record('cloneData', { id: `${env}_clone` }),
62+
createManyData: record('createManyData', [{ id: `${env}_many` }]),
63+
batchData: record('batchData', { results: [] }),
64+
};
65+
}
66+
67+
/** Every lookup answers EMPTY: the principal is the session's user id, no roles. */
68+
function emptyQl(): any {
69+
const empty = async () => [];
70+
return new Proxy({}, { get: (_t, k: string) => (k === 'then' ? undefined : empty) });
71+
}
72+
73+
function authServiceFor(userId: string): any {
74+
return {
75+
api: {
76+
getSession: async () => ({ user: { id: userId, email: `${userId}@example.test` }, session: { userId } }),
77+
},
78+
};
79+
}
80+
81+
/** `authUserId` absent = that environment's kernel carries NO `auth` service. */
82+
function wiring(spec: Record<string, { authUserId?: string }>) {
83+
const reached: Reached[] = [];
84+
const objectqlFrom: string[] = [];
85+
const kernelManager: RestKernelManager = {
86+
async getOrCreate(environmentId: string) {
87+
const entry = spec[environmentId];
88+
return {
89+
async getServiceAsync<T>(name: string): Promise<T> {
90+
if (name === 'auth') {
91+
if (!entry?.authUserId) throw new Error(`no auth service in ${environmentId}`);
92+
return authServiceFor(entry.authUserId) as T;
93+
}
94+
if (name === 'objectql') { objectqlFrom.push(environmentId); return emptyQl() as T; }
95+
if (name === 'protocol') return recordingProtocol(environmentId, reached) as T;
96+
// Absent services resolve `undefined` — the registry's
97+
// quiet absence, not a construction fault.
98+
return undefined as T;
99+
},
100+
} as any;
101+
},
102+
};
103+
return { kernelManager, reached, objectqlFrom };
104+
}
105+
106+
const envRegistry: RestEnvRegistry = {
107+
async resolveByHostname() { return null; },
108+
async resolveById(environmentId: string) {
109+
return environmentId === ENV_A || environmentId === ENV_B ? { driver: environmentId } : null;
110+
},
111+
};
112+
113+
type Handler = (req: any, res: any) => any;
114+
115+
function recordingHttp() {
116+
const table = new Map<string, Handler>();
117+
const on = (m: string) => (p: string, h: Handler) => { table.set(`${m} ${p}`, h); };
118+
return {
119+
table,
120+
get: on('GET'), post: on('POST'), put: on('PUT'), delete: on('DELETE'), patch: on('PATCH'),
121+
use: () => {}, listen: async () => {}, close: async () => {},
122+
} as any;
123+
}
124+
125+
function makeServer(w: ReturnType<typeof wiring>) {
126+
const http = recordingHttp();
127+
const provider = async () => undefined as any;
128+
const sharingFor = async (environmentId?: string) => ({
129+
listShares: async () => {
130+
w.reached.push({ env: environmentId ?? '(unscoped)', method: 'listShares' });
131+
return [{ id: 'share_1' }];
132+
},
133+
});
134+
const args: ConstructorParameters<typeof RestServer> = [
135+
/* 1 server */ http,
136+
/* 2 protocol (control plane) */ recordingProtocol('platform', w.reached),
137+
/* 3 config */ { api: { enableProjectScoping: true, projectResolution: 'auto' } },
138+
/* 4 kernelManager */ w.kernelManager,
139+
/* 5 envRegistry */ envRegistry,
140+
/* 6 defaultEnvironmentIdProvider */ () => ENV_A,
141+
/* 7 authServiceProvider */ undefined,
142+
/* 8 objectQLProvider */ provider,
143+
/* 9 emailServiceProvider */ provider,
144+
/* 10 sharingServiceProvider */ sharingFor,
145+
/* 11 (retired saved-report slot) */ undefined,
146+
/* 12 approvalsServiceProvider */ provider,
147+
/* 13 sharingRulesServiceProvider */ provider,
148+
/* 14 i18nServiceProvider */ provider,
149+
/* 15 analyticsServiceProvider */ provider,
150+
/* 16 settingsServiceProvider */ provider,
151+
/* 17 serviceExistsProvider */ () => false,
152+
/* 18 securityServiceProvider */ provider,
153+
/* 19 requestEnvResolver */ undefined,
154+
/* 20 metadataServiceProvider */ provider,
155+
];
156+
const rs: any = new RestServer(...args);
157+
rs.registerRoutes();
158+
return { rs, table: http.table as Map<string, Handler> };
159+
}
160+
161+
interface RouteCase {
162+
family: string;
163+
method: 'GET' | 'POST';
164+
/** Path below the base (scoped or unscoped). */
165+
path: string;
166+
params: Record<string, string>;
167+
body?: unknown;
168+
/** The downstream call that serving the request makes. */
169+
downstream: string;
170+
}
171+
172+
const ROUTES: RouteCase[] = [
173+
{ family: 'crud read', method: 'GET', path: '/data/:object', params: { object: 'account' }, downstream: 'findData' },
174+
{ family: 'crud write', method: 'POST', path: '/data/:object', params: { object: 'account' }, body: { name: 'x' }, downstream: 'createData' },
175+
{ family: 'data-action', method: 'POST', path: '/data/:object/:id/clone', params: { object: 'account', id: 'r1' }, body: {}, downstream: 'cloneData' },
176+
{ family: 'batch', method: 'POST', path: '/data/:object/createMany', params: { object: 'account' }, body: [{ name: 'x' }], downstream: 'createManyData' },
177+
{ family: 'sharing', method: 'GET', path: '/data/:object/:id/shares', params: { object: 'account', id: 'r1' }, downstream: 'listShares' },
178+
];
179+
180+
async function drive(
181+
table: Map<string, Handler>,
182+
route: RouteCase,
183+
mount: 'scoped' | 'unscoped',
184+
target: string,
185+
) {
186+
const key = `${route.method} ${mount === 'scoped' ? SCOPED : BASE}${route.path}`;
187+
const handler = table.get(key);
188+
if (!handler) throw new Error(`route not mounted: ${key}`);
189+
const concrete = route.path.replace(/:(\w+)/g, (_m, k) => route.params[k] ?? `:${k}`);
190+
const url = mount === 'scoped' ? `${BASE}/environments/${target}${concrete}` : `${BASE}${concrete}`;
191+
const req: any = {
192+
method: route.method,
193+
path: url,
194+
url,
195+
params: { ...route.params, ...(mount === 'scoped' ? { environmentId: target } : {}) },
196+
query: {},
197+
body: route.body,
198+
headers: {
199+
host: 'unbound.example.test',
200+
cookie: 'session=whatever',
201+
...(mount === 'unscoped' ? { 'x-environment-id': target } : {}),
202+
},
203+
};
204+
let status = 0; let body: any; let sent = false;
205+
const res: any = {
206+
status(c: number) { status = c; return res; },
207+
json(b: any) { body = b; sent = true; },
208+
send() { sent = true; }, header() { return res; }, setHeader() { return res; },
209+
end() { sent = true; }, write() { return true; }, type() { return res; },
210+
};
211+
await handler(req, res);
212+
return { status: status || (sent ? 200 : 0), body };
213+
}
214+
215+
describe('a default-environment credential aimed at another environment', () => {
216+
for (const mount of ['scoped', 'unscoped'] as const) {
217+
for (const route of ROUTES) {
218+
it(`⭐ is REFUSED on ${route.family} (${route.method} ${route.path}, ${mount}) — and nothing downstream runs`, async () => {
219+
// B carries no `auth` service, so the identity lookup falls
220+
// back to A's and authenticates A's session for a request
221+
// that resolved to B.
222+
const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: {} });
223+
const { table } = makeServer(w);
224+
225+
const out = await drive(table, route, mount, ENV_B);
226+
227+
expect(out.status).toBe(ANONYMOUS_DENY_STATUS);
228+
// Byte-identical to the anonymous answer: "valid elsewhere"
229+
// must not be distinguishable from "not authenticated".
230+
expect(JSON.stringify(out.body)).toBe(JSON.stringify(ANONYMOUS_DENY_BODY));
231+
expect(w.reached).toEqual([]);
232+
}, 60_000);
233+
234+
it(`POSITIVE CONTROL — the target environment's own credential is served on ${route.family} (${mount})`, async () => {
235+
const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: { authUserId: 'u_beta' } });
236+
const { table } = makeServer(w);
237+
238+
const out = await drive(table, route, mount, ENV_B);
239+
240+
expect(out.status).toBeGreaterThanOrEqual(200);
241+
expect(out.status).toBeLessThan(300);
242+
expect(w.reached.map((r) => r.method)).toEqual([route.downstream]);
243+
}, 60_000);
244+
245+
it(`POSITIVE CONTROL — the default environment's credential, aimed at the default environment, is served on ${route.family} (${mount})`, async () => {
246+
const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: {} });
247+
const { table } = makeServer(w);
248+
249+
const out = await drive(table, route, mount, ENV_A);
250+
251+
expect(out.status).toBeGreaterThanOrEqual(200);
252+
expect(out.status).toBeLessThan(300);
253+
expect(w.reached.map((r) => r.method)).toEqual([route.downstream]);
254+
}, 60_000);
255+
}
256+
}
257+
});
258+
259+
describe('the context records both sides of the comparison', () => {
260+
it('names the resolved environment beside the one that validated the caller', async () => {
261+
const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: {} });
262+
const { rs } = makeServer(w);
263+
const ctx = await rs.resolveExecCtx(ENV_B, { headers: { cookie: 'session=whatever' }, method: 'GET' });
264+
265+
expect(ctx?.userId).toBe('u_alpha');
266+
expect(ctx.__environmentId).toBe(ENV_B);
267+
expect(ctx.__authEnvironmentId).toBe(ENV_A);
268+
// The principal was also resolved against A's engine, not B's: the
269+
// crossing is not only "who" but "whose permission store".
270+
expect(w.objectqlFrom).toEqual([ENV_A]);
271+
}, 60_000);
272+
273+
it('names the same environment twice when the target carries its own auth service', async () => {
274+
const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: { authUserId: 'u_beta' } });
275+
const { rs } = makeServer(w);
276+
const ctx = await rs.resolveExecCtx(ENV_B, { headers: { cookie: 'session=whatever' }, method: 'GET' });
277+
278+
expect(ctx?.userId).toBe('u_beta');
279+
expect(ctx.__environmentId).toBe(ENV_B);
280+
expect(ctx.__authEnvironmentId).toBe(ENV_B);
281+
}, 60_000);
282+
});

0 commit comments

Comments
 (0)