From bd58bb2a450389370614cf9381d3bcd7ac34cf6b Mon Sep 17 00:00:00 2001 From: Sergii Stotskyi Date: Sat, 22 Jul 2023 15:19:00 +0300 Subject: [PATCH] feat: adds accessibleBy helper and deprecates `toMongoQuery` and `accessibleRecordsPlugin` (#795) --- packages/casl-mongoose/README.md | 143 ++++++++++++++---- .../casl-mongoose/spec/accessibleBy.spec.ts | 37 +++++ .../casl-mongoose/spec/mongo_query.spec.ts | 47 ++++-- .../casl-mongoose/src/accessible_records.ts | 4 +- packages/casl-mongoose/src/index.ts | 4 +- packages/casl-mongoose/src/mongo.ts | 27 ++++ 6 files changed, 217 insertions(+), 45 deletions(-) create mode 100644 packages/casl-mongoose/spec/accessibleBy.spec.ts diff --git a/packages/casl-mongoose/README.md b/packages/casl-mongoose/README.md index 262792aa9..e089537c8 100644 --- a/packages/casl-mongoose/README.md +++ b/packages/casl-mongoose/README.md @@ -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 @@ -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! diff --git a/packages/casl-mongoose/spec/accessibleBy.spec.ts b/packages/casl-mongoose/spec/accessibleBy.spec.ts new file mode 100644 index 000000000..c0769bef1 --- /dev/null +++ b/packages/casl-mongoose/spec/accessibleBy.spec.ts @@ -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]) + }) +}) diff --git a/packages/casl-mongoose/spec/mongo_query.spec.ts b/packages/casl-mongoose/spec/mongo_query.spec.ts index 1429a3e47..8a33c54bb 100644 --- a/packages/casl-mongoose/spec/mongo_query.spec.ts +++ b/packages/casl-mongoose/spec/mongo_query.spec.ts @@ -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' }] @@ -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: [ @@ -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 } }] }) }) @@ -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: [ @@ -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'] } }] }) }) @@ -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'] } }] }) }) @@ -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 } }] }) }) @@ -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 } }] }) }) @@ -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' } }] }) }) @@ -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' }] }) }) }) -}) +} diff --git a/packages/casl-mongoose/src/accessible_records.ts b/packages/casl-mongoose/src/accessible_records.ts index b10c34b08..dfa3fcb3c 100644 --- a/packages/casl-mongoose/src/accessible_records.ts +++ b/packages/casl-mongoose/src/accessible_records.ts @@ -8,7 +8,7 @@ function failedQuery( modelName: string, query: QueryWithHelpers ) { - 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') { @@ -53,6 +53,7 @@ AccessibleRecordQueryHelpers >; export type AccessibleRecordQueryHelpers = { + /** @deprecated use accessibleBy helper instead */ accessibleBy: GetAccessibleRecords< HydratedDocument, TQueryHelpers, @@ -69,6 +70,7 @@ export interface AccessibleRecordModel< TQueryHelpers & AccessibleRecordQueryHelpers, TMethods, TVirtuals> { + /** @deprecated use accessibleBy helper instead */ accessibleBy: GetAccessibleRecords< HydratedDocument, TQueryHelpers, diff --git a/packages/casl-mongoose/src/index.ts b/packages/casl-mongoose/src/index.ts index 1df6c0df0..b3350fd51 100644 --- a/packages/casl-mongoose/src/index.ts +++ b/packages/casl-mongoose/src/index.ts @@ -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'; diff --git a/packages/casl-mongoose/src/mongo.ts b/packages/casl-mongoose/src/mongo.ts index bb4964463..7f2d890de 100644 --- a/packages/casl-mongoose/src/mongo.ts +++ b/packages/casl-mongoose/src/mongo.ts @@ -7,6 +7,8 @@ function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) { } /** + * @deprecated use accessibleBy instead + * * Converts ability action + subjectType to MongoDB query */ export function toMongoQuery( @@ -16,3 +18,28 @@ export function toMongoQuery( ): 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>; +} + +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; + } +};