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
Conversation
…ission seam `resolveAuthzContext` gates every posture-conditional refusal on a posture its caller supplies; this door supplied none, so an API key stamped with an organization its owner has left was admitted carrying that organization as its tenant. Classified per #13906 decision 1 option A: never registered stays quiet, every other rejection becomes AuthzStoreUnavailableError (503). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…wiring ablation and the 503 outage arm Eight sections over the real booted plugin: controls in both directions, the ex-member and organization-less API keys under `isolated`, the ablation that removes the `tenancy` service and brings the leak back, the registered-and-broken outage (503, never a quiet undefined), the #15409 session arm, `group` measured rather than assumed, and the seam testkit now able to express a posture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…he system-context census check:slot-lookup reddened on the new test's `const ctx: any` host double — the one shape that gate exists to keep contracted. The double is now typed and the cast narrowed to the single hand-over point. check:system-context-census re-anchored one page citation the seam edit moved (line rot, --fix). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 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 2b917dd72de544876e785d23ab554ed075cf1846 && git checkout 2b917dd72de544876e785d23ab554ed075cf1846
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6615a024c33a9204a7a88273aa40b9ee107f78e3 9c00ac6027ad14e7003d129b878ef25fc671a8f6 && git checkout -B drift-repro 6615a024c33a9204a7a88273aa40b9ee107f78e3 && git merge --no-ff 9c00ac6027ad14e7003d129b878ef25fc671a8f6
node scripts/docs-audit/affected-docs.mjs --json 6615a024c33a9204a7a88273aa40b9ee107f78e3 |
|
Standing down on the red The failing check: Why it is not this PR's — measured on #15980 by the round that first hit it, and filed as #15990: The self-test's fixture writes a stub The 2×2 that establishes tree-independence, exit codes captured before any pipe:
Severity bound: this can only false-RED, never false-green. A failing launcher makes every artifact look stale, and Is there a fix to port? Not yet. #15990 is dispatched and in flight; no patch exists to carry into this PR. When it lands, merging What this comment is not: ⛔ no test was skipped, disabled or quarantined; ⛔ no empty commit; ⛔ the registry was not widened. Per the CI rules I am now spending the one permitted re-run on this PR, which is unspent. If it reproduces — as it did on #15980, byte-identically — that is a second real failure and this PR waits on #15990 rather than being worked around. Generated by Claude Code |
…aring-tenancy-posture
Fixes #15349
Censused under #15256 (maintainer ruling 2026-09-04, item 5): one card per site, and this one is not one line, so it is its own PR.
What was open
SharingServicePlugin's share-link door resolved its caller withresolveAuthzContext({ ql, headers, getSession })— notenancyPosture. The resolver gates every posture-conditional refusal on a posture its caller supplies, so at this door none of them ran: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)activeOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 session arm beside it, which drops anactiveOrganizationIdclaim nosys_memberrow backsAn API key's tenant is
sys_api_key.active_organization_idcopied verbatim — the caller's own stored claim, never vetted against current membership. So under a wall-enforcing posture, a key stamped with an organization its owner has left was admitted here carrying that organization, andcreateLinkminted a capability token on a record inside it.resolveTokenthen serves that record anonymously under a system context for as long as the link lives, which makes this write worse than an ordinary row.The classification — #13906 decision 1 option A, not a
catch { undefined }resolveAdmissionTenancyPosture(private, in this plugin) copies the structure ofpackages/rest/src/rest-server.tsand of the sibling plugin-shaped repair inpackages/cloud-connection/src/marketplace-install-local-plugin.ts(#15353) — structure, not an import:isServiceNotRegisteredError) ⇒ quietundefined. An embedding with noplugin-authis a supported composition and behaves exactly as before.AuthzStoreUnavailableError⇒SERVICE_UNAVAILABLE/ 503. Admission was never decided, so it must not be answered.Two things this deliberately does not reuse, both flagged on the card: this plugin's existing
tenancyprobes (SharingService'stenancy: () =>option atsharing-plugin.tsandsharingPosture()) swallow failure as absence, which is exactly the permissive-on-failure defect #13906 exists to repair — and their walled fallback answers a seeding question, whereundefined("run no posture-conditional refusal") and'single'("a posture that enforces no wall") are not interchangeable.The brand exists only on the async resolution path —
PluginContext.getServicethrows two unbranded plainErrors — so the read goes throughctx.getKernel()?.getServiceAsync. AKernelBase-shaped host (LiteKernel) exposesgetKernel()and nogetServiceAsync, and registers no service factories at all, so absence is the only fault it can report: it keeps the quiet answer, unchanged, exactly as the two sibling seams left it.Verified, not assumed: the outage really reaches the wire
The card asked for the outer net to be checked rather than trusted. It holds, and §5 of the new suite measures it end to end:
verifiedContextFromRequest's catch re-raises the brand (#13279), the route's own catch answerserr.status ?? 500, andAuthzStoreUnavailableErrorcarriesstatus = 503/code = SERVICE_UNAVAILABLE. Measured at the door: 503, on the ex-member arm and on a healthy member and on an anonymous caller — the 401 is not allowed to swallow it.Acceptance evidence
packages/plugins/plugin-sharing/src/share-link-tenancy-posture-admission.test.ts— 28 cases over the real booted plugin (nothing stubs the resolver, the closure, or the route registration), on a realObjectKernelso the branded/unbranded rejection classification is the registry's own. Shape taken from #15365's acceptance rather than reinvented:isolatedorg_alphaorg_alpha's recordsys_share_linkin the fixture table), never from the response body.organization_id = context.tenantId, deny when there is no tenant), with a second organization seeded so "the wall is live" is a control (§1) and not an assumption.tenancyservice, byte-identical fixture otherwise, and the leak returns — the ex-member mints onorg_alphaagain at 201, the member control is unchanged, and no refusal line is written because no refusal was decided.tenancyfactory that throws ⇒ 503 on every arm, and nothing lands.Red-then-green, measured. With only
sharing-plugin.tsreverted toorigin/main(0cf086759) and the suite unchanged: 10 failed / 15 passed, and the ten are exactly the subject assertions — §2 (both verbs plus the refusal line), §3 (both), §5 (all three), §6's session drop, §7'sgrouprefusal. Every control, every ablation arm and the narrowness case stayed green. The mutation was proved on disk by blob hash before the run and the restore proved back to the HEAD blob afterwards; no rebuild leg was owed because the suite imports the plugin source relatively and the only built dependency it reads,@objectstack/core, was never touched.The second call site the card named
packages/plugins/plugin-sharing/src/exec-context-seam.testkit.tsis threaded too, via a new optional second argumentSeamDeployment(posture defaults to absent, so every existing caller keeps a byte-identical envelope — pinned by §8). It is a testkit, not a live door — but it was equally posture-less, so a test written through it could not reproduce a posture-conditional verdict at all, which is that file's own failure mode one level up. §8 gives the parameter a caller and measures both directions.Also in this diff
content/docs/permissions/system-context.mdx— one anchor re-pointed fromsharing-plugin.ts:1088to:1189, pure line rot from the seam edit, produced bynode scripts/check-system-context-census.mjs --fix.any:check:slot-lookupreddened on it, correctly — that gate exists to keep exactly these slot lookups contracted — so the cast is narrowed to the single hand-over point.Verification
pnpm --filter @objectstack/plugin-sharing test— 34 files / 839 passedpnpm --filter @objectstack/plugin-sharing typecheck— clean;check:test-typecheckOK, the ledger's 3 pre-existing errors over 2 files unchanged, andtsc --listFiles -p tsconfig.test.jsonconfirms both edited/added test-layer files are inside that program (so the pass is about them, not around them)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— run as one union after the final commit, at9fb60eef6: 57/57 exit 0pnpm lint— the repo-wideeslint . --no-inline-config, exit 0 (not narrowed)Measurements worth recording
0cf086759, four of the eight non-testresolveAuthzContextcallers supply a posture —rest,runtime,mcpandcloud-connection(cloud-connection: the marketplace-install route supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15353) — not two. The four still without it are this site plusservice-datasource/src/admin-routes.ts,service-settings/src/settings-service-plugin.tsandservice-storage/src/storage-service-plugin.ts.groupwas measured, not assumed unaffected: the ex-member's stamped key is refused under it too (organization_membership_endedkeys onpostureEnforcesWall), while the organization-less key is admitted by design (organization_requiredadditionally requires!postureUsesUnionScope). Both are pinned in §7.bootRequestContext,SeamPrincipalandSeamDeploymentare absent frompackages/plugins/plugin-sharing/dist/**(the testkit is not on the package's entry). The only dist movement is the type-erased private marker lineprivate resolveAdmissionTenancyPosture;inindex.d.ts— identical to what the already-merged sibling repair emits, not callable and carrying no type.Generated by Claude Code