From e5c0e69adae777eb32e7789b0c933514084bb648 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:05:33 +0000 Subject: [PATCH 1/2] fix(plugin-sharing): scope sharing-rule administration to the caller's organization (#8158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SharingRuleService` took its unfiltered admin read branch on the ABSENCE of an organization id rather than on system-ness, so an authenticated, non-system caller holding the org-scoped `manage_sharing` capability with no active organization read every tenant's sharing rules, resolved any of them by id or name, and could evaluate them — a cross-tenant write, since evaluation reconciles `sys_record_share` grants. The three sites that shared the `if (!orgId)` shape (`adminOrgScope`, `getRule`, `findRuleRowByName`) now take the execution context, and an authenticated caller with no resolvable organization is refused with PERMISSION_DENIED (403). System contexts (boot seeding, hooks, backfills) and platform operators (`manage_platform_settings` / the `platform_admin` position) keep the unfiltered read unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../sharing-rule-org-less-caller-scope.md | 50 +++ .../src/sharing-rule-service.ts | 187 +++++++++-- .../plugin-sharing/src/sharing-rule.test.ts | 262 +++++++++++++++ ...aring-rule-org-less-caller.dogfood.test.ts | 299 ++++++++++++++++++ 4 files changed, 774 insertions(+), 24 deletions(-) create mode 100644 .changeset/sharing-rule-org-less-caller-scope.md create mode 100644 packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts diff --git a/.changeset/sharing-rule-org-less-caller-scope.md b/.changeset/sharing-rule-org-less-caller-scope.md new file mode 100644 index 0000000000..4dffd0eac6 --- /dev/null +++ b/.changeset/sharing-rule-org-less-caller-scope.md @@ -0,0 +1,50 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +security(plugin-sharing): a `manage_sharing` holder with no ACTIVE organization no longer reads every tenant's sharing rules (#8158) + +`SharingRuleService` decided its admin read scope on the **absence of an +organization id**, not on system-ness: + +```ts +if (!orgId) return where; // unscoped — every tenant's rows +``` + +That unfiltered branch exists for the system context — boot seeding, the +reconcile hooks, the backfills — which legitimately reads across tenants. But +it was reached by any caller whose context happened to carry no organization, +and the ADR-0111 D6 gate admits any caller holding the **org-scoped** +`manage_sharing` capability. So an authenticated, non-system caller arriving +with neither `organizationId` nor `tenantId` received the system read scope: +`listRules` returned **every organization's** rules, `getRule` resolved any of +them by id or by name, and `evaluateRule` reached those rows too — a +cross-tenant **write**, since it reconciles `sys_record_share` grants. + +**That session is reachable in a real deployment**, measured end to end over +HTTP rather than inferred: a permission-set grant is independent of +organization membership, and an org-scoped grant still resolves when the caller +has no active organization to compare it against. A user holding +`manage_sharing` with no `sys_member` row — a multi-organization deployment +(whose membership reconciler binds nobody), an `invite-only` deployment, a user +removed from their organization, an SSO JIT user pending placement — signs in, +carries the capability, and carries no tenant. + +**The fix** distinguishes "system context" from "no organization id" at the +decision point instead of conflating them: `adminOrgScope`, `getRule` and +`findRuleRowByName` (three sites, one shape) now take the execution context, +and an authenticated caller with no resolvable organization is **refused** with +`PERMISSION_DENIED` (HTTP 403) naming the missing organization. A refusal +rather than an empty list, because `manage_sharing` is declared `scope: 'org'`: +with no organization there is no scope in which it grants anything, and an +empty answer over rules that exist and are actively granting access reads as +"this deployment has no sharing rules". + +**Unchanged**, and covered by tests: system contexts keep the unfiltered read +and the unfiltered seed (boot seeding is untouched); **platform operators** +(`manage_platform_settings`, or the `platform_admin` position) keep it too, +with or without an active organization — that is what the platform-only Setup +sharing pages are, and a single-tenant deployment before its default +organization is bootstrapped has exactly that caller; and an org-bound admin +still sees its own organization's rules plus the platform-global ones, exactly +as #7676 / #7761 left it. diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index f43c1ced05..97b6668d48 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -114,9 +114,135 @@ export class SharingRuleService implements ISharingRuleService { private assertCanManageRules(context: ExecutionContext): void { if (context?.isSystem) return; const caps = Array.isArray(context?.systemPermissions) ? context.systemPermissions : []; - if (caps.includes('manage_sharing') || caps.includes('manage_platform_settings')) return; + if (!caps.includes('manage_sharing') && !caps.includes('manage_platform_settings')) { + throw new Error( + 'PERMISSION_DENIED: sharing-rule administration requires the manage_sharing capability (ADR-0111 D6)', + ); + } + // [#8158] Holding the capability is not enough: an org-scoped capability + // needs an organization to be scoped BY. See + // {@link assertResolvableAdminScope}. + this.assertResolvableAdminScope(context); + } + + /** + * The organization this caller operates in — the ONE spelling of that read. + * + * [#7136] Only the `tenantId` half is a declared field of the envelope; + * `organizationId` is not a field of `ExecutionContext` at all (its history + * is #5858 / `check:org-identifier`, and #7070 explicitly held it out of the + * envelope work), so it stays cast. The asymmetry is the visible marker of + * which of the two names the contract actually knows. + * + * [#8158] Was open-coded at three call sites (`listRules`, `getRule`, + * `defineRule`), with `findRuleRowByName` taking `getRule`'s result as a + * parameter. Three copies of a security-relevant read is how the fall-open + * below stayed invisible: each site could see "no org id", none could see + * whether that meant "system" or "authenticated caller who never selected + * one". + */ + private callerOrgId(context: ExecutionContext): string | undefined { + return ((context as any)?.organizationId ?? context?.tenantId) || undefined; + } + + /** + * [#8158] PLATFORM authority — the two spellings, one predicate. + * + * Extracted from {@link assertCanDeletePlatformGlobalRule} (#7795), whose + * doc block is the authority on WHY both spellings are accepted and why + * accepting either is the fail-safe reading: they are two independent + * channels by which the same unscoped `admin_full_access` grant reaches an + * `ExecutionContext` (a `scope: 'platform'` capability on + * `systemPermissions`; the ADR-0068 D2 built-in position on `positions`), + * and a hand-built context may carry only one. + * + * It is asked TWICE now — once to authorize destroying a platform-global + * rule, once to decide whether an org-less caller may read across tenants — + * so it is one predicate rather than two spellings that can drift apart. + */ + private hasPlatformAuthority(context: ExecutionContext): boolean { + const caps = Array.isArray(context?.systemPermissions) ? context.systemPermissions : []; + if (caps.includes('manage_platform_settings')) return true; + const positions = Array.isArray(context?.positions) ? context.positions : []; + return positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN); + } + + /** + * [#8158] Refuse an authenticated, non-platform caller whose session + * resolves NO organization — the sharing-rule surface's fall-open. + * + * ## The defect + * + * {@link adminOrgScope} used to answer the unfiltered `where` for any caller + * with no org id (`if (!orgId) return where`). That branch exists for + * {@link SYSTEM_CTX} — boot seeding, hooks, backfills — which legitimately + * reads every tenant's rows. But it was reached on the ABSENCE OF AN ORG ID, + * never on system-ness, and {@link assertCanManageRules} admits any caller + * holding the org-scoped `manage_sharing` capability. So an authenticated, + * non-system caller arriving with neither `organizationId` nor `tenantId` + * got the system read scope: {@link listRules} returned EVERY organization's + * rules, {@link getRule} resolved any of them by id or name, and + * {@link evaluateRule} reached those rows too — a cross-tenant WRITE, since + * it reconciles `sys_record_share` grants. Same class #7761 closed for the + * by-id branch, through a different door: there the filter was missing, here + * it was skipped. + * + * ## That context is reachable (measured, not inferred) + * + * `resolveAuthzContext` derives the tenant from the session's active + * organization and stamps it only when truthy, and + * `resolveUserAuthzGrants` resolves an org-SCOPED permission-set grant + * whenever the caller has no active org to compare it against + * (`!(org && tenantId && org !== tenantId)`). A user holding an org-scoped + * `manage_sharing` grant with no `sys_member` row — a multi-org deployment + * (the membership reconciler binds nobody there), an `invite-only` + * deployment, a user removed from their org, an SSO JIT user pending + * placement — therefore logs in, resolves the capability, and carries no + * tenant. Driven end to end over HTTP in + * `packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts`: + * before this guard that session listed both tenants' rules. + * + * ## Refuse, rather than answer empty + * + * Both are fail-closed; the difference is what the caller is told. An empty + * list is the #7676 shape — `{data: []}` over rules that exist and are + * actively granting access, which reads as "this deployment has no sharing + * rules" and sends the operator to look for the wrong bug. A 403 states the + * actual condition and its remedy. `manage_sharing` is declared + * `scope: 'org'` in the spec's capability registry: with no organization + * resolved there is no scope in which it grants anything, so the honest + * answer is a refusal, not an answer. + * + * Asserted in {@link assertCanManageRules}, which every verb already calls + * first — so `defineRule` is covered too, and an org-less caller can no + * longer mint an `organization_id: null` rule (a platform-global one, whose + * grants reach every tenant and which #7795 then forbids them to delete). + * + * ## Two classes keep the unfiltered read, deliberately + * + * - **System contexts** (`isSystem`) — the branch's original and only + * intended reason: boot seeding, the reconcile hooks, the backfills. + * - **Platform operators** — `manage_platform_settings` or the + * `platform_admin` position ({@link hasPlatformAuthority}). Their + * cross-tenant read is what the Setup sharing pages are (the capability + * is `scope: 'platform'`, and those pages are documented platform-only in + * plugin-security's default permission sets), and they hold platform + * authority whether or not an organization is selected. Refusing them + * would be a functional regression dressed as a security fix — a + * single-tenant deployment before its default org is bootstrapped, or one + * running `autoDefaultOrganization: false`, has a platform admin with no + * active organization and nothing else wrong with it. + */ + private assertResolvableAdminScope(context: ExecutionContext): void { + if (this.callerOrgId(context)) return; + if (this.hasPlatformAuthority(context)) return; throw new Error( - 'PERMISSION_DENIED: sharing-rule administration requires the manage_sharing capability (ADR-0111 D6)', + 'PERMISSION_DENIED: sharing-rule administration requires an active organization — this ' + + 'session carries none. manage_sharing is an ORG-scoped capability (ADR-0111 D6), so with ' + + 'no organization resolved there is no tenant whose rules it authorizes, and answering ' + + 'unscoped would expose every tenant’s rules (#8158). Select an active organization and ' + + 'retry. Platform operators (manage_platform_settings or the platform_admin position) and ' + + 'system contexts are unaffected.', ); } @@ -212,10 +338,9 @@ export class SharingRuleService implements ISharingRuleService { if (row.organization_id != null) return; // Boot seeding, hooks, backfills and the plugin machinery, as everywhere else. if (context?.isSystem) return; - const caps = Array.isArray(context?.systemPermissions) ? context.systemPermissions : []; - if (caps.includes('manage_platform_settings')) return; - const positions = Array.isArray(context?.positions) ? context.positions : []; - if (positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)) return; + // [#8158] Both spellings, now through the shared predicate — see + // {@link hasPlatformAuthority}, which carries this block's reasoning. + if (this.hasPlatformAuthority(context)) return; throw new Error( 'PERMISSION_DENIED: deleting a platform-global sharing rule requires platform authority — ' + 'the manage_platform_settings capability or the platform_admin position. Org-scoped ' + @@ -245,13 +370,13 @@ export class SharingRuleService implements ISharingRuleService { throw new Error(`VALIDATION_FAILED: ${MATCH_ALL_CRITERIA_MESSAGE}`); } - // [#7136] Only the `tenantId` half of this read lost its cast: `tenantId` - // is a declared field of the envelope, `organizationId` is not a field of - // it at ALL. That spelling has its own history (#5858 / - // `check:org-identifier`) and was explicitly held out of this change - // (#7070) — so it stays cast, and the asymmetry above is now the visible - // marker of which of the two names the contract actually knows. - const orgId = (context as any)?.organizationId ?? context?.tenantId ?? null; + // [#7136 / #8158] One spelling of this read for the whole service — see + // {@link callerOrgId}. `null` (not `undefined`) is what a null-org row is + // STAMPED with, and only a system context or a platform operator reaches + // this line without an org id (`assertCanManageRules` above refuses the + // authenticated org-less caller, so a `manage_sharing` holder can no + // longer mint a platform-global rule by accident). + const orgId = this.callerOrgId(context) ?? null; const now = new Date().toISOString(); // Authoring path — `full` normalises to `edit`, anything unrecognised is a // loud VALIDATION_FAILED alongside the required-field checks above (#3865). @@ -365,9 +490,19 @@ export class SharingRuleService implements ISharingRuleService { * everything else was over-scoped. That was never a feature: unfiltered * meant an org admin could resolve — and, through {@link deleteRule}, * destroy — another organization's rule from its id alone. + * + * [#8158] Takes the CONTEXT, not a bare org id. The unfiltered branch is + * for a system context (and, since #8158, a platform operator) — a fact + * only the context carries. Handed an org id alone, this function could not + * tell "boot seeding" from "an authenticated caller who never selected an + * organization" and answered unfiltered to both; + * {@link assertResolvableAdminScope} is where that distinction now lives, + * and this signature is what stops a future call site from re-conflating + * them. */ - private adminOrgScope(where: Record, orgId: string | null | undefined): Record { - if (!orgId) return where; + private adminOrgScope(where: Record, context: ExecutionContext): Record { + const orgId = this.callerOrgId(context); + if (!orgId) return where; // system context / platform operator — asserted upstream return { ...where, $or: [{ organization_id: orgId }, { organization_id: null }] }; } @@ -379,10 +514,8 @@ export class SharingRuleService implements ISharingRuleService { const where: any = {}; if (filter.object) where.object_name = filter.object; if (filter.activeOnly) where.active = true; - // `organizationId` is not on the envelope — see defineRule(). - const orgId = (context as any)?.organizationId ?? context?.tenantId; const rows = await this.engine.find('sys_sharing_rule', { - where: this.adminOrgScope(where, orgId), + where: this.adminOrgScope(where, context), orderBy: [{ field: 'name', order: 'asc' }], limit: 1000, context: SYSTEM_CTX, @@ -393,8 +526,6 @@ export class SharingRuleService implements ISharingRuleService { async getRule(idOrName: string, context: ExecutionContext): Promise { this.assertCanManageRules(context); // [ADR-0111 D6] if (!idOrName) return null; - // `organizationId` is not on the envelope — see defineRule(). - const orgId = (context as any)?.organizationId ?? context?.tenantId; // [#7761] The by-id branch carries the SAME tenant scope as the by-name // path — it used to be a bare `{id: idOrName}`, resolved under SYSTEM_CTX // so nothing downstream re-scoped it. An org-scoped sharing admin holding @@ -408,12 +539,12 @@ export class SharingRuleService implements ISharingRuleService { // A platform-global (`organization_id = null`) row stays reachable, for // symmetry with the by-name path — see {@link adminOrgScope}. const byId = await this.engine.find('sys_sharing_rule', { - where: this.adminOrgScope({ id: idOrName }, orgId), + where: this.adminOrgScope({ id: idOrName }, context), limit: 1, context: SYSTEM_CTX, }); if (Array.isArray(byId) && byId[0]) return rowFromRule(byId[0]); - const byName = await this.findRuleRowByName(idOrName, orgId); + const byName = await this.findRuleRowByName(idOrName, context); if (byName) return rowFromRule(byName); return null; } @@ -431,13 +562,21 @@ export class SharingRuleService implements ISharingRuleService { * * No `orgId` (SYSTEM_CTX — boot seeding, hooks, backfills) keeps the * unfiltered by-name lookup it has always had. + * + * [#8158] Third site of the same `if (!orgId)` shape, and takes the CONTEXT + * for the same reason {@link adminOrgScope} does: an authenticated caller + * with no organization used to resolve ANY tenant's rule by name here — the + * door a fix confined to `adminOrgScope` would have left open, since + * {@link getRule} falls through to this lookup whenever the by-id query + * misses (which is exactly what a by-NAME request does). */ - private async findRuleRowByName(name: string, orgId: string | null | undefined): Promise { + private async findRuleRowByName(name: string, context: ExecutionContext): Promise { + const orgId = this.callerOrgId(context); const first = async (where: Record): Promise => { const rows = await this.engine.find('sys_sharing_rule', { where, limit: 1, context: SYSTEM_CTX }); return Array.isArray(rows) && rows[0] ? rows[0] : null; }; - if (!orgId) return first({ name }); + if (!orgId) return first({ name }); // system context / platform operator — asserted upstream return (await first({ name, organization_id: orgId })) ?? (await first({ name, organization_id: null })); } diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 3af30525d9..5d0ad4978b 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -1436,3 +1436,265 @@ describe('[#7795] deleting a platform-global rule requires platform authority', expect(res.grantsRevoked).toBe(0); }); }); + +// ───────────────────────────────────────────────────────────────────── +// [#8158] An authenticated `manage_sharing` holder whose session resolves NO +// organization is REFUSED — it used to get the system read scope. +// +// `adminOrgScope`, `getRule` and `findRuleRowByName` each opened with the same +// `if (!orgId)` shape, answering unfiltered. That branch exists for +// `SYSTEM_CTX` (boot seeding, hooks, backfills) but was reached on the ABSENCE +// OF AN ORG ID rather than on system-ness, while the ADR-0111 D6 gate admits +// any caller holding the org-scoped `manage_sharing` capability. So an +// authenticated, non-system caller with neither `organizationId` nor +// `tenantId` read EVERY tenant's rules, resolved any of them by id or name, +// and — through `evaluateRule`, which reconciles `sys_record_share` — wrote +// across tenants. +// +// ## Anti-vacuity +// +// The fixture seeds TWO organizations, each with its own rule, plus the +// platform-global seed. A single-tenant fixture would pass on the BROKEN build +// (nothing else to leak), so every refusal below is paired with a positive +// half naming the OTHER tenant's row, and the two org-bound personas assert +// their EXACT visible set. +// +// ## Ablation (predicted in advance, before running it) +// +// Restore `if (!orgId) return where` / `if (!orgId) return first({name})` and +// drop the scope assertion, and: +// - every `ORG_LESS_*` refusal test flips red — the calls resolve instead of +// throwing (and the leak assertions show org2's row in the answer); +// - the SYSTEM and PLATFORM tests stay GREEN — they take the unfiltered +// branch either way, which is what makes them the boot-seeding / +// platform-operator regression guard rather than restatements of the fix; +// - the ORG-BOUND exact-set tests stay GREEN — they were never routed +// through the fall-open branch. +// ───────────────────────────────────────────────────────────────────── + +describe('[#8158] a non-system caller with NO organization does not get the system read scope', () => { + let engine: ReturnType; + let rules: SharingRuleService; + + /** + * THE EXPOSED PERSONA: authenticated, holds the org-scoped `manage_sharing` + * capability (so it clears the ADR-0111 D6 gate and reaches the scope + * decision), and carries NEITHER `organizationId` NOR `tenantId` — the shape + * `resolveAuthzContext` produces for a session with no `activeOrganizationId` + * whose permission-set grant resolved anyway. + */ + const ORG_LESS_SHARING_ADMIN = { userId: 'mallory', systemPermissions: ['manage_sharing'] } as any; + /** The same session shape with an explicitly null org — a hand-built context. */ + const ORG_LESS_NULL_ORG = { + userId: 'mallory', organizationId: null, tenantId: null, + systemPermissions: ['manage_sharing'], positions: [], + } as any; + /** Org-bound, `organizationId` spelling (what the older fixtures build). */ + const ORG1_ADMIN = { userId: 'a1', organizationId: 'org1', systemPermissions: ['manage_sharing'] } as any; + /** + * Org-bound, `tenantId` spelling — what `resolveAuthzContext` ACTUALLY + * stamps over HTTP (`ctx.tenantId = session.activeOrganizationId`). Both + * spellings must resolve, or the guard would refuse every real REST caller. + */ + const ORG2_ADMIN = { userId: 'a2', tenantId: 'org2', systemPermissions: ['manage_sharing'] } as any; + /** Boot seeding / hooks / backfills: no org, but SYSTEM. Unfiltered, as always. */ + const BOOT = { isSystem: true, positions: [], permissions: [] } as any; + /** Platform authority with no active org, spelling 1: the `scope: 'platform'` capability. */ + const ORG_LESS_PLATFORM_CAP = { + userId: 'ops', systemPermissions: ['manage_sharing', 'manage_platform_settings'], + } as any; + /** Platform authority with no active org, spelling 2: the ADR-0068 D2 position. */ + const ORG_LESS_PLATFORM_POSITION = { + userId: 'root', systemPermissions: ['manage_sharing'], positions: ['platform_admin'], + } as any; + /** No capability at all — the OLDER gate must still fire first. */ + const ORG_LESS_NOBODY = { userId: 'nobody', systemPermissions: [] } as any; + + const SEEDED = 'share_red_projects_with_execs'; + let seededId = ''; + let org1RuleId = ''; + let org2RuleId = ''; + + const grantsOf = (ruleId: string): Row[] => + (engine._tables.sys_record_share ?? []).filter((g) => g.source === 'rule' && g.source_id === ruleId); + const namesOf = (rows: { name: string }[]): string[] => rows.map((r) => r.name).sort(); + + /** The leading ADR-0112 token of a refusal — see the #7795 block's copy. */ + const refusalCodeOf = async (call: Promise): Promise => { + try { + await call; + } catch (err: any) { + return String(err?.message ?? err ?? '').split(':')[0].trim(); + } + throw new Error('expected the call to be REFUSED, but it resolved successfully'); + }; + + beforeEach(async () => { + engine = makeEngine(); + engine._tables.project = [ + { id: 'p_red', status: 'red', owner_id: 'someone' }, + { id: 'p_green', status: 'green', owner_id: 'someone' }, + ]; + rules = new SharingRuleService({ engine: engine as any, sharing: new SharingService({ engine: engine as any }) }); + + seededId = (await rules.defineRule({ + name: SEEDED, label: 'Red projects → execs', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec', + managedBy: 'package', + } as any, BOOT)).id; + org1RuleId = (await rules.defineRule({ + name: 'org1_rule', label: 'Org1 own', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'alice', + } as any, ORG1_ADMIN)).id; + org2RuleId = (await rules.defineRule({ + name: 'org2_rule', label: 'Org2 own', object: 'project', + criteria: { status: 'green' }, recipientType: 'user', recipientId: 'bob', + } as any, ORG2_ADMIN)).id; + // Live grants on the OTHER tenant's rule, materialised under BOOT so the + // fixture never leans on the gate it measures. Without them, "the refusal + // wrote nothing" could pass over a rule that had nothing to disturb. + await rules.evaluateRule(org2RuleId, BOOT); + }); + + it('PRECONDITION: two organizations, two rules, one platform-global row, live grants', () => { + const rows = engine._tables.sys_sharing_rule; + expect(rows).toHaveLength(3); + expect(rows.find((r) => r.id === seededId)?.organization_id).toBeNull(); + expect(rows.find((r) => r.id === org1RuleId)?.organization_id).toBe('org1'); + // The `tenantId`-spelled persona stamps its org just like the other + // spelling — if this were null the whole two-tenant premise would be gone. + expect(rows.find((r) => r.id === org2RuleId)?.organization_id).toBe('org2'); + expect(grantsOf(org2RuleId)).toHaveLength(1); + // The exposed persona really does clear the ADR-0111 D6 gate, or every + // refusal below would be measuring the OLDER guard. + expect(ORG_LESS_SHARING_ADMIN.systemPermissions).toContain('manage_sharing'); + expect(ORG_LESS_SHARING_ADMIN.organizationId).toBeUndefined(); + expect(ORG_LESS_SHARING_ADMIN.tenantId).toBeUndefined(); + expect(ORG_LESS_SHARING_ADMIN.isSystem).toBeUndefined(); + }); + + // ── the refusal (each paired with the row it used to leak) ─────────── + + it('listRules is refused — it used to return BOTH tenants’ rules', async () => { + const code = await refusalCodeOf(rules.listRules({}, ORG_LESS_SHARING_ADMIN)); + expect(code).toBe('PERMISSION_DENIED'); + // Sourced from the platform's own pairing rather than restated (#7795). + expect(HttpStatusErrorCodeMap[403]).toBe(code); + expect(code).not.toBe('RULE_NOT_FOUND'); + // The rows it used to answer with are really there to be leaked — this is + // what a single-tenant fixture could not say. + expect(namesOf(await rules.listRules({}, BOOT))).toEqual(['org1_rule', 'org2_rule', SEEDED].sort()); + }); + + it('the message names the missing organization, not the missing capability', async () => { + // The two refusals on this surface are both PERMISSION_DENIED; only the + // message tells an operator which one they hit, and therefore what to fix. + await expect(rules.listRules({}, ORG_LESS_SHARING_ADMIN)).rejects.toThrow(/active organization/); + }); + + it('an explicitly NULL org is the same case as an absent one', async () => { + expect(await refusalCodeOf(rules.listRules({}, ORG_LESS_NULL_ORG))).toBe('PERMISSION_DENIED'); + }); + + it('getRule is refused BY ID — the other tenant’s row stays unreachable', async () => { + expect(await refusalCodeOf(rules.getRule(org2RuleId, ORG_LESS_SHARING_ADMIN))).toBe('PERMISSION_DENIED'); + // …and the id really does resolve for someone, so this is a refusal and + // not a miss. + expect((await rules.getRule(org2RuleId, BOOT))?.organization_id).toBe('org2'); + }); + + it('getRule is refused BY NAME — findRuleRowByName is the third door', async () => { + // The by-name path is a separate `if (!orgId)` site: a fix confined to + // `adminOrgScope` leaves this one open, and `getRule` falls through to it + // whenever the by-id query misses — which is what a by-name request is. + expect(await refusalCodeOf(rules.getRule('org2_rule', ORG_LESS_SHARING_ADMIN))).toBe('PERMISSION_DENIED'); + expect((await rules.getRule('org2_rule', BOOT))?.id).toBe(org2RuleId); + }); + + it('evaluateRule is refused — the cross-tenant WRITE never runs', async () => { + const before = JSON.stringify(engine._tables.sys_record_share ?? []); + expect(await refusalCodeOf(rules.evaluateRule(org2RuleId, ORG_LESS_SHARING_ADMIN))).toBe('PERMISSION_DENIED'); + expect(await refusalCodeOf(rules.evaluateRule('org2_rule', ORG_LESS_SHARING_ADMIN))).toBe('PERMISSION_DENIED'); + // The harm in this defect is the grant reconciliation, so the assertion is + // on the grant TABLE, not on the thrown shape. + expect(JSON.stringify(engine._tables.sys_record_share ?? [])).toBe(before); + }); + + it('deleteRule is refused — the other tenant’s rule AND its grants survive', async () => { + expect(await refusalCodeOf(rules.deleteRule(org2RuleId, ORG_LESS_SHARING_ADMIN))).toBe('PERMISSION_DENIED'); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === org2RuleId)).toBeTruthy(); + expect(grantsOf(org2RuleId)).toHaveLength(1); + }); + + it('defineRule is refused — no accidental platform-global rule', async () => { + // An org-less caller stamps `organization_id: null`, i.e. a rule whose + // grants reach EVERY tenant and which #7795 then forbids them to delete. + expect(await refusalCodeOf(rules.defineRule({ + name: 'mallory_rule', label: 'Mallory', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'mallory', + } as any, ORG_LESS_SHARING_ADMIN))).toBe('PERMISSION_DENIED'); + expect(engine._tables.sys_sharing_rule).toHaveLength(3); + }); + + it('the ADR-0111 D6 capability gate still fires FIRST', async () => { + // Ordering pin: an org-less caller with no capability must hear about the + // capability, not about the organization — both are PERMISSION_DENIED and + // the messages are what keep them apart. + await expect(rules.listRules({}, ORG_LESS_NOBODY)).rejects.toThrow(/manage_sharing capability/); + }); + + // ── the permitted side: system + platform keep the unfiltered read ─── + + it('a SYSTEM context still reads every tenant, unfiltered — list, by id, by name', async () => { + expect(namesOf(await rules.listRules({}, BOOT))).toEqual(['org1_rule', 'org2_rule', SEEDED].sort()); + expect((await rules.getRule(org2RuleId, BOOT))?.name).toBe('org2_rule'); + expect((await rules.getRule('org1_rule', BOOT))?.id).toBe(org1RuleId); + }); + + it('a SYSTEM context still SEEDS platform-global rows (boot seeding unbroken)', async () => { + // `bootstrapDeclaredSharingRules` runs on every boot with exactly this + // context; a guard that refused it would take the deployment down rather + // than leak anything. + const row = await rules.defineRule({ + name: 'seeded_on_boot', label: 'Seeded', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec', + managedBy: 'package', + } as any, BOOT); + expect(row.organization_id).toBeNull(); + expect(await rules.evaluateRule('seeded_on_boot', BOOT)).toMatchObject({ matchedRecords: 1 }); + }); + + it('a PLATFORM operator with no active org still reads every tenant — both spellings', async () => { + // The card's own reading: a platform operator on this path is harmless, + // they hold platform authority whether or not an org is selected. This is + // also the shape a single-tenant deployment has BEFORE its default org is + // bootstrapped, so refusing it would be a functional regression. + expect(namesOf(await rules.listRules({}, ORG_LESS_PLATFORM_CAP))) + .toEqual(['org1_rule', 'org2_rule', SEEDED].sort()); + expect(namesOf(await rules.listRules({}, ORG_LESS_PLATFORM_POSITION))) + .toEqual(['org1_rule', 'org2_rule', SEEDED].sort()); + expect((await rules.getRule(org2RuleId, ORG_LESS_PLATFORM_CAP))?.name).toBe('org2_rule'); + expect((await rules.getRule('org2_rule', ORG_LESS_PLATFORM_POSITION))?.id).toBe(org2RuleId); + }); + + // ── the anti-vacuity control: scoped ≠ single-tenant ───────────────── + + it('each org-bound admin sees EXACTLY its own org ∪ platform-global', async () => { + // The pair that distinguishes "scoped correctly" from "there was only one + // tenant's data anyway": two personas, two disjoint answers, one shared + // platform-global row, over the same three-row table. + expect(namesOf(await rules.listRules({}, ORG1_ADMIN))).toEqual(['org1_rule', SEEDED].sort()); + expect(namesOf(await rules.listRules({}, ORG2_ADMIN))).toEqual(['org2_rule', SEEDED].sort()); + expect(await rules.getRule('org2_rule', ORG1_ADMIN)).toBeNull(); + expect(await rules.getRule(org1RuleId, ORG2_ADMIN)).toBeNull(); + }); + + it('the tenantId spelling is honoured — the org-bound REST caller is not refused', async () => { + // `resolveAuthzContext` stamps `tenantId`, never `organizationId`. If the + // guard read only the latter, every real HTTP caller would hit the new + // refusal and this whole surface would 403 in production. + await expect(rules.listRules({}, ORG2_ADMIN)).resolves.toBeTruthy(); + expect((await rules.getRule(org2RuleId, ORG2_ADMIN))?.name).toBe('org2_rule'); + expect((await rules.evaluateRule(org2RuleId, ORG2_ADMIN)).ruleId).toBe(org2RuleId); + }); +}); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts new file mode 100644 index 0000000000..86217acea5 --- /dev/null +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#8158] The HTTP-layer proof that a `manage_sharing` holder whose session +// carries no ACTIVE organization does not read every tenant's sharing rules. +// +// ## What was open +// +// `SharingRuleService` decided its admin read scope on the ABSENCE OF AN ORG +// ID (`if (!orgId) return where` in `adminOrgScope`, and the same shape in +// `getRule` / `findRuleRowByName`). That unfiltered branch exists for +// `SYSTEM_CTX` — boot seeding, hooks, backfills — but it was reached on +// capability, not on system-ness, and the ADR-0111 D6 gate admits any caller +// holding the org-scoped `manage_sharing` capability. An authenticated, +// non-system caller arriving with neither `organizationId` nor `tenantId` +// therefore got the system read scope: every organization's rules, resolvable +// by id and by name, and evaluable — which reconciles `sys_record_share`, so a +// cross-tenant WRITE. +// +// ## Why this file exists at all — the card filed its own gap +// +// > 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. +// +// This file is that measurement, taken through the real login path rather than +// inferred from reading the resolver. Every step below is the product's own: +// better-auth `sign-up` / `sign-in` mint the session, `session.create.before` +// (ADR-0081 D1) is the hook that would have stamped an active organization and +// declines to because the user holds no `sys_member` row, `resolveAuthzContext` +// turns that session into the execution context, and the REST route is the one +// the Setup sharing pages call. Nothing here is simulated, and the +// PRECONDITION tests below assert each link rather than assuming it. +// +// The user shape is ordinary, not contrived: a permission-set grant +// (`sys_user_permission_set`) is independent of organization MEMBERSHIP, and +// `resolveUserAuthzGrants` keeps an org-scoped grant when the caller has no +// active org to compare it against (`!(org && tenantId && org !== tenantId)`). +// A multi-org deployment (whose membership reconciler binds nobody — ADR-0093 +// D1 `no-target-org`), an `invite-only` deployment, a user removed from their +// organization, or an SSO JIT user pending placement all produce it. +// +// ## Anti-vacuity +// +// TWO organizations, one rule each. A single-tenant fixture would pass on the +// BROKEN build, because there would be nothing to leak. The refusal assertions +// are therefore paired with a control proving the other tenant's row is +// present and readable BY SOMEONE (the platform operator), and with an +// org-bound caller who sees its own row and not the other's. +// +// @proof: sharing-rule-org-less-caller + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const RULES = '/sharing/rules'; +const SYS = { isSystem: true } as const; + +const ORG_A = 'org_8158_a'; +const ORG_B = 'org_8158_b'; +const RULE_A = 'rule_8158_tenant_a'; +const RULE_B = 'rule_8158_tenant_b'; +const PASSWORD = 'Member-Pass-123'; +const ORG_LESS_EMAIL = 'orgless-8158@verify.test'; +const ORG_BOUND_EMAIL = 'orgbound-8158@verify.test'; + +interface RuleRow { + id: string; + name: string; + organization_id: string | null; +} + +describe('#8158 — a manage_sharing holder with NO active organization cannot read every tenant', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + /** The harness admin: platform authority, and (by harness design) org-less. */ + let platform: string; + /** The exposed persona: org-scoped `manage_sharing`, no membership, no active org. */ + let orgLess: string; + /** The control persona: the SAME grant, plus a membership in tenant A. */ + let orgBound: string; + let orgLessUserId = ''; + let orgBoundUserId = ''; + + const ruleRow = (organizationId: string, name: string) => ({ + id: `srule_${name}`, + organization_id: organizationId, + name, + label: `Rule of ${organizationId}`, + object_name: 'showcase_project', + // A real predicate: a criteria-less row is inert by ADR-0049 and the + // write guard refuses it, so this is also what makes the row a live one. + criteria_json: JSON.stringify({ health: 'red' }), + recipient_type: 'user', + recipient_id: 'someone', + access_level: 'read', + active: true, + managed_by: 'admin', + customized: false, + }); + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + platform = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // Two tenants — the anti-vacuity premise. + for (const [id, label] of [[ORG_A, 'Tenant A'], [ORG_B, 'Tenant B']] as const) { + await ql.insert('sys_organization', { id, name: label, slug: id }, { context: SYS }); + } + await ql.insert('sys_sharing_rule', ruleRow(ORG_A, RULE_A), { context: SYS }); + await ql.insert('sys_sharing_rule', ruleRow(ORG_B, RULE_B), { context: SYS }); + + // An ORG-SCOPED sharing-admin permission set: `manage_sharing` and + // deliberately NOT `manage_platform_settings` — the card's exposed class is + // precisely the caller who holds the org capability and no platform one. + const psId = 'ps_8158_sharing_admin'; + await ql.insert('sys_permission_set', { + id: psId, + name: 'sharing_admin_8158', + label: 'Sharing Administrator (org-scoped)', + system_permissions: JSON.stringify(['manage_sharing']), + }, { context: SYS }); + + // Real sign-ups: better-auth's own path, through every database hook. + await stack.signUp(ORG_LESS_EMAIL, PASSWORD); + await stack.signUp(ORG_BOUND_EMAIL, PASSWORD); + const uid = async (email: string): Promise => + (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + orgLessUserId = await uid(ORG_LESS_EMAIL); + orgBoundUserId = await uid(ORG_BOUND_EMAIL); + + // The identical grant for both, scoped to tenant A. + for (const userId of [orgLessUserId, orgBoundUserId]) { + await ql.insert('sys_user_permission_set', { + user_id: userId, permission_set_id: psId, organization_id: ORG_A, + }, { context: SYS }); + } + // …and a membership for ONE of them. That single row is the whole + // difference between the two personas: `session.create.before` resolves it + // and stamps `activeOrganizationId`. + await ql.insert('sys_member', { + id: 'mem_8158_bound', organization_id: ORG_A, user_id: orgBoundUserId, role: 'member', + }, { context: SYS }); + + // Sign in AFTER the grants, so both sessions are minted by the same path + // with the membership state above already in place. + orgLess = await stack.signIn(ORG_LESS_EMAIL, PASSWORD); + orgBound = await stack.signIn(ORG_BOUND_EMAIL, PASSWORD); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // ── preconditions: every link of the reachability chain, measured ──── + + it('PRECONDITION: two tenants really do have a rule each', async () => { + const rows = await ql.find('sys_sharing_rule', { + where: { name: { $in: [RULE_A, RULE_B] } }, context: SYS, + }); + const list: RuleRow[] = Array.isArray(rows) ? rows : rows?.records ?? []; + expect(list.find((r) => r.name === RULE_A)?.organization_id).toBe(ORG_A); + expect(list.find((r) => r.name === RULE_B)?.organization_id).toBe(ORG_B); + }); + + it('PRECONDITION: the exposed persona holds no membership, and its SESSION carries no active organization', async () => { + // This is the card's unmeasured half. `session.create.before` (ADR-0081 + // D1) stamps `activeOrganizationId` from the caller's `sys_member` row; + // with no such row it declines, and nothing downstream re-derives one. + const members = await ql.find('sys_member', { where: { user_id: orgLessUserId }, context: SYS }); + expect(Array.isArray(members) ? members : members?.records ?? []).toHaveLength(0); + + const sessions = await ql.find('sys_session', { where: { user_id: orgLessUserId }, context: SYS }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; + expect(rows.length, 'the sign-in really did mint a session row').toBeGreaterThan(0); + for (const s of rows) { + expect( + s.active_organization_id ?? s.activeOrganizationId ?? null, + 'an authenticated session with NO active organization — the state the card asks about', + ).toBeFalsy(); + } + }); + + it('PRECONDITION: the CONTROL persona’s session DOES carry one (so the difference is the membership, not the harness)', async () => { + const sessions = await ql.find('sys_session', { where: { user_id: orgBoundUserId }, context: SYS }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; + expect(rows.length).toBeGreaterThan(0); + expect(rows.some((s) => (s.active_organization_id ?? s.activeOrganizationId) === ORG_A)).toBe(true); + }); + + it('PRECONDITION: both tenants’ rules are visible OVER HTTP to a platform operator', async () => { + // The refusal below would be vacuous if the route answered nothing to + // anybody. It also pins the second permitted class: a platform operator + // with no active organization keeps the unfiltered read it has always had. + const res = await stack.apiAs(platform, 'GET', RULES); + expect(res.status).toBe(200); + const names = ((await res.json()) as { data: RuleRow[] }).data.map((r) => r.name); + expect(names).toContain(RULE_A); + expect(names).toContain(RULE_B); + }); + + // ── the measurement ────────────────────────────────────────────────── + + it('THE REPORTED CASE: listing is refused 403, not answered with every tenant’s rules', async () => { + const res = await stack.apiAs(orgLess, 'GET', RULES); + const payload = res.status === 200 + ? ((await res.json()) as { data: RuleRow[] }).data.map((r) => `${r.name}@${r.organization_id}`) + : await res.text(); + // The failure MESSAGE carries the leak itself — vitest truncates a diff's + // arrays, so an ablation run must be told which tenants' rules came back + // rather than left with `payload: [ …(6) ]`. + expect( + res.status, + 'an org-scoped manage_sharing holder with no active organization must not read the rule ' + + `surface — it answered: ${JSON.stringify(payload)}`, + ).toBe(403); + }); + + it('the refusal names the ORGANIZATION, which is also how we know the capability gate was cleared', async () => { + // Both refusals on this surface are `PERMISSION_DENIED`; only the message + // separates "you lack manage_sharing" from "you have it and no org to use + // it in". Reading the second proves the persona really did carry the + // capability — i.e. that this fixture measures the fall-open and not the + // older ADR-0111 D6 gate. + const res = await stack.apiAs(orgLess, 'GET', RULES); + const body = (await res.json()) as { code?: string; error?: string }; + expect(body.code).toBe('PERMISSION_DENIED'); + expect(body.error ?? '').toMatch(/active organization/); + expect(body.error ?? '').not.toMatch(/requires the manage_sharing capability/); + }); + + it('by-NAME GET of the OTHER tenant’s rule is refused', async () => { + const res = await stack.apiAs(orgLess, 'GET', `${RULES}/${RULE_B}`); + expect(res.status, await res.text()).toBe(403); + }); + + it('by-ID GET of the OTHER tenant’s rule is refused', async () => { + const res = await stack.apiAs(orgLess, 'GET', `${RULES}/srule_${RULE_B}`); + expect(res.status, await res.text()).toBe(403); + }); + + it('EVALUATE — the cross-tenant WRITE — is refused, and reconciles nothing', async () => { + const before = await ql.find('sys_record_share', { where: { source: 'rule' }, context: SYS }); + const res = await stack.apiAs(orgLess, 'POST', `${RULES}/${RULE_B}/evaluate`, {}); + expect(res.status, await res.text()).toBe(403); + const after = await ql.find('sys_record_share', { where: { source: 'rule' }, context: SYS }); + const count = (r: unknown): number => (Array.isArray(r) ? r.length : (r as any)?.records?.length ?? 0); + expect(count(after)).toBe(count(before)); + }); + + it('DELETE of the other tenant’s rule is refused, and the row survives', async () => { + const res = await stack.apiAs(orgLess, 'DELETE', `${RULES}/${RULE_B}`); + expect(res.status, await res.text()).toBe(403); + expect((await ql.findOne('sys_sharing_rule', { where: { name: RULE_B }, context: SYS }))?.id).toBeTruthy(); + }); + + it('CREATE is refused — no org-less caller mints a platform-global rule', async () => { + const res = await stack.apiAs(orgLess, 'POST', RULES, { + name: 'rule_8158_minted_by_orgless', + label: 'Minted with no organization', + object: 'showcase_project', + recipientType: 'user', + recipientId: 'someone', + criteria: { health: 'red' }, + accessLevel: 'read', + }); + expect(res.status, await res.text()).toBe(403); + expect(await ql.findOne('sys_sharing_rule', { + where: { name: 'rule_8158_minted_by_orgless' }, context: SYS, + })).toBeFalsy(); + }); + + // ── the control: the SAME grant, with an organization, still works ─── + + it('the org-BOUND holder of the same grant reads its own tenant and NOT the other', async () => { + // The anti-vacuity pair, over HTTP: same permission set, same object, one + // extra `sys_member` row — and a scoped answer instead of a refusal. + const res = await stack.apiAs(orgBound, 'GET', RULES); + // One read of the body: `text()` then `json()` on the same Response throws + // "Body is unusable", which reports as a failure of the assertion that + // never ran. + const body = (await res.json()) as { data?: RuleRow[]; error?: string }; + expect(res.status, JSON.stringify(body)).toBe(200); + const names = (body.data ?? []).map((r) => r.name); + expect(names).toContain(RULE_A); + expect(names).not.toContain(RULE_B); + }); + + it('the org-BOUND holder cannot reach the other tenant by name or id either (404, never 200)', async () => { + // Not a #8158 assertion — #7676/#7761's — but it is what makes "scoped" + // mean scoped here rather than "listing happens to be filtered". + expect((await stack.apiAs(orgBound, 'GET', `${RULES}/${RULE_B}`)).status).toBe(404); + expect((await stack.apiAs(orgBound, 'GET', `${RULES}/srule_${RULE_B}`)).status).toBe(404); + }); +}); From 9163fa7ba1febab27b3c70ceadc4feaf00a45f5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 22:04:28 +0000 Subject: [PATCH 2/2] docs(sharing): name the missing-organization refusal on both sharing pages (#8158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Typical Errors" list on `services.sharing` enumerates the exact conditions behind each status, and the ADR-0111 D6 section of the sharing-rules page is where a reader of the rule surface looks. This PR adds a refusal to that surface — an authenticated `manage_sharing` holder whose session resolves no active organization now gets 403 PERMISSION_DENIED on every verb — so both pages say so, including which two callers (system contexts, platform operators) are deliberately unaffected. Same reasoning as #8217: the runtime refusing more than the page says is the enforced-but-undocumented inverse of a declared-but-unenforced gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../kernel/runtime-services/sharing-service.mdx | 4 ++-- content/docs/permissions/sharing-rules.mdx | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index 7225c07b29..b6cde6390b 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -57,10 +57,10 @@ mask AND-ed with object CRUD, not a fourth `access_level`. - `FORBIDDEN` (403) — a write denied by the `canEdit` gate. Thrown by the sharing engine middleware; `canEdit` itself returns `false` rather than throwing. - `VALIDATION_FAILED` (400) — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`), or `grant` with a non-`user` `recipientType` (only `user` rows are enforced by the gates; group/position recipients are delivered via sharing rules). -- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1). +- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1). On the sharing-**rule** surface (`ISharingRuleService`, declared in the same canonical source — `listRules` / `getRule` / `defineRule` / `deleteRule` / `evaluateRule`) the same code carries a second condition: the caller holds `manage_sharing` but their session resolves **no active organization**, and an org-scoped capability with no organization has no tenant whose rules it authorizes. System contexts and platform operators (`manage_platform_settings`, or the `platform_admin` position) are unaffected — see [Rule administration](/docs/permissions/sharing-rules). - `NOT_FOUND` (404) — the record is missing **or not visible to the caller** (indistinguishable by design), or a `revoke` share id does not exist / does not belong to the `scope` record. - `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead. -- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, or `controlled_by_parent`). +- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, `controlled_by_parent`, or a **federated** object whose `owner_id` is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit). ## Enforcement is automatic — do not re-check it in a hook diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index 6326e3a03b..d587d676b5 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -161,6 +161,22 @@ covered; an unauthorized call fails with `403 PERMISSION_DENIED`. Boot seeding, lifecycle hooks, and backfills run as system context and are unaffected. +**…and an organization to be scoped by.** `manage_sharing` is declared +`scope: 'org'`, so the capability alone is not enough: the caller's session +must also resolve an **active organization**, which is what scopes every rule +read to "this organization ∪ the platform-global rows". A session that carries +none — a user who has not selected an organization, or whose active +organization was cleared — is refused with the same `403 PERMISSION_DENIED`, +naming the missing organization rather than answering with an empty list. +Answering unscoped would hand that caller **every** organization's rules, and +`evaluate` would reconcile grants across all of them (objectstack#8158). +Two callers are deliberately unaffected, because neither is an org-scoped +principal: **system** contexts, and **platform operators** — a holder of +`manage_platform_settings` or of the built-in `platform_admin` position +administers rules across the deployment whether or not an organization is +selected, which is also what a single-tenant deployment looks like before its +default organization is bootstrapped (ADR-0081 D1). + ### Switching a rule off withdraws the access it granted A sharing rule's grants are **materialized** — evaluating a rule writes real