Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) - #8803
Conversation
… organization (#8747) auditMetaItem declared organizationId and never read it, while the comment above the query described the org scope it would have built. Measured: one context-less read returned three organizations' rows for one (type, name). Build the $or the comment describes — the specific organization plus env-wide (organization_id IS NULL) rows — and thread the organization from the REST route via the resolveExecCtx this file already calls in 40+ handlers. Also drops the dead environmentId from the same call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…on scope in both directions (#8747) Three pins, each covering what the others cannot: - runtime integration test: real ObjectQL + real SqlDriver, three orgs on one (type, name). Own-org AND env-wide rows visible, a third org's not. The env-wide case is the discriminating control that separates 'correctly scoped' from 'hides everything'. - metadata-protocol: the query shape, plus a guard that the comment's claims and the code that backs them cannot drift apart again. - rest: the route threads the organization, is fail-closed without one, and no longer passes the dead environmentId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 15, 2026
PM review — ✅ the fix is right; |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8747
Implements the maintainer ruling recorded on this card (comment
5299824146, delegated adjudication): wire it, do not delete it — build the$orthe method's own comment describes, threadorganizationIdfrom the REST route, and pin both the scoped read and the comment now describing behaviour that exists.The defect
ObjectStackProtocolImplementation.auditMetaItemdeclaredorganizationId?: string | nulland never read it. Directly above its query sat a comment describing the filter it would have built — "include rows for the specific org AND env-wide (organization_id IS NULL) rows" — while thewherewas exactly{ type, name }. The parameter was dead on the caller side too:GET /api/v1/meta/:type/:name/auditnever passed one.Re-measured on current
main, not carried from the escalationThe earlier measurement was taken at
ab8b10f82. #8777 (the write half, #8707) landed at 00:31Z in between, so the premise was re-measured rather than inherited. The disclosure reproduces identically. Three saves of one view name underorg_alpha/org_beta/ env-wide through the realsaveMetaItempath, then oneauditMetaItem({ type, name }):Three rows, three organizations, one caller — plus each row's
actor,note,lock_state,code,operation,sourceandrequest_id.#8777 did not move the target state: it changed
plugin-audit'ssys_audit_logstamping, whereassys_metadata_auditrows are stamped by metadata-protocol's ownrecordMetadataAudit(organization_id: entry.organizationId ?? null), which it did not touch.The fix
1. The query now builds the described filter (
packages/metadata-protocol/src/protocol.ts,auditMetaItem): the caller's organization plus env-wide (organization_id IS NULL) rows, and nothing else. An unresolved organization is fail-closed onto the env-wide rows via?? null— the same normalization the sibling/publisheddoor applies, and symmetric with what an org-less write produces — so omitting the parameter is no longer a skeleton key.The scope is unconditional: it does not depend on a posture, a principal, or a layer below choosing to act. That is the point of the ruling, and it is why the three "compensated lower down" candidates were eliminated by measurement rather than by reading — the driver's tenant wall is armed only from an execution context this read never passed; plugin-security's Layer 0 short-circuits on a principal-less call long before the field gate that would have carried it; and no tenancy posture supplies the scope either.
2. The REST route supplies the organization (
packages/rest/src/rest-server.ts) fromresolveExecCtx, which this file already calls in 40-plus handlers including thePUT /metatwin.computeExecCtxassemblestenantIdfrom the sharedresolveAuthzContext(an API key's principal tenant, else the session'sactiveOrganizationId).No new seam. The PM flagged
rest-server.ts's/publishedcomment, which records thatpackages/restcarries noresolveActiveOrganizationIdand no org plumbing, and that inventing it under a bug fix would be "a new seam smuggled in under a bug fix". That constraint is respected: this reads a field the execution context already carries, and adds no organization-resolution plumbing.3. Swept in the same diff: the call no longer passes
environmentId, which the request type never declared and the body never read. Environment scoping is unaffected — it comes from which protocolresolveProtocolhands back, not from the request payload.Why the env-wide limb is load-bearing, not garnish
Measured: the REST
PUT /meta/:type/:namedoor passes noorganizationId, so every audit row it writes is stampedorganization_id: null. An equality-only filter would have looked correct in any shape assertion and blanked the audit tab on every REST-authored deployment. "Correctly scoped" and "hides everything" are different behaviours, and the tests pin them apart.Tests — both directions, three layers
packages/runtime/src/audit-meta-item-org-scope.integration.test.ts(7 cases) — realObjectQL+ realSqlDriver(sqlite), three organizations on one(type, name)seeded through the real write path. Own-org and env-wide rows visible; a third organization's not; symmetry for the second org; an org with no rows of its own still sees env-wide; org-less and explicit-nullreads are env-wide only.packages/metadata-protocol/src/protocol.audit-org-scope.test.ts(7 cases) — the query shape for every call spelling, plus a guard that the comment's claims and the code backing them cannot drift apart again.packages/rest/src/rest-server-audit-org-scope.test.ts(6 cases) — the route threads the organization, is fail-closed without one, and no longer passesenvironmentId.Reverse verification, direction predicted before running: ablating the scope turns 6 of 7 integration cases red and leaves exactly 1 green — the seeding precondition, which asserts only write-side stamps and is read-independent. Observed exactly that. Because this suite resolves
@objectstack/metadata-protocolfrom its builtdist(runtime's vitest aliases other packages tosrc, not this one), each leg was mutate → rebuild → prove the marker's presence/absence indist→ run; the fix was restored from its commit and confirmed byte-identical, anddistrebuilt so no ablated artifact survives.One assumption was corrected by measurement and the test now pins the real behaviour: an unresolvable execution context never reaches the read at all — the anonymous floor refuses it with a 401 first. That floor is the route's only gate; it carries no capability gate, unlike the
PUTtwin'smanage_metadata, which is exactly why the organization scope has to do the tenant separation.Verification, at
HEAD = 22c032dd9All of the below were run after the final commit (the
origin/mainmerge), against that tree.pnpm --filter @objectstack/metadata-protocol test— 92 files, 1376 tests, all passingpnpm --filter @objectstack/rest test— 118 files, 1948 tests, all passingpnpm --filter @objectstack/runtime test— 158 files, 2400 tests, all passingpnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck— clean. (metadata-protocoldeclares notypecheckscript; stated rather than implied.)Gate families re-derived against the actual changed paths with
node scripts/pm/dispatch-gates.mjs, all green:check:nul-bytes,check:authz-resolver,check:route-envelope,check:filter-alias-parity,check:durability-log-level,check:cross-package-test-inputs,check:query-options-erasure,check:type-check-coverage,check:changeset-gate-self-tests,check:objectui-changeset, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset.check:type-check-debt(the ratchet half) was run properly rather than skipped — full workspace closure built first, aslint.ymldoes — and reports "none above its recorded number". The-1surplus it notes on@objectstack/lintis pre-existing and in a package this diff does not touch.Scope
Region-declared work:
auditMetaItemonly inprotocol.ts(re-derived from the merged tree at:6097; siblingssearchAll:8603anddiffMetaItem:15400are untouched and thousands of lines away), plus the audit handler inrest-server.tsand the co-located pins.mainwas merged before opening this PR. Nothing unrelated is folded in, and the write path is untouched — that is #8707's lane.Generated by Claude Code