Skip to content

Commit 669f340

Browse files
committed
fix: ensure that rulesToQuery and rulesToAST generate condition that respect rule priority
1 parent 5e3649d commit 669f340

3 files changed

Lines changed: 121 additions & 54 deletions

File tree

packages/casl-ability/spec/rulesToAST.spec.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,30 @@ describe('rulesToAST', () => {
4343
const ast = rulesToAST(ability, 'read', 'Post')
4444

4545
expect(ast).to.deep.equal({
46-
operator: 'and',
46+
operator: 'or',
4747
value: [
4848
{
49-
operator: 'not',
49+
operator: 'and',
5050
value: [
51-
{ operator: 'eq', field: 'private', value: true }
51+
{ operator: 'eq', field: 'sharedWith', value: 1 },
52+
{
53+
operator: 'not',
54+
value: [
55+
{ operator: 'eq', field: 'private', value: true }
56+
]
57+
}
5258
]
5359
},
5460
{
55-
operator: 'or',
61+
operator: 'and',
5662
value: [
57-
{ operator: 'eq', field: 'sharedWith', value: 1 },
5863
{ operator: 'eq', field: 'author', value: 1 },
64+
{
65+
operator: 'not',
66+
value: [
67+
{ operator: 'eq', field: 'private', value: true }
68+
]
69+
}
5970
]
6071
}
6172
]

packages/casl-ability/spec/rulesToQuery.spec.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,36 @@ describe('rulesToQuery', () => {
132132

133133
expect(query).toEqual({
134134
$or: [
135-
{ state: 'draft' },
136-
{ _id: 'mega' }
137-
],
138-
$and: [
139-
{ $not: { state: 'archived' } },
140-
{ $not: { private: true } }
135+
{
136+
$and: [
137+
{ state: 'draft' },
138+
{ $not: { state: 'archived' } },
139+
{ $not: { private: true } }
140+
]
141+
},
142+
{
143+
$and: [
144+
{ _id: 'mega' },
145+
{ $not: { state: 'archived' } },
146+
{ $not: { private: true } }
147+
]
148+
}
141149
]
142150
})
143151
})
144152

153+
it('ignores inverted rules that are overridden by higher priority regular rules', () => {
154+
const ability = defineAbility((can, cannot) => {
155+
cannot('manage', 'Post', { id: 1 })
156+
can('manage', 'Post', { id: 1 })
157+
})
158+
const query = toQuery(ability, 'manage', 'Post')
159+
160+
expect(query).toEqual({
161+
$or: [{ id: 1 }]
162+
})
163+
})
164+
145165
it('returns empty query if inverted rule with conditions defined before regular rule without conditions', () => {
146166
const ability = defineAbility((can, cannot) => {
147167
can('read', 'Post', { author: 123 })
Lines changed: 79 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CompoundCondition, Condition, buildAnd, buildOr } from '@ucast/mongo2js';
2-
import { AnyAbility } from '../PureAbility';
3-
import { Generics, RuleOf } from '../RuleIndex';
4-
import { ExtractSubjectType } from '../types';
2+
import type { AnyAbility } from '../PureAbility';
3+
import type { RuleOf } from '../RuleIndex';
4+
import type { ExtractSubjectType } from '../types';
55

66
export type RuleToQueryConverter<T extends AnyAbility, R = object> = (rule: RuleOf<T>) => R;
77
export interface AbilityQuery<T = object> {
@@ -15,39 +15,15 @@ export function rulesToQuery<T extends AnyAbility, R = object>(
1515
subjectType: ExtractSubjectType<Parameters<T['rulesFor']>[1]>,
1616
convert: RuleToQueryConverter<T, R>
1717
): AbilityQuery<R> | null {
18-
const $and: Generics<T>['conditions'][] = [];
19-
const $or: Generics<T>['conditions'][] = [];
20-
const rules = ability.rulesFor(action, subjectType);
21-
22-
for (let i = 0; i < rules.length; i++) {
23-
const rule = rules[i];
24-
const list = rule.inverted ? $and : $or;
25-
26-
if (!rule.conditions) {
27-
if (rule.inverted) {
28-
// stop if inverted rule without fields and conditions
29-
// Example:
30-
// can('read', 'Post', { id: 2 })
31-
// cannot('read', "Post")
32-
// can('read', 'Post', { id: 5 })
33-
break;
34-
} else {
35-
// if it allows reading all types then remove previous conditions
36-
// Example:
37-
// can('read', 'Post', { id: 1 })
38-
// can('read', 'Post')
39-
// cannot('read', 'Post', { status: 'draft' })
40-
return $and.length ? { $and } : {};
41-
}
42-
} else {
43-
list.push(convert(rule));
18+
return mergeRules<T, R, AbilityQuery<R>>(
19+
ability.rulesFor(action, subjectType),
20+
convert,
21+
{
22+
and: (conditions) => ({ $and: conditions }),
23+
or: (conditions) => ({ $or: conditions }),
24+
empty: () => ({})
4425
}
45-
}
46-
47-
// if there are no regular conditions and the where no rule without condition
48-
// then user is not allowed to perform this action on this subject type
49-
if (!$or.length) return null;
50-
return $and.length ? { $or, $and } : { $or };
26+
);
5127
}
5228

5329
function ruleToAST(rule: RuleOf<AnyAbility>): Condition {
@@ -63,19 +39,79 @@ export function rulesToAST<T extends AnyAbility>(
6339
action: Parameters<T['rulesFor']>[0],
6440
subjectType: ExtractSubjectType<Parameters<T['rulesFor']>[1]>,
6541
): Condition | null {
66-
const query = rulesToQuery(ability, action, subjectType, ruleToAST) as AbilityQuery<Condition>;
42+
return mergeRules<T, Condition, Condition>(
43+
ability.rulesFor(action, subjectType),
44+
ruleToAST,
45+
{
46+
and: buildAnd,
47+
or: (conditions) => conditions.length === 1 ? conditions[0] : buildOr(conditions),
48+
empty: () => buildAnd([])
49+
}
50+
);
51+
}
6752

68-
if (query === null) {
69-
return null;
53+
/**
54+
* Converts CASL's sequential, switch-case priority enforcement into flat boolean logic.
55+
*
56+
* CASL evaluates rules from bottom to top (highest priority). When a record is evaluated:
57+
* - If it matches a `cannot` rule, it returns `false`.
58+
* - If it matches a `can` rule, it returns `true`.
59+
* - Thus, a `can` rule is only reached if it was not intercepted by any higher-priority `cannot` rule.
60+
*
61+
* This function flattens this logic for database queries by isolating each `can` rule ("OR" branches)
62+
* and strictly bounding it by all the preceding `cannot` conditions ("AND NOT" bounds).
63+
* Because standard `$or` logic inherently absorbs the overlap of previously matched `can` paths,
64+
* we don't mathematically need to subtract higher-priority `can` rules.
65+
*
66+
* @param rules - The sorted array of CASL rules (highest priority first).
67+
* @param convert - The transformer mapping a CASL rule to the target query/AST format.
68+
* @param hooks - The logical combination hooks for the target format.
69+
*/
70+
function mergeRules<T extends AnyAbility, R, Result>(
71+
rules: RuleOf<T>[],
72+
convert: (rule: RuleOf<T>) => R,
73+
hooks: {
74+
and: (conditions: R[]) => Result,
75+
or: (conditions: R[]) => Result,
76+
empty: () => Result,
7077
}
78+
): Result | null {
79+
const higherCannots: R[] = [];
80+
const orConditions: R[] = [];
81+
let hasUnconditionalCan = false;
7182

72-
if (!query.$and) {
73-
return query.$or ? buildOr(query.$or) : buildAnd([]);
83+
for (let i = 0; i < rules.length; i++) {
84+
const rule = rules[i];
85+
86+
if (rule.inverted) {
87+
if (!rule.conditions) {
88+
break; // stop evaluation on unconditional cannot
89+
}
90+
higherCannots.push(convert(rule));
91+
} else {
92+
if (!rule.conditions) {
93+
hasUnconditionalCan = true;
94+
break; // stop evaluation on unconditional can
95+
}
96+
97+
let cond = convert(rule);
98+
if (higherCannots.length > 0) {
99+
cond = hooks.and([cond, ...higherCannots]) as unknown as R;
100+
}
101+
orConditions.push(cond);
102+
}
74103
}
75104

76-
if (query.$or) {
77-
query.$and.push(buildOr(query.$or));
105+
if (hasUnconditionalCan) {
106+
if (higherCannots.length === 0) {
107+
return hooks.empty();
108+
}
109+
if (orConditions.length === 0) {
110+
return hooks.and(higherCannots);
111+
}
112+
orConditions.push(hooks.and(higherCannots) as unknown as R);
78113
}
79114

80-
return buildAnd(query.$and);
115+
if (orConditions.length === 0) return null;
116+
return hooks.or(orConditions);
81117
}

0 commit comments

Comments
 (0)