Search code examples
mongoosenestjscasl

CASL Mongoose AccessibleRecords plugin throws ForbiddenError despite permissions are sufficient (NestJS)


I'm working with NestJS+Mongoose+CASL. I have Record and User controllers, services and schemas. I want to make a simple findAll endpoint that would return Records that can be accessed by the current user. I have JWT authorization implemented and have access to the logged user, as well as their ability, in the controller; it's working alright.

Here's my Record Schema, where I also enable the plugin:

// imports...

export type RecordDocument = Record & Document;

@Schema()
export class Record {
  @Prop({ required: true })
  name: string;

  @Prop({ required: true })
  description: string;

  @Prop({ type: MongooseSchema.Types.ObjectId, ref: User.name, required: true })
  author: User;
}

export const RecordSchema = SchemaFactory.createForClass<
  Record,
  AccessibleModel<Record>
>(Record).plugin(accessibleRecordsPlugin);

Here's my ability factory:

// imports...

export enum Action {
  MANAGE = 'manage',
  CREATE = 'create',
  READ = 'read',
  UPDATE = 'update',
  DELETE = 'delete',
}

export type Subjects =
  | InferSubjects<typeof User>
  | InferSubjects<typeof Record>
  | 'all';
export type AppAbility = Ability<[Action, Subjects]>;

@Injectable()
export class AbilityFactory {
  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
    @InjectModel(Record.name) private readonly recordModel: AccessibleModel<RecordDocument>,
  ) {}

  defineAbility(user: UserDocument | null): AppAbility {
    const { can, cannot, build } = new AbilityBuilder(
      Ability as AbilityClass<AppAbility>,
    );

    switch (user?.role) {
      case 'USER':
        can(Action.READ, this.userModel, ['email']);
        can(Action.READ, this.recordModel);
        can(Action.UPDATE, this.userModel, ['email', 'password'], {
          _id: user._id,
        });
        break;

      default:
        can(Action.READ, this.userModel, ['email']);
        can(Action.READ, this.recordModel);
        break;
    }

    return build({
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}

record.controller endpoint (here I use custom param decorator and pipe which essentially validate JWT, take userId from the payload, look it up in the DB and return ability based on the user's role):

@Get()
@UseGuards(JwtAuthGuard)
findAll(@RequestUser(AbilityPipe) ability: AppAbility) {
  return this.recordsService.findAll(ability);
}

Everything above works just fine. Here's my records.service:

// imports...

@Injectable()
export class RecordsService {
  constructor(
    @InjectModel(Record.name)
    private readonly recordModel: AccessibleModel<RecordDocument>,
  ) {}
  async findAll(ability?: AppAbility) {
    if (ability) {
      console.log(ability.can(Action.READ, this.recordModel)); // this logs out true
      return this.recordModel.accessibleBy(ability, Action.READ); // <- throws ForbiddenError
    }
    return this.recordModel.find();
  }
}

However, .accessibleBy method throws a ForbiddenError saying Cannot execute "read" on "Record" for some reason, even though the user has such permission. Why does this happen and how to fix it?

Thanks in advance.


Solution

  • Turned out to be a bug: @casl/mongoose doesnt work in case you use classes as subject types. See https://github.com/stalniy/casl/issues/656.