@casl/mongoose
v8.0.2
Published
Allows to query accessible records from MongoDB based on CASL rules
Downloads
76,586
Maintainers
Readme
CASL Mongoose
This package integrates CASL and MongoDB. In other words, it allows to fetch records based on CASL rules from MongoDB and answer questions like: "Which records can be read?" or "Which records can be updated?".
Installation
npm install @casl/mongoose @casl/ability
# or
yarn add @casl/mongoose @casl/ability
# or
pnpm add @casl/mongoose @casl/ability
Usage
@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:
MongoDB adapter
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').ofType('Post'));
} finally {
db.close();
}
console.log(posts);
}
This can also be combined with other conditions with help of $and
operator:
posts = await db.collection('posts').find({
$and: [
accessibleBy(ability, 'update').ofType('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:
// returns { authorId: 1 }
const permissionRestrictedConditions = accessibleBy(ability, 'update').ofType('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: { $eq: [0, 1] } }
and when it's sent to MongoDB, database will return an empty result set.
Mongoose
const Post = require('./Post') // mongoose model
const ability = require('./ability') // defines Ability instance
async function main() {
const accessiblePosts = await Post.find(accessibleBy(ability).ofType('Post'));
console.log(accessiblePosts);
}
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
This plugin is deprecated, the recommended way is to use accessibleBy
helper function
accessibleRecordsPlugin
is a plugin which adds accessibleBy
method to query and static methods of mongoose models. We can add this plugin globally:
const { accessibleRecordsPlugin } = require('@casl/mongoose');
const mongoose = require('mongoose');
mongoose.plugin(accessibleRecordsPlugin);
Make sure to add the plugin before calling
mongoose.model(...)
method. Mongoose won't add global plugins to models that where created before callingmongoose.plugin()
.
or to a particular model:
const mongoose = require('mongoose')
const { accessibleRecordsPlugin } = require('@casl/mongoose')
const Post = new mongoose.Schema({
title: String,
author: String
})
Post.plugin(accessibleRecordsPlugin)
module.exports = mongoose.model('Post', Post)
Afterwards, we can fetch accessible records using accessibleBy
method on Post
:
const Post = require('./Post')
const ability = require('./ability') // defines Ability instance
async function main() {
const accessiblePosts = await Post.accessibleBy(ability);
console.log(accessiblePosts);
}
See CASL guide to learn how to define abilities
or on existing query instance:
const Post = require('./Post');
const ability = require('./ability');
async function main() {
const accessiblePosts = await Post.find({ status: 'draft' })
.accessibleBy(ability)
.select('title');
console.log(accessiblePosts);
}
accessibleBy
returns an instance of mongoose.Query
and that means you can chain it with any mongoose.Query
's method (e.g., select
, limit
, sort
). By default, accessibleBy
constructs a query based on the list of rules for read
action but we can change this by providing the 2nd optional argument:
const Post = require('./Post');
const ability = require('./ability');
async function main() {
const postsThatCanBeUpdated = await Post.accessibleBy(ability, 'update');
console.log(postsThatCanBeUpdated);
}
accessibleBy
is built on top ofrulesToQuery
function from@casl/ability/extra
. Read Ability to database query to get insights of how it works.
In case user doesn’t have permission to do a particular action, CASL will throw ForbiddenError
and will not send request to MongoDB. It also adds __forbiddenByCasl__: 1
condition for additional safety.
For example, lets find all posts which user can delete (we haven’t defined abilities for delete):
const { defineAbility } = require('@casl/ability');
const mongoose = require('mongoose');
const Post = require('./Post');
mongoose.set('debug', true);
const ability = defineAbility(can => can('read', 'Post', { private: false }));
async function main() {
try {
const posts = await Post.accessibleBy(ability, 'delete');
} catch (error) {
console.log(error) // ForbiddenError;
}
}
We can also use the resulting conditions in aggregation pipeline:
const Post = require('./Post');
const ability = require('./ability');
async function main() {
const query = Post.accessibleBy(ability)
.where({ status: 'draft' })
.getQuery();
const result = await Post.aggregate([
{
$match: {
$and: [
query,
// other aggregate conditions
]
}
},
// other pipelines here
]);
console.log(result);
}
or in mapReduce:
const Post = require('./Post');
const ability = require('./ability');
async function main() {
const query = Post.accessibleBy(ability)
.where({ status: 'draft' })
.getQuery();
const result = await Post.mapReduce({
query: {
$and: [
query,
// other conditions
]
},
map: () => emit(this.title, 1);
reduce: (_, items) => items.length;
});
console.log(result);
}
Accessible Fields plugin
accessibleFieldsPlugin
is a plugin that adds accessibleFieldsBy
method to instance and static methods of a model and allows to retrieve all accessible fields. This is useful when we need to send only accessible part of a model in response:
const { accessibleFieldsPlugin } = require('@casl/mongoose');
const mongoose = require('mongoose');
const pick = require('lodash/pick');
const ability = require('./ability');
const app = require('./app'); // express app
mongoose.plugin(accessibleFieldsPlugin);
const Post = require('./Post');
app.get('/api/posts/:id', async (req, res) => {
const post = await Post.accessibleBy(ability).findByPk(req.params.id);
res.send(pick(post, post.accessibleFieldsBy(ability))
});
Method with the same name exists on Model's class. But it's important to understand the difference between them. Static method does not take into account conditions! It follows the same checking logic as Ability
's can
method. Let's see an example to recap:
const { defineAbility } = require('@casl/ability');
const Post = require('./Post');
const ability = defineAbility((can) => {
can('read', 'Post', ['title'], { private: true });
can('read', 'Post', ['title', 'description'], { private: false });
});
const post = new Post({ private: true, title: 'Private post' });
Post.accessibleFieldsBy(ability); // ['title', 'description']
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:
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:
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!
Suppose we have Post
entity which can be described as:
import mongoose from 'mongoose';
export interface Post extends mongoose.Document {
title: string
content: string
published: boolean
}
const PostSchema = new mongoose.Schema<Post>({
title: String,
content: String,
published: Boolean
});
export const Post = mongoose.model('Post', PostSchema);
To extend Post
model with accessibleBy
method it's enough to include the corresponding plugin (either globally or locally in Post
) and use corresponding Model
type. So, let's change the example, so it includes accessibleRecordsPlugin
:
import { accessibleRecordsPlugin, AccessibleRecordModel } from '@casl/mongoose';
// all previous code, except last line
PostSchema.plugin(accessibleRecordsPlugin);
export const Post = mongoose.model<Post, AccessibleRecordModel<Post>>('Post', PostSchema);
// Now we can safely use `Post.accessibleBy` method.
Post.accessibleBy(/* parameters */)
Post.where(/* parameters */).accessibleBy(/* parameters */);
In the similar manner, we can include accessibleFieldsPlugin
, using AccessibleFieldsModel
and AccessibleFieldsDocument
types:
import {
accessibleFieldsPlugin,
AccessibleFieldsModel,
AccessibleFieldsDocument
} from '@casl/mongoose';
import * as mongoose from 'mongoose';
export interface Post extends AccessibleFieldsDocument {
// the same Post definition from previous example
}
const PostSchema = new mongoose.Schema<Post>({
// the same Post schema definition from previous example
})
PostSchema.plugin(accessibleFieldsPlugin);
export const Post = mongoose.model<Post, AccessibleFieldsModel<Post>>('Post', PostSchema);
// Now we can safely use `Post.accessibleFieldsBy` method and `post.accessibleFieldsBy`
Post.accessibleFieldsBy(/* parameters */);
const post = new Post();
post.accessibleFieldsBy(/* parameters */);
And if we want to include both plugins, we can use AccessibleModel
type that provides methods from both plugins:
import {
accessibleFieldsPlugin,
accessibleRecordsPlugin,
AccessibleModel,
AccessibleFieldsDocument
} from '@casl/mongoose';
import * as mongoose from 'mongoose';
export interface Post extends AccessibleFieldsDocument {
// the same Post definition from previous example
}
const PostSchema = new mongoose.Schema<Post>({
// the same Post schema definition from previous example
});
PostSchema.plugin(accessibleFieldsPlugin);
PostSchema.plugin(accessibleRecordsPlugin);
export const Post = mongoose.model<Post, AccessibleModel<Post>>('Post', PostSchema);
This allows us to use the both accessibleBy
and accessibleFieldsBy
methods safely.
Want to help?
Want to file a bug, contribute some code, or improve documentation? Excellent! Read up on guidelines for contributing.
If you'd like to help us sustain our community and project, consider to become a financial contributor on Open Collective
See Support CASL for details