Skip to content

Commit

Permalink
feat: adds accessibleBy and accessibleFieldsBy to casl-mongoose
Browse files Browse the repository at this point in the history
BREAKING CHANGE: preparation to remove deprecated mongoose plugins
  * `accessibleBy` is now just a POJO which has `ofType` method to get query for specific type
  * mongoose `accessibleRecordsPlugin` doesn't throw exception anymore if ability forbids to do an action and instead it sends empty result query to MongoDB
  • Loading branch information
stalniy committed Feb 23, 2024
1 parent 8e5e850 commit a807ac6
Show file tree
Hide file tree
Showing 12 changed files with 331 additions and 403 deletions.
84 changes: 39 additions & 45 deletions packages/casl-mongoose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async function main() {
let posts;

try {
posts = await db.collection('posts').find(accessibleBy(ability, 'update').Post);
posts = await db.collection('posts').find(accessibleBy(ability, 'update').ofType('Post'));
} finally {
db.close();
}
Expand All @@ -51,7 +51,7 @@ 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,
accessibleBy(ability, 'update').ofType('Post'),
{ public: true }
]
});
Expand All @@ -61,7 +61,7 @@ posts = await db.collection('posts').find({

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

const query = {
...permissionRestrictedConditions,
Expand All @@ -71,60 +71,22 @@ const query = {

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.
If there are no permissions defined for particular action/subjectType, `accessibleBy` will return `{ $expr: { $eq: [0, 1] } }` and when it's sent to MongoDB, database 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);
const accessiblePosts = await Post.find(accessibleBy(ability).ofType('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.
Historically, `@casl/mongoose` was intended for super easy integration with [mongoose] but now we re-orient it to be more MongoDB specific package due to complexity working with mongoose types in TS. This plugins are still shipped but deprecated and we encourage you either write own plugins on app level or use `accessibleBy` and `accessibleFieldsBy` helpers

### Accessible Records plugin

Expand Down Expand Up @@ -309,6 +271,38 @@ 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.
### accessibleFieldsBy
`accessibleFieldsBy` is companion helper that allows to get only accessible fields for specific subject type of subject:
```ts
import { accessibleFieldsBy } from '@casl/mongoose';
import { Post } from './models';

accessibleFieldsBy(ability).ofType('Post') // returns accessible fields for Post model
accessibleFieldsBy(ability).ofType(Post) // also possible to pass class if classes are used for rule definition
accessibleFieldsBy(ability).of(new Post()) // returns accessible fields for Post model
```
This helper is pre-configured to get all fields from `Model.schema.paths`, if this is not desired or you need to restrict public fields your app work with, you need to define your own custom helper:
```ts
import { AnyMongoAbility, Generics } from "@casl/ability";
import { AccessibleFields, GetSubjectTypeAllFieldsExtractor } from "@casl/ability/extra";
import mongoose from 'mongoose';

const getSubjectTypeAllFieldsExtractor: GetSubjectTypeAllFieldsExtractor = (type) => {
/** custom implementation of returning all fields */
};

export function accessibleFieldsBy<T extends AnyMongoAbility>(
ability: T,
action: Parameters<T['rulesFor']>[0] = 'read'
): AccessibleFields<Extract<Generics<T>['abilities'], unknown[]>[1]> {
return new AccessibleFields(ability, action, getSubjectTypeAllFieldsExtractor);
}
```
## 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
143 changes: 130 additions & 13 deletions packages/casl-mongoose/spec/accessibleBy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,154 @@
import { defineAbility } from "@casl/ability"
import { MongoAbility, defineAbility } from "@casl/ability"
import { accessibleBy } from "../src"
import { testConversionToMongoQuery } from "./mongo_query.spec"

declare module '../src' {
interface RecordTypes {
Post: true
describe('accessibleBy', () => {
type AppAbility = MongoAbility<[string, Post['kind'] | Post]>
interface Post {
kind: 'Post';
_id: string;
state: string;
private: boolean;
isPublished: boolean | null;
authorId: number;
views: number;
'comments.author': string;
}
}

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

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

expect(query).toEqual({ $expr: { $eq: [0, 1] } })
})

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

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

expect(query).toEqual({ $expr: { $eq: [0, 1] } })
})

describe('it behaves like `toMongoQuery` when converting rules', () => {
testConversionToMongoQuery((ability, subjectType, action) =>
accessibleBy(ability, action)[subjectType])
it('accepts ability action as third argument', () => {
const ability = defineAbility<AppAbility>((can) => {
can('update', 'Post', { _id: 'mega' })
})
const query = accessibleBy(ability, 'update').ofType('Post')

expect(query).toEqual({
$or: [{ _id: 'mega' }]
})
})

it('OR-es conditions for regular rules and AND-es for inverted ones', () => {
const ability = defineAbility<AppAbility>((can, cannot) => {
can('read', 'Post', { _id: 'mega' })
can('read', 'Post', { state: 'draft' })
cannot('read', 'Post', { private: true })
cannot('read', 'Post', { state: 'archived' })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({
$or: [
{ state: 'draft' },
{ _id: 'mega' }
],
$and: [
{ $nor: [{ state: 'archived' }] },
{ $nor: [{ private: true }] }
]
})
})

describe('can find records where property', () => {
it('is present', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', {
isPublished: { $exists: true, $ne: null }
})
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ isPublished: { $exists: true, $ne: null } }] })
})

it('is blank', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { isPublished: { $exists: false } })
can('read', 'Post', { isPublished: null })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({
$or: [
{ isPublished: null },
{ isPublished: { $exists: false } }
]
})
})

it('is defined by `$in` criteria', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { state: { $in: ['draft', 'archived'] } })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ state: { $in: ['draft', 'archived'] } }] })
})

it('is defined by `$all` criteria', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { state: { $all: ['draft', 'archived'] } })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ state: { $all: ['draft', 'archived'] } }] })
})
it('is defined by `$lt` and `$lte` criteria', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { views: { $lt: 10 } })
can('read', 'Post', { views: { $lt: 5 } })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ views: { $lt: 5 } }, { views: { $lt: 10 } }] })
})

it('is defined by `$gt` and `$gte` criteria', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { views: { $gt: 10 } })
can('read', 'Post', { views: { $gte: 100 } })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ views: { $gte: 100 } }, { views: { $gt: 10 } }] })
})

it('is defined by `$ne` criteria', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { authorId: { $ne: 5 } })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ authorId: { $ne: 5 } }] })
})

it('is defined by dot notation fields', () => {
const ability = defineAbility<AppAbility>((can) => {
can('read', 'Post', { 'comments.author': 'Ted' })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] })
})
})
})
})
64 changes: 64 additions & 0 deletions packages/casl-mongoose/spec/accessibleFieldsBy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { MongoAbility, createMongoAbility, defineAbility } from "@casl/ability"
import { accessibleFieldsBy } from "../src"
import mongoose from "mongoose"

describe('accessibleFieldsBy', () => {
type AppAbility = MongoAbility<[string, Post | mongoose.Model<Post> | 'Post']>
interface Post {
_id: string;
title: string;
state: string;
}

// eslint-disable-next-line @typescript-eslint/no-redeclare
const Post = mongoose.model('Post', new mongoose.Schema<Post>({
title: String,
state: String,
}))

describe('when subject type is a mongoose model', () => {
testWithSubjectType(Post, Post)
})

describe('when subject type is a mongoose model name', () => {
testWithSubjectType('Post', Post)
})

function testWithSubjectType(type: mongoose.Model<Post> | 'Post', Model: mongoose.Model<Post>) {
it('returns empty array for empty `Ability` instance', () => {
const fields = accessibleFieldsBy(createMongoAbility<AppAbility>()).ofType(type)

expect(fields).toBeInstanceOf(Array)
expect(fields).toHaveLength(0)
})

it('returns all fields for model if ability does not have restrictions on rules', () => {
const ability = defineAbility<AppAbility>(can => can('read', type))

expect(accessibleFieldsBy(ability).ofType(type).sort())
.toEqual(['_id', '__v', 'title', 'state'].sort())
})

it('returns fields for `read` action by default', () => {
const ability = defineAbility<AppAbility>(can => can('read', type, ['title', 'state']))

expect(accessibleFieldsBy(ability).ofType(type)).toEqual(['title', 'state'])
})

it('returns fields for an action specified as 2nd parameter', () => {
const ability = defineAbility<AppAbility>(can => can('update', type, ['title', 'state']))

expect(accessibleFieldsBy(ability, 'update').ofType(type)).toEqual(['title', 'state'])
})

it('returns fields permitted for the instance when called on model instance', () => {
const ability = defineAbility<AppAbility>((can) => {
can('update', type, ['title', 'state'], { state: 'draft' })
can('update', type, ['title'], { state: 'public' })
})
const post = new Model({ state: 'public' })

expect(accessibleFieldsBy(ability, 'update').of(post)).toEqual(['title'])
})
}
})
10 changes: 5 additions & 5 deletions packages/casl-mongoose/spec/accessible_fields.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineAbility, Ability, SubjectType } from '@casl/ability'
import { MongoAbility, SubjectType, createMongoAbility, defineAbility } from '@casl/ability'
import mongoose from 'mongoose'
import { accessibleFieldsPlugin, AccessibleFieldsModel } from '../src'
import { AccessibleFieldsModel, accessibleFieldsPlugin } from '../src'

describe('Accessible fields plugin', () => {
interface Post {
Expand Down Expand Up @@ -43,7 +43,7 @@ describe('Accessible fields plugin', () => {
})

it('returns empty array for empty `Ability` instance', () => {
const fields = Post.accessibleFieldsBy(new Ability())
const fields = Post.accessibleFieldsBy(createMongoAbility())

expect(fields).toBeInstanceOf(Array)
expect(fields).toHaveLength(0)
Expand Down Expand Up @@ -92,10 +92,10 @@ describe('Accessible fields plugin', () => {
})

describe('when plugin options are provided', () => {
let ability: Ability
let ability: MongoAbility

beforeEach(() => {
ability = defineAbility<Ability>(can => can('read', 'Post'))
ability = defineAbility<MongoAbility>(can => can('read', 'Post'))
})

it('returns fields provided in `only` option specified as string', () => {
Expand Down
Loading

0 comments on commit a807ac6

Please sign in to comment.