Skip to content

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

Description

@hotlong

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:847

const authz = await resolveAuthzContext({ ql, headers, getSession });

Real request headers, so x-api-key is accepted (readHeader(headers, 'x-api-key'), api-key.ts). No posture.

⚠️ Worth noting when this is triaged: this plugin ALREADY resolves a tenancy probe a few hundred lines up (ctx.getService('tenancy') behind SharingTenancyProbe, 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

resolveAuthzContext gates BOTH posture-conditional API-key refusals on a tenancyPosture its 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 tenantId 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 (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 resolveAuthzContext callers on main, exactly two supply a posture:

The 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 tenancy service that was never registered is branded and resolves quietly to "no posture", while one that was registered and FAILED to build must raise AuthzStoreUnavailableError (503). A naive try { ... } 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

Activity

  1. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    Unblocked: pm:blocked → pm:queue. The upstream this card names, #15256, closed 2026-09-04 — PR #15365 merged as a72704375, 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 no tenancyPosture, and the mechanism that made the REST hole exploitable is unchanged here: both posture-conditional guards (organization_required at admission, organization_membership_ended after grants) are gated on a posture being supplied, so a door that supplies none runs neither, and under isolated Layer 0 then compares against the API key's own unvetted active_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-scoping service. Nothing in this repository registers it — only consumers gate on it (ctx.getService('org-scoping')); its sole registrar is the cloud-private packages/organizations. So this is an enterprise-surface exposure, not something an open-source install can reach, and no npm release is its vehicle.
    • Under group the 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 under group was never measured — the stamp comes from the same unvetted tenantId — so ⛔ do not assert group is 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.

  2. added theissue type on Sep 4, 2026
  3. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    Triage: lands in packages/plugins/plugin-sharing/src/sharing-plugin.ts:847 ⇒ domain:services (plugin-sharing folded into that lane on 2026-08-19, when domain:identity was retired from circulation). Graded Bug · priority:p1.

    Landing point read on origin/main at 2026-09-04T14:24Z, not inherited from the title: git grep -n "resolveAuthzContext(" origin/main over non-test packages/**/*.ts returns 10 sites, and this card's is sharing-plugin.ts:847.

    ⚠️ One reading the card does not carry, and the dispatching seat should not be surprised by. The same grep shows a second plugin-sharing call 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: #15256 is discharged, verified rather than assumed: #15256 is CLOSED (completed, 13:58:49Z) with PR #15365 MERGED. ⇒ pm:queue is correct.

    Bug by the mechanical boundary test — the accept set does not widen, a declared guard returns to enforced. priority:p1 rather than the parent's p0: the p0 arm is repaired and merged.


    Generated by Claude Code

  4. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claimed by the PM dispatch loop.

    Claim: session session_01ARYe3yQTQCUFm5qPYNgKaJ, branch claude/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/main just now, which the card does not carry and which change how this should be fixed:

    1. effectiveTenancyPosture is exported from @objectstack/core (packages/core/src/security/index.ts:93). That much is reusable.

    2. ⭐ There is no importable posture-resolver. resolveServiceOrLoud is declared private at packages/runtime/src/http-dispatcher.ts:2180 and 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 no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15350, service-settings: the manifest gate supplies no tenancyPosture to resolveAuthzContext — and returns the unvetted tenantId to its callers #15351 and service-storage: the file-read authorizer supplies no tenancyPosture to resolveAuthzContext — 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.

    3. ⭐ 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-567 is try { ctx.getService('tenancy') } catch { return null; }, and :668 is catch { /* absent — fall through to the walled default */ }. That is precisely the permissive-on-failure shape [finding] two more computeExecCtx seams 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) ⇒ quiet undefined; every other rejection ⇒ AuthzStoreUnavailableError('tenancy', err). The outer net at sharing-plugin.ts:853 already 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

  5. claude commented on Sep 5, 2026

    @claude
    Contributor

    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

  6. claude commented on Sep 5, 2026

    @claude
    Contributor

    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 in dist/index.d.ts. Ruled A: accept as-is. Clause-②: no stands.

    First, a negative result about my own instrument, stated so nobody later mistakes it for evidence: api-surface/ artifacts exist only for packages/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"] with types: "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:

    1. A private member is emitted name-only and type-erased — no signature, no type, not callable, not importable. Nothing downstream can reference it.
    2. Its only semantic effect is nominal branding, and sharing-plugin.ts already carries 8 private members on origin/main. The class was already nominally branded; a ninth changes no assignability that the eighth did not already decide.
    3. The precedent is stronger than the report claimed. @objectstack/cloud-connection is itself a published package — files: ["dist", …], types: "dist/index.d.ts", not private — and it already carries private resolveAdmissionTenancyPosture on origin/main (src/marketplace-install-local-plugin.ts:1699, merged via cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353). So the identical shape is already shipping in a published .d.ts under an accepted decision.

    ⇒ No declaration flip. Clause-②: no.

    Q1 — the KernelBase/LiteKernel residual. 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 whether plugin-auth and plugin-sharing actually 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


    Generated by Claude Code

  7. claude commented on Sep 5, 2026

    @claude
    Contributor

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions