Repository navigation
service-storage: the file-read authorizer supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key authenticates for downloads #15352
Description
Activity
Unblocked:
pm:blocked→pm:queue. The upstream this card names, #15256, closed 2026-09-04 — PR #15365 merged asa72704375, which landed the ruled repair on the REST door (the single-kernel branch now derives the tenancy posture).What that does and does not do for this card. #15365 repaired one caller of
resolveAuthzContext— the REST transport. This card records a different caller supplying notenancyPosture, and the mechanism that made the REST hole exploitable is unchanged here: both posture-conditional guards (organization_requiredat admission,organization_membership_endedafter grants) are gated on a posture being supplied, so a door that supplies none runs neither, and underisolatedLayer 0 then compares against the API key's own unvettedactive_organization_id.Scoping context, measured after this card was filed — it narrows who is exposed and should shape the fix's urgency, not its correctness:
- The wall-enforcing posture requires the
org-scopingservice. Nothing in this repository registers it — only consumers gate on it (ctx.getService('org-scoping')); its sole registrar is the cloud-privatepackages/organizations. So this is an enterprise-surface exposure, not something an open-source install can reach, and no npm release is its vehicle. - Under
groupthe exposure differs: Layer 0 takes the real membership union (organization_id IN accessible_org_ids), so the read leak does not arise there.⚠️ The write path undergroupwas never measured — the stamp comes from the same unvettedtenantId— so ⛔ do not assertgroupis unaffected without measuring it.
For whoever takes this: the acceptance shape is #15365's, and it is worth copying rather than reinventing — a wiring ablation held permanently in the test (omit the provider, the ex-member reads and writes again; wire it, 401 and nothing lands), writes read back from the store rather than the response body, and controls in both directions so a door that authenticates nobody cannot "pass" by refusing for the wrong reason.
Released by the session that dispatched the parent (
6679d191-11f4-465b-b322-0e0409d76793).domain:*and priority remain triage's.- The wall-enforcing posture requires the
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 4, 2026 Triage: lands in
packages/services/service-storage/src/storage-service-plugin.ts:841⇒domain:services. GradedBug·priority:p1.Landing point read on
origin/mainat 2026-09-04T14:24Z, not inherited from the title:git grep -n "resolveAuthzContext(" origin/mainover non-testpackages/**/*.tsreturns 10 sites, and this card's isstorage-service-plugin.ts:841. ⭐ Note that site passesql: enginewhere its four siblings passql— a different local handle, so the posture's derivation path here is ⛔ not assumed to be a copy of theirs.Blocked-by: #15256is discharged, verified rather than assumed: #15256 is CLOSED (completed, 13:58:49Z) with PR #15365 MERGED. ⇒pm:queueis correct.Bugby the mechanical boundary test — the accept set does not widen, a declared guard returns to enforced.priority:p1rather than the parent'sp0: the p0 arm is repaired and merged.Related in the same package: #15169 (
finding,repo:cloud) asks forbuildFileReadAuthorizerand its neighbours to be exported so cloud can bridge storage. Different change, same authorizer — ⛔ not a dependency, but worth reading before dispatch so the two do not collide in one file.
Generated by Claude Code
Claimed by the PM dispatch loop.
Claim: session
session_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-15352-storage-tenancy-posture.
Clause-②: no.⚠️ Thatnois this seat's reading and is NOT binding on you. Re-derive the tier from your own diff and re-declare it. This seat got a tier call wrong in BOTH directions today — on #14552 it predicted a park that measurement retired, and on #14646 it declarednofor a diff that added threepackages/specapi-surface members. Both were caught only because the round re-measured.
⚠️ Instrument limit:api-surface/artifacts exist only forpackages/spec. For a service package the published surface is itsfiles[]+types(dist/**) — sogit diff … -- packages/spec/api-surface/proves nothing here. Ask what actually lands indist/.No fresh ruling needed: #15256's ruling (2026-09-04, item 5) already directed one card per site.
⚠️ Two statements in this card's body are now FALSEMeasured by the #15349 round (PR #15996) and verified by this seat:
- The census is 4 of 8, not 2. This card says exactly two callers supply a posture. It is four:
rest-server.ts,runtime/security/resolve-execution-context.ts,mcp, andcloud-connection. ⛔ Measure the tree; do not repeat the card's number. groupis NOT unaffected. The card's unblock comment says thegroupwrite path was never measured. It has been: the ex-member refusal fires exactly as underisolated, while an organization-less key stays admitted by design.
Readings that hold
effectiveTenancyPostureis exported from@objectstack/core(packages/core/src/security/index.ts:93).- ⭐ No importable posture-resolver exists.
resolveServiceOrLoudisprivateatpackages/runtime/src/http-dispatcher.ts:2180, 2 uses, both in that file. - ⭐ ⛔ Do NOT extract a shared helper. service-datasource: the admin routes supply no
tenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15350 (PR fix(service-datasource): the admin routes derive the tenancy posture — an ex-member's org-stamped API key is no longer admitted #16011) and service-settings: the manifest gate supplies notenancyPosturetoresolveAuthzContext— and returns the unvettedtenantIdto its callers #15351 are in flight on this same seam. The landed sibling fix(plugin-sharing): the share-link admission seam derives the tenancy posture — an ex-member's org-stamped API key no longer mints links into the organization it left #15996 deliberately wrote a privateresolveAdmissionTenancyPosturecopying the structure ofrest-server.tsrather than extracting. Follow that precedent; report it if you disagree and this seat files it as its own card. - Structure to copy:
packages/rest/src/rest-server.ts:2458-2502— never-registered (isServiceNotRegisteredError) ⇒ quietundefined; every other rejection ⇒AuthzStoreUnavailableError('tenancy', err).
Acceptance shape is #15365's, which #15996 followed: a wiring ablation held permanently in the test (omit the provider ⇒ the ex-member is admitted again; wire it ⇒ refused and nothing lands), writes read back from the store rather than the response body, and controls in both directions.
⚠️ Already filed, ⛔ do not re-file: #15997 (on aKernelBase/LiteKernelhost none of these seams resolve a posture — ruled behaviour, family-wide, not yours here).⚠️ CI is currently unreliable and it is not your fault.check-regen-pending.mjs --self-testcan red an innocent PR through a Corepack store holding an unpinned pnpm; it reddenedorigin/main's own push build and four PRs today. Filed as #15990, fix open as PR #16009, hardening as #16012. IfLint & Repo Gatesfails oncheck:merge-driverwith✗ self-test failed -- 1 failure(s) (cases and floor), ⛔ that is not yours — say so and move on.
Generated by Claude Code
- The census is 4 of 8, not 2. This card says exactly two callers supply a posture. It is four:
os-dev-report
{ "issue": 15352, "status": "done", "branch": "claude/issue-15352-storage-tenancy-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/16017", "premise_still_valid": true, "summary": "buildFileReadAuthorizer in packages/services/service-storage/src/storage-service-plugin.ts now derives the deployment's effective tenancy posture per download and threads it into resolveAuthzContext, so organization_required and organization_membership_ended both run at the storage download door. The posture read is a LOCAL resolveAdmissionTenancyPosture (no shared helper extracted, per the dispatch) carrying #13906 decision 1 option A: never registered means branded means quiet undefined; registered and unable to answer raises AuthzStoreUnavailableError, raised inside the authorizer's own try so it takes the #13279 relay already there rather than a new net. Two of the card's statements were false on the current tree and are corrected in the PR body rather than repeated: the census of posture-supplying resolveAuthzContext callers is 4 of 8 on origin/main (rest-server, runtime/security/resolve-execution-context, mcp, cloud-connection), not 2; and group is NOT unaffected -- the ex-member refusal fires under group exactly as under isolated, while the organization-less key stays admitted there by design. Both directions are pinned. The card's stated landing point (line 841) was confirmed on the current tree before any edit. Docs-drift question closed out inside the round with a measured NULL, read per file with positive controls -- see `tests` item 8; the one page that documents this door by its inputs (permissions/attachments-access.mdx, the drift check's declared blind spot) was read in full and states nothing this change falsifies.", "tests": "ALL FIGURES BELOW ARE FROM final commit bf004da92 (git rev-parse --short HEAD at the run). (1) pnpm --filter @objectstack/service-storage test -> exit 0, 'Test Files 36 passed (36) / Tests 562 passed (562)', of which the new src/file-read-tenancy-posture-admission.test.ts contributes 26. (2) pnpm --filter @objectstack/service-storage typecheck -> exit 0; 'check:test-typecheck: OK -- @objectstack/service-storage's test layer compiles under tsconfig.test.json; 0 file(s) / 0 error(s)'. Dependency closure built first: pnpm --filter '@objectstack/service-storage^...' build -> exit 0. (3) pnpm lint -> exit 0. This is the REPO-WIDE eslint run (eslint . --no-inline-config), NOT a declared narrowing -- 72s under the shared verify lock. (4) Gate family derived mechanically by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (it read its own change set from merge base f7db8f4fd: 2 paths; 263 families discovered). Run locally, every one exit 0: check:authz-resolver, check:engine-double-contract, check:where-matcher, check:objectql-double-limit, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:slot-lookup, check:error-status-conformance, check:tenant-chokepoint, check:changeset-gate-self-tests, check:objectui-changeset, check:single-claim-paths, check:doc-authoring, check:auth-mount-ledger. The ratchet-bearing subset (nul-bytes, where-matcher, objectql-double-limit, slot-lookup, engine-double-contract, doc-authoring, authz-resolver, cross-package-test-inputs, changeset-gate-self-tests) was re-run AFTER the final commit, at bf004da92, all exit 0. Exit codes were captured before any pipe (cmd redirected to a file, EXIT read, then tail) -- no pipeline read a status. (5) ABLATION, held PERMANENTLY in the test (section 4). It is a WIRING ablation, not a filesystem one: the harness mounts a PluginContext with no getKernel at all -- byte-identically what storage-service-plugin.test.ts's makeCtx still mounts -- so no source mutation and no rebuild are involved, and there is nothing on disk to restore. Ablated, the ex-member's org-stamped key downloads again: 200 with a live signed URL, and getPresignedDownload called once (the capability read back off the ADAPTER, this read-only door's analogue of the sibling cards' read-the-write-back-from-the-store). Wired under isolated: 401 AUTH_REQUIRED, 0 minted. Controls hold in both directions and are unchanged by the ablation: current member 200, no credential 401, unreachable parent 403 FILE_DOWNLOAD_DENIED (so a door refusing for the wrong reason cannot pass). (6) OUTAGE ARM MEASURED, NOT ASSUMED -- and it is neither 503 nor the 500 measured next door: this door answers 403 FILE_DOWNLOAD_DENIED on a registered-and-failed tenancy service, on both download routes, for all three principals, with 0 capabilities minted. Measured by a throwaway probe test run inside the package and deleted in the same shell (its absence verified). Cause: registerStorageRoutes' authorizeDownload wraps the authorizer in catch { verdict = 'deny' }, absorbing the #13279 re-raise one frame up; pre-existing, filed onto #15999. The pin therefore asserts the outage CLASS ([403,500,503], never 200, never 302, never a minted capability), so a later status repair need not redden a security test. (7) CLAUSE-2 INSTRUMENT -- a real file-swap ablation, with rebuilds, run as one script under one shell with an EXIT/INT/TERM trap and absolute paths. Legs: build WITH the change; swap in BASE's copy of the one changed source (git show f7db8f4fd:path); PROVE the mutation on disk BEFORE measuring -- resolveAdmissionTenancyPosture occurrences 3 -> 0, 'getSession, tenancyPosture' 1 -> 0, git hash-object == BASE blob b1d817d3b3e9152139a9a0faecd1e92af186e27d (which differs from the HEAD blob b08838c3a2b5130fcc3f65898f2aeed53d25bf07 -- the instrument's positive control); rebuild; then RESTORE with git checkout HEAD -- path and PROVE the restore -- hash-object == HEAD blob, git diff HEAD 0 lines, marker counts back to 3 and 1; then a third rebuild that reproduces leg 1's dist byte for byte. Result: dist/index.d.ts sha256 c4fb9b3dd402b03cf542bb5e27429f47caa60c9e5c3bf192da11571e1f05045c and 500960 bytes in BOTH legs -- IDENTICAL. Every leg's build exited 0 and each was asserted before its measurement was read. (8) DOCS-DRIFT VERDICT -- MEASURED NULL, read PER FILE, not by a lane-wide grep. Corpus 405 content/docs pages; enumeration greps carried a positive control on the identical command and scope (the token 'objectstack' fires in 357 of 405; a synthetic token fires in 0), and every per-file zero-hit claim below carries its own same-shape control. Candidates enumerated three ways -- API-key admission terms (x-api-key / active_organization_id / sys_api_key / 'API key'), tenancy-posture terms (tenancyPosture / 'tenancy posture' / postureEnforcesWall / organization_membership_ended / organization_required), and storage-download terms (sys_file / /storage/files / attachment / presigned) -- then READ per file. content/docs/permissions/attachments-access.mdx is the page that documents THIS door by its INPUTS -- exactly the blind spot the drift check declares it cannot see from an emitter-only diff -- so it was read IN FULL (141 lines). Its Download section states 'session + owner-or-parent-read', AUTH_REQUIRED 401 and ATTACHMENT_DOWNLOAD_DENIED 403; it makes NO claim that an API key is admitted on its stored active_organization_id, NO claim that this door performs no tenancy/posture check, and carries no worked example whose outcome moves under a wall-enforcing posture. Also read per file: kernel/runtime-services/storage-service.mdx and kernel/contracts/storage-service.mdx (adapter method signatures only, no admission prose; controls 36 and 32), references/system/object-storage.mdx (AUTO-GEN schema reference, bucket ACL metadata only), permissions/authentication.mdx (ZERO api-key mentions -- control fired 39 on the same command shape; it entered the candidate list only on the phrase 'walled tenancy posture'), permissions/authorization.mdx (one API-key mention, an ADR-0101 link line about MCP stdio; control 60), protocol/kernel/http-protocol.mdx and api/index.mdx and api/client-sdk.mdx and api/error-catalog.mdx (how to PRESENT a key, never what admission decides), and deployment/tenancy-modes.mdx (ZERO hits for api-key / download / storage / file -- control 56 on the same command shape). VERDICT: no content/docs page states, in prose, anything this change falsifies. Nothing edited; content/docs/releases/** untouched.", "mcp_calls": "3 -- issue_read(15352), create_pull_request(16017), add_issue_comment(15999). This report comment went over authenticated repo-scoped REST, not MCP. Every read went over repo-scoped REST too (probe: GET /repos/objectstack-ai/objectstack -> 200; gh CLI absent; GH_TOKEN present, so writes work there as well -- one accidental one-word probe comment on this card was posted while establishing that and DELETED in the same step, HTTP 204, thread verified back to its original three comments). CHANNEL SWITCH DECLARED: the REST SEARCH endpoint alone is refused in this container ('This GitHub API path is not available: sessions are bound to their configured repositories'), so pre-file dedup for the outage finding was done by reading the named neighbour #15999 directly over repo-scoped REST rather than by any search, and the second finding is handed to the PM unfiled for exactly that reason. No wide-table scan on either channel.", "open_questions": [], "out_of_scope_findings": [ "NOT re-filed, MEASURED ONTO #15999 instead (comment 5554157736): at the storage download door the #13279 outage relay is annulled one frame up -- registerStorageRoutes' authorizeDownload wraps the authorizer in catch { verdict = 'deny' }, so a registered-and-failed tenancy service (and, since #13279, an unreachable permission store) renders as 403 FILE_DOWNLOAD_DENIED, an outage answered as a capability denial. #15999 already names storage-service-plugin.ts:921 in its census of six re-raise sites and records the other five as explicitly UNMEASURED, so this is the datum that card asks for rather than a new card; filing a second one would be the re-file the dispatch forbids. The comment also answers #15999's own 'two directions' question for this door: its direction 2 (a shared renderer for an escaped envelope) cannot help here because nothing escapes, and direction 1 is two edits at this door, not one. Fail-closed throughout, 0 capabilities minted. Still unmeasured after the comment: service-settings, cloud-connection, plugin-sharing.", "NOT FILED -- handed to the PM to file, because it is FAMILY-WIDE rather than this card's and the REST search endpoint is refused in this container (so I cannot dedupe it against whatever the siblings may already have opened; filing blind is the one failure mode the dedupe rule exists to stop). content/docs/permissions/attachments-access.mdx's 'Download -- authenticated and parent-scoped' section is now INCOMPLETE rather than wrong: its AUTH_REQUIRED (401) row reads 'Anonymous download of an attachments-scope file', while under a wall-enforcing posture the same 401 is now also the answer for an ex-member's org-stamped API key (isolated and group) and for an organization-less key (isolated only). Not a falsified claim -- the row was already a simplification before this card, since an unknown / revoked / expired key has always produced the same 401 -- so it is an addition, not a correction, and it is true of all four repaired doors (#15349 / #15350 / #15351 / #15352), not just this one. One family-wide docs card after the family lands is the right shape; a per-card edit would write the same paragraph four times." ] }
Generated by Claude Code
os-dev-report
{ "issue": 15352, "status": "done", "branch": "claude/issue-15352-storage-tenancy-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/16017", "premise_still_valid": true, "summary": "MERGE ROUND ONLY -- no implementation change. origin/main merged into the branch and pushed: bf004da92..337bc54ec, an ordinary merge commit (no rebase, no amend, no force-push, no empty commit). The incoming range was exactly one commit, f50c394da (#16002, 'pin the regen self-test fixture to the root's packageManager'), touching exactly one file, scripts/check-regen-pending.mjs. That file set is DISJOINT from this branch's three, git reported no conflicts, and the path is not merge-driver-managed (git check-attr merge -> 'unspecified'), so no os-regen deferral was created or owed. Nothing was regenerated because nothing generator-owned moved. The PR's file list is unchanged at 3 -- scope not widened -- and it is still a draft. The findings from the first round are where the PM placed them: #16018 (family-wide docs addition) and #15999 (the 403 outage measurement).", "tests": "ALL FIGURES BELOW ARE FROM the merged head 337bc54ec, re-run AFTER the merge commit, not before it. (1) THE BLOCKER, verified by running the gate rather than by trusting the API: pnpm check:merge-driver -> exit 0, 'check-regen-pending self-test passed.' -- the '1 failure(s) (cases and floor)' signature is gone on this tree. (2) Dependency closure rebuilt in the fresh worktree, then the package's own suites, folded into ONE lock acquisition to cut my share of a congested queue: pnpm --filter '@objectstack/service-storage^...' build && pnpm --filter @objectstack/service-storage test && pnpm --filter @objectstack/service-storage typecheck -> os-verify-lock VERDICT command-exit 0; 'Test Files 36 passed (36) / Tests 562 passed (562)'; 'check:test-typecheck: OK -- 0 file(s) / 0 error(s)'. (3) Ratchet gate union re-run at 337bc54ec, all exit 0: check:nul-bytes, check:where-matcher, check:objectql-double-limit, check:slot-lookup, check:engine-double-contract, check:doc-authoring, check:authz-resolver, check:cross-package-test-inputs, check:changeset-gate-self-tests. (4) Push verified by content, not by the push's exit code alone: local HEAD == fetched remote HEAD == 337bc54ec684ce009a1708db7cefcaba3c946cde; the PR's head sha matches, draft still true, changed_files still 3 (the merged-in scripts/check-regen-pending.mjs correctly does not appear, being identical to main). Exit codes captured before any pipe throughout. Scope of the re-run is DECLARED rather than assumed complete: AGENTS.md's post-merge rule asks for the full suite only when the incoming commits touch the same packages or behaviour as the diff, and one standalone gate script touches neither -- so this was scoped to the package plus the ratchet union plus the blocker gate itself.", "mcp_calls": "0 for this round -- the merge, the push and this report all went over git and authenticated repo-scoped REST. Round total across both rounds remains 3.", "open_questions": [], "out_of_scope_findings": [ "REPORTED, NOT TOUCHED -- shared state belonging to another agent. The common .git dir carries an outstanding os-regen deferral marker, /home/user/objectstack/.git/os-regen-pending, timestamped 12:36 (about six hours before this session) and naming content/docs/permissions/system-context.mdx and content/docs/references/index.mdx. Neither path is in my diff and neither round of mine created it. It did not block me -- check-regen-pending resolves the PER-WORKTREE git dir (--absolute-git-dir from a linked worktree), and node scripts/check-regen-pending.mjs --pre-push exited 0 from my worktree -- but it is an undischarged deferral sitting in state every worktree shares, so whichever agent's merge recorded it still owes the regeneration, and its own next commit will be refused until it does. Flagged rather than cleared: clearing another agent's debt is exactly the shared-state edit the discipline forbids." ] }
Generated by Claude Code
Censused under #15256 (maintainer ruling 2026-09-04, item 5). That card repaired the REST single-kernel seam; this is the same hole through another door.
This site
packages/services/service-storage/src/storage-service-plugin.ts:841Headers come from the real request via
toWebHeaders(req), sox-api-keyis accepted. No posture, so an ex-member's stamped key resolves auserIdand proceeds intobuildFileReadAuthorizer's ownership and record-reachability checks — which are then evaluated for a principal the wall should have refused at the door.The mechanism, unchanged from #15256
resolveAuthzContextgates BOTH posture-conditional API-key refusals on atenancyPostureits caller supplies:organization_required—packages/core/src/security/api-key.ts,if (!tenantId && tenancyPosture)organization_membership_ended—packages/core/src/security/resolve-authz-context.ts,if (keyPrincipal?.tenantId && input.tenancyPosture)No posture supplied means neither guard runs. The key's
tenantIdissys_api_key.active_organization_idcopied verbatim — the caller's own stored claim, never vetted against current membership. So under a wall-enforcing posture (isolated), an API key stamped with an organization its owner has left is admitted, carrying that organization as its tenant.The census this came from
Of the eight non-test
resolveAuthzContextcallers onmain, exactly two supply a posture:packages/rest/src/rest-server.ts— both wirings, after [decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring underisolated— the wall compares against the caller's own unvetted claim #15256packages/runtime/src/security/resolve-execution-context.ts— already didThe other six do not. This issue is one of them; the ruling directed one issue per site.
Why #15256's PR did not fix it inline
Ruling item 5 says fix in that PR if it is one line per site. It is not. A correct derivation has to carry decision-1-option-A's classification (#13906): a
tenancyservice that was never registered is branded and resolves quietly to "no posture", while one that was registered and FAILED to build must raiseAuthzStoreUnavailableError(503). A naivetry { ... } catch { undefined }at this seam re-introduces precisely the permissive-on-failure defect #13906 was filed to repair — a failure reading as "this check does not apply".Refs: #15256 (the ruling and the repaired REST seam) · #15163 (the framework measurement) · #13906 (decision 1 option A) · ADR-0105 D2/D3 · ADR-0123 D2.
Blocked-by: #15256