Repository navigation
Commit 4185638
docs(permissions): the "How enforcement works" callout names every transport seam that consults the ADR-0069 gate (#23333)
Fixes #23288
Clause-②: no (docs)
## What changed
One callout on one page: the "How enforcement works" box in
`content/docs/permissions/authentication.mdx` (Enterprise Authentication
Hardening, after the Network table). It said that only "the REST server
+ runtime dispatcher block gated sessions". That stopped being true when
PR #23200 (`08b38cb9d8`), PR #23242 (`d9d7eddd01`) and PR #23285
(`bae5841bce`) landed. The new text says:
- every transport seam that resolves a caller from the session consults
the gate, through the one shared decision in `@objectstack/core`
(`evaluateAuthGate` and its helpers);
- those seams are the REST server, the runtime dispatcher (which also
serves the MCP HTTP endpoint), and every route family that a plugin or
service mounts directly on the host server and that resolves its own
caller, at the family's own door;
- a gated session gets `403` with the gate's own code
(`PASSWORD_EXPIRED` or `MFA_REQUIRED`) at whichever of them serves the
request;
- the auth endpoints and a short allow-list kept with that decision
still pass, so the user can remediate.
The page names functions and seams only. It lists no route and no
family, as the card's acceptance asks. No other page is touched.
### The disclosure rule this follows
The card asks for the text "at function level as the lane's disclosure
rule asks". The card family it comes from (#23178, #23190) cites the
`domain:services` lane rule. Verbatim, from
`.claude/skills/pm-dispatch/references/lanes/services.md` (lines 19 and
20 at `4d4040ccb6`):
> - 安全族卡的披露纪律:复现配方不落任何公开面(卡、PR、评论)。
> - 证据以抽象描述或私有通道承载。
In English: on a security-family card, a reproduction recipe never goes
on a public surface (card, PR or comment), and evidence is carried by an
abstract description or a private channel. The record that sets the same
level for this gate's door classes is ADR-0138
(`docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md`,
its Card paragraph): "Classes, positions and functions only: door-level
readings that would work as an exploit recipe stay private". So the page
names functions, the call-site list below is at file:line level only,
and nothing here is a request.
## Measured first (the PM's mechanism assumptions)
All readings are on `origin/main` at `4d4040ccb6`, the base of this
branch.
### 1. Where the gate is consulted (file:line; this list goes in the PR
and the report, never on the page)
| Seam | Consults the gate at | Refuses with |
|---|---|---|
| REST server | `packages/rest/src/rest-server.ts:2343` `enforceAuth`
reads `context.authGate` (`:2348`), and `computeExecCtx` lifts it with
`normalizeAuthGate` (`:3272`) | `403 { error: { code, message } }`
(`:2369`) |
| Runtime dispatcher | `packages/runtime/src/http-dispatcher.ts:1362`
`enforceAuthGate` calls `evaluateAuthGate` (`:1396`), and `dispatch()`
runs it before any domain (`:2746`) | `403` with the gate's code |
| MCP over HTTP | served through `dispatcher.dispatch`
(`packages/runtime/src/dispatcher-plugin.ts:1272`), so behind the
dispatcher's gate above | as above |
| Declared-endpoint fallback |
`packages/runtime/src/endpoint-policy.ts:421` `evaluateAuthGate` (PR
#23242) | `authGateDenial` (`:334`), `403` |
| REST direct mounts | `packages/rest/src/package-routes.ts:83`
(`refusePackageRequest`) and
`packages/rest/src/external-datasource-routes.ts:333`
(`refuseFederationRequest`), PR #23242 | `403` with the gate's code |
| Cloud connection |
`packages/cloud-connection/src/cloud-connection-plugin.ts:234`
(`sessionFromAuthService`), refused at seven doors (`:332` through
`:688`);
`packages/cloud-connection/src/marketplace-install-local-plugin.ts:3378`
(`resolveInstallPrincipal`), refused at `:566`. PR #23242 | `403` with
the gate's code |
| Datasource admin |
`packages/services/service-datasource/src/admin-routes.ts:533`
(`requireDatasourceAdmin`), PR #23200 | `403` with the gate's code |
| Share links |
`packages/plugins/plugin-sharing/src/share-link-routes.ts:123` and
`:282` (the family door); posture lifted at
`packages/plugins/plugin-sharing/src/sharing-plugin.ts:970`. PR #23200 |
`403` with the gate's code |
| Webhook redeliver |
`packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:543`
(`serveRedeliver`), PR #23200 | `403` with the gate's code |
| Storage |
`packages/services/service-storage/src/storage-routes.ts:687` and `:695`
(the family door), PR #23200 | `403` with the gate's code |
| Settings |
`packages/services/service-settings/src/settings-routes.ts:176` and
`:223` (the family door); posture lifted at
`packages/services/service-settings/src/settings-service-plugin.ts:334`.
PR #23285 | `403` with the gate's code |
Each landing's pins were re-run on this tree, and all pass:
| Package | Pin files | Result |
|---|---|---|
| `@objectstack/rest` | `rest-auth-gate.test.ts`,
`direct-mount-auth-gate.pin.test.ts` | 2 files, 16 tests passed |
| `@objectstack/runtime` | `endpoint-policy.auth-gate.pin.test.ts`,
`http-dispatcher.root-auth-gate.test.ts` | 2 files, 25 tests passed |
| `@objectstack/cloud-connection` |
`cloud-connection-auth-gate.pin.test.ts` | 1 file, 12 tests passed |
| `@objectstack/service-datasource` |
`admin-routes-auth-gate.pin.test.ts` | 1 file, 45 tests passed |
| `@objectstack/plugin-sharing` |
`share-link-routes.auth-gate.pin.test.ts` | 1 file, 18 tests passed |
| `@objectstack/plugin-webhooks` |
`webhook-redeliver-auth-gate.pin.test.ts` | 1 file, 8 tests passed |
| `@objectstack/service-storage` |
`storage-routes.auth-gate.pin.test.ts` | 1 file, 42 tests passed |
| `@objectstack/service-settings` |
`settings-routes.auth-gate.pin.test.ts` | 1 file, 22 tests passed |
Each ran as `pnpm --filter PKG exec vitest run --maxWorkers=2 FILE…`
under `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`).
**Where the card's list and the measurement differ.**
- **Three entries reference the field without consulting the gate.**
- The MCP plugin: its stdio face resolves an API key, not a session, and
passes `authGate: undefined` on the record
(`packages/mcp/src/plugin.ts:206`). The MCP HTTP face is gated by the
dispatcher, as in the table.
- `plugin-hono-server`'s current-user endpoints: they withhold
`authGate` on the record
(`packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:564`).
Their routes are the allow-listed bootstrap reads.
- `plugin-auth`'s admin endpoints: their judge, `gateAdmin`
(`packages/plugins/plugin-auth/src/auth-plugin.ts:2706`), reads no
posture. They sit under the auth namespace, which the allow-list's auth
arm exempts as a whole (`packages/core/src/security/auth-gate.ts:296`).
- So none of the three is a seam that refuses a held session. The page
wording covers them: the first is not a session seam, and the other two
are the auth endpoints and the allow-list that "still pass".
- **The card misses one group.** PR #23242 (`d9d7eddd01`, closing
#23190) gated the declared-endpoint fallback, REST's two direct mounts
and cloud-connection's session doors. They are in the table.
- **Doors that resolve no session are not gated, by design.** These are
the public marketplace catalog proxy, cloud-connection's status read,
the token-only approval action page, the HMAC-signed inbound hook and
the runtime-config bootstrap reads. They serve a held session what they
serve an anonymous caller. The page's "every transport seam that
resolves a caller from the session" excludes them, so the sentence stays
true. The i18n plugin's own mount is shadowed by the dispatcher's, which
is gated (PR #23200's measurement).
### 2. The callout's other sentences, checked against the source
- **"Password expiry and enforced MFA share a single session-validation
gate."** True. `AuthManager.computeAuthGate`
(`packages/plugins/plugin-auth/src/auth-manager.ts:8061`) returns
`PASSWORD_EXPIRED` for an expired password and for an
administrator-forced change (`must_change_password`, `:8101`), and
`MFA_REQUIRED` once the grace window has elapsed (`:8135`). The page now
names the forced change.
- **"`customSession` stamps the session with an `authGate` posture."**
True, with one correction: the posture goes on the session **user**. The
return is `user: { ...user, …, authGate }` (`auth-manager.ts:4119`). The
page now says "the session user".
- **"auth, health, and a small allowlist still pass so the user can
remediate."** True at the two model seams. `isAuthGateAllowlisted`
exempts the auth namespace plus `ALLOW_ROUTES` (health, ready,
discovery, the two current-user reads, the approval action page) and the
POST-only inbound hook row (`auth-gate.ts:126` and following). The
raw-host doors pass an empty path, so they grant no exemption at all.
The page keeps "a short allow-list" and gives "health and discovery
among them" as examples, not as a full list.
- **"can't read data"** is now "can't read or change data". The gate
refuses writes as well (the pins above drive `PUT` and `POST` doors).
### 3. Left out on purpose
- **API keys.** #23287 (a key beside a held cookie) is still in the
maintainer's decision box, so the sentence says nothing about keys, per
triage `6149872257`. "Resolves a caller from the session" is true under
either ruling.
- **The outage window.** The dispatcher fails open when an active gate
cannot read the session. That is a separate open card, #23245, which is
not addressed here. The sentence makes no claim about that window.
## Verification (tree `14f73ed586`)
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 43 commands from the one
changed path. All 43 ran, each with its exit code recorded before any
pipe. Reconcile: `--ran` printed `43 derived, 43 run, 0 NOT-MEASURED, 0
UNRUN`, exit 0. Every command exits 0. The named ones:
- `check:doc-anchors`: "499 internal #fragment link(s) across 420 source
file(s) all resolve to a real heading".
- `check:doc-authoring`: "53 published skill files clean — no internal
issue-id references".
- `check:corpus-claim-drift`: "OK, no new claim sites beside a pinned
spelling".
- `check:nul-bytes`: "OK (scanned 11270 text file(s) … no raw ASCII
control bytes)".
- `@objectstack/spec check:docs`: "226 generated files in sync with
packages/spec".
- `@objectstack/lint check:doc-security-posture`: "28
ObjectSchema.create example(s) … carry an os validate-clean security
posture".
- `@objectstack/spec check:skill-examples` first answered PREREQUISITE
NOT MET (exit 3: `client-react` was not built). That is not a
measurement. After `pnpm --filter '@objectstack/client-react...'
--filter '@objectstack/client...' build` it exits 0: "263 prose examples
type-check across 3 surface(s)".
- **Link check, with the `check-links.yml` argv.** lychee `v0.24.2` (the
workflow's pin) with `--offline --root-dir content --fallback-extensions
mdx,md --config lychee.toml 'content/**/*.md' 'content/**/*.mdx'
README.md ARCHITECTURE.md` reports "2460 Total … 2162 OK 🚫 0 Errors 👻
298 Excluded", exit 0. The diff adds no link.
- **MDX compile.** `@mdx-js/mdx` as `fumadocs-mdx` 15.2.3 resolves it,
with `remark-gfm`. Both the base and the edited page compile (exit 0).
Negative control: an unclosed expression fails the same harness (exit
1).
- **What CI owns.** The CI-only rows `dispatch-gates` names (the `Build
Docs` job and the type-check lanes) are not run locally. A docs-only
diff moves no TypeScript.
## Changeset: none (`skip-changeset`)
The diff touches only `content/docs/**`. Measured: across the 71
non-private workspace packages, 4134 files under their `files[]`, plus
each `package.json` and `README.md`, were scanned. No `files[]` entry
points outside its package directory.
- "How enforcement works": 0 hits.
- The new phrase "resolves a caller from the session": 0 hits.
- Positive control, `ExecutionContextSchema`: 48 hits, among them
`packages/lint/dist/index.cjs`.
No package build reads `content/docs` into its output; `git grep` over
package manifests, tsup configs and package scripts finds only
`@objectstack/lint`'s doc checkers. The only packages built here are the
closures above, and the scan says so.
## Acceptance notes
- **A finding outside this card, raised in the report for the seat.**
Held sessions reach `plugin-auth`'s platform-admin mounts. Those mounts
sit under the auth namespace that `isAuthGateAllowlisted` exempts as a
whole, and their judge (`gateAdmin` → `judgePlatformAdmin`) reads no
posture. ADR-0069 D3 holds a session "out of all protected resources
until enrollment + verification". The gate module's header lists the
exemptions it means as "the auth endpoints (change-password, two-factor
enrollment, sign-out)". Whether user administration under that namespace
belongs inside that exemption is a security-boundary question. This was
read from source at function level only. It is not measured, and nothing
about it changes here.
- The card's "what holds" list differs from the measurement in three
entries and misses one group (section 1). The page is written to the
measurement.
---
_Generated by [Claude
Code](https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1623faf commit 4185638
1 file changed
Lines changed: 14 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
593 | 593 | | |
594 | 594 | | |
595 | 595 | | |
596 | | - | |
597 | | - | |
598 | | - | |
599 | | - | |
600 | | - | |
601 | | - | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
602 | 610 | | |
603 | 611 | | |
604 | 612 | | |
| |||
0 commit comments