Skip to content

Commit 2bddb19

Browse files
fix(runtime)!: the analytics dispatcher faces refuse an unauthenticated caller with 401 UNAUTHENTICATED (#21061) (#21098)
Fixes #21061 Clause-②: no (narrowing) This PR carries direction 1 of the emergency triage grade on the card, the door half. Direction 2, the security layer treating "no permission set resolved" as no grant, was split by triage to its own `domain:services` card and is not part of this change. ## What changed `handleAnalyticsRequest` (`packages/runtime/src/domains/analytics.ts`) now opens with the shared anonymous-deny decision, `shouldDenyAnonymous` from `@objectstack/core` (ADR-0056 D2). The call is the handler's first statement. It runs before the analytics service is looked up and before the request body is validated, in the hoisted form `domains/security.ts` and `domains/ai.ts` use. Every analytics dispatcher face (cube read, SQL echo, meta) now answers a caller without a session `401 UNAUTHENTICATED` in the dispatcher-wrapper envelope. A signed-in caller, an API-key caller and an internal system context are unchanged. - ⛔ No second gate at the dispatcher mount: `dispatcher-plugin.ts` is untouched. - ⛔ No zero-set admission rule copied into `service-analytics`: that package is untouched. ## Readings (class level: doors, caller classes, codes and statuses) Stock showcase boot (`pnpm dev -- --fresh`), measured privately on the merge base before the fix and on the branch after it. The signed-in caller is a plain member created on the same boot. | face | caller | before | after | |---|---|---|---| | cube read | unauthenticated | 200 | **401 UNAUTHENTICATED** | | cube read | signed-in member | 200 | 200 | | SQL echo | unauthenticated | 200 | **401 UNAUTHENTICATED** | | SQL echo | signed-in member | 200 | 200 | | meta | unauthenticated | 200 | **401 UNAUTHENTICATED** | | meta | signed-in member | 200 | 200 | | cube read, malformed body | unauthenticated | 400 VALIDATION_FAILED | **401 UNAUTHENTICATED** | | cube read, malformed body | signed-in member | 400 VALIDATION_FAILED | 400 VALIDATION_FAILED | | SQL echo, malformed body | unauthenticated | 400 VALIDATION_FAILED | **401 UNAUTHENTICATED** | | SQL echo, malformed body | signed-in member | 400 VALIDATION_FAILED | 400 VALIDATION_FAILED | | control: record door | unauthenticated / member | 401 / 200 | 401 / 200 | ## Pins - **Runtime unit pin, per face:** `packages/runtime/src/domains/analytics-anonymous-deny.test.ts`. - Anonymous callers get 401: both shapes the dispatcher produces, an unresolved context and the guest envelope. - The service is never consulted: neither the slot lookup nor any service method runs. - A malformed body still gets 401, and an empty slot still gets 401. - Signed-in control: the member is served 200 with their own execution context; a system context passes; an empty slot still answers a member the 404; a malformed member body still reaches the validator's 400. - **Dogfood:** `showcase-anonymous-deny-surfaces.dogfood.test.ts` gains the analytics family in the dispatcher-wrapper family. - Each face is driven anonymously, with a well-formed body and with a malformed one. - A signed-in member is the 200 control, and the member's malformed body is still a 400. - Each anonymous body is classified into exactly one envelope family. The file claims the new matrix row. - No pin title states a request: titles and seam labels name the face. - **Conformance matrix:** a new `enforced` row, `anonymous-deny-analytics`. - It covers a GATE_PIN key on the domain's `shouldDenyAnonymous` call, `analytics:domains/analytics.ts:anonymous-gate`, and the dispatcher-domain key `dispatcher-domain:route-ledger.ts:/analytics`. - That key leaves the shrink-only `authz-ledger-population.baseline.ts`: MAX goes from 33 to 32, with a dated note. - The header figures the row moves are updated: probes 18 to 19, named files 13 to 14, rows 51 to 52, rows with `covers` 7 to 8, covers keys 15 to 17, gate pins 5 to 6, ledger keys 39 with 7 classified and 32 baselined. - **Checklist:** `access-security.anonymous-deny-surfaces` goes to revision 2 with a history entry. It gains the analytics variant, a step that names the three faces and points at the `/analytics` rows of `route-ledger.ts` for the routes, and an acceptance clause for the ordering and the member control. `docs/qa/platform-checklist/README.md` "Change" rule followed: fields edited, `revision` bumped, `history` appended. ## Ablation (reverse verification, from the committed fix) The mutation replaced the gate's condition with a never-true comparison against a planted literal (`ablation-replace.mjs`, anchor hit x1, blob changed). The restore leg was proven on disk: blob equals HEAD and `git diff HEAD` is empty. - **Source-resolved legs (no build):** - The runtime unit pin went **18 failed / 11 passed**. Every anonymous case was red; every signed-in control stayed green. - `authz-conformance.test.ts` went **6 failed / 49 passed**, naming `STALE covers: analytics:domains/analytics.ts:anonymous-gate` and `DEAD PROBE: packages/runtime/src/domains/analytics.ts`. - **dist-resolved leg (dogfood):** - `ablation-dist-preflight` found the marker present in 2 built files after the rebuild. - `showcase-anonymous-deny-surfaces` went **9 failed / 46 passed**: the 5 anonymous analytics cases, the 3 envelope classifications and the shared code-and-message case. All 5 analytics member controls stayed green. - On the restore leg, after a rebuild, the marker was absent from all 6 built files and the tree was clean. - **Direction:** red as predicted, in every leg. ## Verification (HEAD `8364f40c4a`) - **Runtime:** - Full suite: 301 files, 5029 passed, 5 skipped, run at `3905d4f385`. The commits after it change no file under `packages/runtime`. - Typecheck: `tsc --noEmit` plus `check:test-typecheck` over the test layer, exit 0. - **Dogfood:** `showcase-anonymous-deny-surfaces`, `showcase-anonymous-deny` and `authz-conformance` gave 3 files and 114 passed; typecheck exit 0. - **Gates:** `dispatch-gates --commands` derived 93 families at `8364f40c4a`. All 93 were re-run there after the last commit, with each exit code written to disk before reading: 92 exit 0 and 1 exit 1. `--ran` reconciles 93 of 93. - Two were red on an earlier pass and are fixed in `8364f40c4a`: `check-system-context-census`, from the new elevation read site, and `check:doc-authoring`, from a tracker id in the new row's summary string. - The one red predates this branch: `check:platform-checklist`, an absent-symbol anchor in `areas/identity-auth.json`. Control: the same command on `origin/main` `b3d7a70864` exits 1 with the identical single problem. This branch does not touch that file or the file it anchors. - **Lint, a proven narrowing rather than `pnpm lint`:** - Population read from eslint's own config: all 24 changed paths were passed explicitly. eslint ignored the 3 non-TS paths by configuration and linted 21. - Count read from the `--format json` output: 21 linted files, 0 errors, 0 warnings, at `8364f40c4a`. - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), and this diff touches no lint config or rule input. So it cannot move the verdict on any untouched file. - **Scope note:** `turbo ls --affected` was not used for any narrowing claim here. ## File surface beyond the claim, declared - `packages/qa/dogfood/test/authz-conformance.test.ts`: one GATE_PIN probe and one line in the exact-equality list of classified ledger keys. The ruled "enforced row carrying a gate pin" can only be minted there, and that list must equal the population once the key leaves the baseline. - `content/docs/permissions/system-context.mdx`: one anchor on row 51 (the anonymous-deny seams), plus the declared counts regenerated by `check-system-context-census --fix` (108 to 109). The floor reads `ExecutionContext.isSystem`, and that gate is red without the anchor. - Fifteen existing runtime suites drove the analytics route with no identity, as the smallest context that compiled. They now carry a signed-in caller: an `auth` slot answering a session, or the dispatcher's resolution seam stubbed the way the `/packages` fixtures already do. Only identity is stubbed; every expectation is unchanged. - One merge of `origin/main` into the branch, so the gate derivation reads a current tree. ## Acceptance notes Noted only, not filed: - The `anonymous-deny.ts` docblock in `@objectstack/core` names "the five runtime domains" (ai, meta, security, actions, automation). `/packages` and now `/analytics` also hold the floor. The table header in `packages/runtime/src/endpoint-policy.ts` is in the same state. Both are documentation drift; carrier: none. - The `/analytics` rows of `packages/runtime/src/route-ledger.ts` carry no `authz:` declaration. The field is optional and phased, and the classified `/actions` and `/packages` rows carry none either. Carrier: none. - `check:platform-checklist` is red on `main` because of the `identity-auth.json` anchor above. That gate runs on a manual cadence, not per PR. Carrier: none. - The changeset is `minor` with the BREAKING banner, the launch-window convention for a door narrowing. ADR-0087 disposition: `not-required (no-migration-prescription)`. ## Update: head `fa039bb8c5` (the census fix) - **What the head commit fixes:** `Dogfood Regression Gate (3/3)` was red on `8364f40c4a`. The new `GATE_PIN` probe grew the conformance `PROBES` table, which `authz-probe-blind-spot` pins (PR note `5926114686`). - **Two more files beyond the claim's surface:** `packages/qa/dogfood/test/authz-probe-blind-spot.census.ts` and `packages/qa/dogfood/test/authz-probe-blind-spot.test.ts`. - They take the table shape 18 / 13 / 15 → 19 / 14 / 17, the header claim 18 → 19, and a `PROBE_FILE_CENSUS` row for the analytics domain file (gate pin, blind spot 0) with its controls. - No assertion is weakened. They are declared on the card in claim amendment 2 (`5926289415`). - **Verification at `fa039bb8c5`:** - the four `authz` and anonymous-deny dogfood files pass, 149 tests; - the dogfood typecheck exits 0; - CI on this head: 33 success, 2 skipped, 0 red. The "Verification" section above was measured at `8364f40c4a`; the runtime sources are unchanged since. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d98bf24 commit 2bddb19

26 files changed

Lines changed: 657 additions & 92 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the analytics dispatcher faces refuse an unauthenticated caller with `401 UNAUTHENTICATED`, like every other data-serving door (#21061)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export, stored shape or wire shape is removed or renamed, so `objectstack migrate meta` has nothing to rewrite. What moves is which callers the analytics dispatcher faces accept: a caller with no session is now refused before the domain reads anything. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. The three analytics faces the dispatcher mounts under `/api/v1/analytics` (cube read, SQL echo, meta) now answer a caller without a session `401 UNAUTHENTICATED`, in the dispatcher's wrapped envelope (`{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`). They answered that caller as a guest before.
12+
13+
**What changed.** The analytics domain handler opens with the shared anonymous-deny decision (`shouldDenyAnonymous`, ADR-0056 D2), the same floor the `/data`, `/meta`, `/actions`, `/automation` and `/packages` doors stand on, and the one the REST analytics dataset door already applied. The floor is the handler's first statement:
14+
15+
- it runs before the analytics service is looked up, so an unauthenticated caller gets `401` whether or not the analytics capability is installed, never the `404` an empty slot answers;
16+
- it runs before the request body is validated, so a malformed body from an unauthenticated caller is `401`, never the `400 VALIDATION_FAILED` the entry check answers.
17+
18+
**What is not affected.** A signed-in caller, an API-key caller and an internal system context are served exactly as before: the same `200`, the same `400` for a malformed body, the same `404` when no analytics service is installed. Object admission and the row scope behind the service are unchanged by this release.
19+
20+
**If an analytics call now answers `401`,** it was made without a session: send it with the signed-in user's session or bearer token, or with an API key.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **108
13+
because the flag is not one concept: it is a single boolean read at **109
1414
distinct sites across 19 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -138,7 +138,7 @@ that silently does not happen.
138138

139139
### 3. Sharing (`plugin-sharing`)
140140

141-
The largest single consumer — **17 of the 108 sites**.
141+
The largest single consumer — **17 of the 109 sites**.
142142

143143
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
144144
|:--|:---|:---|:---|
@@ -172,7 +172,7 @@ The largest single consumer — **17 of the 108 sites**.
172172
| 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` |
173173
| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict` |
174174
| 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` |
175-
| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
175+
| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
176176
| 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` |
177177
| 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` |
178178
| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
@@ -281,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
281281
independent decisions, and a seed loader plausibly wants the first two but not
282282
the third. The concept is nevertheless **staying as one boolean**:
283283

284-
- **Shipped semantics.** `isSystem` is a published contract with 108 read sites
284+
- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
285285
in 19 packages. Splitting it is a breaking contract change across all of them.
286286
(The ruling was taken when the census read 80 sites in 18 packages; the count
287287
has grown, which strengthens rather than weakens the argument.)
@@ -355,16 +355,16 @@ still holds equal to the census on every pull request:
355355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
356356
| — parsed as a declaration | 22 | ✅ |
357357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
358-
| — parsed as a property **read** | 114 | ✅ |
358+
| — parsed as a property **read** | 115 | ✅ |
359359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
360360
| — the remainder: text inside comments and string literals | 358 | — |
361361
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
362-
| Of those reads: reads of `ExecutionContext.isSystem` | **108** | ✅ |
363-
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
362+
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
363+
| — behaviour-bearing (rows 1–61 above) | 106 | ✅ |
364364
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
365365
| Packages containing at least one elevation read | **19** | ✅ |
366-
| Files containing at least one elevation read | 46 | ✅ |
367-
| — the distinct symbols those reads live in — what this page anchors | 91 | ✅ |
366+
| Files containing at least one elevation read | 47 | ✅ |
367+
| — the distinct symbols those reads live in — what this page anchors | 92 | ✅ |
368368
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
369369

370370
The six rows marked — are a **dated decomposition, not a live claim**: they were
@@ -428,7 +428,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
428428
Renaming a symbol is now a loud red instead of a silent misdirection.
429429

430430
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
431-
cannot say WHICH read inside a function it means, and **8** of the **46**
431+
cannot say WHICH read inside a function it means, and **8** of the **47**
432432
anchored files hold more than one read inside a single symbol. So the population
433433
check runs per file at symbol granularity: every file the census finds a read in
434434
must be anchored, and the set of symbols this page cites into that file must

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,7 @@
838838
"title": "Anonymous requests to every mounted API family answer 401 UNAUTHENTICATED — uniformly, before any resource resolution",
839839
"since": "v15",
840840
"status": "active",
841-
"revision": 1,
841+
"revision": 2,
842842
"priority": "P0",
843843
"surface": "api",
844844
"personas": [
@@ -854,6 +854,7 @@
854854
"steps": [
855855
"boot showcase on the platform-default auth posture; do NOT sign in for the probe half",
856856
"fire unauthenticated requests, one per mounted surface family: GET /api/v1/data/showcase_private_note (data), GET /api/v1/meta (metadata), POST /api/v1/actions/showcase_task/showcase_mark_done/anon-probe-id (dispatcher actions — deliberately a NONEXISTENT record id), GET /api/v1/automation/_status (dispatcher automation — the flow-inventory read; the GET /api/v1/automation flow list is retired, #19543), POST /api/v1/batch {\"operations\": []} (batch), GET /api/v1/security/explain (security-explain)",
857+
"probe the analytics dispatcher family (#21061) at each of its three faces — cube read, SQL echo and meta, their routes being the `/analytics` rows of packages/runtime/src/route-ledger.ts — unauthenticated: the two body-carrying faces once with a well-formed AnalyticsQuery body naming a showcase object cube and once with a body the entry validator refuses, the meta face once; then drive the same calls as a signed-in member as the contrast",
857858
"capture status + full body per surface",
858859
"classify each 401 body into exactly ONE of the two declared envelope families (rest-flat vs dispatcher-wrapper, #5632) — no tolerant cross-family reads",
859860
"sign in as a member and repeat the data + meta reads to prove the gate keys on anonymity, not on the routes",
@@ -875,9 +876,15 @@
875876
{
876877
"clause": "every 401 body classifies into exactly one of the two declared envelope families — a third dialect (hybrid/re-nested body) is a FAIL even though the status is right (#5632)",
877878
"oracle": "api",
878-
"verify": "mutually exclusive family predicates as in showcase-anonymous-deny-surfaces.dogfood.test.ts: rest-flat (top-level error string, no success flag) for /data /meta /batch /security, dispatcher-wrapper for /actions /automation",
879+
"verify": "mutually exclusive family predicates as in showcase-anonymous-deny-surfaces.dogfood.test.ts: rest-flat (top-level error string, no success flag) for /data /meta /batch /security, dispatcher-wrapper for /actions /automation and the three analytics faces",
879880
"evidence": "classified bodies"
880881
},
882+
{
883+
"clause": "the analytics family refuses before it reads anything: each analytics face answers an anonymous caller 401 UNAUTHENTICATED in the dispatcher-wrapper envelope — a malformed body included, never the validator's 400 — while a signed-in member is served 200 on the same face and meets the validator's 400 on the malformed body (#21061: the floor is the analytics handler's first statement)",
884+
"oracle": "api",
885+
"verify": "anonymous: cube read, SQL echo, meta → 401 with code UNAUTHENTICATED, and the malformed body on cube read and SQL echo → 401; signed-in member: the same three → 200, the malformed body → 400",
886+
"evidence": "per-face traces, both callers"
887+
},
881888
{
882889
"clause": "an authenticated member is unaffected: the same data/meta reads answer 200 for a signed-in baseline member",
883890
"oracle": "api",
@@ -906,7 +913,8 @@
906913
"actions (/api/v1/actions — dispatcher-mounted)",
907914
"automation (/api/v1/automation — dispatcher-mounted)",
908915
"batch (/api/v1/batch)",
909-
"security-explain (/api/v1/security/explain)"
916+
"security-explain (/api/v1/security/explain)",
917+
"analytics (/api/v1/analytics — dispatcher-mounted: cube read, SQL echo, meta)"
910918
],
911919
"automated": {
912920
"kind": "dogfood",
@@ -921,14 +929,21 @@
921929
"#5519/#5569/#5570",
922930
"#5632",
923931
"authz-conformance.matrix.ts anonymous-deny rows (covers keys)",
924-
"packages/core/src/security/anonymous-deny.ts"
932+
"packages/core/src/security/anonymous-deny.ts",
933+
"#21061"
925934
],
926935
"history": [
927936
{
928937
"revision": 1,
929938
"date": "2026-08-07",
930939
"change": "new — auth-boundary sweep over every mounted family, grounded in the #2567/#5519 conformance rows and their dogfood pin",
931940
"ref": "claude/platform-test-checklist-ocwugl"
941+
},
942+
{
943+
"revision": 2,
944+
"date": "2026-10-01",
945+
"change": "analytics variant — the three analytics dispatcher faces join the probe set with an anonymous leg (well-formed and malformed body), a signed-in member leg, and the dispatcher-wrapper family; the title's every-mounted-family claim covered a family the steps never drove",
946+
"ref": "#21061"
932947
}
933948
]
934949
},

0 commit comments

Comments
 (0)