NestJS: Authorization (CASL) issue

235 Views Asked by At

The different user types:

export const enum USER_TYPE {
    SUPER_ADMIN = 'SUPER_ADMIN',
    ADMIN = 'ADMIN',
    COMPANY_ADMIN = 'COMPANY_ADMIN',
    STORE_ADMIN = 'STORE_ADMIN',
    MANAGER = 'MANAGER',
}

The AppAbility code using CASL approach

type Actions = 'manage' | 'create' | 'read' | 'update' | 'delete';
type Subjects = InferSubjects<typeof User> | 'all';

type AppAbility = MongoAbility<[Actions, Subjects]>;

const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

@Injectable()
export class UserAbilityFactory {
    createForUser(userType: USER_TYPE): MongoAbility {
        try {
            if (userType === USER_TYPE.SUPER_ADMIN) {
                can('manage', 'all');
            }

            if (userType === USER_TYPE.ADMIN) {
                cannot('create', User, {
                    userType: {
                        $eq: USER_TYPE.SUPER_ADMIN,
                    },
                });

                can('create', User, {
                    userType: {
                        $in: [USER_TYPE.COMPANY_ADMIN, USER_TYPE.STORE_ADMIN],
                    },
                });
            }

            return build();
        } catch (error) {
            throw error;
        }
    }
}

I want the ADMIN user to be able to create users other than SUPER_ADMIN but it creates a SUPER_ADMIN user

The user registration code:

@Post('register')
@UseGuards(RolesGuard)
@Roles(USER_TYPE.SUPER_ADMIN, USER_TYPE.ADMIN, USER_TYPE.COMPANY_ADMIN)
@HttpCode(201)
async register(@Body() userData: CreateUserDto): Promise<any> {
    try {
        const user = await firstValueFrom(this.userService.Signup(userData));
        return sendSuccess(user);
    } catch (error) {
        throw new BadRequestException(error.message);
    }
}

The AuthGuard code:

export const Roles = (...roles: USER_TYPE[]) => SetMetadata('roles', roles);

@Injectable()
export class RolesGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly jwtService: JwtService,
        private readonly redisService: RedisService,
        private readonly userAbilityFactory: UserAbilityFactory
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const requiredRoles = this.reflector.get<USER_TYPE[]>('roles', context.getHandler());
        if (!requiredRoles) return true;

        const request = context.switchToHttp().getRequest();
        const bearerToken = request.headers.authorization;
        if (!bearerToken || !bearerToken.startsWith('Bearer ')) return false;

        const token = bearerToken.split(' ')[1];
        try {
            const decoded = this.jwtService.verifyAccessToken(token);
            const userRole = decoded.userType;

            const ability = this.userAbilityFactory.createForUser(userRole);

            const canAccess = ability.can('create', User);

            if (canAccess) {
                request.user = decoded;
                const userId = decoded.userId;
                const loggedIn = await this.redisService.getValue(userId);
                if (!loggedIn) return false;

                return true;
            }

            return false;
        } catch (error) {
            return false;
        }
    }
}

The canAccess variable always returns true.

What is it that I am doing wrong? I don't understand that. Does my code even check that what kind of user I want to create from the request body?

1

There are 1 best solutions below

0
astoiccoder On

I'm writing an answer here, so that I have more space. It might not be complete, but hopefully point you in the right direction.

I quickly put together a working example based on your code here https://codesandbox.io/p/sandbox/cocky-nash-ytfpyj.

Important to note here is

  1. const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility); needs to be called inside your function createForUser. Otherwise the abilities will accumulate over several calls of that method, because the builder is created outside the function scope. You can test this in the sandbox if you move the creation of the builder outside the function, which will give you unexpected results.
  2. For some reason that I can't explain, the MongoAbility and mongo query conditions only work when you define the abilities via string, i.e. "User" instead of User. With the latter the conditions just don't work. For using strings you need to define the Subjects as type Subjects = User | "User" | "all";
  3. In your case, you would always want to pass objects to ability.can(), because you want to know if your current user can create a specific user payload.
  4. When you want to pass request.body which is of type CreateUserDto like this ability.can('create', request.body), you might need to change everything related to the User class in the sandbox to the CreateUserDto.
  5. Pay close attention that your field names are correct and match! So the field name from the object that you are passing needs to match the field you are checking for in your mongo condition. You are checking for userType -> make sure your User or UserCreateDto class also has the field userType. In your other question, the User class had a field named type only. That way the matching of course cannot work.

Hope this gets you closer to your solution :)