Skip to content

Commit bd58bb2

Browse files
authored
feat: adds accessibleBy helper and deprecates toMongoQuery and accessibleRecordsPlugin (#795)
1 parent 464ba3f commit bd58bb2

File tree

6 files changed

+217
-45
lines changed

6 files changed

+217
-45
lines changed

packages/casl-mongoose/README.md

Lines changed: 111 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,120 @@ yarn add @casl/mongoose @casl/ability
1616
pnpm add @casl/mongoose @casl/ability
1717
```
1818

19-
## Integration with mongoose
19+
## Usage
2020

21-
[mongoose] is a popular JavaScript ODM for [MongoDB]. `@casl/mongoose` provides 2 plugins that allow to integrate `@casl/ability` and mongoose in few minutes:
21+
`@casl/mongoose` can be integrated not only with [mongoose] but also with any [MongoDB] JS driver thanks to new `accessibleBy` helper function.
22+
23+
### `accessibleBy` helper
24+
25+
This neat helper function allows to convert ability rules to MongoDB query and fetch only accessible records from the database. It can be used with mongoose or [MongoDB adapter][mongo-adapter]:
26+
27+
28+
#### MongoDB adapter
29+
30+
```js
31+
const { accessibleBy } = require('@casl/mongoose');
32+
const { MongoClient } = require('mongodb');
33+
const ability = require('./ability');
34+
35+
async function main() {
36+
const db = await MongoClient.connect('mongodb://localhost:27017/blog');
37+
let posts;
38+
39+
try {
40+
posts = await db.collection('posts').find(accessibleBy(ability, 'update').Post);
41+
} finally {
42+
db.close();
43+
}
44+
45+
console.log(posts);
46+
}
47+
```
48+
49+
This can also be combined with other conditions with help of `$and` operator:
50+
51+
```js
52+
posts = await db.collection('posts').find({
53+
$and: [
54+
accessibleBy(ability, 'update').Post,
55+
{ public: true }
56+
]
57+
});
58+
```
59+
60+
**Important!**: never use spread operator (i.e., `...`) to combine conditions provided by `accessibleBy` with something else because you may accidentally overwrite properties that restrict access to particular records:
61+
62+
```js
63+
// returns { authorId: 1 }
64+
const permissionRestrictedConditions = accessibleBy(ability, 'update').Post;
65+
66+
const query = {
67+
...permissionRestrictedConditions,
68+
authorId: 2
69+
};
70+
```
71+
72+
In the case above, we overwrote `authorId` property and basically allowed non-authorized access to posts of author with `id = 2`
73+
74+
If there are no permissions defined for particular action/subjectType, `accessibleBy` will return `{ $expr: false }` and when it's sent to MongoDB, it will return an empty result set.
75+
76+
#### Mongoose
77+
78+
```js
79+
const Post = require('./Post') // mongoose model
80+
const ability = require('./ability') // defines Ability instance
81+
82+
async function main() {
83+
const accessiblePosts = await Post.find(accessibleBy(ability).Post);
84+
console.log(accessiblePosts);
85+
}
86+
```
87+
88+
`accessibleBy` returns a `Proxy` instance and then we access particular subject type by reading its property. Property name is then passed to `Ability` methods as `subjectType`. With Typescript we can restrict this properties only to know record types:
89+
90+
#### `accessibleBy` in TypeScript
91+
92+
If we want to get hints in IDE regarding what record types (i.e., entity or model names) can be accessed in return value of `accessibleBy` we can easily do this by using module augmentation:
93+
94+
```ts
95+
import { accessibleBy } from '@casl/mongoose';
96+
import { ability } from './ability'; // defines Ability instance
97+
98+
declare module '@casl/mongoose' {
99+
interface RecordTypes {
100+
Post: true
101+
User: true
102+
}
103+
}
104+
105+
accessibleBy(ability).User // allows only User and Post properties
106+
```
107+
108+
This can be done either centrally, in the single place or it can be defined in every model/entity definition file. For example, we can augment `@casl/mongoose` in every mongoose model definition file:
109+
110+
```js @{data-filename="Post.ts"}
111+
import mongoose from 'mongoose';
112+
113+
const PostSchema = new mongoose.Schema({
114+
title: String,
115+
author: String
116+
});
117+
118+
declare module '@casl/mongoose' {
119+
interface RecordTypes {
120+
Post: true
121+
}
122+
}
123+
124+
export const Post = mongoose.model('Post', PostSchema)
125+
```
126+
127+
Historically, `@casl/mongoose` was intended for super easy integration with [mongoose] but now we re-orient it to be more MongoDB specific package because mongoose keeps bringing complexity and issues with ts types.
22128

23129
### Accessible Records plugin
24130

131+
This plugin is deprecated, the recommended way is to use [`accessibleBy` helper function](#accessibleBy-helper)
132+
25133
`accessibleRecordsPlugin` is a plugin which adds `accessibleBy` method to query and static methods of mongoose models. We can add this plugin globally:
26134

27135
```js
@@ -201,36 +309,7 @@ post.accessibleFieldsBy(ability); // ['title']
201309
202310
As you can see, a static method returns all fields that can be read for all posts. At the same time, an instance method returns fields that can be read from this particular `post` instance. That's why there is no much sense (except you want to reduce traffic between app and database) to pass the result of static method into `mongoose.Query`'s `select` method because eventually you will need to call `accessibleFieldsBy` on every instance.
203311
204-
## Integration with other MongoDB libraries
205-
206-
In case you don't use mongoose, this package provides `toMongoQuery` function which can convert CASL rules into [MongoDB] query. Lets see an example of how to fetch accessible records using raw [MongoDB adapter][mongo-adapter]
207-
208-
```js
209-
const { toMongoQuery } = require('@casl/mongoose');
210-
const { MongoClient } = require('mongodb');
211-
const ability = require('./ability');
212-
213-
async function main() {
214-
const db = await MongoClient.connect('mongodb://localhost:27017/blog');
215-
const query = toMongoQuery(ability, 'Post', 'update');
216-
let posts;
217-
218-
try {
219-
if (query === null) {
220-
// returns null if ability does not allow to update posts
221-
posts = [];
222-
} else {
223-
posts = await db.collection('posts').find(query);
224-
}
225-
} finally {
226-
db.close();
227-
}
228-
229-
console.log(posts);
230-
}
231-
```
232-
233-
## TypeScript support
312+
## TypeScript support in mongoose
234313
235314
The package is written in TypeScript, this makes it easier to work with plugins and `toMongoQuery` helper because IDE provides useful hints. Let's see it in action!
236315
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { defineAbility } from "@casl/ability"
2+
import { accessibleBy } from "../src"
3+
import { testConversionToMongoQuery } from "./mongo_query.spec"
4+
5+
declare module '../src' {
6+
interface RecordTypes {
7+
Post: true
8+
}
9+
}
10+
11+
describe('accessibleBy', () => {
12+
it('returns `{ $expr: false }` when there are no rules for specific subject/action', () => {
13+
const ability = defineAbility((can) => {
14+
can('read', 'Post')
15+
})
16+
17+
const query = accessibleBy(ability, 'update').Post
18+
19+
expect(query).toEqual({ $expr: false })
20+
})
21+
22+
it('returns `{ $expr: false }` if there is a rule that forbids previous one', () => {
23+
const ability = defineAbility((can, cannot) => {
24+
can('update', 'Post', { authorId: 1 })
25+
cannot('update', 'Post')
26+
})
27+
28+
const query = accessibleBy(ability, 'update').Post
29+
30+
expect(query).toEqual({ $expr: false })
31+
})
32+
33+
describe('it behaves like `toMongoQuery` when converting rules', () => {
34+
testConversionToMongoQuery((ability, subjectType, action) =>
35+
accessibleBy(ability, action)[subjectType])
36+
})
37+
})

packages/casl-mongoose/spec/mongo_query.spec.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,36 @@ import { defineAbility } from '@casl/ability'
22
import { toMongoQuery } from '../src'
33

44
describe('toMongoQuery', () => {
5+
testConversionToMongoQuery(toMongoQuery)
6+
7+
it('returns `null` if there are no rules for specific subject/action', () => {
8+
const ability = defineAbility((can) => {
9+
can('update', 'Post')
10+
})
11+
12+
const query = toMongoQuery(ability, 'Post', 'read')
13+
14+
expect(query).toBe(null)
15+
})
16+
17+
it('returns null if there is a rule that forbids previous one', () => {
18+
const ability = defineAbility((can, cannot) => {
19+
can('update', 'Post', { authorId: 1 })
20+
cannot('update', 'Post')
21+
})
22+
23+
const query = toMongoQuery(ability, 'Post', 'update')
24+
25+
expect(query).toBe(null)
26+
})
27+
})
28+
29+
export function testConversionToMongoQuery(abilityToMongoQuery: typeof toMongoQuery) {
530
it('accepts ability action as third argument', () => {
631
const ability = defineAbility((can) => {
732
can('update', 'Post', { _id: 'mega' })
833
})
9-
const query = toMongoQuery(ability, 'Post', 'update')
34+
const query = abilityToMongoQuery(ability, 'Post', 'update')
1035

1136
expect(query).toEqual({
1237
$or: [{ _id: 'mega' }]
@@ -20,7 +45,7 @@ describe('toMongoQuery', () => {
2045
cannot('read', 'Post', { private: true })
2146
cannot('read', 'Post', { state: 'archived' })
2247
})
23-
const query = toMongoQuery(ability, 'Post')
48+
const query = abilityToMongoQuery(ability, 'Post')
2449

2550
expect(query).toEqual({
2651
$or: [
@@ -39,7 +64,7 @@ describe('toMongoQuery', () => {
3964
const ability = defineAbility((can) => {
4065
can('read', 'Post', { isPublished: { $exists: true, $ne: null } })
4166
})
42-
const query = toMongoQuery(ability, 'Post')
67+
const query = abilityToMongoQuery(ability, 'Post')
4368

4469
expect(query).toEqual({ $or: [{ isPublished: { $exists: true, $ne: null } }] })
4570
})
@@ -49,7 +74,7 @@ describe('toMongoQuery', () => {
4974
can('read', 'Post', { isPublished: { $exists: false } })
5075
can('read', 'Post', { isPublished: null })
5176
})
52-
const query = toMongoQuery(ability, 'Post')
77+
const query = abilityToMongoQuery(ability, 'Post')
5378

5479
expect(query).toEqual({
5580
$or: [
@@ -63,7 +88,7 @@ describe('toMongoQuery', () => {
6388
const ability = defineAbility((can) => {
6489
can('read', 'Post', { state: { $in: ['draft', 'archived'] } })
6590
})
66-
const query = toMongoQuery(ability, 'Post')
91+
const query = abilityToMongoQuery(ability, 'Post')
6792

6893
expect(query).toEqual({ $or: [{ state: { $in: ['draft', 'archived'] } }] })
6994
})
@@ -72,7 +97,7 @@ describe('toMongoQuery', () => {
7297
const ability = defineAbility((can) => {
7398
can('read', 'Post', { state: { $all: ['draft', 'archived'] } })
7499
})
75-
const query = toMongoQuery(ability, 'Post')
100+
const query = abilityToMongoQuery(ability, 'Post')
76101

77102
expect(query).toEqual({ $or: [{ state: { $all: ['draft', 'archived'] } }] })
78103
})
@@ -81,7 +106,7 @@ describe('toMongoQuery', () => {
81106
can('read', 'Post', { views: { $lt: 10 } })
82107
can('read', 'Post', { views: { $lt: 5 } })
83108
})
84-
const query = toMongoQuery(ability, 'Post')
109+
const query = abilityToMongoQuery(ability, 'Post')
85110

86111
expect(query).toEqual({ $or: [{ views: { $lt: 5 } }, { views: { $lt: 10 } }] })
87112
})
@@ -91,7 +116,7 @@ describe('toMongoQuery', () => {
91116
can('read', 'Post', { views: { $gt: 10 } })
92117
can('read', 'Post', { views: { $gte: 100 } })
93118
})
94-
const query = toMongoQuery(ability, 'Post')
119+
const query = abilityToMongoQuery(ability, 'Post')
95120

96121
expect(query).toEqual({ $or: [{ views: { $gte: 100 } }, { views: { $gt: 10 } }] })
97122
})
@@ -100,7 +125,7 @@ describe('toMongoQuery', () => {
100125
const ability = defineAbility((can) => {
101126
can('read', 'Post', { creator: { $ne: 'me' } })
102127
})
103-
const query = toMongoQuery(ability, 'Post')
128+
const query = abilityToMongoQuery(ability, 'Post')
104129

105130
expect(query).toEqual({ $or: [{ creator: { $ne: 'me' } }] })
106131
})
@@ -109,9 +134,9 @@ describe('toMongoQuery', () => {
109134
const ability = defineAbility((can) => {
110135
can('read', 'Post', { 'comments.author': 'Ted' })
111136
})
112-
const query = toMongoQuery(ability, 'Post')
137+
const query = abilityToMongoQuery(ability, 'Post')
113138

114139
expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] })
115140
})
116141
})
117-
})
142+
}

packages/casl-mongoose/src/accessible_records.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ function failedQuery(
88
modelName: string,
99
query: QueryWithHelpers<Document, Document>
1010
) {
11-
query.where({ __forbiddenByCasl__: 1 }); // eslint-disable-line
11+
query.where({ $expr: false }); // rule that returns empty result set
1212
const anyQuery: any = query;
1313

1414
if (typeof anyQuery.pre === 'function') {
@@ -53,6 +53,7 @@ AccessibleRecordQueryHelpers<T, TQueryHelpers, TMethods, TVirtuals>
5353
>;
5454

5555
export type AccessibleRecordQueryHelpers<T, TQueryHelpers = {}, TMethods = {}, TVirtuals = {}> = {
56+
/** @deprecated use accessibleBy helper instead */
5657
accessibleBy: GetAccessibleRecords<
5758
HydratedDocument<T, TMethods, TVirtuals>,
5859
TQueryHelpers,
@@ -69,6 +70,7 @@ export interface AccessibleRecordModel<
6970
TQueryHelpers & AccessibleRecordQueryHelpers<T, TQueryHelpers, TMethods, TVirtuals>,
7071
TMethods,
7172
TVirtuals> {
73+
/** @deprecated use accessibleBy helper instead */
7274
accessibleBy: GetAccessibleRecords<
7375
HydratedDocument<T, TMethods, TVirtuals>,
7476
TQueryHelpers,

packages/casl-mongoose/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ export type {
2525
AccessibleFieldsDocument,
2626
AccessibleFieldsOptions
2727
} from './accessible_fields';
28-
export { toMongoQuery } from './mongo';
28+
29+
export { toMongoQuery, accessibleBy } from './mongo';
30+
export type { RecordTypes } from './mongo';

packages/casl-mongoose/src/mongo.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) {
77
}
88

99
/**
10+
* @deprecated use accessibleBy instead
11+
*
1012
* Converts ability action + subjectType to MongoDB query
1113
*/
1214
export function toMongoQuery<T extends AnyMongoAbility>(
@@ -16,3 +18,28 @@ export function toMongoQuery<T extends AnyMongoAbility>(
1618
): AbilityQuery | null {
1719
return rulesToQuery(ability, action, subjectType, convertToMongoQuery);
1820
}
21+
22+
export interface RecordTypes {
23+
}
24+
type StringOrKeysOf<T> = keyof T extends never ? string : keyof T;
25+
26+
/**
27+
* Returns Mongo query per record type (i.e., entity type) based on provided Ability and action.
28+
* In case action is not allowed, it returns `{ $expr: false }`
29+
*/
30+
export function accessibleBy<T extends AnyMongoAbility>(
31+
ability: T,
32+
action: Parameters<T['rulesFor']>[0] = 'read'
33+
): Record<StringOrKeysOf<RecordTypes>, AbilityQuery> {
34+
return new Proxy({
35+
_ability: ability,
36+
_action: action
37+
}, accessibleByProxyHandlers) as unknown as Record<StringOrKeysOf<RecordTypes>, AbilityQuery>;
38+
}
39+
40+
const accessibleByProxyHandlers: ProxyHandler<{ _ability: AnyMongoAbility, _action: string }> = {
41+
get(target, subjectType) {
42+
const query = rulesToQuery(target._ability, target._action, subjectType, convertToMongoQuery);
43+
return query === null ? { $expr: false } : query;
44+
}
45+
};

0 commit comments

Comments
 (0)