Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feauture request: add field restricting access #254

Open
impcyber opened this issue Jan 23, 2022 · 7 comments
Open

Feauture request: add field restricting access #254

impcyber opened this issue Jan 23, 2022 · 7 comments
Milestone

Comments

@impcyber
Copy link

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.

@liquidautumn
Copy link
Collaborator

Do you have any thoughts on how it can be implemented? I was thinking about this feature quite a lot, there is issue #6 about it, but I can't come up with implementation without significant amount of magic involved. It'd require post-request interceptor to look into response and figure out where in response json subject is positioned, then nullify restricted fields. Or it would be implemented at orm level to avoid selecting unneeded fields or maybe both. Any input appreciated, I'm warming up for biggerish update to this lib and this feature indeed is top priority.

@impcyber
Copy link
Author

impcyber commented Jan 28, 2022

I am thinking about the implementation and I have only two possible options, but I have not tested any:
The first one, as you wrote, is to use the nestjs interceptor:
https://docs.nestjs.com/interceptors#aspect-interception
In this case, Before may work as a nest-casl hook, and After, for example, something like this:

// https://casl.js.org/v5/en/guide/restricting-fields

const ARTICLE_FIELDS = ['title', 'description', 'authorId', 'published'];
const options = { fieldsFrom: rule => rule.fields || ARTICLE_FIELDS};

const fields = permittedFieldsOf(ability, 'update', ownArticle, options);
const rawArticle = pick(reqBody, fields); // { title: 'CASL', description: 'powerful' }

The second one is to use @Exlude() decorator with groups option and Serializer:
https://docs.nestjs.com/techniques/serialization#exclude-properties
https://github.com/typestack/class-transformer#using-groups-to-control-excluded-properties

But for now, these are just speculations.

P.S.
As a workaround for the field access level, I use this set of rules:

  cannot('update', 'ArticleInputDTO', { published: { $exists: true } })
  cannot('update', 'ArticleInputDTO', { comment: { $exists: true } })

...
const input: ArticleInputDTO = { title: 'Some title', published: new Date() }
can('update', ArticleInputDTO) // false

they filter incoming input by existing fields

@dzcpy
Copy link
Contributor

dzcpy commented Jun 18, 2022

Any updates?

@chrber04
Copy link

Would love to see this functionality. One of the big benefits with CASL is that it implements attribute based access control, so it's a shame that this otherwise amazing package is missing it.

@dzcpy
Copy link
Contributor

dzcpy commented Dec 8, 2022

How's the progress? Is there any way to make it move faster? Like code contribution and test etc.

@okonon
Copy link

okonon commented May 22, 2023

@liquidautumn wondering if there is an update for this. I am actively looking at this and was wondering if there is a plan on how to implement it? I would like to see it get get a PR created

@okonon
Copy link

okonon commented May 31, 2023

hi @liquidautumn @chrber04 i recently had to implement support for restricting fields on top of throwing forbidden error to support custom messages.

if you guys think that this makes sense i could probobly create a PR however i am not sure how to support can and ForbiddenError.from().throwUnlessCan
Here is the whole access service class:

import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Ability, AnyAbility, ForbiddenError, subject } from '@casl/ability';
import { AnyObject, Subject } from '@casl/ability/dist/types/types';

import { AuthorizableRequest } from './interfaces/request.interface';
import { AbilityFactory } from './factories/ability.factory';
import { AbilityMetadata } from './interfaces/ability-metadata.interface';
import { UserProxy } from './proxies/user.proxy';
import { CaslConfig } from './casl.config';
import { AuthorizableUser } from './interfaces/authorizable-user.interface';
import { RequestProxy } from './proxies/request.proxy';
import { ConditionsProxy } from './proxies/conditions.proxy';
import { permittedFieldsOf } from '@casl/ability/extra';

@Injectable()
export class AccessService {
  constructor(private abilityFactory: AbilityFactory) {}

  public getAbility<User extends AuthorizableUser<string, unknown> = AuthorizableUser>(user: User): AnyAbility {
    return this.abilityFactory.createForUser(user);
  }

  public hasAbility<User extends AuthorizableUser<string, unknown> = AuthorizableUser>(
    user: User,
    action: string,
    subject: Subject,
    field?: string,
  ): boolean {
    // No user - no access
    if (!user) {
      return false;
    }

    // User exists but no ability metadata - deny access
    if (!action || !subject) {
      return false;
    }

    const { superuserRole } = CaslConfig.getRootOptions();
    const userAbilities = this.abilityFactory.createForUser(user);

    // Always allow access for superuser
    if (superuserRole && user.roles?.includes(superuserRole)) {
      return true;
    }

    return userAbilities.can(action, subject, field);
  }

  public assertAbility<User extends AuthorizableUser<string, unknown> = AuthorizableUser>(
    user: User,
    action: string,
    subject: Subject,
    field?: string,
  ): void {
    if (!this.hasAbility(user, action, subject, field)) {
      const userAbilities = this.abilityFactory.createForUser(user, Ability);
      const relatedRules = userAbilities.rulesFor(action, typeof subject === 'object' ? subject.constructor : subject);
      if (relatedRules.some((rule) => rule.conditions)) {
        throw new NotFoundException();
      }
      throw new UnauthorizedException();
    }
  }

  public async canActivateAbility<Subject = AnyObject>(
    request: AuthorizableRequest,
    ability?: AbilityMetadata<Subject>,
  ): Promise<boolean> {
    const { getUserFromRequest, superuserRole } = CaslConfig.getRootOptions();

    const userProxy = new UserProxy(request, getUserFromRequest);
    const req = new RequestProxy(request);

    // Attempt to get user from request
    const user = userProxy.getFromRequest();

    // No user - no access
    if (!user) {
      return false;
    }

    // User exists but no ability metadata - deny access
    if (!ability) {
      return false;
    }

    // Always allow access for superuser
    if (superuserRole && user.roles?.includes(superuserRole)) {
      return true;
    }

    let userAbilities = this.abilityFactory.createForUser(user, Ability);
    const relevantRules = userAbilities.rulesFor(ability.action, ability.subject);

    req.setConditions(new ConditionsProxy(userAbilities, ability.action, ability.subject));

    // If no relevant rules with conditions or no subject hook exists check against subject class
    if (!relevantRules.every((rule) => rule.conditions) || !ability.subjectHook) {
      // return userAbilities.can(ability.action, ability.subject);
      return throwUnlessCan<Subject>(userAbilities, ability, ability.subject as any, request);
    }

    // Otherwise try to obtain subject
    const subjectInstance = await req.getSubjectHook().run(request);
    req.setSubject(subjectInstance);

    if (!subjectInstance) {
      // return userAbilities.can(ability.action, ability.subject);
      return throwUnlessCan<Subject>(userAbilities, ability, ability.subject as any, request);
    }

    const finalUser = await userProxy.get();
    if (finalUser && finalUser !== userProxy.getFromRequest()) {
      userAbilities = this.abilityFactory.createForUser(finalUser);
    }

    // and match against subject instance
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    // return userAbilities.can(ability.action, subject(ability.subject as any, subjectInstance));
    throwUnlessCan<Subject>(userAbilities, ability, subject(ability.subject as any, subjectInstance), request);


    return true;
  }
}

function throwUnlessCan<Subject = AnyObject>(
  userAbilities: AnyAbility,
  ability: AbilityMetadata<Subject,
    AuthorizableRequest<AuthorizableUser<string, string>, AnyObject>>,
  subjectInstanceOrClass: AnyObject,
  request: AuthorizableRequest<AuthorizableUser<string, string>, AnyObject>,
  ignoreProps?: string[]
) {
  const restrictedFields: Array<string> = [];
  permittedFieldsOf(userAbilities, ability.action, subjectInstanceOrClass, {
    fieldsFrom: rule => {
      if (rule.inverted && rule.fields) {
        restrictedFields.push(...rule.fields);
      }
      return rule.fields || [];
    }
  });

  // const requestBodyRestrictedFields = getRestrictedFields(request.body, restrictedFields);
  const requestBodyRestrictedFields = [];
  for (const prop of restrictedFields) {
    if (ignoreProps?.includes(prop)) continue; // skip if property is in the ignore list

    const parts = prop.split('.');
    let obj = request.body;
    for (const part of parts) {
      if (obj && part in obj) {
        obj = obj[part];
      } else {
        obj = undefined;
        break;
      }
    }
    if (obj !== undefined) {
      requestBodyRestrictedFields.push(prop);
    }
  }

  // loop over fields and check if any of them are not permitted
  let i = requestBodyRestrictedFields.length;
  if (i > 0) {
    while (i--) {
      ForbiddenError.from(userAbilities).throwUnlessCan(ability.action, subjectInstanceOrClass, requestBodyRestrictedFields[i]);
    }
  } else {
    ForbiddenError.from(userAbilities).throwUnlessCan(ability.action, subjectInstanceOrClass);
  }

  return true;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants