Skip to content

fix(rest): carry the caller organization on the /meta write doors so audit rows are not stamped env-wide (#8805) - #8918

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-8805-rest-metadata-audit-org-scope
Aug 15, 2026
Merged

fix(rest): carry the caller organization on the /meta write doors so audit rows are not stamped env-wide (#8805)#8918
qq9340100 merged 1 commit into
mainfrom
claude/issue-8805-rest-metadata-audit-org-scope

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes#8805

The REST /meta write doors passed no organization, so every sys_metadata_audit row a REST-authored metadata write produced was stamped organization_id: null (recordMetadataAudit: entry.organizationId ?? null). Composed with #8803's scoped read — own-org rows plus env-wide ones, a limb that is required rather than optional — that left every REST-authored audit row readable by every tenant, carrying its actor, note, lock_state and request_id. The read half could not close it: the rows were genuinely unscoped, so no filter could separate them.

The premises were measured first, and both hold — one in a narrower form than the card assumed

The ruling on this card is explicitly premised, so this is the first deliverable.

Premise 1 — does resolveExecCtx reliably carry tenantId at this door? Yes, and stronger than "reliably": it is the same source the dispatcher twin reads. computeExecCtx delegates to the shared resolveAuthzContext, which sets tenantId from an API key's principal tenant, else sessionData.session.activeOrganizationId — and activeOrganizationId is the exact field the dispatcher's resolveActiveOrganizationId reads. The REST door's answer is therefore the twin's answer plus API-key principals. Both PUT and DELETE already resolve this context two lines above, for their manage_metadata gate.

Premise 2 — does the dispatcher twin actually stamp a non-null organization end-to-end? This is the half the triage seat flagged as unmeasured and would not assert. Measured, executably, by driving the real HttpDispatcher → real ObjectStackProtocolImplementation → real SysMetadataRepository with a session carrying an active organization, then reading the sys_metadata_audit rows back:

writesys_metadata.organization_idsys_metadata_audit.organization_id
PUT /metadata/view/overdue_gridorg_alphaorg_alpha
PUT /metadata/flow/escalate_overduenullnull

So the twin does stamp a non-null organization end to end, and the asymmetry the card rests on is real. But it is narrower than the card assumed, and that changes the size of the claim: the twin stamps an organization only for types the registry declares allowOrgOverride: trueview, dashboard, report, translation, email_template. For every other type the twin also writes null, deliberately, under the #6190 ruling. The fork clause did not fire (the twin is not env-wide in practice), but the disclosure this PR closes is confined to those five types.

The shape, and why the obvious one is wrong

Threading ctx.tenantId raw would close the disclosure and open an outage. saveMetaItem's organizationId is one value feeding two things — the sys_metadata partition the row lands in and the audit row — and the protocol refuses an org-scoped write of an allowOrgOverride: false type (orgScopedWriteRefusalNOT_OVERRIDABLE, 403). That refusal is itself a maintainer ruling (#6190, option A): coercing such a row to env-wide was considered and rejected, because it rewrites the tenancy statement the author made. So every PUT /meta/object/* from a tenant-admin session would have started answering 403 — trading a disclosure for a regression.

The organization is therefore threaded through organizationIdForMetaWrite, the registry-derived predicate that already answers this question — and it is the dispatcher's own. The two doors now behave identically for the same request, which is the property the card is actually about. A consequence worth stating plainly: a non-overridable type still audits env-wide, and that is correct rather than residue — its write genuinely is environment-wide, so null is its truthful scope. null is now reserved for writes that really are environment-wide.

No new org-resolution seam

The /published comment's seam warning stands and is respected: packages/rest still mints no resolveActiveOrganizationId. The doors read tenantId off the execution context resolveExecCtx already resolves — memoised per request, called in 40+ handlers in this file — which is exactly the precedent #8803 set for the audit read on the sibling route.

⚠️ One PM assumption did not survive: the dispatch note said an executableresolveActiveOrganizationId exists in metadata-protocol. It does not — all five occurrences there are inside comments. The only executable one is HttpDispatcher's private method in packages/runtime. Nothing depended on this: the fix needs no org resolution, only the org the context already carries.

Three changes ride along, each required rather than adjacent

  • publish and rollback move with the save.promoteDraftForPublish resolves the draft through getOverlayRepo(orgId), so once PUT ?mode=draft lands org-scoped, an unscoped publish looks in the env-wide partition and answers no_draft — the Studio designer's save→publish loop, broken. Scoping the save alone is not a smaller change, it is a broken one. rollback resolves its row and history the same way.
  • DELETE moves with it too, and on that verb the organization is not only the audit row: it decides which row a reset destroys. Unscoped, a tenant's "reset to default" would reach past its own overlay and blank the env-wide row for every other tenant.
  • GET /meta/:type/:name/published is now scoped. Its comment justified omitting the organization by a symmetry — "this door resolves exactly the publishes this door can produce" — and the write-side fix is precisely what ends that symmetry. Left alone it would answer 404 about a view the same caller published moments earlier through the same transport, reopening the packages/rest serves /meta/:type/:name/published from the code snapshot too — #8031's fix does not reach the REST transport #8278 defect one partition over. It passes the raw tenant (not the write predicate), which is fail-open in the safe direction: getMetaItemLayered's overlay layer is org-scoped-first, then env-wide, so nothing that resolves today stops resolving. That comment is rewritten in the diff rather than left to mislead.

Lane branch: this is domain:metadata, not domain:cli

The escalation recorded the re-route as a known branch, and it fires — but on a third landing site neither option named. The derivation lands in packages/rest (the doors) while its single implementation lands in packages/metadata-core: organizationIdForMetaWrite / declaresOrgOverride move there from packages/runtime, because @objectstack/rest cannot import @objectstack/runtime — runtime depends on rest, so that edge is a cycle turbo refuses. metadata-core is the package both already depend on and that depends on neither; its barrel records four prior sinkings for exactly this criterion (the FLS projection, the audit-governance table, the write-verb dispatch predicates, the i18n discriminators). Runtime behaviour is unchanged and there is no second copy to drift.

Verification

Gate union derived from the actual changed paths after the final commit and run at 9ce963a67:

  • check:authz-resolver, check:route-envelope, check:cross-package-test-inputs, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:nul-bytes — all green
  • check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs, check-empty-changeset — all green
  • check:type-check-debt --re-measure — green, 33 ledger entries, none above its recorded ceiling. It first refused to run (rest's dist older than its sources after the ablation below) and was re-run against a rebuilt package rather than read as a pass
  • check:engine-double-contract, check:error-code-casing — green (no new fake engine, no new error code)

Suites: @objectstack/rest 1962 passed (119 files), @objectstack/runtime 2452 passed (164 files), @objectstack/metadata-core 162 passed (10 files). typecheck green for all three.

Reverse verification, direction predicted in writing before running. Ablation: packages/rest/src/rest-server.ts reverted to origin/main (the doors pass no organization again). Predicted per-case: 9 red, 5 green. Measured: 9 red, 5 green, case for case — a prose summary line in my prediction file miscounted its own table as "8/6", which is worth stating rather than quietly correcting. The five greens are the load-bearing half, not slack: the non-overridable-type cases pass on the unfixed code precisely because the unfixed door threads nothing, so they are what distinguishes "scopes correctly" from "scopes nothing" — a fix that simply kept passing nothing would satisfy every red case's inverse and fail none of them. Restore was verified byte-identical (git hash-object against the committed blob), not by insertion count.

Severity, honestly

This narrows a disclosure; it does not close a demonstrated live exploit. Whether any multi-tenant deployment authors metadata through the REST door is unmeasured in either direction, as the card says — and the measurement above narrows the exposed surface further, to the five allowOrgOverride: true types. #8707 is not addressed here; it remains open as the sibling of this composition.


Generated by Claude Code

)
The REST `/meta` write doors passed no organization, so every
`sys_metadata_audit` row a REST-authored metadata write produced was stamped
`organization_id: null` (`recordMetadataAudit`: `entry.organizationId ?? null`).
Composed with the scoped audit read — own-org rows PLUS env-wide ones, a limb
that is required rather than optional — that left every REST-authored audit row
readable by every tenant, carrying its actor, note, lock_state and request_id.
The read half could not close it: the rows were genuinely unscoped.
The organization comes from the execution context these doors already resolve
(`resolveExecCtx`), threaded through `organizationIdForMetaWrite` — the same
registry-derived predicate the runtime dispatcher uses. Threading the raw tenant
would have traded the disclosure for an outage: the protocol REFUSES an
org-scoped write of an `allowOrgOverride: false` type (`NOT_OVERRIDABLE`, 403),
so every `PUT /meta/object/*` from a tenant-admin session would have started
failing. Non-overridable types keep writing env-wide, which is their truthful
audit scope.
`publish` and `rollback` move with the save rather than after it: both resolve
their row through the organization, so scoping the save alone would break the
draft to publish loop. `GET /meta/:type/:name/published` is scoped for the same
reason -- its previous comment justified omitting the organization by a symmetry
("this door resolves exactly the publishes this door can produce") that the
write-side fix ends.
`organizationIdForMetaWrite` / `declaresOrgOverride` move from
`@objectstack/runtime` to `@objectstack/metadata-core` so both doors share one
implementation: `@objectstack/rest` cannot import `runtime`, which depends on
it. No new org-resolution seam is minted in `packages/rest`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 4:47pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/rest, @objectstack/runtime.

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

  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • 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/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-core, @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @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/permissions/authentication.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/rest, packages/runtime)
  • content/docs/plugins/index.mdx(via @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/metadata-core, @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/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

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

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

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 tests tooling labels Aug 15, 2026
@qq9340100
qq9340100 marked this pull request as ready for review August 15, 2026 17:09
@qq9340100
qq9340100 added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 7fc01dbAug 15, 2026
27 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8805-rest-metadata-audit-org-scope branch August 15, 2026 17:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] The REST metadata write door stamps every audit row env-wide, so #8747's org scope cannot separate tenants on the REST-authored path

2 participants

@qq9340100@claude