Repository navigation
plugin-sharing: a manage_sharing holder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 12, 2026 Triage: lands in
plugin-sharing(SharingRuleService.adminOrgScopeand itsif (!orgId)siblingsgetRule/findRuleRowByName) ⇒domain:identity, type Bug +security,pm:queue. The fix direction the card suggests — take the unfiltered branch only oncontext.isSystem, refuse/empty for a non-system caller with no resolvable org — is the fail-closed shape and matches the recorded default that the governed side wins; queuing does not need the reachability question answered first, because the fix is invariant-restoring either way.Dispatch notes for the identity seat:
- Step 0 of the dispatch: measure reachability (can a real deployment hand an authenticated
manage_sharingholder a session with noactiveOrganizationId?). The card says this is unverified; the answer changes severity, not the fix. ⚠️ Serial constraint: plugin-sharing's SINGLE-record write gates still read the phantomowner_idon federated objects (the threehasOwnerFieldconsumers #7858 did not touch) #8119 is in-flight in the same package (plugin-sharing, phantomowner_idwrite gates). Do not batch this with plugin-sharing's SINGLE-record write gates still read the phantomowner_idon federated objects (the threehasOwnerFieldconsumers #7858 did not touch) #8119; clear file surfaces against its claim before dispatch. Decision: may an org-level sharing admin DELETE a platform-global (organization_id = null) sharing rule? #7795's landed scope (delete gate) is adjacent history, not a conflict.
Size/model suggestion: M / opus.
Generated by Claude Code
- Step 0 of the dispatch: measure reachability (can a real deployment hand an authenticated
Claim:
domain:identity执行席位 #6022 — sessionsession_01PEVB6w7D7uCszR9Mw1BL73,分支claude/issue-8158-admin-org-scope-fall-open。派发中(mode:subagent,opus)。先记一笔调度失误:这张卡在 19:55 就已进队,我到 20:53 才认领 —— 闲置约一小时。 它是在我 19:53 那次枚举之后两分钟被定级的,而我 20:35 的检查点只核了合并状态、没有重新枚举队列。根因在我自己的检查点文本:我写的是「lane 的
pm:queue已排空」,本意是防越界去碰别的 lane,实际效果却是把"队列已空"当成结论带进了下一轮,而不是每轮都必须重新测量的量。本班已经因为同一类错误让 #8095 空转过 90 分钟 —— 那次是用记忆代替枚举,这次是用上一轮的结论代替枚举。判据已改:每个检查点都重新枚举pm:queue,不论上一轮的结论是什么。派发口径
⚠️ 卡片自己标注了未验证项,这是第一步,不是可跳过的前言:Whether a real deployment can hand an authenticated
manage_sharingholder a session with noactiveOrganizationIdis not measured here.先测可达性,再改代码。 用真实登录路径构造一个"已认证、持 org 级
manage_sharing、但activeOrganizationId缺失"的会话,并实测listRules是否真的返回了别的租户的行。⛔ 不要从代码阅读推断可达性 —— 本 lane 今天已经有两张卡的机制被实测推翻(#8119 的驱动行为、#8009 的加密声称),两次都是"读代码得出的结论"输给了"跑一遍"。若可达:按卡片建议的方向修 —— 在调用点区分 "系统上下文" 与 "没有 org id",而不是在
adminOrgScope里把两者合并。无过滤分支只留给context.isSystem;非系统调用者若解析不出 org,拒绝或返回空。⚠️ 卡片点名getRule与findRuleRowByName有同样的if (!orgId)形状,一并查 —— 只修adminOrgScope会留下两扇同样的门。若不可达:仍然把不变量写下来并加测试守住(卡片自己说了"fix is still cheap and the invariant worth stating"),但据实报告可达性为否,⛔ 不要为了让修复显得必要而夸大。
一条同形状的邻近事实
#8208(本 lane 今天另一个 dev 实测开出的)是同一个轴上的另一处:"调用者没有 active organization"导致写入被打上
organization_id: NULL,随后被 Layer 0 墙挡住,创建者自己都看不见自己刚建的记录。两张卡指向同一个根:没有 active org 的已认证会话在不同子系统里各自落成了相反的方向 —— 一处落开(本卡,读到所有租户),一处落闭(#8208,看不见自己)。⛔ 不要试图在本卡里统一它们,但在报告里记下这个共性,它可能是一张更上游的卡。File surface / 串行
packages/plugins/plugin-sharing/src/sharing-rule-service.ts(adminOrgScope、getRule、findRuleRowByName)。#8119 的代码 PR(#8209)已于 20:4x 合并,该包当前无并发写入者;#8119 只剩一个 docs-only 补丁在飞,不碰代码。本 lane 其余四个(#8009-impl、#8095、#8193、#8144)分别在plugin-auth的不同文件与plugin-security、examples/app-crm,实测不相交。
Generated by Claude Code
{ "issue": 8158, "status": "done", "branch": "claude/issue-8158-admin-org-scope-fall-open", "pr": "https://github.com/objectstack-ai/objectstack/pull/8237", "premise_still_valid": true, "summary": "REACHABILITY: YES, measured through the real login path (not inferred). A user signed up via better-auth, granted an org-scoped `manage_sharing` permission set for tenant A and holding NO `sys_member` row, signs in with a session whose stored `sys_session` row carries no active organization; `GET /api/v1/sharing/rules` as that session answered HTTP 200 with BOTH tenants' rules plus the four platform-global seeds. Fixed in the direction the card suggests: the three `if (!orgId)` sites (`adminOrgScope`, `getRule`, `findRuleRowByName`) now take the ExecutionContext, so 'system' and 'no org' are no longer the same input, and `assertCanManageRules` refuses a non-system caller with no resolvable organization (PERMISSION_DENIED -> 403). Refusal rather than empty result, because an empty list over rules that exist and are granting access is the #7676 shape and `manage_sharing` is declared scope:'org'. Two classes deliberately keep the unfiltered read: system contexts, and platform operators (`manage_platform_settings` / `platform_admin` position) — the card's own 'a platform operator hitting this path is harmless', and the shape the existing `sharing-rule-criteria-required` dogfood test and every pre-default-org single-tenant deployment actually have. `defineRule` is covered by the same guard as a side effect, closing the adjacent hole where an org-less holder minted an `organization_id: null` (platform-global) rule they then could not delete under #7795.", "tests": "① pnpm --filter @objectstack/plugin-sharing test -> 'Test Files 20 passed (20) / Tests 545 passed (545)'. ② npx vitest run test/sharing-rule-org-less-caller.dogfood.test.ts (packages/qa/dogfood) -> 'Test Files 1 passed (1) / Tests 13 passed (13)'. ③ ABLATION (prediction written to file BEFORE running; `git checkout origin/main -- sharing-rule-service.ts`, rebuild plugin-sharing dist, run, then `git checkout <branch> -- <path>` — never git stash): predicted 8 red / 7 green in the new unit block, measured EXACTLY those 8 red ('listRules is refused', 'the message names the missing organization', 'an explicitly NULL org', 'getRule BY ID', 'getRule BY NAME', 'evaluateRule', 'deleteRule', 'defineRule') with the other 90 tests in the file green; predicted 7 red / 6 green in the dogfood file, measured 7 failed / 6 passed. Direction: plain red, as predicted — no inversion. ④ typecheck: plugin-sharing and dogfood both clean. ⑤ gates green: check:nul-bytes, check:changeset-gate-self-tests, check:objectui-changeset, check:test-source-alias, check:docs-audit-scope, check:engine-double-contract, check:query-options-erasure, check:type-check-coverage, check:type-check-debt (full closure built first; 'none above its recorded number' — no ledger raised, no any-cast), check-changeset-no-major, plus check:org-identifier, check:error-code-casing, check:adr-anchors. ⑥ check:objectui-pin-fresh is RED and pre-existing — it compares `.objectui-sha` against objectui HEAD, my diff does not touch that file, and it is red on origin/main identically.", "open_questions": [], "out_of_scope_findings": [] }The measurement, in full
The card's unverified half — can a real deployment hand an authenticated
manage_sharingholder a session with noactiveOrganizationId? — is yes, driven end to end over HTTP in the new
packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts(realbootStackshowcase stack, real better-auth sign-up/sign-in, real REST route).Every link asserted rather than assumed:
link how it was measured the session carries no active organization the stored sys_sessionrow'sactive_organization_idis null — ADR-0081 D1'ssession.create.beforedeclines because the user holds nosys_memberrowthe caller nevertheless holds manage_sharingan org-scoped sys_user_permission_setgrant (tenant A) resolves anyway:resolveUserAuthzGrantskeeps an org-scoped grant when there is no active org to compare it againstthe control differs only by membership a second user with the identical grant plus one sys_memberrow signs in withactiveOrganizationId = tenant Athe rows are really there to leak a platform operator lists both tenants' rules over the same route On the pre-fix build,
GET /api/v1/sharing/rulesas the org-less holder answered HTTP 200:["rule_8158_tenant_a@org_8158_a", "rule_8158_tenant_b@org_8158_b", "share_high_value_red_projects_with_managers@null", "share_new_inquiries_with_field_ops@null", "share_open_tasks_with_manager@null", "share_red_projects_with_execs@null"]
Tenant B's rule to a caller whose only grant was scoped to tenant A. The same session also resolved tenant B's rule by name and by id, evaluated it (the cross-tenant write), deleted it, and created a new
organization_id: nullrule. All five are 403 after the fix.The user shape is ordinary rather than contrived — a permission-set grant is independent of organization membership, so: a multi-organization deployment (whose membership reconciler binds nobody, ADR-0093 D1
no-target-org), aninvite-onlydeployment, a user removed from their organization, or an SSO JIT user pending placement.The three sites, and their dispositions
site shape disposition adminOrgScopeif (!orgId) return wheretakes the ExecutionContext; unfiltered branch now reachable only for system / platform authoritygetRule(by-id branch)passed a bare orgIdintoadminOrgScopepasses the context findRuleRowByNameif (!orgId) return first({name})takes the context — the door a fix confined to adminOrgScopewould have left open, sincegetRulefalls through to it on every by-NAME request(bonus) defineRulestamped organization_id: nullfrom an org-less contextcovered by the same guard in assertCanManageRulesConsumption-radius sweep of the five verbs (
grepoverpackages/):bootstrap-declared-sharing-rules.ts,rule-hooks.ts,bu-tree-recompute.ts,sharing-plugin.ts(4 call sites) andevaluateAllRulesForObjectall pass{isSystem: true}— unaffected. The only non-system caller is the REST route, which passes the resolved context (and{}for a context-less request, which the older ADR-0111 D6 gate already refuses).exec-context-annotation.pin.tsis a type-level pin, not a runtime caller.sharing-rule-criteria-required.dogfood.test.tsdrives this surface as the org-less platform admin — the exact case the platform-authority carve-out keeps working.On #8208 — noted, not unified
#8208 is the same input state (an authenticated session with no active organization) resolved the opposite way one subsystem over: writes get stamped
organization_id: NULLand the Layer 0 wall then hides the row from its own creator. This card's surface fell open, #8208's falls closed. Both are downstream of the platform having no single answer to "what does an authenticated principal with no active organization get?" — the resolver stamps nothing, and each consumer improvises. That looks like an upstream card (one decision, applied by every consumer), deliberately not attempted here.
Generated by Claude Code
- added a commit that references this issue
on Aug 12, 2026 Supersedes the report above — same result, plus the docs commit the PM asked for. Delta only: two hand-written pages now enumerate the new refusal; nothing about the fix, the measurement or the ablation changed.
{ "issue": 8158, "status": "done", "branch": "claude/issue-8158-admin-org-scope-fall-open", "pr": "https://github.com/objectstack-ai/objectstack/pull/8237", "premise_still_valid": true, "summary": "REACHABILITY: YES, measured through the real login path (not inferred). A user signed up via better-auth, granted an org-scoped `manage_sharing` permission set for tenant A and holding NO `sys_member` row, signs in with a session whose stored `sys_session` row carries no active organization; `GET /api/v1/sharing/rules` as that session answered HTTP 200 with BOTH tenants' rules plus the four platform-global seeds. Fixed in the direction the card suggests: the three `if (!orgId)` sites (`adminOrgScope`, `getRule`, `findRuleRowByName`) now take the ExecutionContext, so 'system' and 'no org' are no longer the same input, and `assertCanManageRules` refuses a non-system caller with no resolvable organization (PERMISSION_DENIED -> 403). Refusal rather than empty result, because an empty list over rules that exist and are granting access is the #7676 shape and `manage_sharing` is declared scope:'org'. Two classes deliberately keep the unfiltered read: system contexts, and platform operators (`manage_platform_settings` / `platform_admin` position). `defineRule` is covered by the same guard as a side effect, closing the adjacent hole where an org-less holder minted an `organization_id: null` (platform-global) rule they then could not delete under #7795. SECOND COMMIT (9163fa7ba, at PM request): the refusal is now enumerated in `content/docs/permissions/sharing-rules.mdx` (the ADR-0111 D6 'Rule administration' section — where this surface's 403 already lives, and where a reader of the rule API looks) AND on the `content/docs/kernel/runtime-services/sharing-service.mdx` 'Typical Errors' list the docs-drift bot flagged, riding the existing PERMISSION_DENIED bullet, explicitly scoped to `ISharingRuleService` and cross-linked. That page was taken from current `main` first, so #8217's federated-anchor line is preserved byte-for-byte; the branch was NOT merged with main beyond that one file, so the PR stays a 6-file diff.", "tests": "① pnpm --filter @objectstack/plugin-sharing test -> 'Test Files 20 passed (20) / Tests 545 passed (545)'. ② npx vitest run test/sharing-rule-org-less-caller.dogfood.test.ts (packages/qa/dogfood) -> 'Test Files 1 passed (1) / Tests 13 passed (13)'. ③ ABLATION (prediction written to file BEFORE running; `git checkout origin/main -- sharing-rule-service.ts`, rebuild plugin-sharing dist, run, restore with `git checkout <branch> -- <path>` — never git stash): predicted 8 red / 7 green in the new unit block, measured EXACTLY those 8 red with the other 90 tests in the file green; predicted 7 red / 6 green in the dogfood file, measured 7 failed / 6 passed. Direction: plain red as predicted, no inversion. ④ typecheck: plugin-sharing and dogfood both clean. ⑤ gates green: check:nul-bytes (re-run after the docs commit), check:changeset-gate-self-tests, check:objectui-changeset, check:test-source-alias, check:docs-audit-scope (re-run), check:engine-double-contract, check:query-options-erasure, check:type-check-coverage, check:type-check-debt (full closure built first; 'none above its recorded number' — no ledger raised, no any-cast), check-changeset-no-major, check:org-identifier, check:error-code-casing, check:adr-anchors, and the four docs-path gates re-derived for the second commit: check:doc-formula-expressions, check:quick-reference-counts, check:role-word, check:docs-audit-scope. ⑥ check:objectui-pin-fresh is RED and pre-existing — it compares `.objectui-sha` against objectui HEAD, my diff does not touch that file, and it is red on origin/main identically.", "open_questions": [], "out_of_scope_findings": [] }One judgment call worth your eye before arming
The bot flagged
sharing-service.mdx, but that page documentsISharingService(grant / revoke / listShares / canEdit) — the record-share surface. My refusal is onISharingRuleService. They share one canonical source (packages/spec/src/contracts/sharing-service.ts), which is almost certainly why the bot mapped the diff there, but the page's Methods block lists no rule verb.So I wrote it in both places rather than picking: the substantive paragraph went to
permissions/sharing-rules.mdx§"Rule administration requiresmanage_sharing(ADR-0111 D6)" — the section that already documents this exact gate's 403 and would otherwise be the incomplete one — and the flagged page got one scoped clause on its existingPERMISSION_DENIEDbullet, namingISharingRuleServiceso it cannot be read as acanManageSharescondition, with a link onward. If you would rather the flagged page carried nothing, that clause is a one-line revert.Also: I did not merge
origin/maininto the branch. Onlysharing-service.mdxwas taken from main (so #8217's line survives the edit); the other 9 commits since my branch point are the merge queue's to integrate, which keeps this PR's diff to the 6 files it is about.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Sep 29, 2026 - added a commit that references this issue
on Sep 29, 2026
Found while implementing #7795 (platform-global delete gate). Out of scope there and deliberately not fixed in that PR — #7795's ruling holds the read surface open and untouched, and this is a different axis: it is about the ABSENCE of an org on the context, not about the
organization_id = nullrow class.Filed unassigned.
The shape
SharingRuleService.adminOrgScopefalls open when the caller has no org id:That branch exists for
SYSTEM_CTX(boot seeding, hooks, backfills), which legitimately reads unfiltered. But it is reached on capability, not on system-ness, andassertCanManageRulesadmits any caller holding org-scopedmanage_sharing:So an authenticated, non-system caller arriving with neither
organizationIdnortenantIdgets the system-context read scope.Why that context is reachable
resolveAuthzContext(packages/core/src/security/resolve-authz-context.ts) derives the tenant from the session's active organization and only stamps it when truthy:A better-auth session with no
activeOrganizationId— a user who has not selected an organization, or whose active org was cleared — therefore produces an authenticated context carrying neither field. Nothing on the sharing-rule path re-derives one.Impact
For a caller holding only the org-scoped
manage_sharingcapability,listRulesreturns every organization's sharing rules, andgetRuleresolves any of them by id or name.evaluateRulethen reaches those rows too, which is a cross-tenant write (it reconcilessys_record_sharegrants). This is the same class #7761 closed for the by-id branch — an org-level capability reaching rows outside the caller's tenant — arriving through a different door: there the filter was missing, here it is skipped because the org id is absent.A platform operator hitting this path is harmless (they hold platform authority anyway). The exposed case is a caller with org-level
manage_sharingand no active org.Not verified
Whether a real deployment can hand an authenticated
manage_sharingholder a session with noactiveOrganizationIdis not measured here — only that the resolver and the service both permit it, and that nothing between them closes it. That reachability question is the first thing triage should settle; if it is unreachable in practice the fix is still cheap and the invariant worth stating.Suggested direction (not a decision)
Distinguish "system context" from "no org id" at the call sites rather than conflating them in
adminOrgScope— e.g. take the unfiltered branch only forcontext.isSystem, and refuse (or return empty for) a non-system caller with no resolvable org. That keeps the boot/hook path exactly as it is while removing the fall-open for authenticated callers. Worth checkinggetRuleandfindRuleRowByName, which share the sameif (!orgId)shape.Related: #7795 (this card's origin), #7761 (by-id org filter), #7676 (org-null rows made visible by name), #5852 (plugin-sharing passing
organizationId: nullwhile the active org rides intenantId).Generated by Claude Code