Repository navigation
fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in - #22045
Conversation
…thods and the HTTP outbox's redeliver take the explicit system opt-in StorageMetadataStore getFile, updateFile, deleteFile, getSession, updateSession and deleteSession, and SqlHttpOutbox.redeliver, now pass the explicit system opt-in on their data-engine calls instead of no principal. The door-derived organization stays the driver-level scope on update and delete, redeliver keeps its threaded tenant on every call and states its tenant audit armed, and the by-id updates send the patch alone so the provisioned columns stay the platform's. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…stem opt-in and the tenant scope beside it The storage store's by-id reads carry the opt-in and no tenant; its by-id writes carry the opt-in beside the door's organization and send the patch alone; through the real engine over a real SQL driver a door tenant still cannot update or delete another organization's row. The HTTP outbox's redeliver carries the opt-in on both reads and the reset write, keeps the caller's tenant on every bag, states its tenant audit armed, and still cannot reach a foreign row. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…eliver take the explicit system opt-in Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
… with the engine's dispatch predicates The two wrappers that record the opt-in on the real engine path now route each verb through the engine's own dispatch predicate, and the engine-double ledger counts the storage file's second double. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…enant audit armed Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dd8d8486d7ef675818fa638d3d8edc42b2345802 && git checkout dd8d8486d7ef675818fa638d3d8edc42b2345802
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8caa131e52717ef35fe71742d758f10b8f2b77ee 2665532dc973b8e827b468e1faadfa2c6bbe1214 && git checkout -B drift-repro 8caa131e52717ef35fe71742d758f10b8f2b77ee && git merge --no-ff 2665532dc973b8e827b468e1faadfa2c6bbe1214
node scripts/docs-audit/affected-docs.mjs --json 8caa131e52717ef35fe71742d758f10b8f2b77ee
|
Part of #21908
Clause-②: no
Phase two, stage 2a of the principal-less hand-off closure (ADR-0096 E1 / D5), per the maintainer's ruling on the card (letter A). Seven more producers of a principal-less, non-system data context now take the explicit system opt-in (
isSystem: true) inside their owning service. The deny itself lands last and is not in this PR, so #21908 stays open. ⛔ Noplugin-security,packages/spec,metadata-protocolordocs/adrfile is in the diff, there is no new elevation API, and no door's authorization changed.What moved
Positions are at the base
9a0401fdd3. "Gate fired" means one of the six gates the security middleware still runs before its hand-off (package-managed, system-row, curated-capability, audience-anchor, ADR-0103 engine-owned, ADR-0090 D12 delegated-admin) fires on the producer's calls. Counts are principal-less calls at the hand-off, before and after the change; every "after" call arrived as anisSystemcall.service-storagemetadata-store.ts:373StorageMetadataStore.getFilestorage-routes.ts:453), both download doors (:799,:846), and the chunked completion throughupdateFilerequireUploadSession,:458,:684) before the read. Downloads:authorizeDownload(:808,:855) after the read and before anything is disclosed, because its verdict reads the rowmetadata-store.ts:399StorageMetadataStore.updateFile:472), chunked completion (:720)metadata-store.ts:434StorageMetadataStore.deleteFilemetadata-store.ts:502StorageMetadataStore.getSession:593), chunked completion (:681), progress (:751):617) follows the read, because it reads the row's token, and precedes every writemetadata-store.ts:529StorageMetadataStore.updateSession:658, expiry via:623), chunked completion (:705,:725, expiry via:694, failure stamp via:732), progress (expiry via:770)metadata-store.ts:562StorageMetadataStore.deleteSessionservice-messagingsql-http-outbox.ts:468SqlHttpOutbox.redeliver(two reads, one reset write)POST /api/v1/webhooks/redeliver(plugin-webhookswebhook-outbox-plugin.ts:397) throughMessagingService.redeliverHttp401before the call; the session's active organization is the tenant; the producer's veto runs before the writeRead scope, asked for by the claim:
getFileandgetSessionread by id with no organization scope before the move (no context at all, so notenantIdreached the driver, and the middleware handed the call through before any row or tenant filter) and after it (the middleware short-circuits before the same filters, and still notenantIdreaches the driver).What the opt-in would also have changed, and what holds it
The opt-in skips more than the six gates. Measured on the real engine, per call:
readonlystrip.updateFileandupdateSessionused to send the whole row read back, merged with the patch. Under the strip, the engine tookorganization_id,created_at,updated_at,created_byandupdated_byout of that row on every call. Under the opt-in the strip does not run, so the full row,organization_idincluded and read without tenant scope, would have been written back. Fix inside the store: the two updates now send the caller's patch alone (changedColumns). Measured on SQLite through the real engine: on the base the strip took those five columns; on the change it takes none, and the stored rows are equal on every non-timestamp column, withupdated_atstill stamped by the platform.isSystemwrite,ObjectQL.buildDriverOptionsfills inbypassTenantAudit: truewhen the object is outside the platform tenancy inventory.sys_fileandsys_upload_sessionare in it as tenant-scoped, so nothing changes for them (pinned).sys_http_deliveryis not, so the opt-in would have silenced the audit for a redelivery from a caller with no organization, which is the one lineRedeliverOptionskeeps. Fix inside the outbox: the reset write statesbypassTenantAudit: false. The engine never overwrites an explicit value, and the driver still audits.Pins
tenant-audit-update-delete-half-repairs.test.ts.{ context: { isSystem: true } }and no tenant.ObjectQLover a realSqlDriveron SQLite in the isolated posture: under the opt-in, a door tenant'supdateFile,updateSession,deleteFileanddeleteSessionon a row stamped for another organization are refused (StorageMetadataStoreErrorwrappingRECORD_NOT_FOUND) and leave the row untouched. The still-works half shows the caller's own row and an organization-less row are reached.system-context.pin.test.ts. Both reads and the reset write carry the opt-in. The caller'stenantIdstays on every bag. The reset statesbypassTenantAudit: false, and a caller with no tenant gets none invented.delivery-update-tenant-audit.integration.test.ts.RESOURCE_NOT_FOUNDwith zero writes under the opt-in, and the caller's own row resets.false, the value the driver receives, where they read "absent".Ablations, run at
0f6df0f306. Each mutation went throughscripts/ablation-replace.mjs: the anchor hit, the blob changed, and the restore was proved by blob equal toHEADand an emptygit diff HEAD. The pins import fromsrc, so nodistleg applies.updateFilesends the merged row againorganization_idandcreated_byredeliver's deciding read dropstenantIdDELIVERY_NOT_ELIGIBLEwhere it expectsRESOURCE_NOT_FOUNDbypassTenantAudit: falseremovedtrue("expected true to be false"), and the tenant-less redelivery went silentMeasurement
The measurement used a local instrument in
plugin-security's middleware, never committed. Its instrument file was reverted, and blob5b4ab28045afequalsHEAD.plugin-securitywas rebuilt, andablation-dist-preflight --absentpasses; its positive control found the marker in 2 built files while it was live.For each call from the seven producers, the instrument recorded the producer frame, whether the call was principal-less, and a dry run of the six gates with the system flag cleared. Two runs used it:
POST /api/v1/webhooks/redeliver. It calleddeleteFileanddeleteSessiondirectly.Every door answered the same on the base and on the change (200, or 302 for the redirect). There were 0 gate firings before and after.
Acceptance notes
updateFileandupdateSessionalready accepted the optional organization context (metadata-store.ts:399,:529), asdeleteFileanddeleteSessiondid. OnlygetFileandgetSessiontake none, and they still take none: the reads carry the opt-in without a tenant. No public signature changed.authorizeDownload. At the chunk door the read precedes the resume-token check. Each check reads the row it needs, and each precedes any disclosure and any write. The read's reach is the same before and after (unscoped by principal and by organization).deleteFileanddeleteSessionhave no caller in this repository. They were moved as the ruling wires all seven. Their door check is vacuous.outbox-dispatcher-scope.tsdeclaresREDELIVER_SYSTEM_CONTEXT(module-internal) with its own warrant, and amends the two docblocks that kept the outbox opt-ins offredeliver.messaging-service.tsgets one sentence made true.scripts/engine-double-contract.pinned.jsoncounts the storage test file's second double, which--writegrew.plugin-audit'saudit-writers.test.tsfixture prose still describesupdateSessionwriting the merged full record. That is test residue in another package and is left alone.Verification (head
2665532dc9)Each command ran in the foreground of this worktree, and its exit code was captured before any pipe.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat2665532dc9, with no paths, derived 73 commands. The dispatch-time 51 are a subset. All 73 ran and all exited 0. The reconciliation (--ran) printed: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded an exit code and none of them is 3)".6f366adcb1,check:engine-double-contractexited 1. It had counted the new recording wrapper as a fake engine. The wrapper now routes each verb through the engine's dispatch predicates, and--writegrew the ledger.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: 8 packages outside the dogfood closure had nodist/). They were built, and it then exited 0.2665532dc9.@objectstack/service-storage: 42 files, 659 tests passed.@objectstack/service-messaging: 48 files, 534 passed.typecheckpassed for both, the storage test layer'scheck:test-typecheckincluded.--listFilesconfirms both programs compile the edited test files.@objectstack/plugin-webhooks, which mounts the redeliver door, passed at0f6df0f306: 15 files, 165 tests.dist/; the source is unchanged since230ed9ea15. The files:attachments-permission-matrix,attachments-public-read-acl,attachments-unscoped-delete-gate,field-file-collection,file-field-constraint-refusal,sys-file-metadata-write-refusal,predicate-write-unreadable-not-matched,write-door-unreadable-is-not-found,storage-growth,temporal-storage-e2e,webhook-materialization. None of these files reaches the redeliver door; the scratch harness covered it.pnpm lint. The population was read fromeslint.config.mjs: the**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block plus thepackages/**blocks.eslint --no-inline-config --format jsonover the 7 changed.tsfiles reported 7 files, 0 errors and 0 warnings atc4f10218af. The config enables no type-aware linting (noparserOptions.projectand no typed rules), so no untouched file's verdict can move.Changeset
.changeset/21908-by-id-producers-opt-in.mdgradespatchfor@objectstack/service-storageand@objectstack/service-messaging. It names the opt-in, the patch-only update payload and the held tenant audit. No exported type or entry symbol changed: the new constants and the store helper are module-internal.Generated by Claude Code