Skip to content

Commit ba5ff2f

Browse files
os-zhuangclaude
andauthored
fix(sharing): 停用规则时撤回已物化授权,并修复 DELETE 500 (#4433, #4434) (#4495)
* wip: fix sharing-rule withdrawal (#4433) and DELETE 500 (#4434) An over-granting sharing rule had no withdrawal path on the API surface: deactivating it never withdrew its materialized grants, and the DELETE route answered 500 for both address forms. RC-exit blockers, since v17 advertises "switching a rule off actually withdraws access". #4434 — deleteRule purged sys_record_share with a predicate-shaped engine.delete carrying neither a scalar id nor multi:true, the one shape the engine's dispatch refuses. It threw before ever reaching the rule row, so every DELETE /sharing/rules/:idOrName 500'd. Routed through purgeRuleGrants instead of adding multi:true, so a rule's grants retire exactly one way — SharingService.revoke, as every other withdrawal path already does (AGENTS.md PD #5). #4433 — three independent gaps, one per path the issue walked: - deactivation: the rule-write trigger skipped reconcile on every isSystem write. defineRule (the only implementation behind POST /sharing/rules) writes with SYSTEM_CTX unconditionally, so the skip caught 100% of REST authoring. Now gated on boot phase, the question the skip actually meant to ask. - record touch: evaluateAllForRecord listed activeOnly rules, so a deactivated rule was absent from the loop and its grants were never examined. Lists every rule; inactive ones desire nothing and take reconcileForRecord's existing revoke branch. - boot: the backfill was handed activeOnly rules, making it structurally incapable of revoking. Walks every rule, plus a new sweepOrphanedRuleGrants for grants whose rule row is gone entirely (unreachable by rule iteration). Test fakes: makeEngine().delete now mirrors the real engine's dispatch guard. The looser fake is why #4434 shipped green — the pre-existing "deleteRule drops rule + all its grants" test passed against a delete the running server always rejected. Progress: service + plugin fixes done, regression tests added for both issues. Remaining: rule-rebind.test.ts still pins the old isSystem skip and needs updating; full gates + live-server verification pending. Refs #4433, #4434 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * test(sharing): pin the boot-phase reconcile contract (#4433) rule-rebind.test.ts asserted the defect: 'skips system-context writes' passed a mocked session the real REST path never sends, then asserted the reconcile did NOT happen. Replaced with the corrected contract — deferral is about WHEN a write happens (before the kernel:bootstrapped backfill), not WHO made it — plus cases for both session kinds after boot and the seeding deferral during it. pnpm --filter @objectstack/plugin-sharing test: 243 passed (11 files). Refs #4433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * docs(sharing): document the withdrawal lifecycle + add changeset Live verification on a persistent SQLite datastore confirmed all three withdrawal moments and both DELETE address forms, so the guarantee is now worth stating: an over-granting sharing rule is always recoverable from the API surface. The CI docs-drift advisory flagged six hand-written pages against this change. None asserted anything false — the withdrawal timing was simply undocumented, which is how the behaviour drifted unnoticed in the first place. Added the missing section to permissions/sharing-rules.mdx (the three reconcile moments, delete-by-id-or-name, the boot sweep for grants whose rule row is gone) and sharpened permissions-matrix.mdx, which said rules re-evaluate on record insert/update and stopped there — true but partial now that rule writes and boot also reconcile. The other four flagged pages only list the plugin or its layer and needed no change. content/docs/references/ is generated and was not touched. Changeset: minor on @objectstack/plugin-sharing. Not patch — a `source: 'rule'` grant whose rule is inactive or gone now disappears on upgrade, and DELETE starts succeeding where it used to 500, so callers that treated that 500 as "unsupported" will now really delete. Refs #4433, #4434 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 328ccc5 commit ba5ff2f

8 files changed

Lines changed: 581 additions & 18 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
---
4+
5+
fix(plugin-sharing): deactivating or deleting a sharing rule actually withdraws its grants (#4433, #4434)
6+
7+
An over-granting sharing rule had no withdrawal path on the product's API
8+
surface. Deactivating it left every grant it had materialised in place — not on
9+
the next record touch, not after a full restart — and the DELETE route answered
10+
500 for both address forms it advertises, so the rule could not be removed
11+
either. Together that made a too-broad rule unrecoverable short of hand-editing
12+
`sys_record_share`, against a v17 release note that advertises the opposite
13+
("switching a rule off actually withdraws access").
14+
15+
`minor`, not `patch`: this changes an observable runtime behaviour that
16+
deployments may have adapted to. A `source: 'rule'` grant whose rule is
17+
inactive — or whose rule row is gone — now disappears, on the deactivating
18+
write, on the next touch of the record, and on the next boot. Anything relying
19+
on those rows surviving deactivation (including data repaired by hand around
20+
the old behaviour) will see them revoked on upgrade. `DELETE
21+
/api/v1/sharing/rules/:idOrName` also starts succeeding where it used to 500,
22+
so callers that treated that 500 as "unsupported" will now really delete.
23+
24+
#4433 — three independent gaps, one per path the report walked:
25+
26+
- **The deactivating write.** The `sys_sharing_rule` reconcile trigger skipped
27+
every `isSystem` write, on the theory that those were boot seeding.
28+
`SharingRuleService.defineRule` — the only implementation behind
29+
`POST /sharing/rules`, and the documented way to deactivate a rule — writes
30+
with SYSTEM_CTX unconditionally, because it must reach a platform table the
31+
sharing middleware otherwise gates. So the skip caught 100% of REST
32+
authoring: the withdrawal path built by #3821 existed, had tests (against a
33+
mocked session the real path never sends), and was unreachable in production.
34+
Now gated on boot phase, which is the question the skip actually meant to
35+
ask.
36+
- **The record touch.** `evaluateAllForRecord` listed only active rules, so a
37+
deactivated rule was absent from the loop entirely and its grants were never
38+
examined. It now reconciles every rule; an inactive one desires nothing and
39+
takes the existing revoke-the-remainder branch.
40+
- **The boot pass.** `backfillRuleGrants` was handed an `activeOnly` list,
41+
making it structurally incapable of revoking anything. It now walks every
42+
rule, and a new `sweepOrphanedRuleGrants` retires grants whose rule row is
43+
gone entirely — unreachable by rule iteration, so they need their own sweep.
44+
45+
#4434`deleteRule` purged `sys_record_share` with a predicate-shaped
46+
`engine.delete` carrying neither a scalar id nor `multi: true`, the one shape
47+
the engine's dispatch refuses; it threw before ever reaching the rule row.
48+
Fixed by routing through the same `SharingService.revoke` path every other
49+
withdrawal already uses, rather than adding `multi: true` — a rule's grants now
50+
retire exactly one way instead of two divergent ones.
51+
52+
The unit fakes are part of the fix: `makeEngine().delete` accepted any `where`,
53+
which is why #4434 shipped green — the pre-existing "deleteRule drops rule and
54+
all its grants" test asserted success against a delete the running server
55+
always rejected. The fakes now mirror the real engine's dispatch guard.

content/docs/permissions/permissions-matrix.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
161161
</Callout>
162162

163163
<Callout type="info">
164-
**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Owner/criteria rules are re-evaluated on insert/update via internal sharing rule hooks.
164+
**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Criteria rules are re-evaluated on record insert/update via internal sharing rule hooks, on every write to the rule itself, and once per boot — so deactivating or deleting a rule withdraws the grants it materialized (see [Sharing Rules](/docs/permissions/sharing-rules#switching-a-rule-off-withdraws-the-access-it-granted)).
165165
</Callout>
166166

167167
### Configuration Example

content/docs/permissions/sharing-rules.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,38 @@ covered; an unauthorized call fails with `403 PERMISSION_DENIED`. Boot
161161
seeding, lifecycle hooks, and backfills run as system context and are
162162
unaffected.
163163

164+
### Switching a rule off withdraws the access it granted
165+
166+
A sharing rule's grants are **materialized** — evaluating a rule writes real
167+
`sys_record_share` rows with `source: 'rule'` and `source_id` set to the rule.
168+
Because those rows outlive the evaluation that produced them, withdrawal has to
169+
be an explicit act, and it happens at **three** moments:
170+
171+
| When | What is reconciled |
172+
|:--|:--|
173+
| **The write that deactivates or edits the rule** | That rule's grants, immediately — deactivating with `active: false` (or `POST {basePath}/sharing/rules` with the same name) revokes them before the call returns |
174+
| **The next insert/update of a matching record** | That record's grants for every rule on the object — an inactive rule desires nothing, so its rows are revoked |
175+
| **Every boot** | All rules, plus a sweep of `source: 'rule'` rows whose `source_id` no longer resolves to any rule |
176+
177+
Deleting a rule withdraws its grants too, whether you delete it through
178+
`DELETE {basePath}/sharing/rules/:idOrName` (by id **or** by name) or through
179+
the plain data API in Setup.
180+
181+
The practical guarantee: **an over-granting rule is always recoverable from the
182+
API surface.** Switch it off or delete it, and the access it materialized is
183+
gonenot on the next time somebody happens to touch the record, and not only
184+
after a restart (objectstack#4433, #4434). A grant whose rule row has vanished
185+
entirely is retired by the boot sweep, so a database repaired by handor
186+
upgraded from a build that leaked these rowsconverges on the next start.
187+
188+
<Callout type="info">
189+
Because withdrawal is materialized rather than computed at read time, the
190+
revocation is visible in `sys_record_share` itself. `GET
191+
{basePath}/data/:object/:id/shares` is the fastest way to confirm a rule's
192+
access is really gone, and `POST {basePath}/sharing/rules/:idOrName/evaluate`
193+
forces a reconcile on demand.
194+
</Callout>
195+
164196
### There is no "share every record" rule
165197

166198
The predicate is **mandatory on every authoring path**, whether you declare

packages/plugins/plugin-sharing/src/boot-backfill.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ function makeEngine() {
5858
return t[i];
5959
},
6060
async delete(o: string, opts?: any) {
61+
// Mirror `ObjectQLEngine.delete`'s dispatch guard (#4434) — see the same
62+
// note in sharing-rule.test.ts. A fake looser than the contract it
63+
// stands in for is how a green suite ships a dead route.
64+
const whereId = opts?.where && typeof opts.where === 'object' ? (opts.where as any).id : undefined;
65+
const t0 = typeof whereId;
66+
const scalarId = whereId != null && (t0 === 'string' || t0 === 'number' || t0 === 'bigint');
67+
if (!scalarId && !opts?.multi) throw new Error('Delete requires an ID or options.multi=true');
6168
const t = ensure(o); const where = opts?.where ?? {};
6269
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
6370
return { ok: true };
@@ -127,6 +134,94 @@ describe('backfillRuleGrants (#2926 ③ — seed rows materialize at boot)', ()
127134
});
128135
});
129136

137+
/**
138+
* objectstack#4433 (restart half) — "not at boot".
139+
*
140+
* The boot pass was handed `listRules({ activeOnly: true })`, so a deactivated
141+
* rule was never evaluated and the grants it had materialised survived every
142+
* restart: the rule read `active: false` while the orphaned `source: 'rule'`
143+
* row kept answering. The pass is the last line of defence for withdrawal, so
144+
* it has to walk EVERY rule — `evaluateRule` purges the ones it finds inactive.
145+
*/
146+
describe('boot rule backfill withdraws deactivated rules (#4433)', () => {
147+
let engine: ReturnType<typeof makeEngine>;
148+
let sharing: SharingService;
149+
let rules: SharingRuleService;
150+
151+
beforeEach(async () => {
152+
engine = makeEngine();
153+
sharing = new SharingService({ engine: engine as any });
154+
rules = new SharingRuleService({ engine: engine as any, sharing });
155+
engine._tables.showcase_private_note = [
156+
{ id: 'note_n', title: 'rc1 rule target', owner_id: 'admin' },
157+
];
158+
await rules.defineRule({
159+
name: 'rc1_rule_livetest', label: 'RC1 live test', object: 'showcase_private_note',
160+
criteria: { title: 'rc1 rule target' },
161+
recipientType: 'user', recipientId: 'member_b', accessLevel: 'read',
162+
}, SYS);
163+
// Boot 1 materialises the grant — the issue's step 3 baseline.
164+
await backfillRuleGrants(rules, await rules.listRules({}, SYS));
165+
expect(engine._tables.sys_record_share ?? []).toHaveLength(1);
166+
});
167+
168+
it('revokes a deactivated rule\'s grants on the next boot', async () => {
169+
// Admin switches the rule off. Nothing else touches the record.
170+
await rules.defineRule({
171+
name: 'rc1_rule_livetest', label: 'RC1 live test', object: 'showcase_private_note',
172+
criteria: { title: 'rc1 rule target' },
173+
recipientType: 'user', recipientId: 'member_b', accessLevel: 'read', active: false,
174+
}, SYS);
175+
176+
// Restart: the pass must see the inactive rule to be able to purge it.
177+
await backfillRuleGrants(rules, await rules.listRules({}, SYS));
178+
179+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
180+
});
181+
182+
it('an activeOnly rule list can never withdraw — the #4433 boot repro', async () => {
183+
await rules.defineRule({
184+
name: 'rc1_rule_livetest', label: 'RC1 live test', object: 'showcase_private_note',
185+
criteria: { title: 'rc1 rule target' },
186+
recipientType: 'user', recipientId: 'member_b', accessLevel: 'read', active: false,
187+
}, SYS);
188+
189+
// The old call site, pinned as the defect it was: an inactive rule is
190+
// absent from the list, so the pass has nothing to purge with.
191+
const activeOnly = await rules.listRules({ activeOnly: true }, SYS);
192+
expect(activeOnly).toHaveLength(0);
193+
await backfillRuleGrants(rules, activeOnly);
194+
expect(engine._tables.sys_record_share ?? []).toHaveLength(1); // still granted
195+
196+
// Reconciling every rule is what repairs it.
197+
await backfillRuleGrants(rules, await rules.listRules({}, SYS));
198+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
199+
});
200+
201+
it('still materialises active rules (the #2926 behaviour is untouched)', async () => {
202+
engine._tables.showcase_private_note.push({ id: 'note_2', title: 'rc1 rule target', owner_id: 'admin' });
203+
await backfillRuleGrants(rules, await rules.listRules({}, SYS));
204+
expect((engine._tables.sys_record_share ?? []).map((s) => s.record_id).sort()).toEqual(['note_2', 'note_n']);
205+
});
206+
207+
it('sweeps grants whose rule row vanished before the restart', async () => {
208+
// A rule removed by a path that never reached deleteRule (data-API delete
209+
// with the hook unbound, a migration, a crash mid-delete).
210+
engine._tables.sys_sharing_rule = [];
211+
await backfillRuleGrants(rules, await rules.listRules({}, SYS));
212+
expect(engine._tables.sys_record_share ?? []).toHaveLength(1); // unreachable by rule iteration
213+
214+
expect(await rules.sweepOrphanedRuleGrants()).toBe(1);
215+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
216+
});
217+
218+
it('the sweep is idempotent across repeated boots', async () => {
219+
expect(await rules.sweepOrphanedRuleGrants()).toBe(0);
220+
expect(await rules.sweepOrphanedRuleGrants()).toBe(0);
221+
expect(engine._tables.sys_record_share ?? []).toHaveLength(1);
222+
});
223+
});
224+
130225
describe("backfillRetiredAccessLevels (#3865 — stored 'full' normalises to 'edit')", () => {
131226
let engine: ReturnType<typeof makeEngine>;
132227

packages/plugins/plugin-sharing/src/rule-rebind.test.ts

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,16 @@ describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () =>
171171
listRules: vi.fn(async () => []),
172172
evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 2, expandedUsers: 1, grantsCreated: 2, grantsUpdated: 0, grantsRevoked: 0 })),
173173
revokeRuleGrants: vi.fn(async () => 2),
174+
sweepOrphanedRuleGrants: vi.fn(async () => 0),
174175
};
175176
(plugin as any).ruleService = ruleService;
176177
const ctx = makeCtx();
177178
logger = ctx.logger;
178179
(plugin as any).bindRuleRebindTriggers(engine, ctx);
180+
// [#4433] Boot is over — `kernel:bootstrapped` has run its backfill, so
181+
// runtime rule writes own reconciliation from here. Before this point the
182+
// trigger defers to that pass (see the boot-phase describe below).
183+
(plugin as any).ruleGrantsBootReconciled = true;
179184
});
180185

181186
it('backfills existing records when a rule is created', async () => {
@@ -196,12 +201,26 @@ describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () =>
196201
expect(ruleService.evaluateRule).not.toHaveBeenCalled();
197202
});
198203

199-
it('skips system-context writes — boot backfill owns those', async () => {
200-
await engine.fire('afterInsert', 'sys_sharing_rule', {
201-
result: { id: 'r1' },
204+
/**
205+
* objectstack#4433 — this is the defect, and it hid behind the test that
206+
* used to live here ("skips system-context writes").
207+
*
208+
* `SharingRuleService.defineRule` — the ONLY implementation behind
209+
* `POST /sharing/rules`, the documented way to deactivate a rule — writes
210+
* `sys_sharing_rule` with SYSTEM_CTX unconditionally, because it has to
211+
* reach a platform table the sharing middleware otherwise gates. So the old
212+
* `session.isSystem` skip did not filter out "boot seeding"; it filtered out
213+
* every REST authoring write there is. The old test passed a mocked
214+
* `session: { isSystem: true }` that the real REST path never sends, and
215+
* asserted the reconcile did NOT happen — pinning the bug as the contract.
216+
*/
217+
it('reconciles a SYSTEM_CTX authoring write once boot is done (#4433)', async () => {
218+
// Exactly what `POST /sharing/rules` with `active: false` produces.
219+
await engine.fire('afterUpdate', 'sys_sharing_rule', {
220+
result: { id: 'r1', active: false },
202221
session: { isSystem: true },
203222
});
204-
expect(ruleService.evaluateRule).not.toHaveBeenCalled();
223+
expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true }));
205224
});
206225

207226
it('never fails the authoring write when reconciliation throws', async () => {
@@ -228,3 +247,71 @@ describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () =>
228247
expect(order).toEqual(['rebind', 'reconcile']);
229248
});
230249
});
250+
251+
/**
252+
* [#4433] Boot phase — the predicate that replaced the `isSystem` skip.
253+
*
254+
* The skip exists to avoid duplicating work: declared-rule seeding and package
255+
* bootstrap write `sys_sharing_rule` before `kernel:bootstrapped`, and that
256+
* pass reconciles every rule anyway. That is a statement about WHEN a write
257+
* happens, not about WHO made it — so it is gated on boot phase, which is true
258+
* for exactly the writes the backfill covers and false for every runtime one.
259+
*/
260+
describe('SharingServicePlugin defers reconciliation to the boot backfill (#4433)', () => {
261+
let engine: ReturnType<typeof makeEngine>;
262+
let plugin: SharingServicePlugin;
263+
let ruleService: AnyRecord;
264+
265+
beforeEach(() => {
266+
engine = makeEngine();
267+
plugin = new SharingServicePlugin();
268+
ruleService = {
269+
listRules: vi.fn(async () => []),
270+
evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 0, expandedUsers: 0, grantsCreated: 0, grantsUpdated: 0, grantsRevoked: 0 })),
271+
revokeRuleGrants: vi.fn(async () => 0),
272+
sweepOrphanedRuleGrants: vi.fn(async () => 0),
273+
};
274+
(plugin as any).ruleService = ruleService;
275+
(plugin as any).bindRuleRebindTriggers(engine, makeCtx());
276+
// Boot still in flight — `ruleGrantsBootReconciled` defaults to false.
277+
});
278+
279+
it('skips the per-write reconcile while boot is still in flight', async () => {
280+
await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'seeded' } });
281+
expect(ruleService.evaluateRule).not.toHaveBeenCalled();
282+
});
283+
284+
it('still rebinds the lifecycle hooks during boot', async () => {
285+
// Deferring the reconcile must not defer the binding — a rule seeded at
286+
// boot has to be enforceable for records written straight afterwards.
287+
await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'seeded' } });
288+
expect(ruleService.listRules).toHaveBeenCalled();
289+
});
290+
291+
it('reconciles every write once the backfill has run — user session', async () => {
292+
(plugin as any).ruleGrantsBootReconciled = true;
293+
await engine.fire('afterUpdate', 'sys_sharing_rule', {
294+
result: { id: 'r1', active: false },
295+
session: { userId: 'admin' },
296+
});
297+
expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true }));
298+
});
299+
300+
it('reconciles every write once the backfill has run — system session', async () => {
301+
(plugin as any).ruleGrantsBootReconciled = true;
302+
await engine.fire('afterUpdate', 'sys_sharing_rule', {
303+
result: { id: 'r1', active: false },
304+
session: { isSystem: true },
305+
});
306+
expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true }));
307+
});
308+
309+
it('purges on a post-boot delete regardless of session kind', async () => {
310+
(plugin as any).ruleGrantsBootReconciled = true;
311+
await engine.fire('afterDelete', 'sys_sharing_rule', {
312+
input: { id: 'r1' },
313+
session: { isSystem: true },
314+
});
315+
expect(ruleService.revokeRuleGrants).toHaveBeenCalledWith('r1');
316+
});
317+
});

0 commit comments

Comments
 (0)