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.
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.