iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
👑

Designing Permission Systems

に公開

It's been a year since I posted the following article. I've accumulated some know-how, so I'd like to share what I understand at this point. This article is for backend developers.
https://zenn.dev/dove/articles/bc6933dbb39509

By "permissions" (権限) here, I mean deciding who can use which feature. Is it the same as "authorization" (認可)?

Permission Patterns

When there are four functions like the following:

  • Get
  • Create
  • Update
  • Delete

Permission management can follow these patterns:

  • Mapping functions directly to users (e.g., User A can only perform 'Get', User B can only perform 'Create' and 'Update').
  • Mapping functions to permission identifiers (e.g., if the permission is ADMIN, all functions are allowed. If USER, only 'Get' is allowed, etc.).
  • Mapping functions based on user attributes (e.g., if a user is from Hokkaido and under 25, 'Delete' is allowed).

Mapping functions directly to users

This is useful when you want to assign functions to each user at a granular level. If you don't want to manage permissions in such detail, it becomes tedious as the number of items to handle increases in the future.

Permission Table

id userId allow_get allow_create allow_edit allow_delete
1 1 true false false false
2 2 false true true false

Code Example

function get () {
  if(!user.allow_get){
    throw new Error('Permission error');
  }
  // Retrieval process
}

Mapping Functions to Permission Identifiers

This is probably what most people use. From an implementation perspective, I think this is also the easiest to build. There are several variations within this:

  • The permission identifier is a flag like isAdmin.
  • The permission identifier is an enum type (accepting specific strings like ADMIN, USER, SUPER_USER).
  • The permission identifier is a number representing a level. For example, "Level 3 or higher can use this feature." This seems like something used in the military.

User Table

id permission
1 ADMIN
2 USER
2 SUPER_USER

Code Example

function get () {
  if(user.permission !== 'ADMIN' || user.permission !== 'USER'){
    throw new Error('Permission error');
  }
  // Retrieval process
}

function create () {
  if(user.permission !== 'ADMIN'){
    throw new Error('Permission error');
  }
  // Creation process
}

Mapping Functions Based on User Attributes

You don't see this one very often. The implementation also tends to be complex. I feel that instead of being handled uniformly as a permission, it's often implemented as exception handling within the application's business logic rules, such as "reject in this specific case."

User Table

id age
1 23
2 42
2 56

Code Example

function get () {
  if(user.age > 30){
    throw new Error('Permission error');
  }
  // Retrieval process
}

At Which Layer Should Permission Checks Be Performed?

For applications that separate the controller layer and the service layer, in which layer should you perform permission checks? Considering the reusability of services, it is better to do it in the controller layer. Performing permission checks in the controller layer offers the following benefits:

  • You don't have to pass permission identification information to the service layer.
  • It makes it easier to call that service from other services (when you want to call it without a permission check).

Can Complex Permissions Be Made into Simpler Specifications?

Imagine the following specification.

Suppose there is a counseling service to solve user problems. When a user posts a problem, an administrator acting as a facilitator and several other users participate to discuss the poster's concern. Once the poster's problem is solved, they press the "Problem Solved" button, and the issue is resolved. There are roles for the problem: "Poster", "Facilitator", and "Participant". Each can use the following features:

Poster Facilitator Participant
Post Comment
Evaluation Stamp
Problem Solved Button

Aside from roles, there are two types of authorized users: General Users and Administrators. Each can use the following features:

General User Administrator
Post Problem
Participate as Participant
Participate as Facilitator
Suspend Account
Delete Problem

Now, let's say the service luckily goes viral, and administrators alone can't keep up with responding to problems. So, we've decided to open up the facilitator participation permission to users with over a year of service history. We'll call these users "Super Users" from now on. Each can use the following features:

General User Super User Administrator
Post Problem
Participate as Participant
Participate as Facilitator
Suspend Account
Delete Problem

Now, if you want to perform a permission check for the "Evaluation Stamp" feature, which specification is better?

  • Only available to users whose permission is "Super User" or "Administrator" AND whose role is "Facilitator".
  • Simply available to any user whose role is "Facilitator".

I recommend making permission checks simple conditions rather than composite conditions whenever possible. In other words, I think only the role check in the latter case is sufficient here.

If you have to manage both permissions and roles, the more their numbers increase, the more a combinatorial explosion occurs. Testing all of them is quite difficult. If you can use just the role, you only need to test for the number of role types.

It's easier to hard-code permission identifiers in each controller layer than to perform permission checks in User class methods

I initially used the following approach.

For example, I would have a User class like the one below, create a permission check method within it,

User.ts
class User {
  private permission = "ADMIN";
  
  canGet() {
    if(this.permission === 'ADMIN'){
      return true;
    }
    
    return false;
  }
}

And call it in the controller or service layer.

Controller
class HogeController {
  get(
    req: Request,
    res: Response,
    next: NextFunction,
  ): void {
    // Get the authenticated user
    const currentUser = req.user;
 
    // Permission check
    if(!currentUser.canGet()){
      // If permission check fails, return 403 status code.
      res.sendStatus(403);
      return;
    }
    
    // If permission check passes, return 200 status code.
    res.sendStatus(200);
  }
}

However, I ultimately found the following approach easier to build. In this method, the User class doesn't hold information about which permission name can use which feature.

permissionGuard.ts
type PermissionName = 'ADMIN' | 'USER' | 'SUPER_USER';

export const permissionGuard = (...permissionNames: PermissionName[]) => {
  return (user: User) => {
    // Returns false if the user's permission is not included in the provided permissions.
    if(!permissionNames.include(user.permission)) return false;
    
    return true;
  };
}
Controller
import { permissionGuard } from './permissionGuard';

class HogeController {
  get(
    req: Request,
    res: Response,
    next: NextFunction,
  ): void {
    // Get the authenticated user
    const currentUser = req.user;
 
    // Permission check
    if(!permissionGuard('ADMIN')(currentUser)){
      // If permission check fails, return 403 status code.
      res.sendStatus(403);
      return;
    }
    
    // If permission check passes, return 200 status code.
    res.sendStatus(200);
  }
}

Depending on the framework, this can be achieved with middleware. In NestJS, this is elegantly implemented using decorators.

https://docs.nestjs.com/guards

@UseGuards(JwtGuard, RolesGuard) // Guard feature
class HogeController {
  @Get('/')
  @Roles('ADMIN') // Only available to ADMIN
  get(
    @Req() req: Request,
    @Res() res: Response,
    @Next() next: NextFunction,
  ): void {
    // At this point, the permission check should have passed. Return 200 status code.
    res.sendStatus(200);
  }
}

Discussion