Skip to content

Commit 874a38d

Browse files
feat(core): allow-list the exact ADR-0043 approvals act route on the ADR-0069 auth gate (#22602)
Fixes #22577 Clause-②: yes Refs #22438 (segment 3 of ruling A, the maintainer's 「A + 扫类」, director record 6079645593) **Boundary, in one line:** the ADR-0069 auth-gate allow-list now admits exactly one more route, `approvals/act` (the ADR-0043 action page: `GET` renders, `POST` redeems), at the mount bases and environment scope the gate already normalises, and nothing else; that is the same token-only authentication the self-hosted raw-app mount (`plugin-approvals` `ACT_PATH = '/api/v1/approvals/act'`) has always had. ## The change - `packages/core/src/security/auth-gate.ts`: one entry, `['approvals', 'act']`, in `ALLOW_ROUTES`. Those entries are exact routes at a mount: a route matches only when the path, after the base and the optional scope, is that route and ends there. The `auth` rule is the gate's one prefix rule, and it is untouched. No prefix entry, no wildcard, no other approvals path. The docstrings say why the route is exempt (ADR-0043: the single-use token is the whole authorization) and that anything wider is the maintainer's decision. - `packages/core/src/security/auth-gate.test.ts`: four pins (below). - `.changeset/22577-approvals-act-allowlist.md`: `@objectstack/core` at `minor` (it widens what the gate admits). ## Mechanism, measured on `origin/main` 99801d8 before writing 1. **Who reads the allow-list.** `isAuthGateAllowlisted` has three production readers: REST `enforceAuth` (`rest-server.ts`, passes `req.path`), the dispatcher `enforceAuthGate` (`http-dispatcher.ts`, passes `cleanPath` before the scoped-URL strip), and `shouldDenyAnonymous` (`anonymous-deny.ts`, only when a seam passes a path). No shipped seam passes a path to `shouldDenyAnonymous`: all 13 call sites outside core pass `userId`/`isSystem`/`method` only. So the one live effect is on the dispatcher, for a session that an authentication policy holds (expired password, required MFA). No REST route serves `approvals/act`, so REST `enforceAuth` never sees this path. 2. **The self-hosted mount.** Confirmed at `approvals-plugin.ts:392`: `ACT_PATH = '/api/v1/approvals/act'`, mounted with `rawApp.get` (renders through `peekActionToken`) and `rawApp.post` (redeems through `redeemActionToken`) on `http.server.getRawApp()`. Two global middlewares run in front of every raw-app route: the Hono server's `use('*')` seams, and the one registered middleware, the dispatcher plugin's rate limiter. Neither reads a session or this allow-list. So the self-hosted route has never passed this gate, and the entry adds no authentication the self-hosted path lacks. 3. **Spellings tried** (each one is a pin, with ablation evidence below). - **Admitted:** `/api/v1/approvals/act`, `/approvals/act` (the dispatcher's prefix-stripped shape), `/api/approvals/act`, `?token=abc` on both main spellings, a trailing slash, a doubled trailing slash, doubled inner slashes, `/environments/env_1/approvals/act`, `/api/v1/environments/env_1/approvals/act`, and `/api/v1/projects/env_1/approvals/act`. - **Refused:** - extensions: `/approvals/act/x`, `/api/v1/approvals/act/x/y`, `/approvals/actx`, `/approvals/act-x`, `/api/v1/approvals/acts`. - siblings: `/approvals`, `/api/v1/approvals/requests`, `/api/v1/approvals/requests/req_1/approve`. - the route below a non-mount segment: `/data/approvals/act`, `/api/v1/data/approvals/act`, `/meta/approvals/act`, `/api/v2/approvals/act`, `/rest/v1/approvals/act`. - a scope with no id: `/environments/approvals/act`. - case variants: `/api/v1/Approvals/act`, `/APPROVALS/ACT`. - percent-encoding: `/api/v1/approvals%2Fact`, `/approvals/%61ct`. - The gate does not normalise case or percent-encoding, and this change does not make it. - ⚠️ **For the reviewer:** the environment-scoped spelling is admitted because the gate runs before the dispatcher strips the scope. That spelling reaches the same handler as the unscoped one, through the same token. Refusing it would leave a held session refused there with the policy's 403 and admitted unscoped, for one route. 4. **Segment 2 (the dispatcher's `/approvals/act` domain) is not on `main`, and that is harmless.** A throwaway `HttpDispatcher` measurement used the gated fixture of `http-dispatcher.root-auth-gate.test.ts` and was not committed. Its control is that a held session on `/data/task` answers 403 `PASSWORD_EXPIRED` on both legs. Without the entry (ablated leg) → with the entry: - held session, `GET` and `POST /approvals/act`, and `GET /environments/env-1/approvals/act`: 403 `PASSWORD_EXPIRED` → **404 `ROUTE_NOT_FOUND`**; - held session, `/approvals/requests` and `/approvals/act/x`: 403 → 403 (unchanged); - anonymous, all five paths: 404 `ROUTE_NOT_FOUND` → 404 (unchanged). - So until segment 2 lands, the only change is that a held session gets the same 404 every other caller already gets. No handler runs, and nothing is served. ## Pins (`auth-gate.test.ts`, new `describe` "[#22577] …") - **PIN 1:** every admitted spelling above answers `true`. - **PIN 2:** every refused spelling above answers `false`. - **`evaluateAuthGate`:** a held session passes `/approvals/act` and `/api/v1/approvals/act?token=abc`, and is still held, with the gate's own `code` and `message`, on `/api/v1/approvals/requests/req_1/approve` and `/approvals/act/x`. - **BOUNDARY:** measures the Clause-② widening. The predicate as it stood on 99801d8 is transcribed in the test. The corpus is every path of up to 5 segments over `api v1 approvals act requests environments projects env1 auth health`, plus the 6-segment scoped REST shape, about 122k paths. The set of newly exempt paths must equal exactly the act route at each of the 3 mount bases, unscoped or under one scope (63 paths, built independently of the predicate), and the newly gated set must be empty. Anti-vacuity floors are included. The two existing corpus ratchets (#16839 and #7898) do not change: their vocabularies contain neither `approvals` nor `act`. ## Evidence (head `340dfe9dcc`) - **Ablation.** `node scripts/ablation-replace.mjs` deleted the entry (anchor 1 → 0, blob `264818d54c29` → `13b642e7a462`) around `vitest run src/security/auth-gate.test.ts`. Result: `3 failed | 23 passed (26)`. PIN 1 is red at its first row (`/api/v1/approvals/act: expected false to be true`), and the `evaluateAuthGate` pin and BOUNDARY are red too. PIN 2 stays green, as it should. The restore was proven: the blob equals HEAD `264818d54c29` and `git diff HEAD` is empty. - `pnpm --filter @objectstack/core test`: 88 files, 2293 tests passed. `test:repo`: 3 files, 48 tests passed. `typecheck`: exit 0. That includes `check:test-typecheck` over `tsconfig.test.json`, which compiles `auth-gate.test.ts`; plain `tsconfig.json` does not list it. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 63 commands. All 63 exited 0, and `--ran` reconciled them: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - The first reading of `check:dts-closure` was exit 1, and of `check:dual-build-cjs-loads` exit 3 (`PREREQUISITE NOT MET`). Both were partial-tree states this run had made: a JS-only dependency build for the measurement in point 4 above, then a concurrent gate's builds still writing. Neither named `@objectstack/core`. Rerun on the settled tree, both exited 0 (71 packages, 171 of 171 declaration files present; 107 require entry points load). - **eslint, a declared narrowing.** `eslint --no-inline-config --format json` on the two changed `.ts` files: 2 files, 0 errors, 0 warnings. Both files are in eslint's own population (`--print-config` resolves both), and the 2 is the count from the JSON output. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, as its own comment at `:327` says), so this diff cannot move a verdict on any untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes (not filed; each with its carrier) - **For segment 2's PR (the runtime lane).** The dispatcher's membership gate (`enforceProjectMembership`) runs after this gate, and its skip list has no `/approvals` entry. So on a kernel with membership enforcement and a resolved environment, a signed-in non-member following a forwarded link is refused there before the token is read. The self-hosted mount has no such gate. This card's ruling scope is the ADR-0069 allow-list only, so this is noted for whoever lands the dispatcher domain. Nothing serves the route today, so the effect is not yet reachable. - The `AnonymousDenyInput.path` docstring in `anonymous-deny.ts` lists the exempt control-plane paths in a parenthesis. That list already omitted `me/apps`, and it now omits the act route too. It defers to `isAuthGateAllowlisted`, which is the authority. Carrier: none. - The #7898 ratchet's comment asks a future allow-list change to update both of its sides. Its corpus cannot reach `approvals/act`, so its transcription stays exact over that corpus, and it is left verbatim. The new BOUNDARY test is this change's own ratchet. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b27b894 commit 874a38d

3 files changed

Lines changed: 184 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): the ADR-0069 auth-gate allow-list admits the ADR-0043 approval action route, `approvals/act`, as one exact route
6+
7+
Clause-②: yes
8+
9+
- **What the allow-list admits now.** `isAuthGateAllowlisted` answers `true` for the approval action route `approvals/act`, at the mount bases the gate already recognises (`/api/v1/approvals/act`, `/api/approvals/act`, and the dispatcher's prefix-stripped `/approvals/act`), unscoped or under one `/environments/<id>` (or legacy `/projects/<id>`) scope, with the query string and trailing slashes stripped as for every other entry. Nothing else moves: `/approvals/act/<anything>`, `/approvals/actx`, the approvals base and every `/approvals/requests/…` route stay gated.
10+
- **Why this is not a loosening.** ADR-0043 authenticates the action page by its single-use token alone, and `plugin-approvals`' self-hosted mount (`GET` renders, `POST` redeems on the host app) has never passed through this gate. The entry gives the dispatcher's spelling of the same route the same token-only authentication, so a session held by an authentication policy (expired password, required MFA) is no longer refused there with that policy's `403` before the token is read.
11+
- **Effect on a kernel whose dispatcher serves no `/approvals/act` domain.** A held session that reaches the route gets the dispatcher's `404 ROUTE_NOT_FOUND`, the answer every other caller already gets there, instead of the policy's `403`. Nothing on the REST surface is affected: no REST route serves `approvals/act`, and no shipped seam passes a path to `shouldDenyAnonymous`.

‎packages/core/src/security/auth-gate.test.ts‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,156 @@ describe('auth-gate (ADR-0069 session gate)', () => {
293293
});
294294
});
295295

296+
// ── The ADR-0043 approval action page (ruling A on #22438) ───────────────
297+
//
298+
// `GET` / `POST /api/v1/approvals/act` is session-less by design: the
299+
// single-use token in the request is its whole authorization. The self-hosted
300+
// raw-app mount (`plugin-approvals` `ACT_PATH`) never passes through this
301+
// gate, so the allow-list gives the dispatcher's spelling of the same route
302+
// the same token-only authentication. The card's scope is binding: the exact
303+
// route only, no prefix or wildcard, no other approvals path.
304+
//
305+
// ⛔ These pin the DECISION per path, never the spelling of the entry.
306+
describe('[#22577] the approval action page is exempt as the exact route, and nothing wider', () => {
307+
it('PIN 1 — exempts the exact act route in every spelling the gate already normalises', () => {
308+
for (const p of [
309+
'/api/v1/approvals/act', // the self-hosted mount's ACT_PATH, as a REST-shaped seam sees it
310+
'/approvals/act', // the dispatcher shape: the hono adapter strips the app prefix
311+
'/api/approvals/act', // the third mount base the gate recognises
312+
'/api/v1/approvals/act?token=abc', // the e-mail link's spelling: query stripped
313+
'/approvals/act?token=abc',
314+
'/api/v1/approvals/act/', // trailing slash stripped
315+
'/approvals/act//',
316+
'//approvals//act', // empty segments dropped
317+
'/environments/env_1/approvals/act', // scoped: the dispatcher gates BEFORE its scoped-URL strip
318+
'/api/v1/environments/env_1/approvals/act',
319+
'/api/v1/projects/env_1/approvals/act', // the legacy `projects` spelling of the same scope (ADR-0006)
320+
]) {
321+
expect(isAuthGateAllowlisted(p), p).toBe(true);
322+
}
323+
});
324+
325+
it('PIN 2 — gates every sibling approvals path and every extension of the act route', () => {
326+
for (const p of [
327+
// prefix extensions of the act route
328+
'/api/v1/approvals/act/x', '/approvals/act/x', '/api/v1/approvals/act/x/y',
329+
'/api/v1/approvals/actx', '/approvals/actx', '/approvals/act-x', '/api/v1/approvals/acts',
330+
// the approvals base and its other routes (the REST approvals surface)
331+
'/api/v1/approvals', '/approvals', '/api/v1/approvals/',
332+
'/api/v1/approvals/requests', '/api/v1/approvals/requests/req_1',
333+
'/api/v1/approvals/requests/req_1/approve', '/approvals/requests/req_1/approve',
334+
'/approvals/x', '/x/act',
335+
// the act route below a non-mount segment, whose value a tenant controls
336+
'/data/approvals/act', '/api/v1/data/approvals/act', '/meta/approvals/act',
337+
'/data/environments/x/approvals/act', '/api/v2/approvals/act', '/rest/v1/approvals/act',
338+
// a scope with no id: `approvals` is the id here, and the route is `act`
339+
'/environments/approvals/act', '/api/v1/environments/approvals/act',
340+
// spellings the gate does not normalise: case and percent-encoding
341+
'/api/v1/Approvals/act', '/APPROVALS/ACT', '/approvals/Act',
342+
'/api/v1/approvals%2Fact', '/approvals/%61ct',
343+
]) {
344+
expect(isAuthGateAllowlisted(p), p).toBe(false);
345+
}
346+
});
347+
348+
it('carries through `evaluateAuthGate`: a gated session passes the act route and stays gated next door', () => {
349+
const gated = { id: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'change it' } };
350+
expect(evaluateAuthGate(gated, '/approvals/act')).toBeNull();
351+
expect(evaluateAuthGate(gated, '/api/v1/approvals/act?token=abc')).toBeNull();
352+
// ⭐ The control: the same fixture is still gated on the sibling route.
353+
expect(evaluateAuthGate(gated, '/api/v1/approvals/requests/req_1/approve'))
354+
.toEqual({ code: 'PASSWORD_EXPIRED', message: 'change it' });
355+
expect(evaluateAuthGate(gated, '/approvals/act/x'))
356+
.toEqual({ code: 'PASSWORD_EXPIRED', message: 'change it' });
357+
});
358+
359+
// ⭐ CLAUSE ② — the widening, measured rather than asserted.
360+
//
361+
// `preEntryAllowlisted` is this file's subject as it stood before the
362+
// entry, transcribed from `origin/main` 99801d831f. Over every path of up to
363+
// five segments drawn from a vocabulary that mixes the new route's tokens
364+
// with the existing ones (plus the six-segment scoped REST shape), the set
365+
// of paths whose answer moved must be EXACTLY the act route at each mount
366+
// base the gate recognises, unscoped or under one environment scope, and
367+
// every move must be a newly exempt path, none newly gated.
368+
it('BOUNDARY — newly exempt is exactly the act route at each mount base and scope, nothing else', () => {
369+
const PRE_MOUNT_BASES: readonly (readonly string[])[] = [['api', 'v1'], ['api'], []];
370+
const PRE_SCOPE_SEGMENTS: readonly string[] = ['environments', 'projects'];
371+
const PRE_ALLOW_ROUTES: readonly (readonly string[])[] = [
372+
['health'], ['ready'], ['discovery'], ['me', 'apps'], ['me', 'localization'],
373+
];
374+
const startsWith = (segments: readonly string[], prefix: readonly string[]): boolean => {
375+
if (segments.length < prefix.length) return false;
376+
for (let k = 0; k < prefix.length; k++) if (segments[k] !== prefix[k]) return false;
377+
return true;
378+
};
379+
const preEntryAllowlisted = (rawPath: string | undefined | null): boolean => {
380+
if (!rawPath) return false;
381+
let path = rawPath.split('?')[0] || '/';
382+
let end = path.length;
383+
while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
384+
path = path.slice(0, end) || '/';
385+
const segments = path.split('/').filter((s) => s !== '');
386+
for (const base of PRE_MOUNT_BASES) {
387+
if (!startsWith(segments, base)) continue;
388+
let i = base.length;
389+
let scoped = false;
390+
if (i + 1 < segments.length && PRE_SCOPE_SEGMENTS.includes(segments[i] as string)) {
391+
i += 2;
392+
scoped = true;
393+
}
394+
if (segments[i] === 'auth') {
395+
if (i + 1 < segments.length) return true;
396+
if (!scoped) return true;
397+
}
398+
for (const route of PRE_ALLOW_ROUTES) {
399+
if (segments.length - i === route.length && startsWith(segments.slice(i), route)) return true;
400+
}
401+
}
402+
return false;
403+
};
404+
405+
const SEG = ['api', 'v1', 'approvals', 'act', 'requests', 'environments', 'projects', 'env1', 'auth', 'health'];
406+
const corpus: string[] = ['', '/'];
407+
const walk = (prefix: string, depth: number) => {
408+
if (depth === 0) return;
409+
for (const s of SEG) {
410+
const p = `${prefix}/${s}`;
411+
corpus.push(p);
412+
walk(p, depth - 1);
413+
}
414+
};
415+
walk('', 5);
416+
// The scoped REST shape is six segments deep: `/api/v1/<scope>/<id>/approvals/act`.
417+
walk('/api/v1', 4);
418+
419+
// Built independently of the predicate: base × (no scope | scope × id) × route.
420+
const expected = new Set<string>();
421+
for (const base of PRE_MOUNT_BASES) {
422+
const scopes: string[][] = [[]];
423+
for (const s of PRE_SCOPE_SEGMENTS) for (const id of SEG) scopes.push([s, id]);
424+
for (const scope of scopes) expected.add(`/${[...base, ...scope, 'approvals', 'act'].join('/')}`);
425+
}
426+
427+
const newlyExempt = corpus.filter((p) => isAuthGateAllowlisted(p) && !preEntryAllowlisted(p));
428+
const newlyGated = corpus.filter((p) => !isAuthGateAllowlisted(p) && preEntryAllowlisted(p));
429+
expect(newlyGated).toEqual([]);
430+
expect([...new Set(newlyExempt)].sort()).toEqual([...expected].sort());
431+
432+
// Anti-vacuity: the corpus reaches every expected path, exercises both
433+
// answers on both predicates, and carries the siblings PIN 2 names.
434+
const inCorpus = new Set(corpus);
435+
for (const p of expected) expect(inCorpus.has(p), p).toBe(true);
436+
expect(corpus.length).toBeGreaterThan(100_000);
437+
expect(corpus.filter((p) => preEntryAllowlisted(p)).length).toBeGreaterThan(0);
438+
expect(corpus.filter((p) => !isAuthGateAllowlisted(p)).length).toBeGreaterThan(0);
439+
for (const p of ['/approvals/act/requests', '/api/v1/approvals/requests', '/approvals/act/act']) {
440+
expect(inCorpus.has(p), p).toBe(true);
441+
expect(isAuthGateAllowlisted(p), p).toBe(false);
442+
}
443+
});
444+
});
445+
296446
describe('evaluateAuthGate', () => {
297447
it('returns null when the user carries no authGate', () => {
298448
expect(evaluateAuthGate({ id: 'u1' }, '/api/v1/data/x')).toBeNull();

‎packages/core/src/security/auth-gate.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* Some auth policies (password expiry, enforced MFA) must block an
77
* authenticated user from PROTECTED RESOURCES until they remediate, while
88
* still letting them reach the auth endpoints (change-password, two-factor
9-
* enrollment, sign-out) and a few UI-bootstrap reads.
9+
* enrollment, sign-out), a few UI-bootstrap reads, and the ADR-0043 approval
10+
* action page, which no session authenticates at all.
1011
*
1112
* The posture is computed ONCE, in the auth `customSession` enrichment, and
1213
* attached to the session user as `user.authGate = { code, message }`. The
@@ -103,13 +104,32 @@ const SCOPE_SEGMENTS: readonly string[] = ['environments', 'projects'];
103104
* the dispatcher's probes and discovery document; `/me/apps` and
104105
* `/me/localization` are the current-user reads the remediation UI needs
105106
* (`plugin-hono-server/src/current-user-endpoints.ts`).
107+
*
108+
* `/approvals/act` is the ADR-0043 approval action page: `GET` renders the
109+
* confirm page, `POST` redeems. It is session-less by design. The single-use
110+
* token the request carries is its entire authorization, and the decision is
111+
* recorded as the approver that token binds, never as the session. The
112+
* self-hosted mount (`plugin-approvals`, `ACT_PATH = '/api/v1/approvals/act'`
113+
* on the raw host app) has never passed through this gate. This entry gives
114+
* the dispatcher's spelling of the same route the same token-only
115+
* authentication and nothing more (ruling A on #22438). The same holds for
116+
* `shouldDenyAnonymous` (`anonymous-deny.ts`), which reads this allow-list for
117+
* its control-plane exemption when a seam passes it a path: ADR-0043 serves
118+
* this page to a bearer with no session at all.
119+
*
120+
* ⛔ It is the exact route, matched like every entry above: no `approvals`
121+
* prefix, no wildcard, and no other approvals path. `/approvals/act/x`,
122+
* `/approvals/actx` and every `/approvals/requests/…` route stay gated.
123+
* Admitting anything wider than the self-hosted mount's authentication
124+
* loosens a security boundary, and that decision is the maintainer's.
106125
*/
107126
const ALLOW_ROUTES: readonly (readonly string[])[] = [
108127
['health'],
109128
['ready'],
110129
['discovery'],
111130
['me', 'apps'],
112131
['me', 'localization'],
132+
['approvals', 'act'],
113133
];
114134

115135
/** Do `segments` start with every segment of `prefix`? */
@@ -168,7 +188,8 @@ function matchesAllowlistedRoute(rawPath: string): boolean {
168188
}
169189

170190
/**
171-
* True when `path` is exempt from the auth gate (auth + remediation + health).
191+
* True when `path` is exempt from the auth gate (auth + remediation + health,
192+
* and the ADR-0043 approval action page).
172193
*
173194
* ## FAIL-CLOSED on an absent or empty path (#7898)
174195
*

0 commit comments

Comments
 (0)