Skip to content

Authorization

samuelgfeller edited this page Apr 2, 2024 · 15 revisions

Introduction

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

It is a crucial part to ensure that users can only see the data or perform actions that they are permitted to.

Which authorization method to use

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 what roles are allowed to perform which actions be stored and how that should be verified.

The options that I considered were the following.

1. Define and verify 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 a certain action.

This is done using conditional statements in the authorization service classes.
The conditions inside the if statements enforce the defined rules.

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

$userRole = $this->userRepository->getUserRoleByUserId($loggedInUserId);
// Check if the authenticated user is admin or if he's trying to modify itself
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 needs 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. It's always the developer's task to modify the rules in the source code.

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 the database

In this other 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, for example, "create user", "update client status", "read client" etc.
In another table ("Role_Permissions" above), actions are linked to user roles which define the actions each role is permitted to do.

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 administrators 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 don't know 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 my use-case and requirements.

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

This would require so many columns in the table containing the actions from the second method. I don't think I'd be able to come up with a satisfying concept, especially for the testing part.

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

If different customer instances of the same application need to have their own authorization rules, the rule logic can be extracted from the application core and be replaced with interfaces.
Each application codebase can then implement their own authorization classes, specific to the customer requirements.

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 implement the permission verification rules and determine what actions a user, depending on their role, is allowed to perform.

The roles in the slim-example-project, follow a hierarchical structure, where each role has at least all the permissions of the role 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 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.

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

For the sake of SRP, each function should be extracted into its own class if it grows big with sophisticated rules.

To reduce cyclomatic complexity, they can also be split into smaller functions.

Permission verifier

The permission verifiers are is in the Domain layer which must not have access to the session, but they need to know the role of the authenticated user. It's the task of the UserNetworkSessionDataMiddleware to retrieve and store the authenticated user id in the UserNetworkSessionData DTO which is injected into the constructor of the permission verifier classes.

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

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

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 in the given parameter could look like this:

<?php

namespace App\Domain\User\Service\Authorization;

use App\Application\Data\UserNetworkSessionData;
use App\Domain\Authentication\Repository\UserRoleFinderRepository;
use App\Domain\User\Enum\UserRole;
use Psr\Log\LoggerInterface;

class UserPermissionVerifier
{
    private ?int $loggedInUserId = null;

    public function __construct(
        private readonly UserNetworkSessionData $userNetworkSessionData,
        private readonly UserRoleFinderRepository $userRoleFinderRepository,
        private readonly LoggerInterface $logger,
    ) {
        $this->loggedInUserId = $this->userNetworkSessionData->userId ?? null;
    }

    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 permission verifier

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 given 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 but not their role or status.
  • 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 change 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) 
            // userRoleIsGranted() checks if the authenticated user is allowed to assign a certain role
            && $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 Enum case in the backend, before it is passed to the template renderer or returned as a JSON response.

The content of the Privilege Enum depends on the use cases of the application. In the Slim Example Project, the following cases are relevant for the currently implemented functionalities.

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 the string "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 for the frontend 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 like this, the logic of the function has to be adapted to the specific use-case (see UserPrivilegeDeterminer in the source code).

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. ClientReadResult.php has a $generalPrivilege, $clientStatusPrivilege, $assignedUserPrivilege, and a $noteCreatePrivilege 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 which could use $enum->hasPrivilege(Privilege::Update) to check the privilege, but that can't work for the fetched JSON data as it's PHP.
It 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 templates

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

File: templates/client/client-read.html.php

<?php
// If the string attribute generalPrivilege contains letter "D", display delete button
if (str_contains($clientReadResult->generalPrivilege, 'D')) { ?>
    <button class="btn btn-red" id="delete-client-btn">Delete client</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">` : ''
    }`
    // ...
);

Testing authorization

An example of a user creation and client update test function can be found in Testing Authorization.

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.

Rules for the slim-example-project

The rule configuration for the slim-example-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 refined 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