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 = null row class.
Filed unassigned.
The shape
SharingRuleService.adminOrgScope falls open when the caller has no org id:
private adminOrgScope(where: Record(string, unknown), orgId: string | null | undefined) {
if (!orgId) return where; // ← unscoped: every tenant's rows
return { ...where, $or: [{ organization_id: orgId }, { organization_id: null }] };
}
That branch exists for SYSTEM_CTX (boot seeding, hooks, backfills), which legitimately reads unfiltered. But it is reached on capability, not on system-ness, and assertCanManageRules admits any caller holding org-scoped manage_sharing:
const orgId = (context as any)?.organizationId ?? context?.tenantId; // may be undefined
const rows = await this.engine.find('sys_sharing_rule', {
where: this.adminOrgScope(where, orgId), ... context: SYSTEM_CTX,
});
So an authenticated, non-system caller arriving with neither organizationId nor tenantId gets 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:
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
...
if (tenantId) ctx.tenantId = tenantId;
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_sharing capability, listRules returns every organization's sharing rules, and getRule resolves any of them by id or name. evaluateRule then reaches those rows too, which is a cross-tenant write (it reconciles sys_record_share grants). 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_sharing and no active org.
Not verified
Whether a real deployment can hand an authenticated manage_sharing holder a session with no activeOrganizationId is 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 for context.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 checking getRule and findRuleRowByName, which share the same if (!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: null while the active org rides in tenantId).
Generated by Claude Code
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