Skip to content

Commit 4185638

Browse files
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

File tree

‎content/docs/permissions/authentication.mdx‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -593,12 +593,20 @@ often before the account-level lock engages.
593593
|---|---|
594594
| `allowed_ip_ranges` | Restrict authenticated access to one or more IPv4 CIDR ranges. IP is extracted with trust-proxy awareness (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`); requests whose IP can't be determined fail open. |
595595

596-
> **How enforcement works.** Password expiry and enforced MFA share a single
597-
> *session-validation gate*: `customSession` stamps the session with an
598-
> `authGate` posture, and the REST server + runtime dispatcher block gated
599-
> sessions from data access (auth, health, and a small allowlist still pass so
600-
> the user can remediate). This is why a non-compliant user can sign in but
601-
> can't read data until they fix the issue.
596+
> **How enforcement works.** Password expiry (an administrator-forced password
597+
> change included) and enforced MFA share a single *session-validation gate*.
598+
> `customSession` stamps the session user with an `authGate` posture, and every
599+
> transport seam that resolves a caller from the session consults it through
600+
> the one shared decision in `@objectstack/core` (`evaluateAuthGate` and its
601+
> helpers): the REST server, the runtime dispatcher (which also serves the MCP
602+
> HTTP endpoint), and every route family that a plugin or service mounts
603+
> directly on the host server and that resolves its own caller, at the family's
604+
> own door. A gated session gets `403` with the gate's own code
605+
> (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) at whichever of them serves the
606+
> request. The auth endpoints and a short allow-list kept with that decision
607+
> (health and discovery among them) still pass, so the user can remediate. This
608+
> is why a non-compliant user can sign in but can't read or change data until
609+
> they fix the issue.
602610
603611
---
604612

0 commit comments

Comments
 (0)