Skip to content

fix(identity): API keys are minted against the minter's active organization (#8287) - #8709

Draft
qq9340100 wants to merge 6 commits into
mainfrom
claude/issue-8287-api-key-organization
Draft

fix(identity): API keys are minted against the minter's active organization (#8287)#8709
qq9340100 wants to merge 6 commits into
mainfrom
claude/issue-8287-api-key-organization

Conversation

@qq9340100

@qq9340100qq9340100 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes#8287

Implements the maintainer's option-2 minimal v1 ruling (comment 5281696102) as one integrated cross-lane PR, per the cross-lane designation (comment 5293093118, 「同意」). The prior dev's STOP report (comment 5287641437) 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_key carried no organization column, so key auth established a user but no active organization — and the isolated Layer 0 wall is organization_id = activeOrganizationId, which with no active organization matches no row. Every org-scoped read answered 200 with total 0 while 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: resolveInjectedSystemColumns injects organization_id into every object exceptmanagedBy: 'better-auth' ones, which sys_api_key is — even though better-auth's apiKey plugin is not loaded and the table is hand-rolled ObjectStack. Confirmed at packages/spec/src/data/injected-system-columns.ts (rule 2 returns nothing).

The read side was already wired (also confirmed): resolveApiKeyPrincipal already read an organization into tenantId, and resolveAuthzContext already adopted it. It was reading a column no mint path ever wrote.

What changed, by lane

lanefilechange
domain:metadataplatform-objects/src/identity/sys-api-key.object.tsdeclare active_organization_id, index, list-view columns
domain:metadataplatform-objects/src/apps/translations/*.objects.generated.tsregenerated (check:i18n)
domain:identityplugin-auth/src/managed-extension-fields.tsADR-0105 D7 registration + the drift correction
domain:cliruntime/src/domains/keys.tsmint inherits the active org, membership check, stamping
domain:engine-corecore/src/security/api-key.ts, resolve-authz-context.tscanonical read, posture-conditional refusal, ex-member fail-closed
(transport wiring)runtime/src/security/resolve-execution-context.ts, rest/src/rest-server.tssupply the effective tenancy posture

packages/spec is not touched.

⚠️ The column is named active_organization_id, not organization_id — please read this bit

This 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_key row" 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_session already carries exactly that fact under exactly that name, and both are read into ExecutionContext.tenantId by the one shared resolver. One concept, one name.

Measured consequence, which is the load-bearing half.objectHasOrgIdField (plugin-security security-plugin.ts) tests the registered field set for the literalorganization_id, and computeTenantLayer0Filter (tenant-layer.ts) exempts objects without it. Naming the column organization_id would therefore have made sys_api_keyitself org-walled, and both walled postures exclude NULL:

  • every pre-existing org-less row would vanish from the console's "My Keys" list for its own owner — and under group those keys still authenticate, so it becomes a live credential nobody can see or revoke;
  • new keys fare little better: a key minted in org A disappears from its owner's list the moment they switch to org B.

That is a fresh instance of the exact silent-empty class this card exists to remove. sys_api_key is an owner-scoped credential table like sys_user / sys_session / sys_account, scoped by the Layer 1 sys_api_key_self policy (user_id == current_user.id); keeping it there is what makes this fix additive rather than a trade. Pinned in plugin-security/src/tenant-layer.test.ts against 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.

⚠️ The posture comes from the tenancy service, never from OS_TENANCY_POSTURE

The first push of this branch read the env var via resolveTenancyPosture(). CI's red Build Core exposed that as wrong on two counts, and the second is the one that matters:

  • Mechanically: check:type-source-resolution requires a cross-package type import to resolve to SOURCE, so adding @objectstack/types to core forced a paths rule that collides with core's rootDir: "./src" under the tsup DTS build (TS6059). That gate's own header documents this exact cost.
  • Substantively: OS_TENANCY_POSTURE is what the operator asked for, not what is enforced. Under ADR-0093 D4/D5 a deployment requesting isolated without the enterprise @objectstack/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 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's tenancy service — the same source plugin-security reconciles before handing a posture to Layer 0, so admission and the wall can never disagree. core drops the @objectstack/types dependency 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 resolveAuthzContext callers (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

  1. Ex-member keys fail closed at VERIFY time. No principal, not a degrade to a user-only principal (which would resurrect 200 + total 0). At verify rather than revoke-on-event because membership ends through many paths — better-auth org endpoints, SCIM, a direct sys_member delete, an ADR-0091 window lapsing — and a hook must catch every one or it silently misses. Zero extra queries: resolveUserAuthzGrants has already read sys_member for this user. Pinned by a query-counting test. Scoped to walled postures: under single there is no boundary to cross.
  2. Revocation/audit stamping — NOT implemented here; see "Findings" below. The decision stands; its implementation site turned out to be a fifth lane with a repo-wide blast radius.
  3. Existing org-less keys: posture-conditional refusal, never backfilled.single — no wall, left working. group — the wall derives from the owner's memberships independently of tenantId, so they already work there; left working. isolated — provably dead today, refused at verify time.

⚠️Residual, carried and visible as instructed: refusing under isolated does break the one behaviour the card measured as workingGET /data/sys_user returning 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.code must be registered in ERROR_CODE_LEDGER, which lives in packages/spec/src/api/error-code-ledger.zod.ts — off-limits to this seat. So the refusal uses the standard catalog member UNAUTHENTICATED (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 of 200 + total 0. A machine-readable discriminator rides on ResolvedAuthzContext.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-inputs

Gates 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 Core included.

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. core has no typecheck script (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.organizationId chain 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-objects i18n bundles were regenerated after merging origin/main (twice, most recently at 5f129e53f), and #8149's sys_email rows 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.mdx deserves its own line: it states that sys_api_key "deliberately stay[s] public-posture … row scoping is their guard" — still true, and only because of the naming choice above. organization_id would have falsified that sentence. Two pre-existing docs defects found and filed, not fixed here (#8715).

Findings filed, not fixed here

Generated by Claude Code

…#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
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 4:11pm

Request Review

@github-actions

github-actionsBot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/rest, @objectstack/runtime.

40 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/core)
  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/ai/knowledge-rag.mdx(via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/core)
  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest)
  • content/docs/api/index.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/automation/webhooks.mdx(via @objectstack/core)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/core, packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/core)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth, @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/kernel/services.mdx(via @objectstack/core)
  • content/docs/permissions/authentication.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/core, packages/runtime)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/system-context.mdx(via packages/rest, packages/runtime)
  • content/docs/plugins/anatomy.mdx(via @objectstack/core)
  • content/docs/plugins/development.mdx(via @objectstack/core)
  • content/docs/plugins/index.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/core, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/core, @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/core, @objectstack/runtime)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/core)
  • content/docs/ui/setup-app.mdx(via @objectstack/platform-objects)

5 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/core, @objectstack/plugin-auth, @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx(via @objectstack/core, @objectstack/rest)
  • content/docs/releases/v15.mdx(via @objectstack/core)
  • content/docs/releases/v17.mdx(via @objectstack/core, @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 14, 2026
…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
#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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] API keys carry no organization — under the isolated posture a minted key reads no org data at all (no leak, but the key surface is inert)

2 participants

@qq9340100@claude