Skip to content

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

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-15349-sharing-tenancy-posture
Sep 5, 2026
Merged

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
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-15349-sharing-tenancy-posture

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

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 with resolveAuthzContext({ ql, headers, getSession }) — no tenancyPosture. The resolver gates every posture-conditional refusal on a posture its caller supplies, so at this door none of them ran:

An API key's tenant is sys_api_key.active_organization_id copied 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, and createLink minted a capability token on a record inside it. resolveToken then 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 of packages/rest/src/rest-server.ts and of the sibling plugin-shaped repair in packages/cloud-connection/src/marketplace-install-local-plugin.ts (#15353) — structure, not an import:

  • never registered ⇒ branded (isServiceNotRegisteredError) ⇒ quiet undefined. An embedding with no plugin-auth is a supported composition and behaves exactly as before.
  • registered and unable to buildAuthzStoreUnavailableErrorSERVICE_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 tenancy probes (SharingService's tenancy: () => option at sharing-plugin.ts and sharingPosture()) swallow failure as absence, which is exactly the permissive-on-failure defect #13906 exists to repair — and their walled fallback answers a seeding question, where undefined ("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.getService throws two unbranded plain Errors — so the read goes through ctx.getKernel()?.getServiceAsync. A KernelBase-shaped host (LiteKernel) exposes getKernel() and no getServiceAsync, 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 answers err.status ?? 500, and AuthzStoreUnavailableError carries status = 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 real ObjectKernel so the branded/unbranded rejection classification is the registry's own. Shape taken from #15365's acceptance rather than reinvented:

credential, isolated before after
no credential (control) 401 / 401 401 / 401 — unchanged
CURRENT member's key (control) 200 / 201, row lands 200 / 201 — unchanged
ex-member, key stamped org_alpha 200 / 201, a share link LANDS on org_alpha's record 401 / 401, nothing lands
organization-less key 200 / 201 401
ex-member's session claim (#15409) 201, link lands 403, nothing lands — still signed in
  • Writes are read back FROM THE STORE (sys_share_link in the fixture table), never from the response body.
  • Controls in both directions, in every section: a door that authenticates nobody cannot pass by refusing for the wrong reason.
  • Layer 0 modelled as the hard equality it is (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.
  • The ablation is held permanently (§4): remove the tenancy service, byte-identical fixture otherwise, and the leak returns — the ex-member mints on org_alpha again at 201, the member control is unchanged, and no refusal line is written because no refusal was decided.
  • §5 is the section the REST sibling did not need: a tenancy factory that throws ⇒ 503 on every arm, and nothing lands.

Red-then-green, measured. With only sharing-plugin.ts reverted to origin/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's group refusal. 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.ts is threaded too, via a new optional second argument SeamDeployment (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 from sharing-plugin.ts:1088 to :1189, pure line rot from the seam edit, produced by node scripts/check-system-context-census.mjs --fix.
  • The new test's host double is typed rather than any: check:slot-lookup reddened 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 passed
  • pnpm --filter @objectstack/plugin-sharing typecheck — clean; check:test-typecheck OK, the ledger's 3 pre-existing errors over 2 files unchanged, and tsc --listFiles -p tsconfig.test.json confirms both edited/added test-layer files are inside that program (so the pass is about them, not around them)
  • All 57 gate families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands — run as one union after the final commit, at 9fb60eef6: 57/57 exit 0
  • pnpm lint — the repo-wide eslint . --no-inline-config, exit 0 (not narrowed)

Measurements worth recording

  • The card's census has moved since it was filed. On 0cf086759, four of the eight non-test resolveAuthzContext callers supply a posture — rest, runtime, mcp and cloud-connection (cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353) — not two. The four still without it are this site plus service-datasource/src/admin-routes.ts, service-settings/src/settings-service-plugin.ts and service-storage/src/storage-service-plugin.ts.
  • group was measured, not assumed unaffected: the ex-member's stamped key is refused under it too (organization_membership_ended keys on postureEnforcesWall), while the organization-less key is admitted by design (organization_required additionally requires !postureUsesUnionScope). Both are pinned in §7.
  • No published contract face is added. bootRequestContext, SeamPrincipal and SeamDeployment are absent from packages/plugins/plugin-sharing/dist/** (the testkit is not on the package's entry). The only dist movement is the type-erased private marker line private resolveAdmissionTenancyPosture; in index.d.ts — identical to what the already-merged sibling repair emits, not callable and carrying no type.

Generated by Claude Code

…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
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6615a024c33a9204a7a88273aa40b9ee107f78e3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2b917dd72de544876e785d23ab554ed075cf1846 — the merge of head 9c00ac6027ad14e7003d129b878ef25fc671a8f6 into base 6615a024c33a9204a7a88273aa40b9ee107f78e3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Standing down on the red Lint & Repo Gates: it is not this PR's, and it is the second PR hit by the same already-filed gate defect.

The failing check: Lint & Repo Gatescheck:merge-driverscripts/check-regen-pending.mjs --self-test, job 101350836784, on head 9fb60eef6. Summary line ✗ self-test failed -- 1 failure(s) (cases and floor), with the same case set seen on #15980.

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 package.json into a temp dir under tmpdir() carrying only name and scripts, then runs the stub through ownerRunCommand, which is pnpm <script>. That directory declares no packageManager and has no parent manifest to inherit one from, so the verdict depends on an ambient pnpm resolving there. When it cannot, every stub collapses to "gate exited non-zero", which the script correctly reads as stale — so exactly the stubs whose expected outcome is not stale go red.

The 2×2 that establishes tree-independence, exit codes captured before any pipe:

tree gate runner exit
origin/main real pnpm 0
PR head real pnpm 0
CI merge ref real pnpm 0
origin/main broken pnpm 1
PR head broken pnpm 1

origin/main fails identically under the same condition. The verdict is a function of the runner, not the tree. This PR's diff — the share-link admission seam, its test, the testkit, a changeset and one docs page — touches nothing in the regen-marker machinery.

Severity bound: this can only false-RED, never false-green. A failing launcher makes every artifact look stale, and stale is the refusing verdict, so nothing slips through. It costs a cycle and points a seat at its own innocent diff.

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 main here will clear this.

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

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 5, 2026 20:49
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 2e35765 Sep 5, 2026
36 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15349-sharing-tenancy-posture branch September 5, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-sharing: the exec-context seam supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key keeps its claim

2 participants