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 14, 2024
1 parent 6b17383 commit bc9bdd2
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 327 deletions.
119 changes: 114 additions & 5 deletions packages/casl-mongoose/spec/accessibleBy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { defineAbility } from "@casl/ability"
import { accessibleBy } from "../src"
import { testConversionToMongoQuery } from "./mongo_query.spec"

declare module '../src' {
interface RecordTypes {
Expand All @@ -14,7 +13,7 @@ describe('accessibleBy', () => {
can('read', 'Post')
})

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

expect(query).toEqual({ $expr: { $eq: [0, 1] } })
})
Expand All @@ -25,13 +24,123 @@ describe('accessibleBy', () => {
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((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((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((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((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((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((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((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((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((can) => {
can('read', 'Post', { creator: { $ne: 'me' } })
})
const query = accessibleBy(ability).ofType('Post')

expect(query).toEqual({ $or: [{ creator: { $ne: 'me' } }] })
})

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

expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] })
})
})
})
})
106 changes: 10 additions & 96 deletions packages/casl-mongoose/spec/accessible_records.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Ability, createMongoAbility, defineAbility, ForbiddenError, MongoAbility, SubjectType } from '@casl/ability'
import { Ability, defineAbility, SubjectType } from '@casl/ability'
import mongoose from 'mongoose'
import { AccessibleRecordModel, accessibleRecordsPlugin, toMongoQuery } from '../src'
import { accessibleBy, AccessibleRecordModel, accessibleRecordsPlugin } from '../src'

describe('Accessible Records Plugin', () => {
interface Post {
Expand Down Expand Up @@ -58,7 +58,7 @@ describe('Accessible Records Plugin', () => {
const query = Post.accessibleBy(ability).getQuery()

expect(query).toEqual({
$and: [toMongoQuery(ability, 'Post')]
$and: [accessibleBy(ability).ofType('Post')]
})
})

Expand All @@ -69,7 +69,7 @@ describe('Accessible Records Plugin', () => {

expect(conditions.$and).toEqual([
...existingConditions,
toMongoQuery(ability, 'Post')
accessibleBy(ability).ofType('Post')
])
})

Expand Down Expand Up @@ -107,98 +107,12 @@ describe('Accessible Records Plugin', () => {
})
})

describe('when ability disallow to perform an action', () => {
let query: mongoose.QueryWithHelpers<Post, Post, any, any>

beforeEach(() => {
query = Post.find().accessibleBy(ability, 'notAllowedAction')
})

it('throws `ForbiddenError` for collection request', async () => {
await query.exec()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` when `find` is called', async () => {
await query.find()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` for item request', async () => {
await query.findOne().exec()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` when `findOne` is called', async () => {
await query.findOne()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` for count request', async () => {
await query.count()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` for count request', async () => {
await query.count()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` for `countDocuments` request', async () => {
await query.countDocuments()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` for `estimatedDocumentCount` request', async () => {
await query.estimatedDocumentCount()
.then(() => fail('should not execute'))
.catch((error: any) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect(error.message).toMatch(/cannot execute/i)
})
})

it('throws `ForbiddenError` with correct message when subjectType is a model', async () => {
const anotherAbility = createMongoAbility([
{ action: 'read', subject: Post }
], {
detectSubjectType: o => o.constructor as SubjectType,
})

await Post.find().accessibleBy(anotherAbility, 'update')
.then(() => fail('should never be called'))
.catch((error: unknown) => {
expect(error).toBeInstanceOf(ForbiddenError)
expect((error as ForbiddenError<MongoAbility>).message).toBe('Cannot execute "update" on "Post"')
})
it('returns always empty result query if ability disallow to perform action', () => {
const q = Post.find().accessibleBy(ability, 'notAllowedAction')
expect(q.getQuery()).toEqual({
$and: [
{ $expr: { $eq: [0, 1] } }
]
})
})
})
Expand Down
Loading

0 comments on commit bc9bdd2

Please sign in to comment.