Skip to content

Instantly share code, notes, and snippets.

@MendyLanda
Created August 3, 2023 10:29
Show Gist options
  • Save MendyLanda/b73d30bf61c171d4b41b9a83c4ff2f44 to your computer and use it in GitHub Desktop.
Save MendyLanda/b73d30bf61c171d4b41b9a83c4ff2f44 to your computer and use it in GitHub Desktop.
A Shared Permissions Config for Backend and Frontend with TypeScript

A Shared Permissions Config for Backend and Frontend with TypeScript

In this post, I'm going to share an approach I used to design a role-based access control system with TypeScript. The unique aspect of this system is the shared permissions config, which can be utilized both on the backend (for access control) and on the frontend (for UI control). The main idea is to design a robust and type-safe system that ensures users have the correct permissions for their tasks.

Setting up Roles

Firstly, I define several roles representing different types of users within the system:

export type Role = 'owner' | 'admin' | 'manager' | 'user';

Creating Levels of Access

Next, we define different levels of access for each role. In TypeScript, arrays infer their type from the elements within. However, when we spread an array into another array using the spread operator (...), TypeScript loses the original array type, and instead infers the type from the individual elements. Therefore, to preserve the type safety, we'll use a helper function to create arrays for the levels of access:

function createLevelArray<T extends Role[]>(...args: T) {
  return args;
}

export const LEVEL_1 = createLevelArray('owner');
export const LEVEL_2 = createLevelArray(...LEVEL_1, 'admin');
export const ALL_LEVELS = createLevelArray(...LEVEL_2, 'user');

Defining Permissions

Then, we'll define permissions. The permissions determine which roles can perform specific actions on different entities within the system:

type Permission = {
  [key: string]: { roles: Readonly<Role[]> } | Permission;
};

export interface Permissions {
  [key: string]: Permission;
}

Helper Function to Create Keys

To streamline the process of assigning the same roles to multiple actions, we'll create a helper function createKeys:

function createKeys<T extends string, U>(keys: T[], value: U) {
  return keys.reduce((acc, key) => {
    acc[key] = { roles: value };
    return acc;
  }, {} as { [key in T]: { roles: U } });
}

Ensuring Type Safety

We'll also define a helper function asPermissions to ensure that the permissions object adheres to the expected Permissions interface:

function asPermissions<T>(permissions: T) {
  return permissions as T extends Readonly<Permissions>
    ? T
    : "permission structure is not correct";
}

Defining the Permissions Object

Now we can define our permissions:

export const permissions = asPermissions({
  userManagement: {
    ...createKeys(['read', 'create', 'update', 'delete'], LEVEL_1),
    readMinimal: {
      keys: ['column1', 'column2'],
      roles: ALL_LEVELS,
    },
  },
  productCatalog: {
    create: {
      roles: LEVEL_1,
    },
    ...createKeys(['read', 'update', 'delete'], LEVEL_2),
  },
  // ... more entities
} as const);

Checking Permissions

Lastly, we'll define a function checkPermission to verify if a user with a certain role has the required permissions for a given action:

type Join<K, P> = K extends string | number
  ? P extends string | number
    ? `${K}.${P}`
    : never
  : never;

type StopRecursion<T> = T extends { roles: unknown } ? never : Paths<T>;

type Paths<T, D extends number = 10> = [D] extends [never]
  ? never
  : T extends object
  ? {
      [K in keyof T & (string | number)]-?: T[K] extends { roles: unknown }
        ? `${K}`
        : K extends string | number
        ? Join<K, StopRecursion<T[K]>>
        : never;
    }[keyof T & (string | number)]
  : "";

export function checkPermission(
  userRole: Role,
  path: Paths<typeof permissions>,
): boolean {
  const keys = path.split(".");
  let current: any = permissions;

  for (const key of keys) {
    if (current[key]) {
      current = current[key];
    } else {
      throw new Error("Invalid path to permissions");
    }
  }

  const permission = current as { roles: Readonly<Role[]> };

  if (typeof permission.roles === "undefined") {
    throw new Error("Invalid path to permissions");
  }

  return permission.roles.includes(userRole);
}

In conclusion, this post offers a snapshot of a method I've utilized to craft a shared permissions configuration with TypeScript that harmoniously operates across both backend and frontend, based on my own personal experiences and needs.

P.S. The original code was shared with ChatGPT which helped transform it into a more geenric example and to this blog post. If any parts of the code do not function as expected in your context, feel free to reach out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment