Skip to content

Latest commit

 

History

History
 
 

casl-mongoose

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

CASL Mongoose

@casl/mongoose NPM version CASL Join the chat

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

Integration with mongoose

mongoose is a popular JavaScript ODM for MongoDB. @casl/mongoose provides 2 plugins that allow to integrate @casl/ability and mongoose in few minutes:

Accessible Records plugin

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 calling mongoose.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 of rulesToQuery 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.

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

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

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

License

MIT License