Repository navigation
Commit daafae6
Fixes #23455
Fixes #23456
Clause-②: no
## What changed
This PR covers the family card #23455 (one card for the family) and
#23456. It closes the seams of ruling C′ on #23287 (`6159450584`,
maintainer 「同意」) that PR #23440 did not reach. It then makes the
owner-posture input required, so the compiler names any seam that
forgets it.
The ruling, quoted:
- Item 1: "a held user's API key is refused at every seam with the same
code".
- Item 4: "The refusal is not a revocation: the key resumes the moment
the user enrols (or the account posture clears)".
`AUTH_GATE_SCOPES` decides which posture holds a key. `MFA_REQUIRED` has
scope `account` and holds the key. `PASSWORD_EXPIRED` and
`PASSWORD_RESET_REQUIRED` have scope `credential` and do not.
### 1. The marketplace install-local resolver
(`@objectstack/cloud-connection`, member 1)
`resolveInstallPrincipal` does two new things:
- It hands the shared resolver `userAuthGates:
userAuthGateSourceOf(authService)`. It reaches the auth service through
`ctx.getService('auth')`, read per request, the way its sibling seams
do.
- It asks `evaluateAuthGate` about the resolver's own posture
(`authz.authGate`), instead of the session user it used to capture.
So all five doors refuse a held owner's key through the existing
`refuseHeldSession`: `403`, the posture's `code` and the session's
`message`, before any effect. A held session is refused exactly as
before.
### 2. The MCP stdio face, asked on every call (`@objectstack/mcp`,
member 2, #23456)
Measured on `origin/main`:
- The stdio face already re-resolves its identity per principal-bound
call. `resolvePrincipal` calls `resolveStdioExecutionContext` from every
data-bridge verb and from the record resource (ADR-0101 D1, the same
schedule as revocation and #15348's tenancy posture).
- So a per-call refusal is structurally possible, and that is what is
built. A start-only refusal is not used.
What changed:
- `resolveStdioExecutionContext` takes a REQUIRED `userAuthGates`
parameter. `resolveStdioUserAuthGates(ctx)` reads it per call, beside
`resolveStdioTenancyPosture`. The function now assembles `authGate:
authz.authGate` instead of `authGate: undefined`.
- `resolvePrincipal` asks `evaluateAuthGate` on the context and refuses
a held owner's call before any read or write. The refusal is a thrown
envelope: `code` `MFA_REQUIRED`, `status` 403, and the session's
remediation `message`. That is what REST's `enforceAuth` and the
dispatcher answer, in the shape this transport's other refusals take
(`recordNotFoundError`).
- A held key still STARTS the transport, and one `warn` line names the
hold and the remedy. The key is valid and nothing revokes it. A refusal
to start would outlast the remediation until someone restarted the
process, which item 4 rules out.
- So a posture that appears mid-session refuses the next call, and one
that clears serves the next call, with no restart.
### 3. `resolveApiKeyPrincipal` (`@objectstack/core`, member 3): wired,
not retired, on an unmeasured premise
The ruling's premise for retirement is that no non-test caller exists
here or in `objectstack-ai/objectui` or `objectstack-ai/cloud`.
| Repo | Reading |
|:--|:--|
| this repo, at `fc4aff4a17` | `git grep resolveApiKeyPrincipal` finds
the definition, its tests and three re-exports (core's
`security/index.ts`, runtime's `security/api-key.ts` and
`security/index.ts`), plus prose. No non-test call. |
| objectui `main` at `52d61943c0` | Fetched into a private ref and `git
grep`ped there: zero hits for `resolveApiKeyPrincipal`. Control: the
same grep for `resolveAuthzContext` returns 8 lines (comments and docs),
so the grep reads that tree. |
| cloud | **NOT MEASURED.** This session has no read access: `add_repo`
answered "you don't have access", and a REST read answered 403. No
published cloud package exists on npm to read instead. `/search/*` was
not used. |
Since a live cloud caller could not be ruled out, the ruling's
prohibition branch was taken: no retirement, and no silent fail-open.
- `resolveApiKeyPrincipal` takes a REQUIRED `userAuthGates`.
- A `held` verdict collapses to no principal (fail closed).
- Its docblock states the cost: a principal-or-nothing answer cannot
carry the posture's code, so a request-answering host should read
`resolveApiKeyAdmission` and refuse `held` with `gate.code`.
Open question 1 asks the seat to retire it once cloud is read.
### 4. The class fix: the owner-posture input is REQUIRED
(`@objectstack/core`)
This is PR #23440's seat record item 6:
- `ResolveAuthzInput.userAuthGates` changes from `?: UserAuthGateSource`
to `: UserAuthGateSource | undefined`.
- On `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`,
`userAuthGates` is now required.
- `tenancyPosture` is required with it, because a required parameter
cannot follow an optional one.
`undefined` is a value a caller passes on purpose. The docblocks say
where it is legitimate: a kernel with no `auth` service. They also say
where it is not: a seam that could reach one.
**Premise measured: it holds.** Every in-tree production caller names
its source without new plumbing.
| Caller | Source |
|:--|:--|
| REST `computeExecCtx`, runtime `resolveExecutionContext`, sharing
resolver, datasource admin, settings resolver, storage door gate reader
| `userAuthGateSourceOf(…)` already (PR #23440) |
| cloud-connection `resolveInstallPrincipal` | new,
`ctx.getService('auth')` |
| MCP `resolveStdioExecutionContext` (start probe and per call) | new,
`ctx.getService('auth')` |
| storage `buildFileReadAuthorizer` | new, `registry.getService('auth')`
through a shared `resolveGateAuthService` |
| core `resolveAuthzContext` and `resolveApiKeyPrincipal` | pass their
own required input through |
The compiler's enumeration matches the `git grep` census: after the
change, every package holding a call site type-checks (see
Verification).
**Storage, measured.** The authorizer's call is an explicit
pass-through, not a second gate:
- Every storage route is mounted through `mountFamilyRoute`. Its door
asks `buildAuthGateSessionReader`, which already refuses a held owner's
key on the same request.
- `composeStorageRoutes` binds the reader and the authorizer from the
same `auth` service.
- So the authorizer now resolves a held key truthfully, as a held
principal, and does not judge it again.
**Test and kit callers.**
- 141 object literals in tests and the sharing testkit gained an
explicit `userAuthGates: undefined`, and 20 positional test calls gained
explicit `undefined` arguments. They present no key, or test something
other than the owner's posture, so their subjects are unchanged.
- The dogfood helper `resolveAuthzFor` (`armed.ts`) documents itself as
resolving "the SAME way the REST entry point does", so it gets the real
source from the stack's `auth` service.
## Pins (three facts per seam, PR #23440's shape)
| Seam | Pin | Held (`MFA_REQUIRED`) | Credential scope only | Posture
clears |
|:--|:--|:--|:--|:--|
| install-local, all five doors |
`cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts`
| `403`, `{ code: 'MFA_REQUIRED', message }` on every door, zero
effects, ledger untouched | served (install and list) | `403`, then
served; key row unchanged |
| MCP stdio, per call |
`mcp/src/stdio-api-key-account-posture.pin.test.ts` (real kernel, real
core chain) | start succeeds with one warning; query, get, create and
the record resource reject `{ code: 'MFA_REQUIRED', status: 403, message
}`; nothing written; a real `StdioServerTransport` `tools/call` answers
`isError` with the remediation message | served | same live session: a
posture that appears refuses the next call, one that clears serves the
next call |
| core | `core/src/security/api-key-account-posture.test.ts` |
`resolveApiKeyPrincipal` answers no principal | answers the owner |
answers the owner again |
The compile-level pin is in the same core file. Four `@ts-expect-error`
lines cover each omitted input: the `ResolveAuthzInput` key, the
admission with two and with three positional arguments, and the
principal with four. Explicit `undefined` controls sit beside them. They
live in a function that is never invoked and are read by
`check:test-typecheck`'s tsc program, where an unused directive is
TS2578.
### Negative control (reverse verification), on the committed head
`a15aecc0aa`
Each leg went through `scripts/ablation-replace.mjs`. The anchor hit
exactly once, and the blob changed. A restore trap covered all four
paths. After each leg the blob equals HEAD and `git diff HEAD` is empty.
The cloud-connection and MCP subjects are imported from `src`, and the
compile pins read `src`, so no dist leg applies.
| Leg | Mutation | Result |
|:--|:--|:--|
| A | install-local resolver passes `userAuthGates: undefined` | `2
failed / 1 passed`: the held and clears cases are red, the
credential-scope control is green |
| B | stdio per-call refusal disabled (the `if (gate)` guard ANDed with
a never-true clock comparison) | `2 failed / 1 passed`, same split |
| C1 | `ResolveAuthzInput.userAuthGates?` optional again |
`check:test-typecheck` red: one unledgered type error in
`api-key-account-posture.test.ts` (the unused directive) |
| C2 | `resolveApiKeyAdmission`'s `userAuthGates?` optional again |
`check:test-typecheck` red, same file |
## Verification
Every reading below is on head `fc4aff4a17`. That is the branch after
`origin/main` `394f65e891` was merged in, with `pnpm install
--frozen-lockfile` and the closure rebuilt.
- **Full suites** (each package's own `test` script, `--maxWorkers=2`),
all exit 0:
- core 98 files / 2441 tests;
- cloud-connection 46 / 555;
- mcp 37 / 420;
- service-storage 56 / 987;
- plugin-sharing 45 / 1058;
- rest 283 / 5852 passed, 326 skipped.
- **Touched test files in the other caller packages**, all exit 0:
- plugin-security 2 files / 120 tests;
- plugin-auth 1 / 17;
- cli integration layer
(`security-catalog-echo-env-kernel.integration.test.ts`, the one cli
file in the diff) 1 / 9;
- dogfood 15 / 163: the 12 touched dogfood tests, plus the three suites
that call the edited `resolveAuthzFor` helper.
- **Typecheck** (`pnpm --filter … run typecheck`, including each
package's `check:test-typecheck` ledger) exits 0 for all 13 packages
that hold a call site: core, mcp, cloud-connection, service-storage,
rest, runtime, plugin-sharing, plugin-security, plugin-auth,
service-datasource, service-settings, dogfood and cli.
- **ESLint, narrowed:** `eslint --no-inline-config` over the 37 changed
`.ts` files reports 0 errors and 0 warnings.
- **Gates:**
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 76 commands from `fc4aff4a17`. All
76 ran, each recording its exit code before any pipe.
- `--ran`: `76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN`.
- One gate needed a second run. `check:dual-build-cjs-loads` first
refused with exit 3 (PREREQUISITE NOT MET: eight packages outside this
diff had no `dist/`). After those were built, it exited 0 (110 entry
points across 68 packages load). The reconciled list carries the re-run.
- `check:adr-0087-registration` verified the changeset's
`runtime-interface-only` operand.
- **Size:** 38 files, +1055 / −171 = 1226 changed lines against the
merge base, under the 3,000-line threshold.
## Acceptance notes
- **The stdio client reads the message, not the code.** The data tools
render a thrown error with `errorResult(messageOf(err))`, and the record
resource writes `{ error: message }`. Both are shared by the HTTP and
stdio faces, and both flatten EVERY coded refusal on this transport to
its message, `RECORD_NOT_FOUND` included. #15942 adopted envelope
rendering (`errorResultFromThrown`) for the action tools only. The stdio
refusal itself carries `code` and `status`, and the client receives the
session's remediation message verbatim. Reported as an out-of-scope
finding, and see Open question 2. The HTTP MCP face is unaffected: the
dispatcher refuses the whole request with `403 MFA_REQUIRED` before the
MCP server sees it.
- **The stdio face's principal-free calls are as before.**
`list_objects`, `describe_object`, prompts and resource listings resolve
no principal (the ADR-0101 boundary: the identity binds row data). So a
held key reaches them exactly as a revoked key does today. Carrier:
none.
- **Stale chain prose outside this diff:**
- ADR-0101 names `resolveApiKeyPrincipal` as part of the stdio verify
chain (Tier H, not edited).
- `qa/dogfood/test/authz-conformance.matrix.ts`'s stdio row does the
same.
The chain is `resolveAuthzContext` → `resolveApiKeyAdmission`. This PR's
own docblock in `mcp/src/plugin.ts` is corrected.
- **The changeset's remedy is one prose line, not a FROM → TO block.**
Measured with `check:adr-0087-registration`: a detectable FROM → TO for
a required-parameter change is refused under every honest disposition.
- `no-migration-prescription` and `runtime-interface-only` inherit the
prescription refusal.
- `type-surface-only` needs a base type that was erased.
- `runtime-export-retired` needs a retired export.
So the remedy is one instruction line, and the marker is
`runtime-interface-only` on
`resolve-authz-context.ts#ResolveAuthzInput`. The gate verified that
marker.
- **`@objectstack/runtime` is graded `minor` with no source change.**
Its entry re-exports `resolveApiKeyPrincipal` explicitly, and the rest
through `export *`, so its importers see the new required inputs.
- **No governed surface** is in the file list: no `.claude/**`,
`docs/adr/**`, `skills/**`, `AGENTS.md` or workflows.
## Open questions
1. **Retire `resolveApiKeyPrincipal` once cloud is read?** The ruling's
premise could not be measured on cloud from this session.
- A. The seat reads cloud. On zero callers, a follow-up retires it under
`runtime-export-retired @objectstack/core#resolveApiKeyPrincipal`, and
`@objectstack/runtime`'s two explicit re-exports go with it.
- B. Keep it wired as here.
- Recommendation: **A**. The collapse cannot carry the posture's code
(item 1), it has no in-tree caller, and the startup axis retires
zero-pull exports at once. B stays correct and fail-closed in the
meantime.
2. **The stdio code on the wire.**
- A. Leave the shared tool layer as it is: every coded refusal is
presented as its message on both faces.
- B. A separate card renders coded bridge refusals through
`errorResultFromThrown` on the data tools and the record resource. That
makes the code machine-readable on both faces for every refusal: a
widening of what a client reads, with tests that pin today's text to
re-judge.
- Recommendation: **B, as its own card**. It reaches far beyond this
family.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96a7f43 commit daafae6
38 files changed
Lines changed: 1055 additions & 171 deletions
File tree
- .changeset
- packages
- cli/test
- cloud-connection/src
- core/src/security
- mcp/src
- plugins
- plugin-auth/src
- plugin-security/src
- plugin-sharing/src
- qa/dogfood/test
- rest/src
- services/service-storage/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
| 101 | + | |
101 | 102 | | |
102 | 103 | | |
103 | 104 | | |
| |||
| 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 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
0 commit comments