fix(identity): API keys are minted against the minter's active organization (#8287) - #8709
Conversation
…#8287) `sys_api_key` carried no organization, so under `OS_TENANCY_POSTURE=isolated` a minted key authenticated a user with no active organization and the Layer 0 wall (`organization_id = activeOrganizationId`) could match nothing: every org-scoped read answered `200` with `total 0` while the console went on offering minting. The column was absent by an inherited rule, not by oversight — `resolveInjectedSystemColumns` skips `managedBy: 'better-auth'` objects, and `sys_api_key` carries that flag even though better-auth's `apiKey` plugin is not loaded and the table is hand-rolled ObjectStack. - declare `active_organization_id` on `sys_api_key` (+ index, list columns) - register it as an ADR-0105 D7 managed extension field, and correct that registry's long-standing drift (its comment said every column here is an extension field; the set listed one) - mint (`POST /keys`) inherits the caller's active organization, re-checks membership against `sys_member` at mint time, and refuses under a walled posture rather than handing back a key that cannot read - the verifier reads ONE spelling (PD #12), refuses an org-less key under `isolated`, and the shared resolver fails an ex-member's key closed using the membership set it had already read — zero extra queries The column is deliberately NOT named `organization_id`: that name would make `sys_api_key` itself org-walled, hiding pre-existing org-less rows from their own owners while they keep authenticating under `group`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…ts (#8287) Adding the dependency made `check:test-source-alias` and `check:type-source-resolution` both fire: a unit test or typecheck that reads a sibling's built `dist/` is a verdict about a build, not about the code in this checkout, and the dangerous direction is the one that PASSES. Anchored regex aliases (array form) so the bare entry cannot swallow the `/node` subpath. Also adds the changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…-key-organization
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 5 package(s): 40 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also reference the affected code. These are read-only:
|
…ot the env (#8287) Fixes the red `Build Core` at 2b993c9 and, underneath it, a correctness bug the build failure exposed. The build break: `check:type-source-resolution` requires a cross-package type import to resolve to SOURCE, so adding `@objectstack/types` to `core` forced a `paths` rule — which collides with core's `rootDir: "./src"` under the tsup DTS build (TS6059). That gate's own header documents this exact cost. The bug it exposed is the more important half. `resolveTenancyPosture()` reads `OS_TENANCY_POSTURE`, which is what the operator ASKED for — not what is ENFORCED. Under ADR-0093 D4/D5 a deployment requesting `isolated` without the enterprise organizations runtime resolves to `single` and runs with no wall at all, so the env-reading version would have refused org-less API keys on a deployment that has no organization boundary to enforce. The posture is now an explicit input, resolved from the kernel's `tenancy` service — the same source plugin-security reconciles before handing a posture to the Layer 0 wall, so admission and the wall can never disagree. `core` drops the `@objectstack/types` dependency entirely, and both gates go quiet on their own rather than by registry widening. An ABSENT posture disables the two posture-conditional refusals, leaving behaviour exactly as before: that is a question about the deployment, not about the credential, so an unwired transport is never made worse — only less strict. Wired here: the runtime dispatcher/MCP path and the REST data API, which are the surfaces the card measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…-key-organization
#8287) The tsconfig `paths` and vitest `alias` entries were added to satisfy check:type-source-resolution / check:test-source-alias when core briefly depended on @objectstack/types. That dependency is gone — the tenancy posture now arrives from the kernel's `tenancy` service — so both entries resolve nothing, and their comments describe a `resolveTenancyPosture` call that no longer exists. Left in place they would mislead the next author and re-arm the TS6059 rootDir collision the moment anyone re-added the import. Both gates stay green without them, because the predicate is the IMPORT, not the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
… dispatch predicates (#8287) `check:engine-double-contract` went red at 339a1b6: the organization-inheritance suite added a second engine double to http-dispatcher.keys.test.ts, taking the file from 1 unguarded double to 2 on both the delete and the update slice while the shrink-only baseline records 1. The baseline is NOT raised — that is an explicit gate-weakening action and the gate's own message rules it out ("pin the new one rather than raising it"). The new `makeOrgKernel` double now routes both write verbs through assertEngineUpdateDispatch / assertEngineDeleteDispatch from @objectstack/metadata-core, so it cannot be looser than ObjectQL itself. The file's pre-existing makeKernel double keeps its measured DEBT entry untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…erResult (#8287) `check:type-check-debt --re-measure` went red at 339a1b6: @objectstack/runtime's TEST_DEBT records 227 raw tsc errors and the tree measured 235 (+8). All eight are TS18048 'res.response' is possibly 'undefined' in the organization-inheritance suite this PR added — `HttpDispatcherResult.response` is optional, and the runtime test layer is hidden from tsc at the package level, so `pnpm test` going green said nothing about them. The ledger is NOT raised — it is a shrink-only ratchet and raising it is on the maintainer's floor. A `responseOf()` helper narrows once and throws a named error when a dispatcher answers no response at all, so the failure stays distinguishable from a wrong status. Scoped to the new suite: the older suite's nine reads are the file's share of the frozen number, and pressing that down is a separate improvement to bank, not a rider on this repair. Re-measured: runtime back to exactly 227. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…-key-organization
…-key-organization # Conflicts: # packages/core/src/security/resolve-authz-context.test.ts
Gate repair — verification at the final head
|
| ablation | gate | suite |
|---|---|---|
| unpin the two write verbs | RED — both PINNED messages return verbatim | green |
unwrap the responseOf() calls |
236 vs the 227 ceiling → RED | green |
The second landed at 236, not the predicted 235: the ablation leaves responseOf declared and unused, which is one error the pre-fix tree did not carry. Reported as measured rather than rounded to the prediction. Both restored with git checkout HEAD -- ... from the committed fix, tree confirmed clean after each.
Sibling merge
origin/main carrying #8713 (#8613) was merged at 538209590. Exactly the ruled conflict shape: one content conflict in packages/core/src/security/resolve-authz-context.test.ts; resolve-authz-context.ts and security/index.ts both auto-merged. It was a pure both-sides-appended collision — both describe blocks kept in full, nothing dropped or reconciled; the merged file runs 34 files / 820 tests green, and both sides' exports survive in the security barrel.
Union at 538209590, clean tree — all green
Re-derived with scripts/pm/dispatch-gates.mjs against the branch's actual changed paths, run after the final commit:
check:authz-resolver · check:changeset-gate-self-tests · check:cross-package-test-inputs · check:kernel-hook-pairs · check:objectui-changeset · check:route-envelope · check:test-source-alias · check:type-source-resolution · check:query-options-erasure · check:type-check-coverage · check:type-check-debt --re-measure · check:i18n · check:engine-double-contract · check:nul-bytes · check:error-code-casing · check-adr-0087-registration · check-changeset-no-major · check-empty-changeset · check-cross-package-test-inputs
Build: 70/70 turbo tasks. Tests: core 34 files / 820 tests, runtime 156 files / 2379 tests — 0 failed.
Both ledger files are untouched by this branch — git diff origin/main...HEAD -- scripts/engine-double-contract.baseline.json scripts/check-type-check-coverage.mjs is empty.
Derivation gap, recorded not fixed
check:engine-double-contract is not in the re-derived set — absent by design, handed to human judgement in the script's closing prose. That is already filed as #8632; this instance is attached there as a second measurement rather than duplicated. check:type-check-debt is derived, correctly, with its --re-measure form named. dispatch-gates.mjs is not touched here.
Generated by Claude Code
…tead of misreading them (#8287) `check:where-matcher-conformance` went red once the engine-double gate stopped aborting the ESLint job ahead of it: `makeOrgKernel`'s find matcher is an `Object.entries(where).every(...)` body with no combinator branch, so it read `$or`/`$and` as an ordinary FIELD NAME, compared `row.$or` (undefined) against the array, matched nothing, and would have handed a suite an empty result set with nothing erroring — shape (b) in that gate's header. Fixed by refusal, not by implementing the combinator: the `makeKernel` matcher 160 lines above in this same file already refuses with this exact message, so this keeps one convention in one file, and refusal is what 140 of the 233 discovered matchers already do. The baseline is NOT touched — third shrink-only ratchet on this branch, same rule. Gate now reports 233/233 conforming, 141 by refusing (+1, exactly this matcher). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…-key-organization
Third gate repaired, and the full CI sequence run — head
|
| job | steps run | result |
|---|---|---|
lint (.github/workflows/lint.yml) |
48 | 48 green |
typecheck (same file) |
34 | 34 green |
82/82 green. Zero failures, and zero NOT MEASURED — every step executed for real in this environment. I specifically checked the ones that could plausibly have passed by skipping (check:required-contexts, check:shard-attestation, check:release-body, check:published-files, check:merge-driver, check:release-notes): each ran its real self-test and assertion battery, none took a degraded or offline path.
That sequence includes both turbo build invocations, turbo run typecheck (122 tasks), check:type-check-debt (33 ledger entries, none above its recorded number), check:authorable-surface, check:i18n, and the two gates repaired earlier on this branch.
Also green at this head: core 34 files / 820 tests, runtime 156 files / 2379 tests.
All three ratchet ledgers untouched by this branch — git diff origin/main...HEAD over engine-double-contract.baseline.json, where-matcher-conformance.baseline.json and check-type-check-coverage.mjs is empty.
origin/main was merged again at 52cc65835 (conflict-free this time) so the run is against current main.
On the derivation gap
check:where-matcher is not in the derived union — and unlike check:engine-double-contract, it is not in the script's judgement-call prose either. grep -c "where-matcher" scripts/pm/dispatch-gates.mjs returns 0, so a dev following that script's own output has no path to this gate at all. Attached to #8632 as a third instance with that measurement; dispatch-gates.mjs is not touched here.
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31828764827 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue-failure triage → checklist item 3, re-queued onceAnswering the triage bot above rather than re-queueing blind. Verdict: not this PR's regression, not a recurring flake — most likely a same-batch semantic conflict. Re-queued exactly once; if it fails again the next reader should NOT re-queue a third time without new evidence. Item 1 — is the failing test in a package this PR changes? No. The failure is Item 2 — is it a known recurring flake? Not established, and I checked rather than assuming. The other recent queue failure I sampled (run Item 3 — batch interaction. This is where the evidence points. The queue is demonstrably not blocked: Correcting my own first reading, for the record: I initially suspected the queue's checkout shape (shallow / no upstream) made this deterministic for everyone. The interleaved successes above refute that, so it is not shared infrastructure damage and there is nothing to stop-the-bleeding on. Landing preconditions re-verified at Generated by Claude Code |
Fixes #8287
Implements the maintainer's option-2 minimal v1 ruling (comment
5281696102) as one integrated cross-lane PR, per the cross-lane designation (comment5293093118, 「同意」). The prior dev's STOP report (comment5287641437) was inherited rather than re-derived; its measurements are re-verified below and two are corrected.The defect
On
OS_TENANCY_POSTURE=isolated, a minted API key read nothing.sys_api_keycarried no organization column, so key auth established a user but no active organization — and theisolatedLayer 0 wall isorganization_id = activeOrganizationId, which with no active organization matches no row. Every org-scoped read answered200withtotal 0while the console went on offering minting. No cross-tenant leak; the failure was in the other direction.The column was absent by an inherited rule, not oversight:
resolveInjectedSystemColumnsinjectsorganization_idinto every object exceptmanagedBy: 'better-auth'ones, whichsys_api_keyis — even though better-auth'sapiKeyplugin is not loaded and the table is hand-rolled ObjectStack. Confirmed atpackages/spec/src/data/injected-system-columns.ts(rule 2 returnsnothing).The read side was already wired (also confirmed):
resolveApiKeyPrincipalalready read an organization intotenantId, andresolveAuthzContextalready adopted it. It was reading a column no mint path ever wrote.What changed, by lane
domain:metadataplatform-objects/src/identity/sys-api-key.object.tsactive_organization_id, index, list-view columnsdomain:metadataplatform-objects/src/apps/translations/*.objects.generated.tscheck:i18n)domain:identityplugin-auth/src/managed-extension-fields.tsdomain:cliruntime/src/domains/keys.tsdomain:engine-corecore/src/security/api-key.ts,resolve-authz-context.tsruntime/src/security/resolve-execution-context.ts,rest/src/rest-server.ts⛔
packages/specis not touched.active_organization_id, notorganization_id— please read this bitThis is the one place I departed from what the card and the prior dev's report assumed, and it is deliberate. The ruling says "the organization is recorded on the
sys_api_keyrow" and does not name the column.Semantics. This value is not "the organization that owns this row", it is "the organization this credential makes ACTIVE" — which is the ruling's own third clause.
sys_sessionalready carries exactly that fact under exactly that name, and both are read intoExecutionContext.tenantIdby the one shared resolver. One concept, one name.Measured consequence, which is the load-bearing half.
objectHasOrgIdField(plugin-securitysecurity-plugin.ts) tests the registered field set for the literalorganization_id, andcomputeTenantLayer0Filter(tenant-layer.ts) exempts objects without it. Naming the columnorganization_idwould therefore have madesys_api_keyitself org-walled, and both walled postures exclude NULL:groupthose keys still authenticate, so it becomes a live credential nobody can see or revoke;That is a fresh instance of the exact silent-empty class this card exists to remove.
sys_api_keyis an owner-scoped credential table likesys_user/sys_session/sys_account, scoped by the Layer 1sys_api_key_selfpolicy (user_id == current_user.id); keeping it there is what makes this fix additive rather than a trade. Pinned inplugin-security/src/tenant-layer.test.tsagainst the real field set, with the counterfactual.If the maintainer prefers
organization_id, it is a one-line rename plus accepting the Layer-0 consequences above.tenancyservice, never fromOS_TENANCY_POSTUREThe first push of this branch read the env var via
resolveTenancyPosture(). CI's redBuild Coreexposed that as wrong on two counts, and the second is the one that matters:check:type-source-resolutionrequires a cross-package type import to resolve to SOURCE, so adding@objectstack/typestocoreforced apathsrule that collides with core'srootDir: "./src"under the tsup DTS build (TS6059). That gate's own header documents this exact cost.OS_TENANCY_POSTUREis what the operator asked for, not what is enforced. Under ADR-0093 D4/D5 a deployment requestingisolatedwithout the enterprise@objectstack/organizationsruntime resolves tosingleand runs with no wall at all — so the env-reading version would have refused org-less API keys on a deployment with no organization boundary to enforce, breaking working automation to uphold a wall that does not exist.The posture is now an explicit input, resolved via
effectiveTenancyPosture()from the kernel'stenancyservice — the same sourceplugin-securityreconciles before handing a posture to Layer 0, so admission and the wall can never disagree.coredrops the@objectstack/typesdependency entirely and both gates go quiet on their own, not by registry widening.An absent posture disables both posture-conditional refusals, leaving behaviour exactly as before. That is deliberate: it is a question about the deployment, not about the credential, so an unwired transport is never made worse — only less strict. Wired here: the runtime dispatcher/MCP path and the REST data API, the two surfaces the card measured. Four other
resolveAuthzContextcallers (plugin-sharing, service-storage, service-settings, mcp stdio) are not yet wired and therefore keep today's behaviour — declared rather than silent.The three follow-through decisions
200 + total 0). At verify rather than revoke-on-event because membership ends through many paths — better-auth org endpoints, SCIM, a directsys_memberdelete, an ADR-0091 window lapsing — and a hook must catch every one or it silently misses. Zero extra queries:resolveUserAuthzGrantshas already readsys_memberfor this user. Pinned by a query-counting test. Scoped to walled postures: undersinglethere is no boundary to cross.single— no wall, left working.group— the wall derives from the owner's memberships independently oftenantId, so they already work there; left working.isolated— provably dead today, refused at verify time.isolateddoes break the one behaviour the card measured as working —GET /data/sys_userreturning the key owner's own row, which is walled by member-id enumeration rather than the org column. Decided: refuse.Distinguishability of the refusal — scoped down, and why
The decision asked for "a distinguishable error code". A new
error.codemust be registered inERROR_CODE_LEDGER, which lives inpackages/spec/src/api/error-code-ledger.zod.ts— off-limits to this seat. So the refusal uses the standard catalog memberUNAUTHENTICATED(401), which is also what that ledger's own admission rule prescribes: "If the condition is generic (not found / permission / validation / rate limit), use the standard catalog instead of registering a synonym" — and since #8211 that rule is mechanically enforced, so a synonym might well be refused admission anyway. The behavioural requirement is met in full: the failure is loud at call time (401) instead of200 + total 0. A machine-readable discriminator rides onResolvedAuthzContext.authRefusal.reason, deliberately lowercase so it can never be mistaken for a wire code. Filed for the spec seat as #8708.Verification
Union of derived gates run after the final commit, at
5f129e53f, clean tree — all green:check:authz-resolver·check:route-envelope·check:cross-package-test-inputs·check:kernel-hook-pairs·check:test-source-alias·check:type-source-resolution·check:changeset-gate-self-tests·check:objectui-changeset·check:i18n·check:query-options-erasure·check:type-check-coverage·check:nul-bytes·check:error-code-casing·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-cross-package-test-inputsGates the dispatch prompt did not name, surfaced by re-deriving against the actual changed paths:
check:authz-resolver,check:route-envelope,check:cross-package-test-inputs,check:kernel-hook-pairs,check:test-source-alias,check:type-source-resolution.Build, as CI runs it: 31/31 turbo build tasks green,
Build Coreincluded.Tests at
5f129e53f: core 803 · runtime 2379 · rest 1903 · plugin-auth 1204 · plugin-security 1138 · platform-objects 369 · mcp 185 — 7,981 passed, 0 failed.corehas notypecheckscript (pre-existing ledger entry); its types are checked by the tsup DTS build, which passes.Reverse verification (fix committed first, direction predicted before running): restoring the old
row.organization_id ?? row.organizationIdchain turned the canonical-read pins RED — 4 failed / 15 passed, exactly the spelling and org-read assertions — then restored from the commit and re-confirmed green.Fixture triage: four pre-existing fixtures spelled the retired column. All four were re-spelled rather than replaced — each merely used the alias, and each assertion still reads a value the mint path really produces. Two lived in
runtime, outside the packages this change edits, and were found by sweeping the verifier's consumption radius rather than the edited package.packages/platform-objectsi18n bundles were regenerated after mergingorigin/main(twice, most recently at5f129e53f), and #8149'ssys_emailrows were confirmed still present. Both regenerations were no-ops.Docs screened
All hand-written docs carrying API-key vocabulary were screened. Accurate and unaffected:
api/index.mdx,api/client-sdk.mdx,ai/agents.mdx,ai/connect-mcp.mdx,getting-started/build-with-claude-code.mdx,deployment/environment-variables.mdx. Screened, zero API-key vocabulary:permissions/authentication.mdx,permissions/system-context.mdx. Different subsystem (outbound connector auth, not inbound keys):automation/connectors.mdx,references/integration/connector.mdx.permissions/authorization.mdxdeserves its own line: it states thatsys_api_key"deliberately stay[s] public-posture … row scoping is their guard" — still true, and only because of the naming choice above.organization_idwould have falsified that sentence. Two pre-existing docs defects found and filed, not fixed here (#8715).Findings filed, not fixed here
sys_api_key.active_organization_id#8707 — audit rows are stamped from the actor's active organization in preference to the record's own, and the record-side fallback cannot seeactive_organization_id. The unimplemented half of follow-through decision 2: the decision stands, but its site isplugin-audit(a fifth lane) and the precedence flip touches every audited object. Landing only the narrow half would have been inert, i.e. declared-but-unenforced.UNAUTHENTICATED#8708 — whether a refused key deserves a registered error code, routed to thepackages/specseat.ApiKeyreference table documents better-auth's apiKey-plugin schema — a plugin this platform does not load and a shapesys_api_keydoes not have #8715 — theApiKeyreference table documents better-auth's apiKey-plugin schema (rate limits,enabled,metadata) for a plugin this platform does not load; plus ask_live_…key prefix in the HTTP-protocol example whereosk_is load-bearing.Generated by Claude Code