Skip to content

Authorization

Samuel Gfeller edited this page Dec 7, 2023 · 16 revisions

Authorization is a process that determines what a user is allowed to do, based on their role and the specific rules that are defined.

It's a crucial part to ensure that users can only perform actions that they are permitted to do.

Choosing the Authorization Method

There are different ways of performing authorization, but it always involves:

  • Actions, things that user wants to do such as creating, reading, updating, etc.
  • User roles (attributed to each user)
  • Permission rules that define which roles can perform which actions

User roles are always stored in the database.

The question now is where should the rules that define which roles can do which actions be stored and how should they be handled.

The options that I considered were the following.

1. Define and handle permissions in service classes

With this method, each module would have authorization service classes. They contain functions for each action with the logic that defines what a user role is allowed to do. Or more specifically, if the role of the currently authenticated user is permitted to do the action.

This check is done using if statements. The conditions inside the if statements enforce the defined rules.

If the action is modifying a user and only admins or the user himself are permitted to do that, the core of the authorization function would look like this:

$userRole = $this->userRepository->getUserRoleByUserId($loggedInUserId);
// Check if the authenticated user is admin or if he's trying to modify himself
if ($userRole === UserRole::ADMIN || $loggedInUserId === $userIdToModify) {
    // Authorized
    return true;
}
// Forbidden
return false;

This allows for a very fine-grained control over the authorization rules. Highly specific rules can be defined very efficiently and effectively with only the strict necessary logic inside PHP.

But this means that authorization rules are tightly coupled with the source code. When the rules change, the application code has to be changed which is not very flexible in production.

The big downside when thinking scalability is that the administrator cannot specify the actions that each role is permitted to do in a frontend interface.

With this method, a solid testing strategy that tests every action with each role is crucial to ensure that the authorization rules are enforced correctly and no errors are made when changing the rules.

2. Store permission rules in database

In this approach, the authorization rules are stored in the database.
This article explains the concept and illustrates it like this:

Every action is described in a table (Permissions in the ER model above). Actions can be "create user", "update client status", "read client" etc.
In another table (Role_Permissions), actions are linked to user roles which permit the roles to perform the actions.

The source code would then only contain the logic that checks with the database table if the action is permitted for the role of the currently authenticated user.

That way, the authorization rules are decoupled from the source code. This would allow having a frontend interface where the administrator can specify the actions that each role is permitted to do.
Also, if there are multiple instances of the same application running, they could easily each have their own rules stored in the database.

The big downside here is that the table with actions can become huge and messy quickly, especially when the rules are very specific. When, for instance, different roles are allowed to mutate different fields of the same resource.

It is hard to keep an overview and especially create a concept that keeps things neat and maintainable.

Testing is another challenge. If permissions are fluid and can be changed in a frontend, I can't imagine how each case could be tested.

Decision

When I tried the second method with permissions stored in the database, it was quickly very clear that the first option was far better for this use-case and my requirements.

One frequent case is when a user with an inferior role is not allowed to change all columns from a table.
E.g. authenticated user with role Managing Advisor is allowed to edit the own profile and change other users but only if their role is Advisor or inferior and not all roles are available.

This would require so many columns in the table with actions and such a well-thought through concept. I don't think I'd be able to come up with something I'd be satisfied with, especially with the testing part.
I have the feeling that it's not possible to keep things clean, simple and testable with that method.

The first method is much more lightweight and effective as only the strictly needed logic has to be implemented, which makes it a lot less complex.
With PHPUnit data providers, it is also relatively easy to test the permissions for each role.

If one day, different customers that use the same application code want to have different authorization rules, the authorization rule logic has to be extracted from the core of the application code and be replaced with interfaces. Each application instance can then implement their own authorization methods.

Role-Based Access Control

In a role-based authorization system, each user is assigned a role, and each role has certain permissions. Users inherit the permissions of their assigned role.

The authorization service classes use these roles and permissions to determine what actions a user is allowed to perform.

The roles in this project follow a hierarchical structure, where each role has the permissions of the roles below it.
To facilitate authorization verification, they have a hierarchy integer value associated.
Administrator with the highest privilege has the hierarchy value 1. The value increases as the privilege decreases.

Role Description Hierarchy
Administrator Has all permissions. Can perform all actions. 1
Managing Advisor Is allowed to perform almost all actions. Manages users with a lower role. 2
Advisor Can mutate resources with limited rights. 3
Newcomer Very limited permissions. 4

Authorization with Service Classes

Permission verifiers

Permission verifiers are the service classes that contain the logic related to enforcing authorization rules.

Each module has its own permission verifier that typically contains the following functions:

  • isGrantedToCreate()
  • isGrantedToRead()
  • isGrantedToUpdate()
  • isGrantedToDelete()

Optionally, there may be additional functions for more specific actions.

For the sake of SRP, each function could be extracted into its own class, especially if it grows big with sophisticated rules.
To reduce cyclomatic complexity, they can also be split into smaller functions.

Each of the above-mentioned functions returns a boolean value that indicates if the user is allowed to perform the action or not.

Permission verifier methods

To define the rules, the user role and hierarchy of the currently authenticated user is needed as well as all user roles hierarchies mapped by their name.

The domain layer should not have access to the session, so the authenticated user id is identified in the UserNetworkSessionDataMiddleware that populates the UserNetworkSessionData DTO which is injected into the constructor of the permission verifier class.

With the id of the authenticated user, the user role hierarchy can be retrieved from the injected UserRoleFinderRepository class with the method getRoleHierarchyByUserId().

To get the hierarchy of all user roles, the getUserRolesHierarchies() function of the same repository class UserRoleFinderRepository.

While defining the rule, the user role name is referenced using a case of the Enum UserRole for improved readability and easier refactoring. UserRole::MANAGING_ADVISOR->value returns the name of the role as a string.

A simple authorization case to check if the authenticated user is granted to read the user passed in the parameter could look like this:

public function isGrantedToRead(?int $userIdToRead = null, bool $log = true): bool
{
    // loggedInUserId retrieved from `UserNetworkSessionData` DTO in the constructor
    if ($this->loggedInUserId) {
        $authenticatedUserRoleHierarchy = $this->userRoleFinderRepository->getRoleHierarchyByUserId(
            $this->loggedInUserId
        );
        // Returns array with role name as key and hierarchy as value ['role_name' => hierarchy_int]
        // !Lower hierarchy number means higher privileged role
        $userRoleHierarchies = $this->userRoleFinderRepository->getUserRolesHierarchies();
        
        // Only managing advisor and higher privileged are allowed to see other users
        // If the user role hierarchy int of the authenticated user is lower or equal
        // than the one from the managing advisor -> authorized
        if ($authenticatedUserRoleHierarchy <= $userRoleHierarchies[UserRole::MANAGING_ADVISOR->value]
            // or user wants to view his own profile in which case also -> authorized
            || $this->loggedInUserId === $userIdToRead) {
            return true;
        }
    }
    if ($log === true) {
        $this->logger->notice('User ' . $this->loggedInUserId . ' tried to read user but isn\'t allowed.');
    }
    return false;
}

If the authorization fails, the function returns false and logs the attempt.

Permission checks for creation and deletion work the same way.

Update authorization cases

When updating a resource, it can be a little bit trickier when different roles are permitted to update different values.
For each field, there needs to be a verification of the permission that works independently of the other fields.

This can be done by creating and populating the array $grantedUpdateKeys with the allowed update columns that the user wants to change.

The array with the values to update is passed in the parameter $valuesToUpdate.

The last part of the permission verification is a loop that checks if all the values from $valuesToUpdate are in the $grantedUpdateKeys array. If they're not, there is at least one value that the user is not permitted to update and thus the authorization fails.

Example

Let's take as example the isGrantedToUpdate() function of the UserPermissionVerifier.

Rules:

  • All users are permitted to update the personal info (name, email, etc.) from their own profile.
  • Only Admins and Managing advisors can update other users.
  • Admins can change all values of every user.
  • Managing advisors too can change all values but only if the user role of the user they want to update is Advisor or inferior, and they can't change the role to anything higher than Advisor.

File: src/Domain/User/Service/Authorization/UserPermissionVerifier.php

public function isGrantedToUpdate(array $userDataToUpdate, string|int $userIdToUpdate, bool $log = true): bool 
{
    $grantedUpdateKeys = [];
    $authenticatedUserRoleHierarchy = $this->userRoleFinderRepository->getRoleHierarchyByUserId(
        $this->loggedInUserId
    );
    $userToUpdateRoleData = $this->userRoleFinderRepository->getUserRoleDataFromUser((int)$userIdToUpdate);
    // Returns array with role name as key and hierarchy as value ['role_name' => hierarchy_int]
    // !Lower hierarchy number means higher privileged role
    $userRoleHierarchies = $this->userRoleFinderRepository->getUserRolesHierarchies();
   
    // Only managing advisor or higher privileged can change users
    if ((($authenticatedUserRoleHierarchy <= $userRoleHierarchies[UserRole::MANAGING_ADVISOR->value]
                // but only if user to change is advisor or lower
                && $userToUpdateRoleData->hierarchy >= $userRoleHierarchies[UserRole::ADVISOR->value])
            // if user role is higher privileged than managing advisor (admin) -> authorized
            || $authenticatedUserRoleHierarchy < $userRoleHierarchies[UserRole::MANAGING_ADVISOR->value])
        // or if the user edits his own profile, also authorized to the next section
        || $this->loggedInUserId === (int)$userIdToUpdate
    ) {
        // Things that managing advisor and owner user are allowed to change
        // Personal info are values such as first name, last name and email
        $grantedUpdateKeys[] = 'personal_info';
        $grantedUpdateKeys[] = 'first_name';
        $grantedUpdateKeys[] = 'surname';
        $grantedUpdateKeys[] = 'email';
        $grantedUpdateKeys[] = 'password_hash';
        $grantedUpdateKeys[] = 'theme';
        $grantedUpdateKeys[] = 'language';
        
        // Things that only managing_advisor and higher privileged are allowed to change
        // If the user is managing advisor we know by the parent if-statement that the user to change has not higher
        // role than advisor
        if ($authenticatedUserRoleHierarchy <= $userRoleHierarchies[UserRole::MANAGING_ADVISOR->value]) {
            $grantedUpdateKeys[] = 'status';
            // Check if the authenticated user is granted to attribute role if the key is in the data to update
            if (array_key_exists('user_role_id', $userDataToUpdate) 
            && $this->userRoleIsGranted(
                $userDataToUpdate['user_role_id'],
                $userToUpdateRoleData->id,
                $authenticatedUserRoleHierarchy,
                $userRoleHierarchies
            ) === true) {
                $grantedUpdateKeys[] = 'user_role_id';
            }
        }
    }
    // Check that the data that the user wanted to update is in $grantedUpdateKeys array
    foreach ($userDataToUpdate as $key => $value) {
        // If at least one array key doesn't exist in $grantedUpdateKeys it means that user is not permitted
        if (!in_array($key, $grantedUpdateKeys, true)) {
            if ($log === true) {
                $this->logger->notice(
                    'User ' . $this->loggedInUserId . ' tried to update user but isn\'t allowed to change' .
                    $key . ' to "' . $value . '".'
                );
            }
            return false;
        }
    }
    // All keys in $userDataToUpdate are in $grantedUpdateKeys
    return true;
}

Get Privilege Level in Anticipation for the Frontend

Actions are usually initiated by the frontend, and it has to know what actions the user is allowed to perform before the action is executed to display the correct elements (buttons, links, dropdown values, fields, etc.).

Frontend templates are independent of domain service classes, including permission verifiers. The backend has to handle the task of determining permissions, and then relay this information to the frontend.

Privilege Enum

When the permission is linked to a specific ressource, the privilege is added to the "result object" in the form a string corresponding to a Privilege.php Enum case in the backend, before it is passed to the template renderer or returned as JSON response.

The content of the Privilege Enum depends on the use cases of the application. In this project, the following cases are relevant.

File src/Domain/Authorization/Privilege.php

namespace App\Domain\Authorization;

enum Privilege
{
    // The case names and values correspond to the following privileges:
    // R: Read, C: Create, U: Update, D: Delete
    // They can be combined or used individually depending on the needs of the application.
    // To check if a privilege is allowed, the frontend can check if the letter of the privilege is in the value.
    // For instance, if update privilege is required, the client can check if privilege value contains "U".

    // No rights
    case N;
    // Allowed to Read
    case R;
    // Allowed to Create and Read
    case CR;
    // Allowed only to Create (needed when the user is not allowed to see hidden notes but may create some)
    case C;
    // Allowed to Read, Create and Update
    case CRU;
    // Allowed to Read, Create, Update and Delete
    case CRUD;
}

Set privilege level for resource or column

The classes that are responsible for checking each action and providing the appropriate privilege are called PrivilegeDeterminer.

For the client module, the service class that handles this is called ClientPrivilegeDeterminer.
It has a function getMutationPrivilege which accepts two parameters $clientOwnerId and optionally $column.

If the column is specified, the update privilege level for the specific column is checked; otherwise the privilege for the entire resource is evaluated.

The function checks for the highest required privilege (delete) and if granted, it returns early as that means that the user is also allowed to update, create and also read the resource.
If the highest privilege is not granted, the function continues to check for the next highest privilege (update) and so on.

If permissions are not hierarchical, the logic of the function has to be adapted to that specific use-case (see UserPrivilegeDeterminer).

File src/Domain/Client/Service/Authorization/ClientPrivilegeDeterminer.php

public function getMutationPrivilege(?int $clientOwnerId, ?string $column = null): string
{
    // Check first against the highest privilege, if allowed, directly return otherwise continue down the chain
    if ($this->clientAuthorizationChecker->isGrantedToDelete($clientOwnerId, false)) {
        return Privilege::CRUD->name;
    }
    if ($column !== null
        // Column value does not matter, only the key
        && $this->clientAuthorizationChecker->isGrantedToUpdate([$column => 'value'], $clientOwnerId, false)
    ) {
        return Privilege::CRU->name;
    }
    // Other checks for "create" and "read" if needed
    // Default no privilege
    return Privilege::N->name;
}

The service classes responsible for preparing the data calls getMutationPrivilege function to set the privilege level in the result DTO (or elsewhere).

If privileges vary for different fields and they are used by the frontend, multiple "privilege" attributes can be added to the DTO. E.g. UserResultData has a $generalPrivilege, a $userRolePrivilege and a $statusPrivilege attribute.

The awesome thing with this entire authorization method is that it is highly use-case specific. There is no boilerplate code. The logic is only as complex as it needs to be.

Verify Privilege Level in the frontend

Resources from the backend may be displayed in the frontend either through the PHP-View template renderer or via JSON Ajax requests.

Previously there was a hasPrivilege method and Enums were passed to the PHP templates that called $enum->hasPrivilege(Privilege::Update), but that didn't work for the fetched JSON data. That meant that there were two separate ways of checking privileges.
Simplicity is key, so now both PHP templates and Javascript use the same method which is described below.

The privilege level is transmitted as string corresponding to the letters of a Privilege Enum case.

Check privilege in PHP template renderer

The template can decide if it displays a "delete" button for instance, by checking if the privilege attribute of the given resource DTO ($user) contains the required letter "D" corresponding to the action "Delete".

File templates/user/user-read.html.php

<?php
// If the string attribute generalPrivilege contains letter "D", display delete button
if (str_contains($user->generalPrivilege, 'D')) { ?>
    <button class="btn btn-red" id="delete-user-btn">Delete user</button>
    <?php
} ?>

The same mechanism is used to display an edit icon, to enable or disable select dropdowns or to display create buttons.

Privileges can also be passed to the template renderer in an own PhpRenderer attribute if they are independent of the resource.

Check privilege in Javascript

A response from the server may look like this:

{
  "id": 1,
  "message": "This is a note.",
  "privilege": "CRU"
}

The letters "CRU" that are transmitted mean that the authenticated user is allowed to create, read and update the note.

Now with Javascript, it can be verified if the user has the required permission by checking if the "privilege" value contains the required letter corresponding to the action.

Example

The "Delete note" button should be displayed if the letter "D" is in the "privilege" value.

const note = noteJsonFromResponse;

noteContainer.insertAdjacentHTML('beforeend', 
    // Html code for note
    // ...
    `${/* Show delete button if user has required privilege */ 
        note.privilege.includes('D') ? 
        `<img class="btn-above-note delete-note-btn" alt="delete" 
            src="assets/general/general-img/del-icon.svg">` : ''
    }`
    // ...
);

Defining Permissions

It is imperative to have a document that defines the permissions for each user role.

This is to have a clear overview of what every user role is allowed to do and what is forbidden.

I find a checklist with the same set of rules for every role practical for this.
If the role is allowed to do the action, the box is checked. If not, it is left empty.

Configuration for this project

The rule configuration for this project is written in the most simple way possible, primarily containing rules that vary between user roles and grouping those that change together.

A rule might relate to a single action and a specific field of a resource, or cover multiple actions. It is as fine-grained as needed for the moment but should be extended if needed.

Newcomer

⬜ Create Newcomer
⬜ Modify Newcomer
⬜ Delete Newcomer
⬜ Change Newcomer to the advisor user role
⬜ Modify Advisor
⬜ Delete Advisor
⬜ Change Advisor User Role (back to newcomer)
⬜ Manage Managing Advisor and higher (create, modify, delete)

⬜ Create Client
✅ View Clients not assigned to oneself
✅ View Clients assigned to oneself
⬜ Assign Client to a User
⬜ Change Client Status when assigned to oneself
⬜ Change Client Status when not assigned to oneself
⬜ Modify Client personal info (Tel, Email, Location, Name, Main Note) when assigned to oneself
⬜ Modify Client personal info (Tel, Email, Location, Name, Main Note) when not assigned to oneself
⬜ Delete Client when assigned to oneself
⬜ Delete Client when not assigned to oneself
⬜ View Deleted Clients

✅ Record/Modify/Delete Notes for the Client
⬜ Mark Note as Confidential
⬜ View Confidential Note/Main Note
✅ View Notes from Other Users
⬜ Modify/Delete Notes from Other Users
⬜ View Deleted Notes

⬜ View User Activity (History)

Advisor

⬜ Create Newcomer
⬜ Modify Newcomer
⬜ Delete Newcomer
⬜ Change Newcomer to the advisor user role
⬜ Modify Advisor
⬜ Delete Advisor
⬜ Change Advisor User Role (back to newcomer)
⬜ Manage Managing Advisor and higher (create, modify, delete)

✅ Create Client
✅ View Clients not assigned to oneself
✅ View Clients assigned to oneself
⬜ Assign Client to a User
✅ Change Client Status when assigned to oneself
⬜ Change Client Status when not assigned to oneself
✅ Modify Client personal info (Tel, Email, Location, Name, Main Note) when assigned to oneself
✅ Modify Client personal info (Tel, Email, Location, Name, Main Note) when not assigned to oneself
⬜ Delete Client when assigned to oneself
⬜ Delete Client when not assigned to oneself
⬜ View Deleted Clients

✅ Record/Modify/Delete Notes for the Client
✅ Mark Note as Confidential
✅ View Confidential Note/Main Note
✅ View Notes from Other Users
⬜ Modify/Delete Notes from Other Users
⬜ View Deleted Notes

⬜ View User Activity (History)

Managing Advisor

✅ Create Newcomer
✅ Modify Newcomer
✅ Delete Newcomer
✅ Change Newcomer to the advisor user role
✅ Modify Advisor
✅ Delete Advisor
✅ Change Advisor User Role (back to newcomer)
⬜ Manage Managing Advisor and higher (create, modify, delete)

✅ Create Client
✅ View Clients not assigned to oneself
✅ View Clients assigned to oneself
✅ Assign Client to a User
✅ Change Client Status when assigned to oneself
✅ Change Client Status when not assigned to oneself
✅ Modify Client personal info (Tel, Email, Location, Name, Main Note) when assigned to oneself
✅ Modify Client personal info (Tel, Email, Location, Name, Main Note) when not assigned to oneself
✅ Delete Client when assigned to oneself
✅ Delete Client when not assigned to oneself
✅ View Deleted Clients

✅ Record/Modify/Delete Notes for the Client
✅ Mark Note as Confidential
✅ View Confidential Note/Main Note
✅ View Notes from Other Users
✅ Modify/Delete Notes from Other Users
✅ View Deleted Notes

✅ View User Activity (History)

Administrator

✅ Create Newcomer
✅ Modify Newcomer
✅ Delete Newcomer
✅ Change Newcomer to the advisor user role
✅ Modify Advisor
✅ Delete Advisor
✅ Change Advisor User Role (back to newcomer)
✅ Manage Managing Advisor and higher (create, modify, delete)

✅ Create Client
✅ View Clients not assigned to oneself
✅ View Clients assigned to oneself
✅ Assign Client to a User
✅ Change Client Status when assigned to oneself
✅ Change Client Status when not assigned to oneself
✅ Modify Client personal info (Tel, Email, Location, Name, Main Note) when assigned to oneself
✅ Modify Client personal info (Tel, Email, Location, Name, Main Note) when not assigned to oneself
✅ Delete Client when assigned to oneself
✅ Delete Client when not assigned to oneself
✅ View Deleted Clients

✅ Record/Modify/Delete Notes for the Client
✅ Mark Note as Confidential
✅ View Confidential Note/Main Note
✅ View Notes from Other Users
✅ Modify/Delete Notes from Other Users
✅ View Deleted Notes

✅ View User Activity (History)

Clone this wiki locally