Repository navigation
Commit 0f05907
Fixes #23287
Clause-②: yes (narrowing) — the published contract widens (a closed
posture vocabulary with a declared scope, the new
`PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth
service contract, new `@objectstack/core` exports) and the API-key door
narrows (a key whose owner holds an account-scope posture is refused
where it was served).
## What changed
This is the code half of ruling C′ (director record `6159450584`, the
maintainer's 「同意」). The ADR-0069 D3 sentence (ruling item 5) is a
separate Tier H PR. The key-lifecycle policy (ruling item 6) is #23367.
Nothing here touches the cloud repository.
**1. Posture codes are a closed vocabulary, and each code declares its
scope (`@objectstack/spec/kernel`, ruling items 1 and 2).**
- `AuthGateCodeSchema` = `PASSWORD_EXPIRED` | `PASSWORD_RESET_REQUIRED`
| `MFA_REQUIRED`. `ExecutionContext.authGate.code` is now typed against
it.
- `AUTH_GATE_SCOPES` declares each code's scope once, beside the codes:
`MFA_REQUIRED` → `account`; `PASSWORD_EXPIRED` and
`PASSWORD_RESET_REQUIRED` → `credential`. The table is typed total over
the enum, so a new code without a scope does not compile.
- `PASSWORD_RESET_REQUIRED` is registered under
`@objectstack/plugin-auth` in the error-code ledger.
**2. One per-user posture computation (`@objectstack/plugin-auth`).**
`AuthManager.resolveUserAuthGates(userId, { organizationId })` answers
every posture the user holds, most urgent first: forced reset, then
expiry, then MFA. It is declared on the contract as
`IAuthService.resolveUserAuthGates`. The session gate
(`computeAuthGate`) takes its first entry, so a session sees what it saw
before. The key seam reads the whole list, so a credential-scope entry
at the head cannot hide an MFA requirement behind it. It is keyed by
user id, which is the shape seat 1's waiter note asks for: #23335's
impersonation leg can call the same oracle.
**3. One judgment at the shared key-admission seam (`@objectstack/core`,
ruling item 3).**
- `resolveApiKeyAdmission` takes an optional `userAuthGates` source. It
asks it for the key's owner, in the key's own organization, AFTER the
tenancy refusal. An account-scope posture yields a new outcome, `held`,
carrying the principal and the posture.
- `resolveAuthzContext` resolves a held key as a held principal: the
owner, `credential: 'api_key'`, and the posture on `authGate`. A held
key does not fall through to the session path. The session arm now
carries `credential: 'session'` and the session's own posture, so a door
reads one field for either credential.
- `userAuthGateSourceOf(authService)` binds the source from an auth
service. A posture computation that rejects is the branded store outage
(`503`), never an admission.
- The refusal is not a revocation (ruling item 4): nothing is written,
and the same key is admitted again once the posture clears.
**4. REST and the dispatcher drop the cookie re-read after a key has
admitted a user.** `RestServer.computeExecCtx` and
`HttpDispatcher.enforceAuthGate` now re-read the session for the gate
only when a session admitted the caller. For a key, they use the key's
answer. The dispatcher learns the admitting credential through a new
`onAdmission` option on `resolveExecutionContext`; the envelope's
`authGate` stays withheld on that face, as before. The declared-endpoint
fallback (`dispatcher-plugin.ts`, gate step ②) does the same.
**5. The five raw-host families, measured on `main` `e4ec6ec1a`.**
| Family | Admits an API key? | Here |
|---|---|---|
| datasource admin (`requireDatasourceAdmin`) | yes, through
`resolveAuthzContext` | the door asks `evaluateAuthGate` about the
resolver's posture, and wires `userAuthGates` |
| share links (`SharingServicePlugin` resolver) | yes | the resolver
wires `userAuthGates`; the posture rides the spread envelope to the door
|
| settings (`SettingsServicePlugin` resolver) | yes | the resolver wires
`userAuthGates` and puts `authz.authGate` on the context |
| storage (`registerStorageFamily` door) | yes, on the parent-governed
download | the door's gate reader asks `resolveApiKeyAdmission` for a
key, and reads the session only when no key was recognised |
| webhook redeliver (`serveRedeliver`) | **no**: the door authenticates
by session alone | no code change; a pin holds that a key alone is `401`
on both faces |
So the claim's assumption that the families needed only test changes
does not hold: four of the five read the posture off the session user,
which a key-admitted request never has. Each now reads the shared
resolver's answer.
**6. The forced reset (ruling item 2).** `sys_user.must_change_password`
now answers `PASSWORD_RESET_REQUIRED` instead of reusing
`PASSWORD_EXPIRED`. `runAdminSetUserPassword` (the `set_user_password`
action) carries `revokeSessionsAndKeys`, on by default. When on, after
the new password is set, it revokes every live session (ADR-0069 D4's
shape: `expires_at` past, `revoked_at`, `revoke_reason:
admin_password_reset`) and every unrevoked API key
(`sys_api_key.revoked`) of the user. The response and the audit row
report what landed: `revoked: { sessions, apiKeys, failed, complete }`.
A refused write is counted and logged, and the reset still succeeds. The
act records no reason, so the switch defaults on, which is the
suspected-compromise default (see Open questions). The action gains the
switch as a param, translated in the four platform-objects bundles.
## Seven faces, each with its pin
Every face pins three facts: a key whose owner holds `MFA_REQUIRED` gets
`403 MFA_REQUIRED` with the session's message; a key whose owner holds
only a credential-scope posture is served; and the same key is served
again once the posture clears.
| Face | Pin |
|---|---|
| core seam |
`packages/core/src/security/api-key-account-posture.test.ts` |
| REST | `packages/rest/src/rest-api-key-account-posture.pin.test.ts`
(also: a held cookie beside the key is never re-read) |
| dispatcher |
`packages/runtime/src/http-dispatcher.api-key-account-posture.pin.test.ts`,
plus three key cases in `endpoint-policy.auth-gate.pin.test.ts` for the
declared-endpoint fallback |
| datasource admin |
`service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts`
(every mounted route) |
| share links |
`plugin-sharing/src/share-link-routes.api-key-account-posture.pin.test.ts`
(booted plugin) |
| settings |
`service-settings/src/settings-routes.api-key-account-posture.pin.test.ts`
(booted plugin, every route) |
| storage |
`service-storage/src/storage-routes.api-key-account-posture.pin.test.ts`
(composed mount, every route) |
| webhook redeliver |
`plugin-webhooks/src/webhook-redeliver-api-key.pin.test.ts`
(non-admission, both faces) |
The forced-reset switch is pinned in `admin-user-endpoints.test.ts`: the
default on position, the off position, a malformed value keeping the
default, an incomplete revocation, and an engine with no read surface.
The posture list is pinned in `auth-manager.test.ts`.
### Negative control: the seam's judgment removed
The `held` return in `resolveApiKeyAdmission` was replaced through
`scripts/ablation-replace.mjs` in WRAP mode, on the committed tree
`2d2dd3fa4`. Anchor 1 → 0, blob `8f6d0a1daa71` → `aaabc628a1e4`. Core
was rebuilt, and `ablation-dist-preflight.mjs --absent` read the marker
absent from all 14 built files. The face pins import core through its
`exports`, so this dist leg was required.
| Face | Ablated |
|---|---|
| core seam | `4 failed / 13 passed` |
| REST | `2 failed / 3 passed` |
| dispatcher + declared endpoint | `3 failed / 21 passed` |
| datasource admin | `12 failed / 12 passed` |
| share links | `2 failed / 1 passed` |
| settings | `2 failed / 1 passed` |
| storage | `11 failed / 20 passed` |
| webhook redeliver | `2 passed`: green by construction, because the
door never asks the seam |
Only the held cases went red. The credential-scope, cookie-beside-key
and session controls stayed green. Restore: the blob after restore
equals HEAD (`8f6d0a1daa71`) and `git diff HEAD` is empty. Core was then
rebuilt, and the preflight read the marker present in `dist/index.js`
and `dist/index.cjs` with a clean tree.
## Verification (head `56004af60`)
Tests and type checks, per package, all passing:
- Full suites on the pre-merge tree, every touched package:
- spec 673 files (1 stale-artifact case, re-run green after
regeneration);
- core 98 / 2439; plugin-auth 140 / 2815; rest 282 / 5850; runtime 374 /
5460;
- plugin-sharing 45 / 1058; plugin-webhooks 19 / 201; service-datasource
45 / 838;
- service-settings 46 / 826; service-storage 56 / 987; platform-objects
70 / 1091.
- After `origin/main` (`b5b1bf727`) was merged in, rebuilt and re-run:
runtime 374 / 5463; spec 672 of 673 files. The one red case was the
ADR-0122 isomorphic-pin count, raised 785 → 787 for the two new enums
(`93db5c5f0`) and re-run green.
- `pnpm --filter … run typecheck` exits 0 for all eleven packages,
including their `check:test-typecheck` ledgers.
- ESLint, narrowed, on `56004af60`: `eslint --no-inline-config --format
json` over the 45 changed `.ts` files reports 45 files linted, 0 errors,
0 warnings, none ignored. `eslint.config.mjs` never enables type-aware
linting, so this diff cannot move a verdict on an untouched file.
**Gates, on head `56004af60`.**
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 125 commands from
that tree.
- All 125 ran, each recording its exit code before any pipe, and **all
125 exited 0**.
- `--ran` reconcile: `125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN`.
- Earlier passes caught and fixed these on the way:
- the tenant-audit census and its prose figures (two new write sites,
regenerated, and again after the merge);
- the posture seam's fake engine: read-only, `limit`-bound, refusing an
unimplemented combinator;
- ADR-0122 bare-alias naming for the two enums, with their isomorphic
pins (785 → 787);
- a typed `auth` slot lookup in the settings plugin;
- six spec gates that refused (exit 3) on a stale dist until spec was
rebuilt.
- A full workspace build (75 tasks) preceded the final pass, so
`check:dual-build-cjs-loads`, `check:i18n` and `check:skill-examples`
measured rather than refused. The i18n bundles, which carry the new
action param, are in sync with the generator.
- `pnpm --filter @objectstack/spec check:generated`: every artifact up
to date. The regenerated `skills/**` index lines are forced by
`check:skill-refs`, which measured stale on this diff before
regeneration.
- **Size:** 61 files, +2678 / −204 = **2882** changed lines against
`origin/main`, under the 3,000-line threshold.
## Acceptance notes
- **The Console does not recognise `PASSWORD_RESET_REQUIRED` yet.**
objectui's `detectAuthGate` (`packages/auth/src/auth-gate-events.ts` at
`origin/main` `b434f5f4`, and at the pinned `.objectui-sha`) raises the
remediation overlay for `MFA_REQUIRED` and `PASSWORD_EXPIRED` only, and
`RemediationOverlay` routes `PASSWORD_EXPIRED` to the change-password
form. Once this lands, a force-reset user gets `403
PASSWORD_RESET_REQUIRED` and no overlay. See Open questions; this PR
should not land ahead of the objectui change.
- **Faces outside the seven that admit keys and do not ask the owner
yet:** the marketplace install-local doors in
`@objectstack/cloud-connection`, which read the gate off the session
user only, and the MCP stdio face (`resolveStdioExecutionContext`),
which carries no gate at all. Both are reported for the seat to route.
The cloud-connection binding doors, the platform-admin judge and the
webhook redeliver door authenticate by session alone.
- **Declared endpoints:** the fallback's `authRequired` floor keys on
the session principal (`resolveSessionPrincipalId`), so a key alone
meets `401` on a default endpoint whatever its owner's standing. The
posture bites on `authRequired: false` endpoints, which run as the key's
principal. Pre-existing, not changed here.
- **Storage corner:** the storage door asks the admission seam, not the
full resolver, so a key whose membership ended AND whose owner is held
answers `403 MFA_REQUIRED` there, while REST answers the membership
refusal's `401`. Both refuse.
- **MFA grace clock:** the posture list evaluates every policy, so a
user under a forced reset has their MFA grace clock started on first
sight instead of after the reset clears.
- **Session provenance:** a `credential`-scope posture still holds every
session, an SSO-opened one included, as before. The ruling's scope
decides what holds a key; it does not change the session gate.
- **Stale two-code comments** at the doors (`share-link-routes.ts`,
`settings-routes.ts`, `storage-routes.ts`, the two REST sub-route
families, `platform-admin-gate.ts`) now name the closed vocabulary.
- **`skills/**`:** the only paths are `gen:skill-refs` outputs
(`skills/objectstack-{api,data,ui}/references/_index.md`, one line
each), generator-owned under the #11705 carve-out. Line readings: each
file 46 → 46, 72 → 72 and 66 → 66; all `SKILL.md` together 4408 → 4408.
## Open questions
1. **Landing order with objectui (the Console) — a sequencing decision
for the seat.** objectui recognises only `MFA_REQUIRED` and
`PASSWORD_EXPIRED` as remediation codes, so once this lands a
force-reset user gets `403 PASSWORD_RESET_REQUIRED` and no
change-password overlay.
- A. objectui first: one small objectui change makes `detectAuthGate`
accept `PASSWORD_RESET_REQUIRED` and routes it to the change-password
form. It is harmless before the backend emits the code. This PR waits on
it and on the `.objectui-sha` bump.
- B. One landing: this PR carries the pin bump and manifest
regeneration, the way a sibling-breaking removal ships.
- C. Land this first and accept a window where force-reset users are
stuck on raw 403s.
- Recommendation: **A.** It is backward-compatible, keeps each repo's
diff in its own repo, and leaves no window. C strands exactly the users
an administrator just reset.
2. **The forced reset records no reason, so "on by default when the
reason is a suspected compromise" has no reason to read.** I measured
the act (`runAdminSetUserPassword`, the `set_user_password` action): its
body is `userId`, `newPassword` / `generatePassword` and
`mustChangePassword`.
- A. What is built: the switch is ON for every forced reset, which
treats every forced reset as a possible compromise. Turning it off is
the administrator's explicit act.
- B. Add a `reason` field (`suspected_compromise` | `routine`) whose
value sets the switch's default. That is a new API and action field
nobody pulls today.
- Recommendation: **A**, the narrowest honest default. B adds a surface
whose only reader would be the default it replaces.
3. **The Clause-② grammar.** The closed form takes both facts:
`Clause-②: yes (narrowing)` reads as "widens one surface and narrows
another" (`scripts/pm/clause2-line.mjs`: value `yes`, arm `narrowing`).
Nothing is picked silently; noted here only because the dispatch asked.
## 维护者速读(草稿)
- **改了什么:** API key 现在和它主人的登录会话同答:主人被「强制两步验证」扣住时,key 在所有接口上返回同样的 403
MFA_REQUIRED;主人只是密码过期或被管理员强制改密时,key 照常工作。管理员强制改密改用自己的码
PASSWORD_RESET_REQUIRED,并带一个「退出所有会话并吊销 API key」的开关,默认开。
- **为什么改:** 裁决 C′:扣账号的姿态扣住账号的每种凭证,扣密码的姿态只扣密码登录;同一个请求在不同接口上要同一个答案。
- **风险与代价(含回滚):** 开了强制两步验证的部署里,没绑定的员工手里的 key
在宽限期满后会被拒,直到本人绑定(不需要重新签发)。管理员重置密码默认会让该用户在所有设备上退出、key 被吊销。Console
尚不认识新码,需先合 objectui 的配套改动。回滚:回退本 PR 即恢复旧行为;已吊销的 key 需在 sys_api_key
上逐个恢复。
- **席位意见:**
- **你要做的:** 确认 objectui 配套改动的先后顺序(见 Open questions);ADR-0069 D3 那句另走
Tier H PR。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73c7dc4 commit 0f05907
61 files changed
Lines changed: 2678 additions & 204 deletions
File tree
- .changeset
- content/docs
- permissions
- references
- api
- data
- kernel
- docs/audits
- packages
- core/src/security
- platform-objects/src
- apps/translations
- identity
- plugins
- plugin-auth/src
- plugin-sharing/src
- plugin-webhooks/src
- rest/src
- runtime/src
- security
- services
- service-datasource/src
- __tests__
- service-settings/src
- service-storage/src
- spec
- api-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
- contracts
- kernel
- skills
- objectstack-api/references
- objectstack-data/references
- objectstack-ui/references
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
602 | 602 | | |
603 | 603 | | |
604 | 604 | | |
605 | | - | |
606 | | - | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
607 | 616 | | |
608 | 617 | | |
609 | 618 | | |
| |||
961 | 970 | | |
962 | 971 | | |
963 | 972 | | |
964 | | - | |
965 | | - | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
966 | 977 | | |
967 | 978 | | |
968 | 979 | | |
| |||
971 | 982 | | |
972 | 983 | | |
973 | 984 | | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
974 | 992 | | |
975 | 993 | | |
976 | 994 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
191 | 191 | | |
192 | | - | |
193 | | - | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
| 226 | + | |
227 | 227 | | |
228 | | - | |
| 228 | + | |
229 | 229 | | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
| 236 | + | |
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | | - | |
| 250 | + | |
251 | 251 | | |
252 | 252 | | |
253 | 253 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
| 304 | + | |
| 305 | + | |
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
269 | 269 | | |
270 | 270 | | |
271 | 271 | | |
| 272 | + | |
272 | 273 | | |
273 | 274 | | |
274 | 275 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
436 | 436 | | |
437 | 437 | | |
438 | 438 | | |
| 439 | + | |
439 | 440 | | |
440 | 441 | | |
441 | 442 | | |
| |||
0 commit comments