diff --git a/packages/casl-mongoose/README.md b/packages/casl-mongoose/README.md index e089537c8..c427c1898 100644 --- a/packages/casl-mongoose/README.md +++ b/packages/casl-mongoose/README.md @@ -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(); } @@ -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 } ] }); @@ -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, @@ -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 @@ -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( + ability: T, + action: Parameters[0] = 'read' +): AccessibleFields['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! diff --git a/packages/casl-mongoose/spec/accessibleBy.spec.ts b/packages/casl-mongoose/spec/accessibleBy.spec.ts index bc9fe1514..cb6b34ac6 100644 --- a/packages/casl-mongoose/spec/accessibleBy.spec.ts +++ b/packages/casl-mongoose/spec/accessibleBy.spec.ts @@ -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((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((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((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', { 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((can) => { + can('read', 'Post', { 'comments.author': 'Ted' }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] }) + }) + }) }) }) diff --git a/packages/casl-mongoose/spec/accessibleFieldsBy.spec.ts b/packages/casl-mongoose/spec/accessibleFieldsBy.spec.ts new file mode 100644 index 000000000..b3629245b --- /dev/null +++ b/packages/casl-mongoose/spec/accessibleFieldsBy.spec.ts @@ -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']> + interface Post { + _id: string; + title: string; + state: string; + } + + // eslint-disable-next-line @typescript-eslint/no-redeclare + const Post = mongoose.model('Post', new mongoose.Schema({ + 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', Model: mongoose.Model) { + it('returns empty array for empty `Ability` instance', () => { + const fields = accessibleFieldsBy(createMongoAbility()).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(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(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(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((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']) + }) + } +}) diff --git a/packages/casl-mongoose/spec/accessible_fields.spec.ts b/packages/casl-mongoose/spec/accessible_fields.spec.ts index 67fe5226c..0c246c2cc 100644 --- a/packages/casl-mongoose/spec/accessible_fields.spec.ts +++ b/packages/casl-mongoose/spec/accessible_fields.spec.ts @@ -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 { @@ -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) @@ -92,10 +92,10 @@ describe('Accessible fields plugin', () => { }) describe('when plugin options are provided', () => { - let ability: Ability + let ability: MongoAbility beforeEach(() => { - ability = defineAbility(can => can('read', 'Post')) + ability = defineAbility(can => can('read', 'Post')) }) it('returns fields provided in `only` option specified as string', () => { diff --git a/packages/casl-mongoose/spec/accessible_records.spec.ts b/packages/casl-mongoose/spec/accessible_records.spec.ts index 1391f69d6..69827182e 100644 --- a/packages/casl-mongoose/spec/accessible_records.spec.ts +++ b/packages/casl-mongoose/spec/accessible_records.spec.ts @@ -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 { @@ -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')] }) }) @@ -69,7 +69,7 @@ describe('Accessible Records Plugin', () => { expect(conditions.$and).toEqual([ ...existingConditions, - toMongoQuery(ability, 'Post') + accessibleBy(ability).ofType('Post') ]) }) @@ -107,98 +107,12 @@ describe('Accessible Records Plugin', () => { }) }) - describe('when ability disallow to perform an action', () => { - let query: mongoose.QueryWithHelpers - - 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).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] } } + ] }) }) }) diff --git a/packages/casl-mongoose/spec/mongo_query.spec.ts b/packages/casl-mongoose/spec/mongo_query.spec.ts deleted file mode 100644 index 8a33c54bb..000000000 --- a/packages/casl-mongoose/spec/mongo_query.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 = abilityToMongoQuery(ability, 'Post', 'update') - - 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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, '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 = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] }) - }) - }) -} diff --git a/packages/casl-mongoose/src/accessibleBy.ts b/packages/casl-mongoose/src/accessibleBy.ts new file mode 100644 index 000000000..8115d3cc4 --- /dev/null +++ b/packages/casl-mongoose/src/accessibleBy.ts @@ -0,0 +1,34 @@ +import { AnyMongoAbility, Generics, SubjectType } from '@casl/ability'; +import { ToAbilityTypes } from '@casl/ability/dist/types/types'; +import { rulesToQuery } from '@casl/ability/extra'; + +function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) { + const conditions = rule.conditions!; + return rule.inverted ? { $nor: [conditions] } : conditions; +} + +export const EMPTY_RESULT_QUERY = { $expr: { $eq: [0, 1] } }; +export class AccessibleRecords { + constructor( + private readonly _ability: AnyMongoAbility, + private readonly _action: string + ) {} + + /** + * In case action is not allowed, it returns `{ $expr: { $eq: [0, 1] } }` + */ + ofType(subjectType: T): Record { + const query = rulesToQuery(this._ability, this._action, subjectType, convertToMongoQuery); + return query === null ? EMPTY_RESULT_QUERY : query as Record; + } +} + +/** + * Returns accessible records Mongo query per record type (i.e., entity type) based on provided Ability and action. + */ +export function accessibleBy( + ability: T, + action: Parameters[0] = 'read' +): AccessibleRecords['abilities']>[1]> { + return new AccessibleRecords(ability, action); +} diff --git a/packages/casl-mongoose/src/accessibleFieldsBy.ts b/packages/casl-mongoose/src/accessibleFieldsBy.ts new file mode 100644 index 000000000..e082eabc1 --- /dev/null +++ b/packages/casl-mongoose/src/accessibleFieldsBy.ts @@ -0,0 +1,16 @@ +import { AnyMongoAbility, Generics } from "@casl/ability"; +import { AccessibleFields, GetSubjectTypeAllFieldsExtractor } from "@casl/ability/extra"; +import mongoose from 'mongoose'; + +const getSubjectTypeAllFieldsExtractor: GetSubjectTypeAllFieldsExtractor = (type) => { + const Model = typeof type === 'string' ? mongoose.models[type] : type; + if (!Model) throw new Error(`Unknown mongoose model "${type}"`); + return 'schema' in Model ? Object.keys((Model.schema as any).paths) : []; +}; + +export function accessibleFieldsBy( + ability: T, + action: Parameters[0] = 'read' +): AccessibleFields['abilities'], unknown[]>[1]> { + return new AccessibleFields(ability, action, getSubjectTypeAllFieldsExtractor); +} diff --git a/packages/casl-mongoose/src/index.ts b/packages/casl-mongoose/src/index.ts index b3350fd51..2b9aa296c 100644 --- a/packages/casl-mongoose/src/index.ts +++ b/packages/casl-mongoose/src/index.ts @@ -1,5 +1,5 @@ -import { AccessibleFieldDocumentMethods, AccessibleFieldsModel } from './accessible_fields'; -import { AccessibleRecordModel, AccessibleRecordQueryHelpers } from './accessible_records'; +import { AccessibleFieldDocumentMethods, AccessibleFieldsModel } from './plugins/accessible_fields'; +import { AccessibleRecordModel, AccessibleRecordQueryHelpers } from './plugins/accessible_records'; export interface AccessibleModel< T, @@ -17,14 +17,16 @@ export interface AccessibleModel< >, TMethods, TVirtuals> {} -export { accessibleRecordsPlugin } from './accessible_records'; -export type { AccessibleRecordModel } from './accessible_records'; -export { getSchemaPaths, accessibleFieldsPlugin } from './accessible_fields'; +export { accessibleRecordsPlugin } from './plugins/accessible_records'; +export type { AccessibleRecordModel } from './plugins/accessible_records'; +export { getSchemaPaths, accessibleFieldsPlugin } from './plugins/accessible_fields'; export type { AccessibleFieldsModel, AccessibleFieldsDocument, AccessibleFieldsOptions -} from './accessible_fields'; +} from './plugins/accessible_fields'; -export { toMongoQuery, accessibleBy } from './mongo'; -export type { RecordTypes } from './mongo'; +export { accessibleBy } from './accessibleBy'; +export type { AccessibleRecords } from './accessibleBy'; + +export { accessibleFieldsBy } from './accessibleFieldsBy'; diff --git a/packages/casl-mongoose/src/mongo.ts b/packages/casl-mongoose/src/mongo.ts deleted file mode 100644 index a0b5a1413..000000000 --- a/packages/casl-mongoose/src/mongo.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AnyMongoAbility } from '@casl/ability'; -import { AbilityQuery, rulesToQuery } from '@casl/ability/extra'; - -function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) { - const conditions = rule.conditions!; - return rule.inverted ? { $nor: [conditions] } : conditions; -} - -/** - * @deprecated use accessibleBy instead - * - * Converts ability action + subjectType to MongoDB query - */ -export function toMongoQuery( - ability: T, - subjectType: Parameters[1], - action: Parameters[0] = 'read' -): AbilityQuery | null { - return rulesToQuery(ability, action, subjectType, convertToMongoQuery); -} - -export interface RecordTypes { -} -type StringOrKeysOf = 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( - ability: T, - action: Parameters[0] = 'read' -): Record, AbilityQuery> { - return new Proxy({ - _ability: ability, - _action: action - }, accessibleByProxyHandlers) as unknown as Record, AbilityQuery>; -} - -export const EMPTY_RESULT_QUERY = { $expr: { $eq: [0, 1] } }; -const accessibleByProxyHandlers: ProxyHandler<{ _ability: AnyMongoAbility, _action: string }> = { - get(target, subjectType) { - const query = rulesToQuery(target._ability, target._action, subjectType, convertToMongoQuery); - return query === null ? EMPTY_RESULT_QUERY : query; - } -}; diff --git a/packages/casl-mongoose/src/accessible_fields.ts b/packages/casl-mongoose/src/plugins/accessible_fields.ts similarity index 66% rename from packages/casl-mongoose/src/accessible_fields.ts rename to packages/casl-mongoose/src/plugins/accessible_fields.ts index d2d84a55f..524449160 100644 --- a/packages/casl-mongoose/src/accessible_fields.ts +++ b/packages/casl-mongoose/src/plugins/accessible_fields.ts @@ -1,6 +1,6 @@ -import { wrapArray, Normalize, AnyMongoAbility, Generics } from '@casl/ability'; -import { permittedFieldsOf, PermittedFieldsOptions } from '@casl/ability/extra'; -import type { Schema, Model, Document } from 'mongoose'; +import { AnyMongoAbility, Generics, Normalize, wrapArray } from '@casl/ability'; +import { AccessibleFields, GetSubjectTypeAllFieldsExtractor } from '@casl/ability/extra'; +import type { Document, Model, Schema } from 'mongoose'; export type AccessibleFieldsOptions = { @@ -46,17 +46,17 @@ export interface AccessibleFieldDocumentMethods { */ export interface AccessibleFieldsDocument extends Document, AccessibleFieldDocumentMethods {} -function modelFieldsGetter() { - let fieldsFrom: PermittedFieldsOptions['fieldsFrom']; +function getAllSchemaFieldsFactory() { + let getAllFields: GetSubjectTypeAllFieldsExtractor; return (schema: Schema, options: Partial) => { - if (!fieldsFrom) { + if (!getAllFields) { const ALL_FIELDS = options && 'only' in options ? wrapArray(options.only as string[]) : fieldsOf(schema, options); - fieldsFrom = rule => rule.fields || ALL_FIELDS; + getAllFields = () => ALL_FIELDS; } - return fieldsFrom; + return getAllFields; }; } @@ -65,21 +65,19 @@ export function accessibleFieldsPlugin( rawOptions?: Partial ): void { const options = { getFields: getSchemaPaths, ...rawOptions }; - const fieldsFrom = modelFieldsGetter(); + const getAllFields = getAllSchemaFieldsFactory(); - function istanceAccessibleFields(this: Document, ability: AnyMongoAbility, action?: string) { - return permittedFieldsOf(ability, action || 'read', this, { - fieldsFrom: fieldsFrom(schema, options) - }); + function instanceAccessibleFields(this: Document, ability: AnyMongoAbility, action?: string) { + return new AccessibleFields(ability, action || 'read', getAllFields(schema, options)).of(this); } function modelAccessibleFields(this: Model, ability: AnyMongoAbility, action?: string) { - const document = { constructor: this }; - return permittedFieldsOf(ability, action || 'read', document, { - fieldsFrom: fieldsFrom(schema, options) - }); + // using fake document because at this point we don't know how Ability's detectSubjectType was configured: + // does it use classes or strings? + const fakeDocument = { constructor: this }; + return new AccessibleFields(ability, action || 'read', getAllFields(schema, options)).of(fakeDocument); } schema.statics.accessibleFieldsBy = modelAccessibleFields; - schema.method('accessibleFieldsBy', istanceAccessibleFields); + schema.method('accessibleFieldsBy', instanceAccessibleFields); } diff --git a/packages/casl-mongoose/src/accessible_records.ts b/packages/casl-mongoose/src/plugins/accessible_records.ts similarity index 64% rename from packages/casl-mongoose/src/accessible_records.ts rename to packages/casl-mongoose/src/plugins/accessible_records.ts index 1eff42e38..59de14adf 100644 --- a/packages/casl-mongoose/src/accessible_records.ts +++ b/packages/casl-mongoose/src/plugins/accessible_records.ts @@ -1,27 +1,8 @@ -import { Normalize, AnyMongoAbility, Generics, ForbiddenError } from '@casl/ability'; -import { Schema, QueryWithHelpers, Model, Document, HydratedDocument, Query } from 'mongoose'; -import { EMPTY_RESULT_QUERY, toMongoQuery } from './mongo'; +import { AnyMongoAbility, Generics, Normalize } from '@casl/ability'; +import { Document, HydratedDocument, Model, Query, QueryWithHelpers, Schema } from 'mongoose'; +import { accessibleBy } from '../accessibleBy'; -function failedQuery( - ability: AnyMongoAbility, - action: string, - modelName: string, - query: QueryWithHelpers -) { - query.where(EMPTY_RESULT_QUERY); - const anyQuery: any = query; - - if (typeof anyQuery.pre === 'function') { - anyQuery.pre((cb: (error?: Error) => void) => { - const error = ForbiddenError.from(ability).unlessCan(action, modelName); - cb(error); - }); - } - - return query; -} - -function accessibleBy( +function accessibleRecords( baseQuery: Query, ability: T, action?: Normalize['abilities']>[0] @@ -34,11 +15,7 @@ function accessibleBy( throw new TypeError(`Cannot detect subject type of "${baseQuery.model.modelName}" to return accessible records`); } - const query = toMongoQuery(ability, subjectType, action); - - if (query === null) { - return failedQuery(ability, action || 'read', subjectType, baseQuery.where()); - } + const query = accessibleBy(ability, action).ofType(subjectType); return baseQuery.and([query]); } @@ -80,7 +57,7 @@ export interface AccessibleRecordModel< } function modelAccessibleBy(this: Model, ability: AnyMongoAbility, action?: string) { - return accessibleBy(this.where(), ability, action); + return accessibleRecords(this.where(), ability, action); } function queryAccessibleBy( @@ -88,7 +65,7 @@ function queryAccessibleBy( ability: AnyMongoAbility, action?: string ) { - return accessibleBy(this, ability, action); + return accessibleRecords(this, ability, action); } export function accessibleRecordsPlugin(schema: Schema): void {