Skip to content

Commit

Permalink
feat: adds accessibleBy helper and deprecates toMongoQuery and `acc…
Browse files Browse the repository at this point in the history
…essibleRecordsPlugin` (#795)
  • Loading branch information
stalniy authored Jul 22, 2023
1 parent 464ba3f commit bd58bb2
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 45 deletions.
143 changes: 111 additions & 32 deletions packages/casl-mongoose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,120 @@ yarn add @casl/mongoose @casl/ability
pnpm add @casl/mongoose @casl/ability
```

## Integration with mongoose
## Usage

[mongoose] is a popular JavaScript ODM for [MongoDB]. `@casl/mongoose` provides 2 plugins that allow to integrate `@casl/ability` and mongoose in few minutes:
`@casl/mongoose` can be integrated not only with [mongoose] but also with any [MongoDB] JS driver thanks to new `accessibleBy` helper function.

### `accessibleBy` helper

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]:


#### MongoDB adapter

```js
const { accessibleBy } = require('@casl/mongoose');
const { MongoClient } = require('mongodb');
const ability = require('./ability');

async function main() {
const db = await MongoClient.connect('mongodb://localhost:27017/blog');
let posts;

try {
posts = await db.collection('posts').find(accessibleBy(ability, 'update').Post);
} finally {
db.close();
}

console.log(posts);
}
```

This can also be combined with other conditions with help of `$and` operator:

```js
posts = await db.collection('posts').find({
$and: [
accessibleBy(ability, 'update').Post,
{ public: true }
]
});
```

**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:

```js
// returns { authorId: 1 }
const permissionRestrictedConditions = accessibleBy(ability, 'update').Post;

const query = {
...permissionRestrictedConditions,
authorId: 2
};
```

In the case above, we overwrote `authorId` property and basically allowed non-authorized access to posts of author with `id = 2`

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.

#### Mongoose

```js
const Post = require('./Post') // mongoose model
const ability = require('./ability') // defines Ability instance

async function main() {
const accessiblePosts = await Post.find(accessibleBy(ability).Post);
console.log(accessiblePosts);
}
```

`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:

#### `accessibleBy` in TypeScript

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:

```ts
import { accessibleBy } from '@casl/mongoose';
import { ability } from './ability'; // defines Ability instance

declare module '@casl/mongoose' {
interface RecordTypes {
Post: true
User: true
}
}

accessibleBy(ability).User // allows only User and Post properties
```

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:

```js @{data-filename="Post.ts"}
import mongoose from 'mongoose';

const PostSchema = new mongoose.Schema({
title: String,
author: String
});

declare module '@casl/mongoose' {
interface RecordTypes {
Post: true
}
}

export const Post = mongoose.model('Post', PostSchema)
```

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.

### Accessible Records plugin

This plugin is deprecated, the recommended way is to use [`accessibleBy` helper function](#accessibleBy-helper)

`accessibleRecordsPlugin` is a plugin which adds `accessibleBy` method to query and static methods of mongoose models. We can add this plugin globally:

```js
Expand Down Expand Up @@ -201,36 +309,7 @@ post.accessibleFieldsBy(ability); // ['title']
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.
## Integration with other MongoDB libraries
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]
```js
const { toMongoQuery } = require('@casl/mongoose');
const { MongoClient } = require('mongodb');
const ability = require('./ability');

async function main() {
const db = await MongoClient.connect('mongodb://localhost:27017/blog');
const query = toMongoQuery(ability, 'Post', 'update');
let posts;

try {
if (query === null) {
// returns null if ability does not allow to update posts
posts = [];
} else {
posts = await db.collection('posts').find(query);
}
} finally {
db.close();
}

console.log(posts);
}
```
## TypeScript support
## TypeScript support in mongoose
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!
Expand Down
37 changes: 37 additions & 0 deletions packages/casl-mongoose/spec/accessibleBy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineAbility } from "@casl/ability"
import { accessibleBy } from "../src"
import { testConversionToMongoQuery } from "./mongo_query.spec"

declare module '../src' {
interface RecordTypes {
Post: true
}
}

describe('accessibleBy', () => {
it('returns `{ $expr: false }` when there are no rules for specific subject/action', () => {
const ability = defineAbility((can) => {
can('read', 'Post')
})

const query = accessibleBy(ability, 'update').Post

expect(query).toEqual({ $expr: false })
})

it('returns `{ $expr: false }` if there is a rule that forbids previous one', () => {
const ability = defineAbility((can, cannot) => {
can('update', 'Post', { authorId: 1 })
cannot('update', 'Post')
})

const query = accessibleBy(ability, 'update').Post

expect(query).toEqual({ $expr: false })
})

describe('it behaves like `toMongoQuery` when converting rules', () => {
testConversionToMongoQuery((ability, subjectType, action) =>
accessibleBy(ability, action)[subjectType])
})
})
47 changes: 36 additions & 11 deletions packages/casl-mongoose/spec/mongo_query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,36 @@ import { defineAbility } from '@casl/ability'
import { toMongoQuery } from '../src'

describe('toMongoQuery', () => {
testConversionToMongoQuery(toMongoQuery)

it('returns `null` if there are no rules for specific subject/action', () => {
const ability = defineAbility((can) => {
can('update', 'Post')
})

const query = toMongoQuery(ability, 'Post', 'read')

expect(query).toBe(null)
})

it('returns null if there is a rule that forbids previous one', () => {
const ability = defineAbility((can, cannot) => {
can('update', 'Post', { authorId: 1 })
cannot('update', 'Post')
})

const query = toMongoQuery(ability, 'Post', 'update')

expect(query).toBe(null)
})
})

export function testConversionToMongoQuery(abilityToMongoQuery: typeof toMongoQuery) {
it('accepts ability action as third argument', () => {
const ability = defineAbility((can) => {
can('update', 'Post', { _id: 'mega' })
})
const query = toMongoQuery(ability, 'Post', 'update')
const query = abilityToMongoQuery(ability, 'Post', 'update')

expect(query).toEqual({
$or: [{ _id: 'mega' }]
Expand All @@ -20,7 +45,7 @@ describe('toMongoQuery', () => {
cannot('read', 'Post', { private: true })
cannot('read', 'Post', { state: 'archived' })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({
$or: [
Expand All @@ -39,7 +64,7 @@ describe('toMongoQuery', () => {
const ability = defineAbility((can) => {
can('read', 'Post', { isPublished: { $exists: true, $ne: null } })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ isPublished: { $exists: true, $ne: null } }] })
})
Expand All @@ -49,7 +74,7 @@ describe('toMongoQuery', () => {
can('read', 'Post', { isPublished: { $exists: false } })
can('read', 'Post', { isPublished: null })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({
$or: [
Expand All @@ -63,7 +88,7 @@ describe('toMongoQuery', () => {
const ability = defineAbility((can) => {
can('read', 'Post', { state: { $in: ['draft', 'archived'] } })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ state: { $in: ['draft', 'archived'] } }] })
})
Expand All @@ -72,7 +97,7 @@ describe('toMongoQuery', () => {
const ability = defineAbility((can) => {
can('read', 'Post', { state: { $all: ['draft', 'archived'] } })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ state: { $all: ['draft', 'archived'] } }] })
})
Expand All @@ -81,7 +106,7 @@ describe('toMongoQuery', () => {
can('read', 'Post', { views: { $lt: 10 } })
can('read', 'Post', { views: { $lt: 5 } })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ views: { $lt: 5 } }, { views: { $lt: 10 } }] })
})
Expand All @@ -91,7 +116,7 @@ describe('toMongoQuery', () => {
can('read', 'Post', { views: { $gt: 10 } })
can('read', 'Post', { views: { $gte: 100 } })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ views: { $gte: 100 } }, { views: { $gt: 10 } }] })
})
Expand All @@ -100,7 +125,7 @@ describe('toMongoQuery', () => {
const ability = defineAbility((can) => {
can('read', 'Post', { creator: { $ne: 'me' } })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ creator: { $ne: 'me' } }] })
})
Expand All @@ -109,9 +134,9 @@ describe('toMongoQuery', () => {
const ability = defineAbility((can) => {
can('read', 'Post', { 'comments.author': 'Ted' })
})
const query = toMongoQuery(ability, 'Post')
const query = abilityToMongoQuery(ability, 'Post')

expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] })
})
})
})
}
4 changes: 3 additions & 1 deletion packages/casl-mongoose/src/accessible_records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function failedQuery(
modelName: string,
query: QueryWithHelpers<Document, Document>
) {
query.where({ __forbiddenByCasl__: 1 }); // eslint-disable-line
query.where({ $expr: false }); // rule that returns empty result set
const anyQuery: any = query;

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

export type AccessibleRecordQueryHelpers<T, TQueryHelpers = {}, TMethods = {}, TVirtuals = {}> = {
/** @deprecated use accessibleBy helper instead */
accessibleBy: GetAccessibleRecords<
HydratedDocument<T, TMethods, TVirtuals>,
TQueryHelpers,
Expand All @@ -69,6 +70,7 @@ export interface AccessibleRecordModel<
TQueryHelpers & AccessibleRecordQueryHelpers<T, TQueryHelpers, TMethods, TVirtuals>,
TMethods,
TVirtuals> {
/** @deprecated use accessibleBy helper instead */
accessibleBy: GetAccessibleRecords<
HydratedDocument<T, TMethods, TVirtuals>,
TQueryHelpers,
Expand Down
4 changes: 3 additions & 1 deletion packages/casl-mongoose/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ export type {
AccessibleFieldsDocument,
AccessibleFieldsOptions
} from './accessible_fields';
export { toMongoQuery } from './mongo';

export { toMongoQuery, accessibleBy } from './mongo';
export type { RecordTypes } from './mongo';
27 changes: 27 additions & 0 deletions packages/casl-mongoose/src/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) {
}

/**
* @deprecated use accessibleBy instead
*
* Converts ability action + subjectType to MongoDB query
*/
export function toMongoQuery<T extends AnyMongoAbility>(
Expand All @@ -16,3 +18,28 @@ export function toMongoQuery<T extends AnyMongoAbility>(
): AbilityQuery | null {
return rulesToQuery(ability, action, subjectType, convertToMongoQuery);
}

export interface RecordTypes {
}
type StringOrKeysOf<T> = keyof T extends never ? string : keyof T;

/**
* Returns Mongo query per record type (i.e., entity type) based on provided Ability and action.
* In case action is not allowed, it returns `{ $expr: false }`
*/
export function accessibleBy<T extends AnyMongoAbility>(
ability: T,
action: Parameters<T['rulesFor']>[0] = 'read'
): Record<StringOrKeysOf<RecordTypes>, AbilityQuery> {
return new Proxy({
_ability: ability,
_action: action
}, accessibleByProxyHandlers) as unknown as Record<StringOrKeysOf<RecordTypes>, AbilityQuery>;
}

const accessibleByProxyHandlers: ProxyHandler<{ _ability: AnyMongoAbility, _action: string }> = {
get(target, subjectType) {
const query = rulesToQuery(target._ability, target._action, subjectType, convertToMongoQuery);
return query === null ? { $expr: false } : query;
}
};

0 comments on commit bd58bb2

Please sign in to comment.