From 7567352691f00a66e5ac63ccfa0f8d7836a46226 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 17:53:02 +0000 Subject: [PATCH] fix(plugin-sharing): require platform authority to delete a platform-global sharing rule (#7795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deleteRule` on a row with `organization_id = null` now refuses a caller who holds only the org-scoped `manage_sharing` capability, with a `PERMISSION_DENIED` the REST layer answers as 403. `manage_platform_settings`, the built-in `platform_admin` position and system contexts stay permitted. Such a rule belongs to no organization and its criteria query runs unscoped, so the delete purged every tenant's `sys_record_share` grants under it — a cross-tenant destructive act authorized by a `scope: 'org'` capability. Both measurements the ruling rests on were re-verified against this build first: the boot seeder revives the rule under a NEW id on the next restart (a revocation wearing removal's clothes), and an org admin's `active: false` creates a second org-stamped row while the shared rule keeps running (the safe lever refused, the destructive one permitted). 403 and not 404 is deliberate: #7760 opened listing, reading and evaluating seeded rules to org admins on purpose, and all three are untouched here, so a 404 would contradict a read the same caller can perform one call earlier. Maintainer ruling 2026-08-12 (方向 B), quoted verbatim in the guard's docblock. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- ...latform-global-sharing-rule-delete-gate.md | 26 ++ .../src/sharing-rule-service.ts | 112 ++++++++ .../plugin-sharing/src/sharing-rule.test.ts | 254 +++++++++++++++++- 3 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 .changeset/platform-global-sharing-rule-delete-gate.md diff --git a/.changeset/platform-global-sharing-rule-delete-gate.md b/.changeset/platform-global-sharing-rule-delete-gate.md new file mode 100644 index 0000000000..d69bba9319 --- /dev/null +++ b/.changeset/platform-global-sharing-rule-delete-gate.md @@ -0,0 +1,26 @@ +--- +'@objectstack/plugin-sharing': patch +--- + +Refuse deletion of a platform-global sharing rule to org-scoped callers (#7795) + +`SharingRuleService.deleteRule` now requires **platform** authority to delete a +sharing rule whose `organization_id` is `null` — a row seeded from declared +metadata that belongs to no organization. A caller holding only the org-scoped +`manage_sharing` capability is refused with `PERMISSION_DENIED`, which the REST +layer answers as **403**; the `manage_platform_settings` capability, the +built-in `platform_admin` position, and system contexts are all still permitted. + +Why: such a rule's criteria query runs unscoped, so deleting it purged **every** +tenant's `sys_record_share` grants under it — a cross-tenant destructive act +authorized by a capability declared `scope: 'org'`. Two measured facts made it +worse: the boot seeder re-creates the rule on the next restart under a *new* id, +so the delete was a revocation wearing removal's clothes rather than a removal; +and the safe lever was unavailable while the destructive one was not — an org +admin's `active: false` creates a second, org-stamped row and leaves the shared +rule running, so deletion was the only lever an org admin had over it. + +Deliberately **403, not 404**: the row is intentionally visible — listing, +reading and evaluating platform-global rules stay open to org admins, exactly as +shipped — so answering "no such rule" would contradict a read the same caller +can perform one call earlier. Nothing on the read/evaluate surface changes. diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 91b04618ba..f43c1ced05 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -12,6 +12,10 @@ import type { // has declared for every one of these context parameters since #6523 (the // #6206 ruling: no per-site subset contracts). import type { ExecutionContext } from '@objectstack/spec/kernel'; +// [#7795] The built-in platform-operator position (ADR-0068 D2) — one of the +// two spellings of platform authority the ruling names; see +// {@link SharingRuleService.assertCanDeletePlatformGlobalRule}. +import { BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec/identity'; import type { SharingEngine } from './sharing-service.js'; import type { SharingService } from './sharing-service.js'; import { normalizeAccessLevel, normalizeStoredAccessLevel } from './access-level.js'; @@ -116,6 +120,111 @@ export class SharingRuleService implements ISharingRuleService { ); } + /** + * [#7795] DELETING a platform-global (`organization_id = null`) rule requires + * PLATFORM authority. Org-scoped `manage_sharing` does not authorize it. + * + * Maintainer ruling, 2026-08-12 (方向 B), quoted verbatim and untranslated: + * + * > **裁定:方向 B —— read/evaluate 保持开放,delete 需要平台级权限。** + * > + * > - `deleteRule` 对 `organization_id = null` 的行,要求调用者持有平台级权限 + * > (`manage_platform_settings` 或 `platform_admin` 位置);仅持 org 级 + * > `manage_sharing` 者拒绝。 + * > - 错误面用 **403 `PERMISSION_DENIED`**,不是 404 —— 该行是有意可见的, + * > 404 会撒谎。 + * > - #7760 开放的能力(列出、查看、评估种子规则)全部保持不动。 + * + * ## Why only DELETE, and only this row class + * + * `manage_sharing` is declared `scope: 'org'` in the spec's capability + * registry, but a null-org rule belongs to no organization: its criteria + * query runs unscoped under {@link SYSTEM_CTX}, so {@link deleteRule}'s + * grant purge revokes EVERY tenant's `sys_record_share` rows, not just the + * caller's. That is the one act on this surface an org-level capability + * should not reach, and the two measurements the ruling rests on say why — + * both re-verified against this build before the guard was written: + * + * 1. **The delete is a revocation wearing removal's clothes.** + * `bootstrapDeclaredSharingRules` re-seeds declared rules on every boot, + * and {@link defineRule}'s existence lookup under a null org is `{name}` — + * which matches nothing once the row is deleted, so the insert branch + * mints a fresh `uid('srule')`. Measured: the rule returns after a + * restart under a DIFFERENT id, with its grants re-materialised. The + * profile of an outage, not of an administrative change. + * 2. **The safe lever is unavailable while the destructive one is not.** + * An org admin cannot deactivate this row: `defineRule`'s existence + * lookup is deliberately strict (`{name, organization_id: orgId}`, held + * that way by #7676 so one org cannot upsert over a row other orgs read), + * so `active: false` from an org admin creates a SECOND, org-stamped row + * and leaves the shared one running. Measured: two rows, the null-org one + * still `active: true`. Scoped + reversible refused, cross-tenant + + * irreversible-until-reboot permitted — the inverse of the safe + * arrangement, and closing the destructive lever is the structural fix. + * + * ## Two spellings of platform authority, and why BOTH are accepted + * + * They are not synonyms — they are two independent channels by which the + * SAME underlying grant (an unscoped `admin_full_access`) reaches an + * `ExecutionContext`: + * + * - `manage_platform_settings` — a `scope: 'platform'` CAPABILITY, arriving + * on `context.systemPermissions`. `admin_full_access` carries it; + * `organization_admin` deliberately withholds it (it gets only + * `manage_org_users` / `setup.access` / `setup.write`), which is exactly + * what makes it a discriminator between a platform operator and a tenant + * admin — the same reasoning plugin-security's + * `PLATFORM_ADMIN_ONLY_CAPABILITIES` probe encodes. + * - `platform_admin` — a built-in POSITION (ADR-0068 D2), arriving on + * `context.positions`, DERIVED by the shared resolver from the unscoped + * `admin_full_access` user grant (never a stored boolean). + * + * A context built by the shared authz resolver carries both. A HAND-BUILT + * context — the population ADR-0096 D3 is still eliminating, and which + * plugin-security's probe comment names the sharing service as part of — + * may carry only one. Accepting either is therefore the fail-safe reading of + * a ruling that names both, and refusing on the absence of both cannot + * silently over-refuse a genuine platform operator. + * + * ## The error surface is 403 `PERMISSION_DENIED`, deliberately not 404 + * + * The row is DELIBERATELY visible: #7760 opened listing, reading and + * evaluating seeded rules to org admins on purpose, and this guard leaves + * all three untouched. A 404 here would be the platform lying about a row + * the caller could list and read one call earlier. The message prefix is + * what `rest-server.ts`'s sharing-rule `handleError` maps to HTTP 403 + + * `{code: 'PERMISSION_DENIED'}`, which is also the pairing the spec's own + * `HttpStatusErrorCodeMap[403]` records. ⛔ Do not "harden" this into a 404 + * or a silent no-op — both re-open the lie this shape exists to avoid. + * + * Placed AFTER {@link getRule} resolves, so a row the caller cannot see at + * all keeps its existing silent-no-op behaviour rather than gaining a new + * refusal that would disclose the row's existence. + * + * ⚠️ Recorded consequence, accepted by the ruling: with delete closed, an org + * admin has NO lever at all over a platform-global rule. The ruling + * explicitly declines to pre-build a per-org suppression mechanism + * (「⛔ 不做 D」) absent measured demand — do not add one here. + */ + private assertCanDeletePlatformGlobalRule(row: SharingRuleRow, context: ExecutionContext): void { + // Only the platform-global class is gated — an org's own rows are + // untouched, and so is every read verb. + 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; + 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 ' + + 'manage_sharing does not authorize it, because this rule belongs to no organization and ' + + 'deleting it revokes every tenant’s grants under it (#7795). It remains listable, ' + + 'readable and evaluable.', + ); + } + async defineRule(input: DefineSharingRuleInput, context: ExecutionContext): Promise { this.assertCanManageRules(context); if (!input.name) throw new Error('VALIDATION_FAILED: name is required'); @@ -336,6 +445,9 @@ export class SharingRuleService implements ISharingRuleService { this.assertCanManageRules(context); // [ADR-0111 D6] const row = await this.getRule(idOrName, context); if (!row) return; + // [#7795] A platform-global row is visible to an org admin by design + // (#7760) but is NOT theirs to destroy — 403, never 404. + this.assertCanDeletePlatformGlobalRule(row, context); // Drop materialised grants first so we don't orphan them. // // [#4434] This used to be a predicate-shaped `engine.delete` on diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 50c85560f9..3af30525d9 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { assertEngineDeleteDispatch } from '@objectstack/objectql'; +// [#7795] The platform's own code↔status pairing, so the delete-refusal tests +// assert an HTTP status they SOURCE rather than one they restate. +import { HttpStatusErrorCodeMap } from '@objectstack/spec/api'; import { SharingService } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; import { TeamGraphService, expandPrincipal } from './team-graph.js'; @@ -833,7 +836,18 @@ describe('[ADR-0111 D6] sharing-rule management gate', () => { let engine: ReturnType; let rules: SharingRuleService; const MALLORY = { userId: 'mallory', systemPermissions: [] } as any; - const RULE_ADMIN = { userId: 'admin', systemPermissions: ['manage_sharing'] } as any; + /** + * [#7795] `organizationId` added — this persona is a TENANT sharing admin, + * and the REST layer never builds one without an org. Omitting it made + * `defineRule` stamp `organization_id: null`, so the rule this block created + * to exercise the CAPABILITY gate was incidentally a PLATFORM-GLOBAL row — + * a shape no org admin can author through the API at all. That was invisible + * while every verb shared one gate; it stops being invisible now that delete + * judges the row's class too (see the #7795 block at the end of this file). + * The subject here is unchanged: `manage_sharing` authorizes the full + * surface for the caller's OWN organization. + */ + const RULE_ADMIN = { userId: 'admin', organizationId: 'org1', systemPermissions: ['manage_sharing'] } as any; const LEGACY_ADMIN = { userId: 'admin', systemPermissions: ['manage_platform_settings'] } as any; const input = { @@ -1184,3 +1198,241 @@ describe('[#7761] getRule by-id is scoped to the caller organization', () => { expect((await rules.getRule(org1RuleId, BOOT))?.organization_id).toBe('org1'); }); }); + +// ───────────────────────────────────────────────────────────────────── +// [#7795] DELETING a platform-global (`organization_id = null`) rule requires +// PLATFORM authority. Maintainer ruling 2026-08-12, 方向 B — quoted verbatim +// and untranslated in `sharing-rule-service.ts`'s +// `assertCanDeletePlatformGlobalRule` docblock, which is the authority on the +// decision; this file measures it. +// +// The split, in one line: read/list/evaluate stay OPEN (that is what #7760 +// deliberately unlocked), delete alone closes to org-scoped callers. +// +// ## The persona is the whole test +// +// Every refusal below is driven as an ORG-LEVEL admin **holding +// `manage_sharing`** — the exact caller the ruling refuses, and the only one +// that can fail against the pre-fix build. Written as an unprivileged user it +// would pass before AND after the fix (the ADR-0111 D6 gate refuses that +// caller several lines earlier) and would prove nothing at all. The +// `ORG1_ADMIN` fixture therefore carries `manage_sharing` on purpose, and +// `the ADR-0111 D6 gate still fires FIRST` below pins that the two refusals +// stay distinguishable. +// +// ## Why `code` and `status` are asserted separately +// +// The harm this closes and the harm a 404 would cause are different bugs, so a +// loose `.toThrow()` — or even `/PERMISSION_DENIED/` as a substring — cannot +// tell them apart. `refusalCodeOf` parses the ADR-0112 code TOKEN (the prefix +// `rest-server.ts`'s sharing-rule `handleError` dispatches on to pick the HTTP +// status) and the assertions pair it against the spec's own +// `HttpStatusErrorCodeMap`: it must be the code the platform pairs with **403** +// and must not be either 404-shaped code. A regression that answered 404 fails +// as a NAMED, different assertion rather than sliding through. +// +// And the 404 is not merely a worse status, it is a false statement — which is +// why `the 404 a stricter reading would have chosen would be a LIE` asserts +// that the very same refused caller can still READ the row it may not delete. +// +// ## Ablation (predicted in advance, then measured) +// +// Remove the `assertCanDeletePlatformGlobalRule` call from `deleteRule` and: +// the four refusal tests flip RED; every permitted-side and #7760 read-surface +// pin below stays GREEN. Plain red, not "more diagnostics" and not inverted — +// the guard is a new refusal on a path that previously succeeded, and the pins +// sit on the refusal itself. +// ───────────────────────────────────────────────────────────────────── + +describe('[#7795] deleting a platform-global rule requires platform authority', () => { + let engine: ReturnType; + let rules: SharingRuleService; + + /** + * THE REFUSED PERSONA: an org-scoped sharing admin. Holds `manage_sharing` + * (so it clears the ADR-0111 D6 gate and reaches the new one) and carries an + * `organizationId` (so it is org-scoped, not platform). This is the shape the + * REST layer builds for a tenant admin. + */ + const ORG1_ADMIN = { userId: 'admin', organizationId: 'org1', systemPermissions: ['manage_sharing'] } as any; + /** Platform authority, spelling 1: the `scope: 'platform'` CAPABILITY. */ + const PLATFORM_SETTINGS_ADMIN = { + userId: 'ops', organizationId: 'org1', + systemPermissions: ['manage_sharing', 'manage_platform_settings'], + } as any; + /** Platform authority, spelling 2: the built-in POSITION (ADR-0068 D2). */ + const PLATFORM_ADMIN_POSITION = { + userId: 'root', organizationId: 'org1', + systemPermissions: ['manage_sharing'], positions: ['platform_admin'], + } as any; + /** No `manage_sharing` at all — must still be refused by the OLDER gate. */ + const MALLORY = { userId: 'mallory', organizationId: 'org1', systemPermissions: [] } as any; + /** The seeder's / boot context — carries no org, which is what stamps `organization_id: null`. */ + const BOOT = { isSystem: true, positions: [], permissions: [] } as any; + + const SEEDED = 'share_red_projects_with_execs'; + let seededId = ''; + let org1RuleId = ''; + + const grantsOf = (ruleId: string): Row[] => + (engine._tables.sys_record_share ?? []).filter((g) => g.source === 'rule' && g.source_id === ruleId); + + /** + * The ADR-0112 code TOKEN carried by a refused call, or a failure if the call + * was not refused at all. Deliberately the leading token rather than a + * substring match: `PERMISSION_DENIED` and `RULE_NOT_FOUND` must be + * distinguishable, because they are the two different bugs this card sits + * between. + */ + 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 }) }); + + // The platform-global package seed — defined with no org, exactly as + // `bootstrapDeclaredSharingRules` does on every boot. + 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; + // The caller's OWN org rule — the control that proves a refusal below comes + // from the row's platform-global class and not from delete breaking wholesale. + org1RuleId = (await rules.defineRule({ + name: 'org1_rule', label: 'Org1 own', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'alice', + } as any, ORG1_ADMIN)).id; + + // Materialise both rules' grants under BOOT, so the fixture never depends + // on the very gate these tests measure. + await rules.evaluateRule(seededId, BOOT); + await rules.evaluateRule(org1RuleId, BOOT); + }); + + it('the fixture really is platform-global, org-stamped, and carrying live grants', () => { + const rows = engine._tables.sys_sharing_rule; + expect(rows.find((r) => r.id === seededId)?.organization_id).toBeNull(); + expect(rows.find((r) => r.id === seededId)?.managed_by).toBe('package'); + expect(rows.find((r) => r.id === org1RuleId)?.organization_id).toBe('org1'); + // Without live grants the "grants survive" assertions below could pass over + // a rule that never had any to lose. + expect(grantsOf(seededId)).toHaveLength(1); + expect(grantsOf(org1RuleId)).toHaveLength(1); + // And the refused persona really does clear the OLDER gate, or the refusal + // under test would be indistinguishable from ADR-0111 D6's. + expect(ORG1_ADMIN.systemPermissions).toContain('manage_sharing'); + }); + + // ── the refusal ────────────────────────────────────────────────────── + + it('an org admin holding manage_sharing is refused — 403 PERMISSION_DENIED, by id', async () => { + const code = await refusalCodeOf(rules.deleteRule(seededId, ORG1_ADMIN)); + // `code`: the exact ADR-0112 token, not a substring. + expect(code).toBe('PERMISSION_DENIED'); + // `status`: sourced from the platform's own code↔status pairing rather than + // restated, so this cannot agree with itself. + expect(HttpStatusErrorCodeMap[403]).toBe(code); + // …and distinctly NOT 404-shaped, in either of that status's spellings — + // the standard catalog's, and this route's own `RULE_NOT_FOUND`. + expect(code).not.toBe(HttpStatusErrorCodeMap[404]); + expect(code).not.toBe('RULE_NOT_FOUND'); + }); + + it('an org admin holding manage_sharing is refused — by NAME as well as by id', async () => { + const code = await refusalCodeOf(rules.deleteRule(SEEDED, ORG1_ADMIN)); + expect(code).toBe('PERMISSION_DENIED'); + expect(HttpStatusErrorCodeMap[403]).toBe(code); + expect(code).not.toBe('RULE_NOT_FOUND'); + }); + + it('the refusal leaves the rule row AND every tenant’s grants intact', async () => { + await expect(rules.deleteRule(seededId, ORG1_ADMIN)).rejects.toThrow(); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === seededId)).toBeTruthy(); + // The grants are the assertion that matters: the harm in this defect is the + // cross-tenant grant purge, so a "fix" that threw but still purged — or + // that purged before throwing — would satisfy a row-only check while + // leaving the damage done. + expect(grantsOf(seededId)).toHaveLength(1); + }); + + it('the 404 a stricter reading would have chosen would be a LIE — the caller can still READ the row', async () => { + // This is the ruling's stated reason for 403 over 404, made executable: + // the row is DELIBERATELY visible (#7760), so answering "no such rule" to + // the very caller that can list and read it one call earlier would be the + // platform contradicting itself. + const readable = await rules.getRule(seededId, ORG1_ADMIN); + expect(readable?.id).toBe(seededId); + expect(readable?.organization_id).toBeNull(); + expect(await refusalCodeOf(rules.deleteRule(seededId, ORG1_ADMIN))).toBe('PERMISSION_DENIED'); + }); + + it('the ADR-0111 D6 gate still fires FIRST for a caller without manage_sharing', async () => { + // Ordering pin: the new guard resolves the row before judging it, so it + // must not have displaced the capability gate that runs before any read. + // Both refusals are PERMISSION_DENIED; the messages keep them apart. + await expect(rules.deleteRule(seededId, MALLORY)).rejects.toThrow(/manage_sharing capability/); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === seededId)).toBeTruthy(); + }); + + // ── the permitted side ─────────────────────────────────────────────── + + it('the manage_platform_settings capability authorizes the delete', async () => { + await expect(rules.deleteRule(seededId, PLATFORM_SETTINGS_ADMIN)).resolves.toBeUndefined(); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === seededId)).toBeUndefined(); + expect(grantsOf(seededId)).toHaveLength(0); + }); + + it('the platform_admin position authorizes the delete', async () => { + await expect(rules.deleteRule(seededId, PLATFORM_ADMIN_POSITION)).resolves.toBeUndefined(); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === seededId)).toBeUndefined(); + expect(grantsOf(seededId)).toHaveLength(0); + }); + + it('a system context (boot seeding, hooks, backfills) still deletes', async () => { + await expect(rules.deleteRule(seededId, BOOT)).resolves.toBeUndefined(); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === seededId)).toBeUndefined(); + }); + + it('the org admin can still delete its OWN organization’s rule', async () => { + // The control: delete did not simply stop working for org admins. + await expect(rules.deleteRule(org1RuleId, ORG1_ADMIN)).resolves.toBeUndefined(); + expect(engine._tables.sys_sharing_rule.find((r) => r.id === org1RuleId)).toBeUndefined(); + expect(grantsOf(org1RuleId)).toHaveLength(0); + }); + + // ── #7760's read/evaluate surface is a REGRESSION surface, not collateral ── + + it('#7760 stays whole: the org admin can still LIST the platform-global rule', async () => { + const names = (await rules.listRules({}, ORG1_ADMIN)).map((r) => r.name); + expect(names).toContain(SEEDED); + expect(names).toContain('org1_rule'); + }); + + it('#7760 stays whole: the org admin can still GET it, by id and by name', async () => { + expect((await rules.getRule(seededId, ORG1_ADMIN))?.name).toBe(SEEDED); + expect((await rules.getRule(SEEDED, ORG1_ADMIN))?.id).toBe(seededId); + }); + + it('#7760 stays whole: the org admin can still EVALUATE it — grants reconcile', async () => { + const res = await rules.evaluateRule(seededId, ORG1_ADMIN); + expect(res.ruleId).toBe(seededId); + expect(res.matchedRecords).toBe(1); + // Evaluate is a WRITE. It must still reach the grant table, not merely + // return a shape — a gate applied to the whole null-org class instead of + // to `delete` alone would show up right here. + expect(grantsOf(seededId)).toHaveLength(1); + expect(res.grantsRevoked).toBe(0); + }); +});