Skip to main content

NestJS Authorization Module

Objectives

Breaking down complexity and isolate it. One place to solve a specific authorization for a scope. One implementation to handle all different situations in our system. It should not be possible to use it in a wrong way.

You should not need to understand the complete system, to know if something is authorized

We also want to avoid any specific code for modules, collections, or something else, in the authorization module.

Explanation of Terms

Permissions

We have string-based permissions. For examples check "enum Permission". It includes all available permissions and is not separated by concerns or abstraction levels. The permissions have different implicit scopes like instance, school, or named scope like team and course.

(Feature Flag Permissions)

Some of the permissions are used like feature flags. We want to separate and move these in the future. Please keep that in mind, while becoming familiar with permissions.

Roles

We have a role collection, where each role has a permissions array that includes string-based permissions. Roles inherit permissions from the roles they have in their "roles" field. Like the "user" role, some of these roles are abstract and only used for inheritance. Some others are scope based with a prefix name like team*, or course*.

The "real" user roles by name are external person, student, teacher and administrator. All of these are in the school scope and the superhero is in the scope of an instance.

In future we want to remove the inherit logic. We want to add scope types to each role. Add more technical users

Entities

The entities are the representation of a single document of the collection, or the type. They are used for authorization for now, but should be replaced by domain objects soon.

Domain Objects

They are not really introduced. They should map between the repository layer and the domain.

In future they are the base for authorization and the authorization service doesn't know anything about entities anymore.

Scopes

Everything what the system, or a user wants to do, is executed in a scope. A scope means an area like the complete instance, the school, the course, the user itself and so on. The scopes are highly bound to the real domain objects that we have in our domain.

Scope Actions

The permission for a base action, like they are defined in CRUD operations, is needed to execute something in a scope. The most implicit action you ever need is the "read" action. That means, you must have the authorization to "read" the scope, otherwise it should not exist for you. :-) The other possible action is to have write access to the scope. It is a combination of delete, edit, create from CRUD side.

From our current perspective, we need no differentiation. But we force the implementation in a way, that allows us to add some more.

Scope Permission

We have different situations where it is hard to say you can write/read to the domain scope. We need the possibility to define different permissions for a single domain scope, or a single domain object itself.

Let say the user can edit his own user account, but we want to disallow that they can change his age. But an administrator should have the authorization to do it.

or another case.

A student has limited permissions in a team, where he is only a member, but would have more permissions in a team, where he is the owner. So, at this point, we need to distinguish between instances of domain objects.

User(s)

In authorization scope it can be a system user, or a real user in our application. Each user has a role with permissions in the scope of the domain object they want to interact with. Each authorization requires a user.

System Users

We have console operations, or operations based on API_KEYS that are used between internal services for already authorized operations like copy and copy files in file service. For this we want to use system user and roles with own permissions.

They are not introduced for now

Rules

The rules are implemented with a strategy pattern and are called from the authorization service. The implementation should solve the authorization for a domain object based on the executed action. It implements a check for which domain object, entity, or additional conditions should be used.

The rule must validate our scope actions. We highly recommend that every single operation and check in the rule is implemented as an additional method to keep the logic clean and moveable.

User (Role) Permissions vs Scope Based Permissions

The permissions of the user come from his role. These permissions have no explicit scope. But implicitly the roles external person, student, teacher and administrator are in the school scope. The superhero is implicitly in the scope of the instance. On some instances external persons are "collected" in the "ExpertenSchule" - which is a unique school with a specialized type (SchoolPurpose.EXTERNAL_PERSON_SCHOOL) to provide instance wide - accounts for experts that may be invited to multiple schools.

It exists also scope based permissions. A user can have different (scope)roles in different (domain)scopes. For example, in teams where the student can have team member role in one team, or team administrator in another.

In future we want to switch the implicit scope of the user role permissions to explicit scopes like in boards. At the moment we must handle scope-, user- and system-user-permissions as separated special cases in our implementation. By implementing user role permissions bound to scopes, we can do it in one way for all situations.

How should you authorize an operation?

Authorization must be handled in use cases (UC). They solve the authorization and orchestrate the logic that should be done in services, or private methods. You should never implement authorization on service level, to avoid different authorization steps. When calling other internal micro service for already authorized operations please use a queue based on RabbitMQ.

How to use authorization service?

Please avoid to catch the errors of the authorization in UC. We set empty array as required for passing permissions to make it visible that no string base permission is needed.

Example 1 - Execute a Single Operation

   this.authorizationService.checkPermission(user, course, AuthorizationContextBuilder.write([])
// or
this.authorizationService.hasPermission(user, course, AuthorizationContextBuilder.write([])
// next orchestration steps

Example 2 - Set Permission(s) of User as Required

// Multiple permissions can be added. For a successful authorization, the user needs all of them.
await this.authorizationService.hasPermission(
userId,
course,
AuthorizationContextBuilder.read([Permissions.COURSE_VIEW])
);
// next orchestration steps

Example 4 - Define Context for Multiple Places

/** const **/
export const FileStorageAuthorizationContext = {
create: AuthorizationContextBuilder.write([Permission.FILESTORAGE_CREATE]),
read: AuthorizationContextBuilder.read([Permission.FILESTORAGE_VIEW]),
update: AuthorizationContextBuilder.write([Permission.FILESTORAGE_EDIT]),
delete: AuthorizationContextBuilder.write([Permission.FILESTORAGE_REMOVE]),
};

/** UC **/
this.authorizationService.hasPermission(userId, course, PermissionContexts.create);
// do other orchestration steps

How to use in our use cases

Example - Create a school by superhero

async createSchoolBySuperhero(userId: EntityId, params: { name: string }) {

const user = this.authorizationService.getUserWithPermissions(userId);
this.authorizationService.hasAllPermissions(user, [Permission.SCHOOL_CREATE]);

const school = new School(params);
await this.schoolService.save(school);

return true;
}

Example - Create user by admin


async createUserByAdmin(userId: EntityId, params: { email: string, firstName: string, lastName: string, schoolId: EntityId }) {

const user = this.authorizationService.getUserWithPermissions(userId);

const context = AuthorizationContextBuilder.write([Permission.INSTANCE, Permission.CREATE_USER])
await this.authorizationService.checkPermission(user, school, context);

const newUser = new User(params)
await this.userService.save(newUser);

return true;
}

Example - Edit course by admin

// admin
async editCourseByAdmin(userId: EntityId, params: { courseId: EntityId, description: string }) {

const course = this.courseService.getCourse(params.courseId);
const user = this.authorizationService.getUserWithPermissions(userId);
const school = course.school;

const context = AuthorizationContextBuilder.write([Permission.INSTANCE, Permission.CREATE_USER]);
this.authorizationService.checkPermissions(user, school, context);

course.description = params.description;
await this.courseService.save(course);

return true;
}

Example - Create a Course

// User can create a course in scope a school, you need to check if he can it by school
async createCourse(userId: EntityId, params: { schoolId: EntityId }) {
const user = this.authorizationService.getUserWithPermissions(userId);
const school = this.schoolService.getSchool(params.schoolId);

this.authorizationService.checkPermission(user, school
{
action: Actions.write,
requiredPermissions: [Permission.COURSE_CREATE],
}
);

const course = new Course({ school });
await this.courseService.saveCourse(course);

return course;
}

Example - Create a Lesson

// User can create a lesson to course, so you have a courseId
async createLesson(userId: EntityId, params: { courseId: EntityId }) {
const course = this.courseService.getCourse(params.courseId);
const user = this.authorizationService.getUserWithPermissions(userId);
// check authorization for user and course
this.authorizationService.checkPermission(user, course
{
action: Actions.write,
requiredPermissions: [Permission.COURSE_EDIT],
}
);

const lesson = new Lesson({course});
await this.lessonService.saveLesson(lesson);

return true;
}

How to write a rule

@Injectable()
export class TaskRule implements Rule<Task> {
constructor(
private readonly authorizationHelper: AuthorizationHelper,
// Only if the rule has access to the rule.
// It dependece on dependency order.
private readonly additionalRule: AdditionalRule,
authorisationInjectionService: AuthorizationInjectionService
) {
authorisationInjectionService.injectAuthorizationRule(this);
}

public isApplicable(user: User, object: unknown): boolean {
const isMatched = object instanceof Task;

return isMatched;
}

public hasPermission(user: User, object: Task, context: AuthorizationContext): boolean {
// ...

return result;
}
}

It is good practice to make read, write, or scopes explicit over different private methods!

Structure of the Authorization Components

feathers-* (legacy/deprecated)

It exists an adapter to call featherJS endpoints that solve authorizations.

This service is only used in news and should not be used in any other place. We want to remove it completely.

Authorization Module

The authorization module is the core of authorization. It collects all needed information and handles it behind a small interface. It exports the authorization service that can be used in your use case over injections.

Reference.loader

It should be used only inside of the authorization module. It is used to load registered resources by the id and name of the resource. This is needed to solve the API requests from external services. (API implementation is missing for now)

Please keep in mind that it can have an impact on the performance if you use it wrongly. We keep it as a separate method to avoid the usage in areas where the domain object should exist, because we see the risk that a developer could be tempted by the ease of only passing the id.

authorization-context.builder

We export an authorization context builder to prepare the parameter for the authorization service called "authorization context". This is optional and not required atm. But it enables us to easily change the structure of the authorization context without touching many different places.

shared/domain/interface/*

rolename.enum

An enum that holds all available role names.

permission.enum

An enum that holds all available permission names, however it's mixing all domain scopes atm.

Working other Internal MicroServices

We propose to use the infra-authorization-client module, a copy of them, or a similar implementation based on the open swagger api, to authorize requests for services that cannot authorize the operations.

Legacy Tech Stack FeatherJS Hooks

In featherJS all the authorization is done in hooks. Mostly before hooks and sometimes in after hooks. Before and after means before, or after the database operation. For self-written services before, or after the call of the operation that should be executed. They work similar to express middleware and bring their own request context.

It exists hooks that can be used for all http(s) calls, or for specific type based on CRUD operations. Additionally it also exists the find operations that are a http(s) GET requests without the ID of a specific element. Each function that adds to the hooks will be executed in order. Hooks for all methods first, then hooks for specific methods.

Each hook exists for a featherJS service that exposes directly the api endpoints directly. Additional it exists a global hook pattern for the whole application.

Example: hooks/index.js

Desired Changes in Future

Some small steps are done. But many next steps still exist. They follow our general target.

  1. Implementation of Scope Based Permissions as a general solution instead of User Permissions that has only implicit school scopes for now. Remove populate logic in reference loader. Solve eager loading in course groups.
  2. Remove inheritance from roles, because we want to write it explicitly into the collection documents. Moving role api endpoints to NestJS. Fixing of dashboard to handle roles in the right way as superhero.
  3. Switching entity-based authorization to domain objects based in steps.
  4. Cleanup of feature flags from user permissions. Add existing feature flags to rules on places where it make sense.