Skip to content

test(rest): drive the #13906 fail-closed window against the auth-gate allow-list (#15021 measurement) - #16658

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-15021-auth-gate-allowlist-window
Sep 7, 2026
Merged

test(rest): drive the #13906 fail-closed window against the auth-gate allow-list (#15021 measurement)#16658
os-sales merged 4 commits into
mainfrom
claude/issue-15021-auth-gate-allowlist-window

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Part of #15021 — the measurement half only.

#15021 remains open after this PR merges, deliberately, and no closing keyword names it anywhere in this body. The card's remaining half is a runtime-authorization fork that the 2026-08-28 negative-boundary ruling reserves to the maintainer, so the card has to survive to reach the decision inbox.

No behaviour change. packages/rest/src/rest-server.ts is not edited — that file is held by open PR #16648 (card #16337), and a measurement should not need to rewrite the door it reads. The whole diff is one new test file.

Why this exists

#15021 declares itself NOT MEASURED in its author's own words — "I did not drive this" — and is a reading of where the AuthzStoreUnavailableError throw sits relative to the pathExempt computation in enforceAuth. Triage routed it as work rather than escalating it, and scoped the work to exactly one thing:

Scope is the measurement, and only that: drive an active gate, a failing session re-read, and a request to an allow-listed path, with a positive control on the same fixture.

This is that drive.

Verdict — ⚠️ IT REPRODUCES

Under the #13906 decision-2-B window (isAuthGateActive() answered true and the gate's session re-read then failed), every allow-listed path receives the same 503 as a protected one. enforceAuth is never reached, so pathExempt is never computed for that request, and the refusal is byte-identical on an allow-listed and a protected path.

Nothing is chosen here. The card's options 2 and 3 are a runtime-authorization judgement; this PR states the reading and stops.

What was driven

All legs run the real composition — the public entry to computeExecCtx (resolvePackageRouteExecutionContext), then the real consumer (enforceAuth) on the same request. Constructor seams only; nothing private is replaced.

leg wiring result
§1 CONTROL A — the instrument fires active gate, re-read FAILS, protected path 503 SERVICE_UNAVAILABLE / object: auth_gate, re-read counter proves the fault was reached
§1 CONTROL B — the allow-list is LIVE active gate, re-read healthy, gated user, allow-listed path served: context carries authGate, enforceAuth does not block
§1 CONTROL B′ — the gate is not simply off the same healthy wiring, protected path 403 with the gate's own code
§2 SUBJECT active gate, re-read FAILS, allow-listed path ⚠️ 503 on every allow-listed path shape; nothing written to the wire by the consumer
§2 SUBJECT the two answers compared byte-identical — the refusal carries no path sensitivity at all
§2 corroboration the gate block, read from today's source it contains the throw and mentions neither isAuthGateAllowlisted nor req.path

CONTROL B is what makes the verdict readable: on this exact fixture, on these exact paths, the allow-list does keep a gated user through. Remove only the fault and the door opens; put the fault back and it does not. So the 503 is the window, not an allow-list that never applied.

⭐ Reachability qualifier — please read this before ruling

The card's impact sentence is "a gated user cannot remediate during a session-backend fault." The seam-level finding above is certain. The impact claim needs one more fact, and §3 measures it:

  • §3 — across all three project-scoping configurations, the only allow-listed route patterns this server mounts are the discovery documents (GET /api/v1/discovery, GET /api/v1/environments/:environmentId/discovery).
  • §3 driven — GET /api/v1/discovery answers 200 while the gate re-read is failing: its handler resolves no execution context, so the window cannot reach it.
  • §5 — a pattern census is not the same question as a concrete-path one, because a greedy matcher such as /api/v1/:object/:id would capture /api/v1/auth/sign-in. Measured against the shipped :param / * semantics, over five canonical remediation paths and all three configurations: zero mounted patterns capture any of them. That is now a pinned assertion, not a recorded number.
  • Reading (⛔ stated as a reading, not driven here): those paths are served by raw-app mounts that never enter computeExecCtxplugin-auth mounts rawApp.all('/api/v1/auth/*') (default basePath /api/v1/auth) and plugin-hono-server registers /auth/me/* ahead of it.

⇒ On today's tree the defect is real at the seam and not reachable through any mounted remediation route on this door. That is a materially different thing to rule on than the card's impact sentence implies, in the direction that makes option 1 easier — and it is fragile in the direction that makes it harder. §5 is the tripwire: if it ever goes red, a mounted route now answers a remediation path, the qualifier is gone, and §2 says every such answer is a 503 for the duration of the fault. ⛔ It must not be relaxed to let a new mount pass.

Grading inputs for triage — ⛔ not a re-grade

Triage named the two conditions that would move priority:p3. Both are measured (§4), and both are reported as readings:

  1. Is the window transient? Measured: yes. Nothing latches. One auth service across two requests; with the fault cleared between them the second request is served. The memo is keyed per-request (a WeakMap on the request object), so the refusal does not carry forward.
  2. Can the gate's own re-read fail independently of a broader outage? Measured: the seam issues two session reads per request and refuses on the second alone — the first RESOLVED (identity was established) and the second THREW. ⚠️ That shows the code path admits such a fault because the two reads are separate calls. ⛔ It is not evidence about how often a real session backend fails on exactly one of two consecutive reads.

The fork — for the decision inbox

Stated as the card states them, ⛔ with no recommendation and no ranking:

  1. Leave it. A 503 is transient and honest; the gate could not be read, so nothing about this caller was determined — including whether they are gated at all. Now with §3 and §4 attached: the window lasts exactly as long as the fault, and no mounted remediation route on this door is inside it.
  2. Narrow the refusal to non-exempt paths — consult isAuthGateAllowlisted(req.path) before raising. Cost: computeExecCtx starts branching on the request path for an authorization decision, and the memoized per-request context becomes path-sensitive. ⚠️ The memo is real: resolveExecCtx caches on (req, environmentId) with no path in the key.
  3. Move the refusal into enforceAuth — carry "the gate could not be read" as a distinct context value and let the consumer that already knows about pathExempt decide. Widens ExecutionContext.

Options 2 and 3 both change packages/rest/src/rest-server.ts, which #16648 holds. Whoever takes the ruling should sequence against that PR.

Verification

All of it ran on d084b089da, this branch's final commit.

  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/auth-gate-allowlist-fault-window.measurement.test.ts14 tests, all green.
  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/rest-auth-gate.test.ts src/execctx-authz-input-seam-reachability.test.ts src/rest-exec-ctx-principal-kind.test.ts src/package-door-execctx-fault-reachability.test.ts — the sibling pins on this window: 4 files, 93 tests, all green and unmodified.
  • pnpm --filter @objectstack/rest typecheck.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands and reconciled with --ran: 46 derived, 46 run, 0 UNRUN. 44 exit 0.

⚠️ Two of the 46 exit 3 = PREREQUISITE NOT MET and are recorded as NOT MEASURED, ⛔ never as a pass: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Both read built output across the whole tree, and this worktree built only the @objectstack/rest dependency closure. Declared to CI, which builds the whole tree and is the authority on them. The narrowing is safe to declare rather than close locally for a reason that is checkable: this diff adds one .test.ts, git grep finds no importer of it anywhere in the repo, and tsup's entry is src/index.ts — so nothing it changes can reach any package's dist/.

Every exit code above was captured before any pipe.

skip-changeset: this diff publishes nothing. packages/rest's files is ["dist","README.md","CHANGELOG.md"] and tsup's entry is src/index.ts, so a .test.ts under src/ never reaches dist.

验收备注

Out of scope for this card, filed nowhere, recorded here so ACCEPT can see them:

If a repair lands

§2 pins what the tree does, including the answer the card questions. Options 2 and 3 both change it. When one lands, re-aim the assertion in place and quote the superseded text beside it — the discipline execctx-authz-input-seam-reachability.test.ts already follows for this same window. ⛔ Do not delete the leg.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ


Generated by Claude Code

… allow-list

Measurement only — no behaviour change, and `rest-server.ts` is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…urement

Both are the grading inputs triage named: whether the window latches across
requests, and whether the gate's own re-read can fail while identity
resolution stayed healthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…ation path

The reachability qualifier the fork needs: §2 proves the seam is path-blind,
this proves the refusal reaches no remediation route on this door today. Red
here means that qualifier is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 7, 2026
@github-actions github-actions Bot added the size/l label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 289bb4364220fbeba7e8ed9bcb034652eb25d5fbpackageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants