Repository navigation
plugin-sharing: the exec-context seam supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key keeps its claim #15349
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/plugins/plugin-sharing/src/sharing-plugin.ts:847⇒domain:services(plugin-sharingfolded into that lane on 2026-08-19, whendomain:identitywas retired from circulation). 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 issharing-plugin.ts:847.⚠️ One reading the card does not carry, and the dispatching seat should not be surprised by. The same grep shows a secondplugin-sharingcall site —src/exec-context-seam.testkit.ts:96. It is a testkit rather than a live door, so it is outside the census of eight non-test callers, but it is inside the package and will need the same posture threading or an explicit note saying why not. ⛔ Triage has not judged which.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.
Generated by Claude Code
Claimed by the PM dispatch loop.
Claim: session
session_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-15349-sharing-tenancy-posture.
Clause-②: no.Basis for the clause-② call, stated so it can be falsified rather than taken: the maintainer ruling of 2026-08-28 draws the negative boundary as 「运行时权限/安全行为变更不是条款② …… 条款②只指已发布契约面」. This card changes a runtime authorization decision and adds no published contract face. ⇒ normal review tier.
No fresh ruling is needed to dispatch this. #15256's ruling (2026-09-04, item 5) already directed one card per site and said fix-inline only where it is one line per site — which this is not, for the reason the card body gives.
Three readings measured on
origin/mainjust now, which the card does not carry and which change how this should be fixed:-
effectiveTenancyPostureis exported from@objectstack/core(packages/core/src/security/index.ts:93). That much is reusable. -
⭐ There is no importable posture-resolver.
resolveServiceOrLoudis declaredprivateatpackages/runtime/src/http-dispatcher.ts:2180and used at exactly two sites, both inside that one file. So the classification shape has to be reproduced, not imported. ⛔ It should not be extracted into a shared helper in this PR either: cards service-datasource: the admin routes supply notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15350, service-settings: the manifest gate supplies notenancyPosturetoresolveAuthzContext— and returns the unvettedtenantIdto its callers #15351 and service-storage: the file-read authorizer supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key authenticates for downloads #15352 are live on this same seam, and a helper extracted by one collides with the other three. If the implementing seat judges extraction correct, it belongs in its own card. -
⭐ The probe this plugin already has cannot be reused as-is — which is the one point the card's own "cheapest of the six" note could mislead on. Both existing resolutions swallow failure as absence:
sharing-plugin.ts:566-567istry { ctx.getService('tenancy') } catch { return null; }, and:668iscatch { /* absent — fall through to the walled default */ }. That is precisely the permissive-on-failure shape [finding] two morecomputeExecCtxseams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 was filed to repair; reusing either accessor would re-open the hole this card closes.
The correct structure to copy is
packages/rest/src/rest-server.ts:2458-2502: never-registered (isServiceNotRegisteredError) ⇒ quietundefined; every other rejection ⇒AuthzStoreUnavailableError('tenancy', err). The outer net atsharing-plugin.ts:853already re-raises rather than laundering an outage into a 401 ([#13279]), so a thrown error should propagate correctly — to be verified, not assumed.Triage's open question about the second call site (
src/exec-context-seam.testkit.ts:96) is carried into the dispatch: thread the posture there too, or state why not.
Generated by Claude Code
-
os-dev-report
{ "issue": 15349, "status": "done", "branch": "claude/issue-15349-sharing-tenancy-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/15996", "premise_still_valid": true, "summary": "The share-link admission seam in packages/plugins/plugin-sharing/src/sharing-plugin.ts now derives the effective tenancy posture and passes it to resolveAuthzContext, so all three posture-conditional refusals become reachable at this door: organization_required, organization_membership_ended, and the #15409 session-claim drop. The derivation is a private resolveAdmissionTenancyPosture that copies the STRUCTURE of rest-server.ts and of the sibling plugin-shaped repair in cloud-connection (#15353) — never registered is branded and resolves quietly to undefined, every other rejection becomes AuthzStoreUnavailableError, which the outer net re-raises and the route answers as SERVICE_UNAVAILABLE / 503. No shared helper was extracted (sibling cards #15350/#15351/#15352 are live on this seam); nothing was imported from http-dispatcher's private resolveServiceOrLoud. The testkit at exec-context-seam.testkit.ts was threaded too, via a new OPTIONAL second argument so every existing caller keeps a byte-identical envelope. Two PM readings updated by measurement: the census is now 4 of 8 callers supplying a posture, not 2 (rest, runtime, mcp, cloud-connection); and `group` is NOT unaffected — its ex-member refusal fires exactly as under `isolated`, while its organization-less key stays admitted by design.", "tests": "Union of the whole gate family run AFTER the final commit, at 9fb60eef6 (git rev-parse --short HEAD from that run). Every exit code captured before any pipe (cmd > file 2>&1; EXIT=$?). (1) NEW SUITE: pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2 src/share-link-tenancy-posture-admission.test.ts -> 'Test Files 1 passed (1) / Tests 28 passed (28)', exit 0. (2) RED-THEN-GREEN, the permanent reverse verification: with ONLY sharing-plugin.ts restored to origin/main (0cf086759) and the suite untouched -> 'Test Files 1 failed (1) / Tests 10 failed | 15 passed (25)', exit 1. The ten reds are exactly the subject assertions (section 2 both verbs plus the refusal line; section 3 both; section 5 all three 503 arms; section 6 session drop; section 7 group refusal); every control, every ablation arm and the narrowness case stayed green. MUTATION PROVED ON DISK: git hash-object after checkout equalled the base blob 2d57974679f1697d39d3c1a929618d036d599148 and the anchored greps read resolveAdmissionTenancyPosture=0 / bare-call=1. RESTORE PROVED: hash back to the HEAD blob 8d17bf6064d2661bc84bcc193f2c325414a3a66c, git diff HEAD empty (0 bytes), git status porcelain empty; the script carried trap '...' EXIT INT TERM with an absolute REPO_ROOT path and restored with `git checkout HEAD -- ABS`, never a bare checkout. NO REBUILD LEG WAS OWED and this is stated rather than skipped: the suite imports ./sharing-plugin.js RELATIVELY so vitest runs plugin-sharing SOURCE; the one built dependency it reads is @objectstack/core's dist, built fresh beforehand (grep of packages/core/dist/index.js: organization_membership_ended 3, organization_required 1, warnSessionOrganizationClaimDropped 2, effectiveTenancyPosture 2) and never mutated. (3) PACKAGE: pnpm --filter @objectstack/plugin-sharing test -> '34 passed (34) / 839 passed (839)', exit 0. (4) TYPECHECK: pnpm --filter @objectstack/plugin-sharing typecheck exit 0; verdict line 'check:test-typecheck: OK — ... 2 file(s) / 3 error(s) / 3 pinned signature(s) held in test-typecheck-debt.json'; those 3 are the pre-existing ledger entries in position-graph.test.ts and sharing-rule.test.ts, unchanged. NOT-MEASURED trap closed explicitly: tsc --listFiles -p tsconfig.test.json shows 1 hit each for share-link-tenancy-posture-admission.test.ts and exec-context-seam.testkit.ts, so both edited test-layer files are really inside that program. (5) GATE FAMILY: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 57 families (43 matched + 7 convention/always-runs headings; the tool's own Reconciliation line says 57) — harvested with --commands and all 57 run in one union at 9fb60eef6: 57 of 57 exit 0. Two went red on the way there and were repaired in-tree, not routed around: check:slot-lookup on the new test's `const ctx: any` host double (typed properly, cast narrowed to one hand-over point) and check:system-context-census line rot (one anchor re-pointed by its own --fix). check:i18n first answered exit 3 = PREREQUISITE NOT MET, read as NOT MEASURED rather than green or red, and re-run to exit 0 after building the closure its own message names; check:dual-build-cjs-loads and check:type-check-debt likewise needed the workspace build and then passed ('check-type-check-coverage --re-measure: OK — 12 ledger entr(ies) re-measured, 140 raw tsc error(s), none above its recorded number'). (6) REPO-WIDE: pnpm lint (eslint . --no-inline-config) exit 0 — run in full, NOT narrowed, so no narrowing declaration is owed. (7) Control-byte scan of the edited source: grep -naP over the seam file exits 1 (no control bytes), captured before any pipe.", "mcp_calls": "2 — create_pull_request and this report comment. Everything else went through git, the repo-scoped REST read (probed 200 first), and the public-repo page payload for the card body; no search calls were needed because no new issue had to be filed.", "open_questions": [ { "question": "A KernelBase-shaped host (LiteKernel: serverless / edge) exposes getKernel() but no getServiceAsync, so all four repaired seams resolve NO posture there even when plugin-auth has registered `tenancy` as a plain instance. That is the ruled behaviour (#15256's landed comment says such a host 'keeps the previous quiet answer, unchanged'), and I did not deviate from it. But it is a family-wide residual, not a per-card one: on such a host the guards this card restores stay unreachable. Is that worth its own card across all four seams?", "options": [ "A — leave it: the ruling is explicit, the classification is sound on that host (no service factories exist there, so absence really is the only fault it can report), and a per-card deviation would fragment a settled shape", "B — one new card covering all four seams: fall back to the SYNC ctx.getService probe when no async accessor exists, justified by the same fact (KernelBase has no factories, so a throw there can only mean absence)", "C — measure reachability first: establish whether plugin-auth + plugin-sharing actually boot together on a LiteKernel host before spending a card on it" ], "recommendation": "A for this PR (done), then C as a cheap measurement card if you want B decided on evidence. I deliberately did not open B myself: it would re-decide a Zone-1 ruling from the outside, and the fix belongs to all four seams at once or to none." }, { "question": "One dist line moves that is worth your eyes given Clause-2 is `no`: TypeScript emits `private resolveAdmissionTenancyPosture;` into packages/plugins/plugin-sharing/dist/index.d.ts. I judged this NOT a published contract face — it is name-only, type-erased, not callable, the class is already nominally branded by its many existing private members, and the already-merged sibling repair (#15353) emits the byte-identical line in cloud-connection's d.ts. Confirming rather than assuming, because the clause said to stop and say so if a contract face were needed.", "options": [ "A — accept as-is: a private marker is not a contract face, and it matches the merged precedent", "B — re-shape it as a module-level non-exported function so dist moves not at all, at the cost of diverging from the sibling's shape" ], "recommendation": "A. No import, export, type or callable surface is added; bootRequestContext, SeamPrincipal and SeamDeployment are all absent from dist entirely (measured by grep over dist/**), so the testkit change is invisible to consumers." } ], "out_of_scope_findings": [] }
Generated by Claude Code
PM ruling on the two open questions in the round's report. Both measured before answering, because this seat has been wrong on a tier call twice today.
Q2 — the
private resolveAdmissionTenancyPosture;line indist/index.d.ts. Ruled A: accept as-is.Clause-②: nostands.First, a negative result about my own instrument, stated so nobody later mistakes it for evidence:
api-surface/artifacts exist only forpackages/spec— no plugin carries one. So "the PR moves no api-surface artifact" is true and worthless here; the instrument does not cover this package and can neither confirm nor refute. The mechanical check that settled #14646 does not apply to a plugin.The real published surface is
files: ["dist", "README.md", "CHANGELOG.md"]withtypes: "dist/index.d.ts", so the line genuinely does ship. The question was worth asking rather than waving through.It is still not a contract face, on three measured grounds:
- A
privatemember is emitted name-only and type-erased — no signature, no type, not callable, not importable. Nothing downstream can reference it. - Its only semantic effect is nominal branding, and
sharing-plugin.tsalready carries 8privatemembers onorigin/main. The class was already nominally branded; a ninth changes no assignability that the eighth did not already decide. - The precedent is stronger than the report claimed.
@objectstack/cloud-connectionis itself a published package —files: ["dist", …],types: "dist/index.d.ts", notprivate— and it already carriesprivate resolveAdmissionTenancyPostureonorigin/main(src/marketplace-install-local-plugin.ts:1699, merged via cloud-connection: the marketplace-install route supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15353). So the identical shape is already shipping in a published.d.tsunder an accepted decision.
⇒ No declaration flip.
Clause-②: no.Q1 — the
KernelBase/LiteKernelresidual. Ruled A for this PR (as done), then C as its own card. ⛔ Not B.Agreed, and for the reason the round gave rather than a new one: B would re-decide a Zone-1 ruling from inside a single card, and #15256's landed comment is explicit that such a host "keeps the previous quiet answer, unchanged". A per-card deviation would fragment a settled shape across four seams — and this card owns one of them, not the family.
The residual is real and should not evaporate just because A was right: on a
KernelBase-shaped host the guards this card restores stay unreachable. So C is filed as its own card — measure whetherplugin-authandplugin-sharingactually boot together on such a host before anyone spends B. If reachability is nil, B costs nothing to skip; if it is real, B gets decided on evidence and across all four seams at once, which is where it belongs.Two PM readings the round corrected, recorded so the card does not keep misinforming the next reader
- The census is 4 of 8, not 2. The card body (and my claim comment repeating it) says exactly two callers supply a posture —
rest-server.tsandruntime/security/resolve-execution-context.ts. Measured now: four — those two plusmcpandcloud-connection. The card's census was written before the sibling repairs landed and is stale; the sibling cards service-datasource: the admin routes supply notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15350 / service-settings: the manifest gate supplies notenancyPosturetoresolveAuthzContext— and returns the unvettedtenantIdto its callers #15351 / service-storage: the file-read authorizer supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key authenticates for downloads #15352 carry the same stale sentence and their seats should not be surprised by it. groupis NOT unaffected. hotlong's comment (5541719825) said thegroupwrite path was never measured and ⛔ not to assert it was unaffected. The round measured it: the ex-member refusal fires exactly as underisolated, while an organization-less key stays admitted by design. The warning is now discharged by measurement rather than still open.
Generated by Claude Code
- A
os-dev-report
{ "issue": 15349, "status": "done", "branch": "claude/issue-15349-sharing-tenancy-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/15996", "premise_still_valid": true, "summary": "Follow-up round: merged origin/main (f50c394da, carrying #16002) into the branch and pushed. Merge commit 9c00ac602, made with scripts/pm/os-regen-merge.sh — NO rebase, NO amend, NO force-push, NO empty commit. Git reported zero conflicts. The branch delta versus origin/main is still exactly the five files the PR intends, so nothing widened. The blocker is cleared and verified by running it, not by reading a status: pnpm check:merge-driver exits 0 on the merged tree, ending with 'check-regen-pending self-test passed.' ⭐ ONE CORRECTION TO MY PREVIOUS REPORT, and it is the reason this round earned more than a merge: the gate family I reported as '57 of 57 green' was UNDER-DERIVED. dispatch-gates was run at 86cc33e39, one commit BEFORE content/docs/permissions/system-context.mdx entered the change set, and I then harvested --commands from that stale derivation — so the union I ran at 9fb60eef6 used the old 57-command list and never covered the 29 doc families that path pulls in, check:merge-driver among them. Re-derived on the merged tree the card owes 86 families, and all 86 are green at 9c00ac602. The previous report's '57 of 57' was true of what it ran and NOT the card's whole answer; treat 86 as the number.", "tests": "All at the pushed HEAD 9c00ac602 (git rev-parse --short HEAD from these runs), every exit code captured before any pipe. (1) MERGE: scripts/pm/os-regen-merge.sh exit 0 — step 1 'Merge made by the ort strategy', 18 files from main, zero conflicts; step 2 printed the per-path notice 'KEEPING the branch's bytes of content/docs/permissions/system-context.mdx — the branch changed it and main did not', which is the correct side under that script's measured per-file rule; step 3 'merge left no additional changes to commit'. MERGE_HEAD absent afterwards, git status porcelain empty, no os-regen-pending marker (the driver never ran — no os-regen path was touched on both sides), so no deferral was owed and nothing was hand-edited. (2) THE BLOCKER: pnpm check:merge-driver exit 0, verdict line 'check-regen-pending self-test passed.' (3) GENERATED ARTIFACTS, because main moved packages/spec: full workspace build (turbo run build over packages/*, 71 of 71 successful, VERDICT command-exit 0) then pnpm --filter @objectstack/spec check:generated exit 0 — 'All 15 generated artifacts are up to date', so no regeneration commit was owed and none was invented. (4) GATE FAMILY, re-derived on the merged tree: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack now reports 86 families (Reconciliation line), harvested with --commands to exactly 86 runnable lines and all 86 run at 9c00ac602 in two batches: 86 of 86 exit 0, zero non-zero. The 29 additions over my previous list all match via content/docs/permissions/system-context.mdx (confirmed from the tool's own 'matched via' annotations), and they include check:merge-driver, check:docs-single-h1, check:doc-anchors, check:doc-frontmatter, check:docs-redirects, the spec doc gates and the rest of the content/docs family. (5) PACKAGE: pnpm --filter @objectstack/plugin-sharing test -> '34 passed (34) / 839 passed (839)'; pnpm --filter @objectstack/plugin-sharing typecheck exit 0, 'check:test-typecheck: OK — ... 2 file(s) / 3 error(s) / 3 pinned signature(s)', the same pre-existing ledger entries, unchanged. Both through the shared verify lock (VERDICT command-exit 0, held 53s after waiting 8m50s — two earlier attempts returned exit 99 queue-timeout, read as NOT MEASURED and retried on the kept OS_VERIFY_LOCK_SLOT rather than reported as anything). (6) REPO-WIDE: pnpm lint (eslint . --no-inline-config) exit 0, run in full, not narrowed — done during a lock queue interval rather than idling. (7) PUSH: git push (no flags) 9fb60eef6..9c00ac602; remote ref and local HEAD both 9c00ac6027ad14e7003d129b878ef25fc671a8f6; PR 15996 head sha now 9c00ac602, still draft, base main, mergeable_state 'blocked' (checks pending) and NOT 'dirty'.", "mcp_calls": "0 this round — the merge, the verification and the push went through git; the PR head and the report comment went through the repo-scoped REST channel, probed 200 first. Running total for the card: 2.", "open_questions": [], "out_of_scope_findings": [] }
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/plugins/plugin-sharing/src/sharing-plugin.ts:847Real request headers, so
x-api-keyis accepted (readHeader(headers, 'x-api-key'),api-key.ts). No posture.ctx.getService('tenancy')behindSharingTenancyProbe, ADR-0105 D1 late binding) for its own wall reconciliation. So this site may be the cheapest of the six — but the probe's shape and the classification decision-1-option-A requires still have to be reconciled, which is why it is filed rather than patched blind.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