NestJs CASL Authorization guard based on user attributes and policies

6.9k views Asked by At

I am trying to implement a generic policy based guard in NestJS and CASL for list / get endpoints. I am following the documentation here https://docs.nestjs.com/security/authorization#integrating-casl.

A user could be part of multiple groups. I need to check if Article or Books entity which has groupId matches any of the user's groups. And only allow user to read them if the user's list of groups matches Article.id and Books.id

However I can't get it seems to be working

// User Object - from request.user after decoded from AuthGuard('jwt')

user = {
  groups: ['department-1', 'department-2']

}
// Article Entity
export class Article extends UUIDBase {
  @Column({ nullable: false })
  groupId: string

  @Column({ nullable: true })
  description: string

  @Column({ nullable: true })
  name: string
}
// Books Entity
export class Books extends UUIDBase {
  @Column({ nullable: false })
  groupId: string

  @Column({ nullable: true })
  description: string

  @Column({ nullable: true })
  name: string

  @Column({ nullable: true })
  description: string
}
// CASL-ability.factory.ts
type Subjects = typeof Articles | typeof User | Articles | User | 'all'

export type AppAbility = Ability<[Action, Subjects]>

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: any) {
    const { can, build } = new AbilityBuilder<Ability<[Action, Subjects]>>(
      Ability as AbilityClass<AppAbility>,
    )

    console.log('USER IS ADMIN', user.groups)
    if (user.groups.includes(Groups.ADMIN)) {
      can(Action.Manage, 'all') // read-write access to everything
    } else {
      can(Action.Read, 'all') // read-only access to everything
    }

    return build()
  }
}
// @CheckPolicies decorator
interface IPolicyHandler {
  handle(ability: AppAbility): boolean
}

type PolicyHandlerCallback = (ability: AppAbility, can?, user?) => boolean

export declare type PolicyHandler = IPolicyHandler | PolicyHandlerCallback

import { SetMetadata } from '@nestjs/common'

export const CHECK_POLICIES_KEY = 'check_policy'
export const CheckPolicies: any = (...handlers: PolicyHandler[]) =>
  SetMetadata(CHECK_POLICIES_KEY, handlers)
// PoliciesGuard.ts

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private caslAbilityFactory: CaslAbilityFactory,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const policyHandlers =
      this.reflector.get<PolicyHandler[]>(
        CHECK_POLICIES_KEY,
        context.getHandler(),
      ) || []

    const { user } = context.switchToHttp().getRequest()
    const ability = this.caslAbilityFactory.createForUser(user)

    return policyHandlers.every(handler =>
      this.execPolicyHandler(handler, ability),
    )
  }

  private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
    if (typeof handler === 'function') {
      return handler(ability)
    }
    return handler.handle(ability)
  }
}

And I am using it like this in Articles controller

@ApiTags('Articles')
@Controller('Articles')
@UseGuards(AuthGuard('jwt'))
export class ArticlesController {
  constructor(public service: ArticlesService) {}
  @Get('/articles/:id')
  @UseGuards(PoliciesGuard)
  @CheckPolicies((ability: AppAbility) => {
    ability.can(Action.Read, Articles)
  })
  async listArticles(@Query() query, @Param() param) {
    return this.service.listArticles(query, param)
  }

  @Get('/articles/:id')
  @UseGuards(PoliciesGuard)
  @CheckPolicies((ability: AppAbility) => {
    ability.can(Action.Read, Articles)
  })
  async getArticle(@Param() params, @Query() query) {
    return this.service.getArticle(params, query)
  }

And I am using it like this in Books controller

@ApiTags('Books')
@Controller('Books')
@UseGuards(AuthGuard('jwt'))
export class BooksController {
  constructor(public service: BooksService) {}
  @Get('/books/:id')
  @UseGuards(PoliciesGuard)
  @CheckPolicies((ability: AppAbility) => {
    ability.can(Action.Read, Books)
  })
  async listBooks(@Query() query, @Param() param, @Body() body) {
    return this.service.listBooks(query, param, body)
  }

  @Get('/books/:id')
  @UseGuards(PoliciesGuard)
  @CheckPolicies((ability: AppAbility) => {
    ability.can(Action.Read, Books)
  })
  async getBooks(@Param() params, @Query() query) {
    return this.service.getBooks(params, query)
  }

1

There are 1 answers

0
davidgpgr94 On

Try to change @CheckPolicies((ability: AppAbility) => {ability.can(Action.Read, Books)}) by adding a return before ability.can... or removing the brackets.

With what you have, that policy handler always returns undefined, so it always gives you an Unauthorized.