Git Product home page Git Product logo

nest-casl's Introduction

Access control for Nestjs with CASL

CI Build Dependabot status semantic-release NPM version

Nest.js

CASL

Installation

Install npm package with yarn add nest-casl or npm i nest-casl

Peer dependencies are @nestjs/core, @nestjs/common and @nestjs/graphql

Application configuration

Define roles for app:

// app.roles.ts

export enum Roles {
  admin = 'admin',
  operator = 'operator',
  customer = 'customer',
}

Configure application:

// app.module.ts

import { Module } from '@nestjs/common';
import { CaslModule } from 'nest-casl';
import { Roles } from './app.roles';

@Module({
  imports: [
    CaslModule.forRoot<Roles>({
      // Role to grant full access, optional
      superuserRole: Roles.admin,
      // Function to get casl user from request
      // Optional, defaults to `(request) => request.user`
      getUserFromRequest: (request) => request.currentUser,
    }),
  ],
})
export class AppModule {}

superuserRole will have unrestricted access. If getUserFromRequest omitted request.user will be used. User expected to have properties id: string and roles: Roles[] by default, request and user types can be customized.

Permissions definition

nest-casl comes with a set of default actions, aligned with Nestjs Query. manage has a special meaning of any action. DefaultActions aliased to Actions for convenicence.

export enum DefaultActions {
  read = 'read',
  aggregate = 'aggregate',
  create = 'create',
  update = 'update',
  delete = 'delete',
  manage = 'manage',
}

In case you need custom actions either extend DefaultActions or just copy and update, if extending typescript enum looks too tricky.

Permissions defined per module. everyone permissions applied to every user, it has every alias for every({ user, can }) be more readable. Roles can be extended with previously defined roles.

// post.permissions.ts

import { Permissions, Actions } from 'nest-casl';
import { InferSubjects } from '@casl/ability';

import { Roles } from '../app.roles';
import { Post } from './dtos/post.dto';
import { Comment } from './dtos/comment.dto';

export type Subjects = InferSubjects<typeof Post, typeof Comment>;

export const permissions: Permissions<Roles, Subjects, Actions> = {
  everyone({ can }) {
    can(Actions.read, Post);
    can(Actions.create, Post);
  },

  customer({ user, can }) {
    can(Actions.update, Post, { userId: user.id });
  },

  operator({ can, cannot, extend }) {
    extend(Roles.customer);

    can(Actions.manage, PostCategory);
    can(Actions.manage, Post);
    cannot(Actions.delete, Post);
  },
};
// post.module.ts

import { Module } from '@nestjs/common';
import { CaslModule } from 'nest-casl';

import { permissions } from './post.permissions';

@Module({
  imports: [CaslModule.forFeature({ permissions })],
})
export class PostModule {}

Access control

Assuming authentication handled by AuthGuard. AccessGuard expects user to at least exist, if not authenticated user obtained from request acess will be denied.

// post.resolver.ts

import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { AccessGuard, UseAbility, Actions } from 'nest-casl';

import { CreatePostInput } from './dtos/create-post-input.dto';
import { UpdatePostInput } from './dtos/update-post-input.dto';
import { PostService } from './post.service';
import { PostHook } from './post.hook';
import { Post } from './dtos/post.dto';

@Resolver(() => Post)
export class PostResolver {
  constructor(private postService: PostService) {}

  // No access restrictions, no request.user
  @Query(() => [Post])
  posts() {
    return this.postService.findAll();
  }

  // No access restrictions, request.user populated
  @Query(() => Post)
  @UseGuards(AuthGuard)
  async post(@Args('id') id: string) {
    return this.postService.findById(id);
  }

  // Tags method with ability action and subject and adds AccessGuard implicitly
  @UseGuards(AuthGuard, AccessGuard)
  @UseAbility(Actions.create, Post)
  async createPost(@Args('input') input: CreatePostInput) {
    return this.postService.create(input);
  }

  // Use hook to get subject for conditional rule
  @Mutation(() => Post)
  @UseGuards(AuthGuard, AccessGuard)
  @UseAbility(Actions.update, Post, PostHook)
  async updatePost(@Args('input') input: UpdatePostInput) {
    return this.postService.update(input);
  }
}

Subject hook

For permissions with conditions we need to provide subject hook in UseAbility decorator. It can be class implementing SubjectBeforeFilterHook interface

// post.hook.ts
import { Injectable } from '@nestjs/common';
import { Request, SubjectBeforeFilterHook } from 'nest-casl';

import { PostService } from './post.service';
import { Post } from './dtos/post.dto';

@Injectable()
export class PostHook implements SubjectBeforeFilterHook<Post, Request> {
  constructor(readonly postService: PostService) {}

  async run({ params }: Request) {
    return this.postService.findById(params.input.id);
  }
}

passed as third argument of UserAbility

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.update, Post, PostHook)
async updatePost(@Args('input') input: UpdatePostInput) {
  return this.postService.update(input);
}

Class hooks are preferred method, it has full dependency injection support and can be reused. Alternatively inline 'tuple hook' may be used, it can inject single service and may be useful for prototyping or single usage use cases.

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility<Post>(Actions.update, Post, [
  PostService,
  (service: PostService, { params }) => service.findById(params.input.id),
])
async updatePost(@Args('input') input: UpdatePostInput) {
  return this.postService.update(input);
}

CaslSubject decorator

CaslSubject decorator provides access to lazy loaded subject, obtained from subject hook and cached on request object.

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.update, Post, PostHook)
async updatePost(
  @Args('input') input: UpdatePostInput,
  @CaslSubject() subjectProxy: SubjectProxy<Post>
) {
  const post = await subjectProxy.get();
}

CaslConditions decorator

Permission conditions can be used in resolver through CaslConditions decorator, ie to filter selected records. Subject hook is not required.

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.update, Post)
async updatePostConditionParamNoHook(
  @Args('input') input: UpdatePostInput,
  @CaslConditions() conditions: ConditionsProxy
) {
  conditions.toSql(); // ['"userId" = $1', ['userId'], []]
  conditions.toMongo(); // { $or: [{ userId: 'userId' }] }
}

CaslUser decorator

CaslUser decorator provides access to lazy loaded user, obtained from request or user hook and cached on request object.

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.update, Post)
async updatePostConditionParamNoHook(
  @Args('input') input: UpdatePostInput,
  @CaslUser() userProxy: UserProxy<User>
) {
  const user = await userProxy.get();
}

Access service (global)

Use AccessService to check permissions without AccessGuard and UseAbility decorator

// ...
import { AccessService, Actions, CaslUser } from 'nest-casl';

@Resolver(() => Post)
export class PostResolver {
  constructor(private postService: PostService, private accessService: AccessService) {}

  @Mutation(() => Post)
  @UseGuards(AuthGuard)
  async updatePost(@Args('input') input: UpdatePostInput, @CaslUser() userProxy: UserProxy<User>) {
    const user = await userProxy.get();
    const post = await this.postService.findById(input.id);

    //check and throw error
    // 403 when no conditions
    // 404 when conditions set
    this.accessService.assertAbility(user, Actions.update, post);

    // return true or false
    this.accessService.hasAbility(user, Actions.update, post);
  }
}

Testing

Check package e2e tests for application testing example.

Advanced usage

User Hook

Sometimes permission conditions require more info on user than exists on request.user User hook called after getUserFromRequest only for abilities with conditions. Similar to subject hook, it can be class or tuple. Despite UserHook is configured on application level, it is executed in context of modules under authorization. To avoid importing user service to each module, consider making user module global.

// user.hook.ts

import { Injectable } from '@nestjs/common';

import { UserBeforeFilterHook } from 'nest-casl';
import { UserService } from './user.service';
import { User } from './dtos/user.dto';

@Injectable()
export class UserHook implements UserBeforeFilterHook<User> {
  constructor(readonly userService: UserService) {}

  async run(user: User) {
    return {
      ...user,
      ...(await this.userService.findById(user.id)),
    };
  }
}
//app.module.ts

import { Module } from '@nestjs/common';
import { CaslModule } from 'nest-casl';

@Module({
  imports: [
    CaslModule.forRoot({
      getUserFromRequest: (request) => request.user,
      getUserHook: UserHook,
    }),
  ],
})
export class AppModule {}

or with dynamic module initialization

//app.module.ts

import { Module } from '@nestjs/common';
import { CaslModule } from 'nest-casl';

@Module({
  imports: [
    CaslModule.forRootAsync({
      useFactory: async (service: SomeCoolService) => {
        const isOk = await service.doSomething();

        return {
          getUserFromRequest: () => {
            if (isOk) {
              return request.user;
            }
          },
        };
      },
      inject: [SomeCoolService],
    }),
  ],
})
export class AppModule {}

or with tuple hook

//app.module.ts

import { Module } from '@nestjs/common';
import { CaslModule } from 'nest-casl';

@Module({
  imports: [
    CaslModule.forRoot({
      getUserFromRequest: (request) => request.user,
      getUserHook: [
        UserService,
        async (service: UserService, user) => {
          return service.findById(user.id);
        },
      ],
    }),
  ],
})
export class AppModule {}

Custom actions

Extending enums is a bit tricky in TypeScript There are multiple solutions described in this issue but this one is the simplest:

enum CustomActions {
  feature = 'feature',
}

export type Actions = DefaultActions | CustomActions;
export const Actions = { ...DefaultActions, ...CustomActions };

Custom User and Request types

For example, if you have User with numeric id and current user assigned to request.loggedInUser

class User implements AuthorizableUser<Roles, number> {
  id: number;
  roles: Array<Roles>;
}

interface CustomAuthorizableRequest {
  loggedInUser: User;
}

@Module({
  imports: [
    CaslModule.forRoot<Roles, User, CustomAuthorizableRequest>({
      superuserRole: Roles.admin,
      getUserFromRequest(request) {
        return request.loggedInUser;
      },
      getUserHook: [
        UserService,
        async (service: UserService, user) => {
          return service.findById(user.id);
        },
      ],
    }),
    //  ...
  ],
})
export class AppModule {}

nest-casl's People

Contributors

andreialecu avatar belgamo avatar dependabot[bot] avatar dzcpy avatar hotrungnhan avatar kvokov avatar liquidautumn avatar martinpavlik avatar ruscon avatar stonepaw avatar thefern2 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

nest-casl's Issues

Nest JS authorization with CASL doesn't work as expected

EXPECTING:
Be able to get user info with id equal to my id only (which is saved in JWT token).

CURRENT RESULT:
I am able to get info about all users with some id.

Used Nest Js docs while creating this solution. Do appreciate your help.

  1. /casl-ability.factory.ts
type Subjects = InferSubjects<typeof User | typeof Role | 'User'> | 'all';
export type AppAbility = Ability<[Action, Subjects]>;

export class CaslAbilityFactory {
  createForUser(userDataFromJWT: JwtAccessTokenInput) {
    const { can, cannot, build } = new AbilityBuilder<
      Ability<[Action, Subjects]>
    >(Ability as AbilityClass<AppAbility>);

    // TESTING THIS CASE
    can(Action.Read, User, {
      id: userDataFromJWT.sub,
    });

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

  private hasRole(roles: unknown[], role: UserRoles): boolean {
    return roles.includes(role);
  }
}
  1. /getUser.policyHandler.ts
    export class GetUserPolicyHandler implements IPolicyHandler {
      handle(ability: AppAbility) {
        return ability.can(Action.Read, User);
      }
    }
  1. /types.ts
export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

export interface IPolicyHandler {
  handle(ability: AppAbility): boolean;
}

type PolicyHandlerCallback = (ability: AppAbility) => boolean;

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;
  1. /policies.guard.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 ctx = GqlExecutionContext.create(context);
    const { user }: { user: JwtAccessTokenInput } = ctx.getContext().req;
    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);
  }
}
  1. /user.resolver.ts
@Resolver(() => User)
export class UserResolver {
  constructor(private readonly userService: UserService) {}

  @Query(() => User, { name: 'user' })
  @UseGuards(PoliciesGuard)
  @CheckPolicies(new GetUserPolicyHandler())
  @UseInterceptors(UserNotExistsByIDInterceptor)
  async findOne(@Args('id', { type: () => Int }) id: number): Promise<User> {
    return await this.userService.findOne(id);
  }
}

Multiple Abilities => UseAbility should be able to take in arrays

The implementation of the library either requires coupled permissions on multiple entities or different routes.

Let's say I want to define create on Entity1 and define delete on Entity2 => I will either need to couple the definitions or separate out routes.

Offered solution:

  @UseGuards(JwtAuthGuard, MultiAccessGuard)
  @UseMultiAbility([
    {
      action: Actions.update,
      subject: OrganizationEntity,
      subjectHook: OrganizationHook,
    },
    {
      action: Actions.create,
      subject: OrganizationUserEntity,
    },
    {
      action: Actions.create,
      subject: OrganizationRoleEntity,
    },
  ])

Currently using this solution in a local implementation. Would be good to have it available out of the box.

Similarly for the rest of the proxies.

Cannot read properties of undefined (reading 'includes')

I don't know why this happens, what i am trying to do is to simply add the AccessGuard and UseAbility Hook example working. it seems the error comes from the UseAbility.

ERROR [ExceptionsHandler] Cannot read properties of undefined (reading 'includes') TypeError: Cannot read properties of undefined (reading 'includes') at AccessService.canActivateAbility (D:\github.com\qribtech\qrib_backend\node_modules\nest-casl\src\access.service.ts:74:37) at AccessGuard.canActivate (D:\github.com\qribtech\qrib_backend\node_modules\nest-casl\src\access.guard.ts:30:37) at processTicksAndRejections (node:internal/process/task_queues:96:5) at GuardsConsumer.tryActivate (D:\github.com\qribtech\qrib_backend\node_modules\@nestjs\core\guards\guards-consumer.js:16:17) at canActivateFn (D:\github.com\qribtech\qrib_backend\node_modules\@nestjs\core\router\router-execution-context.js:134:33) at D:\github.com\qribtech\qrib_backend\node_modules\@nestjs\core\router\router-execution-context.js:42:31 at D:\github.com\qribtech\qrib_backend\node_modules\@nestjs\core\router\router-proxy.js:9:17

Question: what to do if `request.user` adheres to a different type than `AuthorizableUser`

Hi,

First of all, thanks for your work on this package. I prefer this over the approach currently present in the NestJS docs.

I am using nest-keycloak-connect for authentication. With that I don't have control over the shape of the request.user object. It turns out this type does not have a roles key. Instead it looks something like this:

{
    realm_access: {
        roles: string[]
    },
    resource_access: {
        account: {
            roles: string[]
        }
    }
    // ...
}

getUserFromRequest() appears to have an opinion about the type of request.user. I imagine ideally it would be a generic type with an opinion about the return type of that method as a means to transform the user.

Am I missing something? Or is there another way to achieve this?

Following these instructions did not work for me. AbilityFactory.createForUser() would be called ahead of getUserFromRequest and getuserHook.

Condition on Ability not working

I am using nest-casl library to implement authorization on my GraphQL API.

I set the permission as follows:

  // customers.permission.ts
  customer({ user, can }) {
    can(Actions.read, Customer, { id: user.id });
  }

And used the guard and ability as follows:

  // customers.service.ts
  @UseGuards(GqlAuthGuard, AccessGuard)
  @UseAbility(Actions.read, Customer)
  @Query(() => Customer, { name: 'getCustomerById' })
  async getCustomerById(
    @Args('id')
    id: string,
  ) {
    return await this.customersService.getCustomerById(id);
  }

But unfortunately, my customer can query other customers by their ID. This should not be happening, Can anyone help, please?

Question: How to use custom condition matcher?

According to https://casl.js.org/v5/en/advanced/customize-ability#custom-conditions-matcher-implementation

import {
  PureAbility,
  AbilityBuilder,
  AbilityTuple,
  MatchConditions,
  AbilityClass
} from '@casl/ability';

type AppAbility = PureAbility<AbilityTuple, MatchConditions>;
const AppAbility = PureAbility as AbilityClass<AppAbility>;
const lambdaMatcher = (matchConditions: MatchConditions) => matchConditions;

export default function defineAbilityFor(user: any) {
  const { can, build } = new AbilityBuilder(AppAbility);

  can('read', 'Article', ({ authorId }) => authorId === user.id);
  can('read', 'Article', ({ status }) => ['draft', 'published'].includes(status));

  return build({ conditionsMatcher: lambdaMatcher });
}

Seems like it's much more flexible than MongoQuery matcher. However in TypeScript a lamda function cannot be passed to the third argument.

No overload matches this call.
  Overload 1 of 2, '(action: string | string[], subject: typeof Team | (typeof Team)[], conditions?: MongoQuery<Team>): RuleBuilder<Ability<AbilityTuple<string, Subject>, MongoQuery<...>>>', gave the following error.
    Type '({ id }: { id: any; }) => boolean' has no properties in common with type 'MongoQuery<Team>'.
  Overload 2 of 2, '(action: string | string[], subject: typeof Team | (typeof Team)[], fields?: string | string[], conditions?: MongoQuery<Team>): RuleBuilder<Ability<AbilityTuple<string, Subject>, MongoQuery<...>>>', gave the following error.
    Argument of type '({ id }: { id: any; }) => boolean' is not assignable to parameter of type 'string | string[]'.
      Type '({ id }: { id: any; }) => boolean' is missing the following properties from type 'string[]': pop, push, concat, join, and 28 more.ts(2769)
```

`getUserFromRequest` typing issues

It appears that getUserFromRequest allows returning an object that doesn't contain a roles field.

The roles field seems to be hardcoded in other places, and is required. Here's a simple repro:

	type User = { name: string };

	CaslModule.forRoot<Roles, User>({
      getUserFromRequest: (request) => {
        return {
          name: 'test',
        };
      },

This leads to errors further down. Ideally, the signature of getUserFromRequest would verify that a AuthorizableUser<T> is being returned.

Bug when using a condition on read permission

Hey @liquidautumn,

Thanks for your work on this lib!

I get the following error when trying to restrict a Get User/:id route access to let the user access only his infos. FYI, restriction works fine if using a patch/put route.

Could it be caused by an empty request.body from a Get request?

"message": "Cannot convert undefined or null to object",
"stack":
at AccessService.isThereAnyFieldRestriction (C:\dev\engine-api\node_modules\nest-casl\src\access.service.ts:140:46)
at AccessService.canActivateAbility (C:\dev\engine-api\node_modules\nest-casl\src\access.service.ts:119:42)

Permission file:

export const userPermissions: Permissions<Role, Subjects, Actions> = {
    user({ user, can, cannot }) {
        can(Actions.read, User, { id: user.id }); // This case is causing the exception
        can(Actions.update, User, { id: user.id }); // This case works fine
        cannot(Actions.delete, User);
    },

    sentry({ extend, can }) {
        extend(Role.User);
        can(Actions.delete, User);
    },
};

Hook file

@Injectable()
export class UserSubjectHook implements SubjectBeforeFilterHook<User, Request> {
    constructor(readonly userService: UserService) {}

    async run({ params }: Request): Promise<User> {
        return this.userService.getById(params.id);
    }
}

Problematic Controller route:

    @Get(':id')
    @UseAbility(Actions.read, User, UserSubjectHook)
    @UsePipes(ParseUUIDPipe)
    async getOne(@Param('id') id: string) {
        const entity = await this.userService.getById(id);

        const getUserResponse: GetUserResponse = {
            id: entity.id,
            email: entity.email,
            roles: entity.roles,
        };

        return getUserResponse;
    }

Error [ERR_REQUIRE_ESM]: require() of ES Module

When running i'm having this error:

/home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/nest-casl/dist/access.service.js:15
const flat_1 = require("flat");
               ^
Error [ERR_REQUIRE_ESM]: require() of ES Module /home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/flat/index.js from /home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/nest-casl/dist/access.service.js not supported.
Instead change the require of index.js in /home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/nest-casl/dist/access.service.js to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (/home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/nest-casl/dist/access.service.js:15:16)
    at Object.<anonymous> (/home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/nest-casl/dist/casl.module.js:13:26)
    at Object.<anonymous> (/home/axelreid/MyFiles/Fullstack Projects/zap/backend/node_modules/nest-casl/dist/index.js:4:21)
    at Object.<anonymous> (/home/axelreid/MyFiles/Fullstack Projects/zap/backend/dist/src/app.module.js:20:21)
    at Object.<anonymous> (/home/axelreid/MyFiles/Fullstack Projects/zap/backend/dist/src/main.js:8:22)

I tried the version of nest-casl from ^1.8.8 to ^1.9.1

Tried with the node versions v18.x and v20.x

Tried to change the nestjs related packages also

tsconfig.json:

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false,
    "esModuleInterop": true,
    "moduleResolution": "Node"
  }
}

Also tried to change around the config a little bit but i couldn't find a solution

I didn't have this problem just before, when i was putting project on a server i had to play around with the package.json but didn't change deps much, i mostly removed node_modules and package-lock.json and re-install many times

Question: how to get conditions from (global) AccessService

When I'm using the provided example for accessing the AccessService:

//check and throw error
// 403 when no conditions
// 404 when conditions set
this.accessService.assertAbility(user, Actions.update, post);

// return true or false
this.accessService.hasAbility(user, Actions.update, post);

These functions are returning a boolean. All methods available seem to return a boolean. However the conditions are not exposed.

For example: my user can('read', Book) but with certain conditions.

The guards are providing me with those conditions and I can apply them to my queries. The access service does not give me those conditions.

Am I missing something here or is it not possible to access the conditions from this context.

Many thanks in advance!

Unable to combine rules

ConditionsProxy.get currently combins all the conditions into a single object. This prevents you from declaring rules like the following.

Ex: Anyone can access public uploads, and users can access their own uploads

everyone({ can }) {
  can(Actions.read, Upload, { public: true });
},

user({ can, cannot, user }) {
  can(Actions.read, Upload, { user: user.id, public: false });
},

Calling conditions.get() produces the following object:

{ user: '4663a9ea-627c-483e-8e28-88739ffa0dc0', public: true }

Is there a possible way to work around this?

The problematic code can be found here:

return Object.assign({}, ...this.conditions);

Thanks!

Not restricting access when no hook is specified in UseAbility

So I have this permissions:

member({ user, can }) {
    can(Actions.read, User, { id: user.id });
    can(Actions.update, User, { id: user.id });
}

In controller:

 @UseGuards(JwtAuthGuard, AccessGuard)
  @UseAbility(Actions.read, User, UserHook)
  @Get(':id')

Will work fine with UserHook getting the User given :id as param.

But when I do this for a getall:

  @UseGuards(JwtAuthGuard, AccessGuard)
  @UseAbility(Actions.read, User)
  @Get()

as no hook is specified (or if I send null), It will allow access to the @get route. Is this intended behaviour?

Feauture request: add field restricting access

CASL supports restricting access to fields:
https://casl.js.org/v5/en/guide/restricting-fields

can('update', 'Article', ['title', 'description'], { authorId: user.id });
...
defineAbilityFor(user).can('update', 'Article', 'published'); // false

But, as I see it, there is no functionality for accessService:
https://github.com/getjerry/nest-casl/blob/master/src/access.service.ts#L18

public hasAbility(user: AuthorizableUser, action: string, subject: Subject): boolean {

It would be great to implement that a feature.

Get subject through proxy

In some cases, ie for superuser, subject could be undefined.
Use proxy, same as for user and conditions.

Question: Fetching roles/permissions from a database, advanced ability factory conditions

Hi, I am interested in utilizing this library, however, it's not entirely clear to me how to go about 1) integrating actions/roles/permissions fetched from a database and 2) being able to apply fine-grained abilities based on a user's role(s). As a workaround, I've forked the library and implemented my own ability factory, access service, etc, but I'd love to leverage any points of extensibility that may/may not exist. Thanks in advance!

feature request: UnwrapCaslSubjectPipe

If you want to get subject from hook into a class method argument, you need to use SubjectProxy and manually get the subject (example from docs)

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.update, Post, PostHook)
async updatePost(
  @Args('input') input: UpdatePostInput,
  @CaslSubject() subjectProxy: SubjectProxy<Post>
) {
  const post = await subjectProxy.get();
}

This can be automated through a generic pipe

import { Injectable, NotFoundException, PipeTransform } from '@nestjs/common';
import type { SubjectProxy } from 'nest-casl';

@Injectable()
export class UnwrapCaslSubjectPipe<T> implements PipeTransform<SubjectProxy<T>, Promise<T>> {
    async transform(subjectProxy: SubjectProxy<T>): Promise<T> {
        const subject = await subjectProxy.get();

        if (!subject) {
            throw new NotFoundException();
        }

        return subject;
    }
}

And code will be like this

@Mutation(() => Post)
@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.update, Post, PostHook)
async updatePost(
  @Args('input') input: UpdatePostInput,
  @CaslSubject(UnwrapCaslSubjectPipe) post: Post,
) {
  // do anything with post oject
}

params are not typed on run method when implementing SubjectBeforeFilterHook

Firstly, thanks for the nice library!

When we define a class which implements the SubjectBeforeFilterHook as outlined in the readme, the params object that is pulled from Request seems to have any typed which is not ideal. Is this the intended behaviour or am I doing something wrong here?

I also inspected the example code in this repo under: https://github.com/getjerry/nest-casl/blob/master/src/__specs__/app/post/post.hook.ts#L11 and noticed the same issue.

Is there a way to have this typed?

import { Injectable } from '@nestjs/common'
import type { Answer } from '@prisma/client/my-service'
import type { SubjectBeforeFilterHook, Request } from 'nest-casl'

import type { AnswerService } from './answer.service'

Injectable()
export class AnswerHook implements SubjectBeforeFilterHook<Answer, Request> {
  constructor(readonly answerService: AnswerService) {}

  async run({ params }: Request): Promise<Answer> {
    // params has any type.
    return this.answerService.get(params.someField)
  }
}

Thanks!

Feauture request: allow user to set how to detect subject type

Users are not allowed to set how to detect subject type when they are building abilities. It may cause the wrong subject type detection and get an unpredictable result.

casl docs

e.g.

it('test', async () => {
  expect(accessService.hasAbility(user, Actions.delete, new Post())).toBeTruthy(); // not pass
});

The test above would not pass but can be solved by the below solutions.

ability.factory.ts

...
// For PureAbility skip conditions check, conditions will be available for filtering through @CaslConditions() param
if (abilityClass === PureAbility) {
  return ability.build({ conditionsMatcher: nullConditionsMatcher, detectSubjectType: object => object.constructor as ExtractSubjectType<Subjects> });
}
return ability.build({ detectSubjectType: object => object.constructor as ExtractSubjectType<Subjects> });

or

it('test', async () => {
  const post = new Post();
  expect(accessService.hasAbility(user, Actions.delete, subject(post.constructor as any, post))).toBeTruthy(); // pass
});

Feature Request: A dynamic user roles value, multiple user roles (array) or single value user role.

Hello, first of all, it is an excellent library and also helps a lot. But, can we make the roles property to be dynamic? I mean it could take either an array of an enum or a single value enum. Cause I encountered an error when I tried to supply a single value enum to the roles property.

Then, I took a look at this line of code:

user.roles?.forEach((role) => {

The forEach will cause the error when roles are supplied by a single value enum. Instead we could do something like this:

if (Array.isArray()) {
  user.roles?.forEach((role) => {
    ability.permissionsFor(role);
  });
} else {
  ability.permissionsFor(user.roles);
}

Thanks.

Cannot read properties of undefined (reading 'everyone')

Hi,
I'm looking to use nest with casl (hence the use of this lib). I'm using prisma for the data part (which isn't native to nest-casl, so can cause problems).
Here is the definition of my permissions:
image

And this is the error I receive:
image

feature request: pure subject hook tuple

In some situations we find that a subject tuple hook is possible that only requires data from the request and does not require a service. Eg construct an subject that only has an id from the id passed into the request.

Currently we pass a dummy service that does nothing but it would be more convienent to have a SubjectTupleHook that doesn't have a service.

Question: Is there support for Prisma Models?

I recently changed from TypeORM to prisma, and am wondering if there is a way to get my existing project using Nest-CASL to work with Prisma. At the moment I receive an error because Prisma models are a type, not a class.

image

`@UseAbility` does not allow string based subject

You can define permissions like:

export const userPermissions: Permissions<Roles, Subjects, Actions> = {
  everyone({ user, can }) {
    can(Actions.read, 'User', { userId: user.id });
  },
};

However, the types of the @UseAbility decorator cannot take a string for the subject parameter so @UseAbility(Actions.read, 'User') shows a type error.

It can be //@ts-ignored and everything works. It would be great to fix the type however.

Acess params on Hook

Currently:

import { User } from '@entities/user.entity';
import { Injectable } from '@nestjs/common';
import { Request, SubjectBeforeFilterHook } from 'nest-casl';
import { UserService } from '../user.service';

@Injectable()
export class UserHook implements SubjectBeforeFilterHook<User, Request> {
  constructor(readonly userService: UserService) {}

  async run({ params }: Request) {
    return this.userService.getOne(params.input.id);
  }
}

This is when the id of the resource to modify is pased in the dto itself however i am using put ---> users/:id and thus the id is not part of the dto. How can we solve this

Allow access to `AbilityFactory`

I'd like to be able to use packRules to share permissions with an Angular front end app.

The following hack works:

	const user = userProxy?.getFromRequest();
    if (user) {
      const abilityFactory: AbilityFactory = (this.accessService as any)
        .abilityFactory;
      console.log(packRules(abilityFactory.createForUser(user).rules));

Where this.accessService basically hijacks this private field from AccessService:

constructor(private abilityFactory: AbilityFactory) {}

Exporting AbilityFactory, or an API to get to the underlying casl abilities would be great.

Typeorm entities instead of dto and how to get rid of resolvers?

Hello,

i am pretty new to nestjs and i am trying to implement a RBAC with predefined roles and permissions into a project.
e.g. https://casl.js.org/v5/en/cookbook/roles-with-static-permissions
I checked mostly of the nestjs-casl repo and also accesscontrol/acl ones and i think that this one is the simplest to set up also because is structured on splitted permissions file that helps a lot to have a clean code.

I am not using GraphQL and i don't know it very well, i have understood that resolvers are in the repo because it relies on it.
Th readme was a little confusing on the part because there are resolvers but no controller, that could be found in the files.

Also i saw that you are using dto to get the schema object that is passed to casl permissions.

It's possible use this without resolvers and passing Typeorm entity class instead of dto?
I tried to play a little bit to use them changing subject type detection on ability.factory.ts
https://casl.js.org/v5/en/guide/subject-type-detection

Would make sense? or the structure of this module cannot be easily changed?

can and cannot produce the same conditions

I believe that this is related to #260.

Code like the following:

can(Actions.update, Movie);
cannot(Actions.update, Movie, { status: 'PUBLISHED' });

produces the following conditions, sql, and ast:

// conditions.get()
{ status: 'PUBLISHED' }

// conditions.toSql()
[ '"status" = $1', [ 'PUBLISHED' ], [] ]

// conditions.toAst()
{ operator: 'eq', value: 'PUBLISHED', field: 'status' }

As you can see, the provided objects do not indicate that a cannot rule was used, so it's indistinguishable in code from the can rules.

Alias group names

My user's credentials are provided by AD so I have groups like "Super-Duper-Admin-User" and "Totally-Normal-Not-Admin-User" that represent "admin" and "user" groups in this application. I've gotten this package to work with these groups by something like the snippet below. I'd much rather follow the pattern in the readme. Is there a way to "alias" these super long group names to the shortened aliases?

export const permissions: Permissions<Roles, Subjects, Actions> = {
  'Super-Duper-Admin-User'({ can }) {
    can(Actions.manage, 'all');
  },

  'Totally-Normal-Not-Admin-User'({ cannot }) {
    cannot(Actions.manage);
  },
};

allow subject hook injection to pull from the entire module scope

As it is currently written injection of the services in the subject hook can only resolve services directly provided in the current module.

It is a common pattern to seperate out services into submodules when in a large mono-repo. For example data-access services can be place in a data-access module for import into multiple feature modules.

This is a fairly simple change and only requires adding { strict: false } to the moduleRef.get. I will make a pr shortly.

Question: mock UserProxy and SubjectProxy in unit tests

How do I mock the proxies in a unit test? the examples only covers e2e tests, but unit tests don't work the same way.
I tried mocking the proxies in jest, but I can't figure out why it's not working

@ApiTags('Customers')
@ApiBearerAuth('jwt')
@UseGuards(AccessGuard)
@Controller('customers')
class CustomerController {

  @ApiResponse({ type: [Customer] })
  @ApiQuery({ name: 'companyId', required: false, description: 'Filter by company, only available for SuperAdmins' })
  @UseAbility(Actions.read, Customer)
  @Get()
  async findAll(@CaslUser() userProxy: UserProxy<UserMetaObj>, @Query('companyId') companyId?: string) {
    const user = await userProxy.get()
    if (user.roles.includes(Role.SuperAdmin)) return this.customerService.findAll(companyId)
    return this.customerService.findAll(user.companyId)
  }

  @ApiResponse({ type: Customer })
  @ApiParam({ name: 'id', type: String })
  @UseAbility(Actions.read, Customer, CustomerHook)
  @Get(':id')
  findOne(@CaslSubject() subjectProxy: SubjectProxy<Customer>) {
    return subjectProxy.get()
  }

}

test

const mockSubjectProxy = {
  get: jest.fn().mockResolvedValue(mockCustomer),
} as unknown as SubjectProxy<Customer>

const mockUserProxy = {
  get: jest.fn().mockResolvedValue({ id: mockUser.id, roles: [Role.Caregiver], companyId: mockEmployee.companyId }),
} as unknown as UserProxy<UserMetaObj>

describe('CustomerController', () => {
  let controller: CustomerController
  let service: CustomerService

  beforeEach(async () => {
    jest.resetAllMocks()

    const module: TestingModule = await Test.createTestingModule({
      imports: [
        CaslModule.forRoot<Role>({
          getUserFromRequest: (): UserMetaObj => ({
            id: mockUser.id,
            roles: [Role.Caregiver],
            companyId: mockEmployee.companyId,
          }),
        }),
        CaslModule.forFeature({ permissions: customerPermissions }),
      ],
      controllers: [CustomerController],
      providers: [
        {
          provide: EmployeeService,
          useClass: MockEmployeeService,
        },
        {
          provide: CustomerService,
          useClass: MockCustomerService,
        },
      ],
    }).compile()

    controller = module.get<CustomerController>(CustomerController)
    service = module.get<CustomerService>(CustomerService)
  })

  describe('findAll', () => {
    it('should call the service with the correct parameters', async () => {
      const spy = jest.spyOn(service, 'findAll')
      await controller.findAll(mockUserProxy)
      expect(spy).toHaveBeenCalledWith(mockEmployee.companyId, mockEmployee.id)
    })
  })

  describe('findOne', () => {
    it('should return a customer', async () => {
      const spy = jest.spyOn(mockSubjectProxy, 'get')
      await controller.findOne(mockSubjectProxy)
      expect(spy).toHaveBeenCalled()
    })
  })
})

throws the following error:

TypeError: Cannot read properties of undefined (reading 'roles')

      36 |   async findAll(@CaslUser() userProxy: UserProxy<UserMetaObj>, @Query('companyId') companyId?: string) {
      37 |     const user = await userProxy.get()
    > 38 |     if (user.roles.includes(Role.SuperAdmin)) return this.customerService.findAll(companyId)
         |              ^
      39 |     return this.customerService.findAll(user.companyId)
      40 |   }
      41 |

Where does "InferSubject" come from?

Hi, thanks for making this awesome module. I have a question though, in the documentation I saw there is a type InferSubject in the code. Where does it come from? And also, what does it do?

How to define permissions for nested writes

For example:

a product resolver that can create related variants:

    async createProduct(@Args() args: CreateOneProductArgs, @Info() info?: GraphQLResolveInfo) {
        const select = new PrismaSelect(info).value
        return this.productsService.createProduct({
            ...args,
            ...select,
        })
    }

with

{
  "data": {
    "sku": 'example',
    "variants": {
      "create": [
        {
          "sku": 'example-a',
          "brands": 'example',
          "price": 100
        }
      ]
    }
  }
}

Can I use double UseAbility decorators in this case?

@UseGuards(AuthGuard, AccessGuard)
@UseAbility(Actions.create, Product)
@UseAbility(Actions.create, ProductVariant)

I have defined following rules:

export type Subjects = InferSubjects<
    typeof Product | typeof ProductVariant | typeof ProductCategory | typeof ProductType
>

...

    storeManager({ can, cannot }) {
        can(Actions.read, Product)
        can(Actions.create, Product)
        can(Actions.update, Product)
        cannot(Actions.delete, Product)
        can(Actions.manage, ProductVariant)
    },

assertAbility throws 401

The function "assertAbility" on AccessService always throws a 401 Unauthorized error (hasAbility returns false). The code below is pretty much identical to the provided sample in the documentation. When I use the decorators, everything works as expected. What could cause this weird behavior?

NOT WORKING:

constructor(private readonly accessService: AccessService) {}

@UseGuards(JwtAuthGuard)
@Query(() => User, { nullable: true })
async user(@Args("id") id: string, @CaslUser() userProxy: UserProxy<AuthUser>) {
  const user = await userProxy.get();
  const subject = this.userService.findById(id);

  this.accessService.assertAbility(user, Actions.read, subject);

  return subject;
}

WORKING:

@UseGuards(JwtAuthGuard, AccessGuard)
@UseAbility(Actions.read, User, UserHook)
...

Here are the configured permissions, nothing special.

user({ user, can }) {
  can(Actions.read, User, { id: user.id });
  can(Actions.update, User, { id: user.id });
  can(Actions.delete, User, { id: user.id });
}

Property 'X' does not exist on type 'AuthorizableUser<Role, string>'

I tried to access some fields like workspaceTeamId

type Subjects = InferSubjects<typeof User> | 'all';

export const permissions: Permissions<Role, Subjects, Actions> = {
  super_admin({ can }) {
    can(Actions.manage, 'all');
  },

  user({ user, cannot }) {
    cannot(Actions.read, User, {
      workspaceTeamId: { $ne: user.workspaceTeamId }, // <- Error Property 'workspaceTeamId' does not exist on type 'AuthorizableUser<Role, string>'
   }).because('You can only read users in your workspace');  },
};

I got this error

Property 'workspaceTeamId' does not exist on type 'AuthorizableUser<Role, string>'

app.module

@Module({
  imports: [
    CaslModule.forRoot<
      Role,
      { id: Types.ObjectId; roles: Role[] },
      { user: UserDocument }
    >({
      superuserRole: Role.SUPER_ADMIN,
      getUserFromRequest: (request) => ({
        id: request.user._id.toString(),
        ...request.user,
      }),
    }),
    // ....
})

Id checking doesn't work properly. It allows patch any user instead of himself

Hi, I just use very simple example from documentation
I have the following code

export enum Roles {
  Admin = "admin",
  Teacher = "teacher",
  Guest = "guest"
}

Register nest-casl in module

CaslModule.forRoot<Roles, EmployeeResponse, ExpressRequest>({
      superuserRole: Roles.Admin,
      getUserFromRequest: request => request.employee,
})      

Entity is

@Entity({ name: "employees" })
export class EmployeeEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({
    type: "enum",
    enum: Roles,
    default: Roles.Guest
  })
  roles: Roles[];
}

The permisiion file is

export const permissions: Permissions<Roles, Subjects, Actions> = {
  [Roles.Teacher]({ user, can }) {
    can(Actions.read, EmployeeEntity, { id: user.id });
    can(Actions.update, EmployeeEntity, { id: user.id });
  }
};

And my controller is

  @Patch(":id")
  @UseGuards(AuthGuard, AccessGuard)
  @UseAbility(Actions.update, EmployeeEntity)
  @UsePipes(new ValidationPipe())
  async update(
    @Param("id") id: string,
    @Body() updateEmployeeDto: UpdateEmployeeDto
  ): Promise<EmployeeResponse> {
    const employee = await this.employeeService.update(id, updateEmployeeDto);

    return this.employeeService.buildEmployeeResponse(employee);
  }

And the problem is user with Guest role can update the user with Teacher role. Also it's about @Get(:id). Also any of this user can update Admin.
How can fix it?

And the second problem is can(Actions.read, EmployeeEntity, { id: user.id }); also works for findAll method. But I wanna allow read user only himself

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.