Architectural approaches to authorization in server applications: Activity-Based Access Control Framework

Автор оригинала: Gleb Bondarchuk
  • Перевод

This article is about security. I’ll focus on this in the context of web applications, but I’ll also touch on other types of applications. Before I describe approaches and frameworks, I want to tell you a story.


Background


Throughout my years working in the IT sphere, I’ve had the opportunity to work on projects in a variety of fields. Even though the process of authenticating requirements remained relatively consistent, methods of implementing the authorization mechanism tended to be quite different from project to project. Authorization had to be written practically from scratch for the specific goals of each project; we had to develop an architectural solution, then modify it with changing requirements, test it, etc. All this was considered a common process that developers could not avoid. Every time someone implemented a new architectural approach, we felt more and more that we should come up with a general approach that would cover the main authorization tasks and (most importantly) could be reused on other applications. This article takes a look at a generalized architectural approach to authorization based on an example of a developed framework.


Approaches to Creating a Framework


As usual, before developing something new, we need to decide what problems we’re trying to solve, how the framework will help us solve them, and whether or not there is already a solution to these issues. I’ll walk you through each step, starting with identifying issues and describing our desired solution.


We’re focusing on two styles of coding: imperative and declarative. Imperative style is about how to get a result; declarative is about what you want to get as a result.


The declarative style is convenient because it only requires a small amount of time and effort to achieve the desired result. For example, authorization can be done in the form of a description of the user's roles for accessing the resource, permissions, etc. However, the declarative style does not and cannot solve every possible problem (at least for authorization purposes). This is where the imperative style comes in handy.


The imperative style is useful in that it provides additional flexibility in implementation. For example in authorization it describes how the mechanism for assigning permissions to users will be implemented — statically or dynamically. It also describes what permissions will depend on.


A totally general-purpose framework for solving all goals and tasks obviously will not work. We need to select an architectural framework that everyone can have in common but that also leaves the implementation of the authorization logic to the discretion of the user. This is very similar to the concept of abstraction used in the development field. There has always been a dilemma about the level of abstraction. An overly abstract framework on the one hand, is very flexible but requires a lot of additional implementation; a less abstract one is not so flexible but requires a minimum of additional implementation.


Creating a Framework for Authorization


We decided that the framework we created should be:


  1. Easy to use — to save users from reading a multi-page manual, settings, etc.
  2. Flexible — so that it can be adapted to different goals and applications
  3. Reliably capable of handling errors

Declarative style


Authorization can be implemented declaratively by using configuration files (for example, xml, yaml, or properties) or by using Java annotations.


We decided to use Java annotations due to the fact that:


  1. Java annotations are a tool of both the Java language itself and the JVM in particular, which allows you to process annotations both at runtime and at compile time.
  2. Annotations are easy to use because it is easy to see which resource is limited and why.
  3. Annotations are flexible enough in the configuration because they are part of the Java language.

Authorization Implementation Approaches


There are many things on which you can base your authorization:


  • User roles (very convenient in applications with a small granularity of roles).
  • User permissions (convenient in applications with a more granular distribution of rights, i.e. when the usual set of roles is insufficient).
  • The user's actions — also convenient in cases of granular distribution of rights, i.e. instead of declaratively indicating what rights are needed to access the resource, the action that the user performs with the resource (for example, create, modify, delete) is indicated. The number and type of actions are only limited by your requirements and imagination. Action-based authorization is convenient because there is no need to change access rights later — the rights are declaratively described by the action, and the action with the resource usually is not changed. The rights that are necessary to perform the action can be changed, however.

Configuration and Error Handling


This point deserves special attention. A couple of times I've come across good frameworks and libraries with poor error handling, especially in terms of configuration. In this case, the lack of detailed documentation makes the framework almost completely useless.


As mentioned above, we decided to use Java annotations to implement authorization in a declarative style. Another advantage of this choice is compile-time handling of configuration errors; basically, we could check our work earlier in the process. Java provides an annotation processing mechanism that allows applications to process annotations at compile time.


Here we can also cite the Java Module System which was developed by Oracle and came out along with JDK 9. One of its most important advantages is also error handling at compile time.


Level of Abstraction


The framework’s approach to abstraction is:


  • Resources which require authorization are classified. This can be an organization, a project, a subproject — any entity.
  • The developer creates actions for each type of resource.
  • A custom annotation is created for each type of resource; the annotation indicates the action(s) performed on the resource.
  • The application developer creates an action handler (validator) for each type of resource (or for all of them at once).
  • We bind user roles and/or user permissions to actions. This remains a task for the application developer, and it can be done in a variety of ways. This is what provides sufficient flexibility for our purposes.

Easy-ABAC Framework


The Easy-ABAC Framework takes into account all of the considerations and approaches we’ve discussed so far.
Let's look at this framework in a simple Spring Boot project.
First, let's add a dependency to the project (we will use maven):


<dependency>
  <groupId>com.exadel.security</groupId>
  <artifactId>easy-abac</artifactId>
  <version>1.1</version>
</dependency>

At the time of this article’s publication, the latest version is 1.1.


Adding the configuration is necessary to plug in the aspects of the framework:


@SpringBootApplication
@Import(AbacConfiguration.class)
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Let's assume we have a Project resource to which we want to restrict access. Let's create the necessary skeleton as described in the [documentation] (https://github.com/exadel-inc/activity-based-security-framework).


1. Description of required actions


Let's assume we have the following user roles in our application:


  • Admin
  • Developer
  • Project owner

Let's define possible actions with the project:


  • View
  • Edit
  • Close
  • Delete

Note that the actions can be very different; you can edit only open projects, view only your projects, etc. The number and type of actions are only constrained by the requirements for authorization in the application.


Let's describe it in terms of the framework:


import com.exadel.easyabac.model.core.Action;

public enum ProjectAction implements Action {
    VIEW,
    UPDATE,
    CLOSE,
    DELETE
}

Only one thing is required here: the implementation of the com.exadel.easyabac.model.core. action marker interface. Everything else in the enum is at the discretion of the developer.
I’ll note right away that it is through this enum that it is convenient to bind to the user's role and/or user permissions either statically or dynamically


2. Creating Annotations for Managing Access Control


Let's create an annotation-identifier for the project:


@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface ProjectId {
}

We’ll need it to determine the project identifier among the method parameters.


Let's create an annotation to control access to projects:


import com.exadel.easyabac.model.annotation.Access;
import com.exadel.easyabac.model.validation.EntityAccessValidator;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Access(identifier = ProjectId.class)
public @interface ProjectAccess {

    ProjectAction[] actions();

    Class<? extends EntityAccessValidator> validator();
}

The annotation must contain actions and validator methods, otherwise we will get compilation errors:


Error:(13, 9) java: value() method is missing for @com.example.abac.model.ProjectAccess
Error:(13, 9) java: validator() method is missing for @com.example.abac.model.ProjectAccess

You should also pay attention to Target:


@Target({ElementType.METHOD, ElementType.TYPE})

Annotation can be used either on a method level or on a type-level. In a type level case, the annotation is applied to all instance methods of a given type.


3. Creating a Validator for Checking Access Rights


All we have to do now is add a validator:


import com.exadel.easyabac.model.validation.EntityAccessValidator;
import com.exadel.easyabac.model.validation.ExecutionContext;
import com.example.abac.model.ProjectAction;
import org.springframework.stereotype.Component;

@Component
public class ProjectValidator implements EntityAccessValidator<ProjectAction> {

    @Override
    public void validate(ExecutionContext<ProjectAction> context) {
        // here get current user actions
        // and compare them with context.getRequiredActions()
    }
}

The validator can be made either the default (so that it is not explicitly indicated in the annotation every time):


@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Access(identifier = ProjectId.class)
public @interface ProjectAccess {

    ProjectAction[] value();

    Class<? extends EntityAccessValidator> validator() default ProjectValidator.class;
}

or specified explicitly in each annotation:


@ProjectAccess(value = ProjectAction.VIEW, validator = ProjectValidator.class)

4. Access Restriction


Now the only step left in restricting access to the resources is to place out annotations:


import com.exadel.easyabac.model.annotation.ProtectedResource;
import com.example.abac.Project;
import com.example.abac.model.ProjectAccess;
import com.example.abac.model.ProjectAction;
import com.example.abac.model.ProjectId;
import org.springframework.web.bind.annotation.*;

@RestController
@ProtectedResource
@RequestMapping("/project/{projectId}")
public class ProjectController {

    @GetMapping
    @ProjectAccess(ProjectAction.VIEW)
    public Project getProject(@ProjectId @PathVariable("projectId") Long projectId) {
        Project project = ...; // get project here
        return project;
    }

    @PostMapping
    @ProjectAccess({ProjectAction.VIEW, ProjectAction.UPDATE})
    public Project updateProject(@ProjectId @PathVariable("projectId") Long projectId) {
        Project project = ...; // update project here
        return project;
    }

    @PostMapping("/close")
    @ProjectAccess(ProjectAction.CLOSE)
    public Project updateProject(@ProjectId @PathVariable("projectId") Long projectId) {
        Project project = ...; // close project here
        return project;
    }

    @DeleteMapping
    @ProjectAccess(ProjectAction.DELETE)
    public Project updateProject(@ProjectId @PathVariable("projectId") Long projectId) {
        Project project = ...; // delete project here
        return project;
    }
}

The @ProtectedResource annotation is used to designate resources for which authorization is needed, in this case, all instance methods of the class must contain at least one @Access-based annotation. If this requirement is not met, there will be compilation errors.


The @PublicResource annotation, on the other hand, is used to indicate a method that does not require an authorization in the case when the class containing the method is marked as @ProtectedResource.


So now we’ve finished configuration! Be careful to note that the annotation doesn’t necessarily have to be placed on a controller; it can be placed on any class.


5. Validator Implementation


Let's take a closer look at how this works. The framework provides a skeleton for building an authorization architecture in an application. It is up to the user to write the authorization logic. We did this to allow for the fact that application processing can be done in many different ways.


Permissions checking is done in a validator that must implement the interface EntityAccessValidator, specifically the validate method:


public void validate(ExecutionContext<Action> context);

ExecutionContext contains the necessary information about the required access rights to the resource and meta-information about the context of the call: context.getRequiredActions() will return a list of Actions that the user must have.


Next, you need to get a list of Actions available to the currently logged-in user (figuring out exactly how to do this is a responsibility for the application developer). Action(s) can be bound to the user in various ways: statically bound to the user's role, dynamically through the database, etc.


As a result, we have 2 Actions lists (current and required), but we still have to compare them. If at least one Action is missing, the user cannot be authorized. You can create your own exception like an AccessDeniedException, and once you’ve processed it in ExceptionHandler, you can return HTTP status 403 (this is at the discretion of the application developer).


An example of the validator implementation can be viewed here.



Framework sequence diagram


Comparative Analysis


Of course, before we wrote something new, we made sure that the same solution didn’t already exist. We also considered similar solutions and determined whether or not they were suitable for our purposes.


We considered Apache Shiro, JAAS, and Spring Security. Apache Shiro and JAAS do not provide sufficient flexibility, and they don’t have a very convenient configuration interface. JAAS does not use a declarative style at all, and Apache Shiro only has one through a configuration file. Undoubtedly, these frameworks are convenient for solving some problems, but they didn’t fit the bill for ours.


Spring Security is a powerful mechanism and is also very flexible (as a framework of this level should be). It uses a declarative style for authorization, but does not have a built-in mechanism for checking the configuration during compile time. The configuration via annotations process for complex authorizations is rather cumbersome. The flexibility that Spring has requires additional costs to implement the required mechanism.


That’s why we developed Easy-ABAC Framework the way we did; it fills in the gaps from and complements other frameworks.


Further Framework Development


The framework we developed currently includes a basic authorization mechanism and is quite flexible. We took into consideration the need for built-in implementation of out-of-the-box validators. At the moment, the framework can only be used in Spring-based applications. We hope to expand this in the future, as well as develop a more convenient and flexible configuration.


Areas of Use


  1. Java applications with granular authorizations
  2. Multi-tenant applications
  3. Applications with dynamic access rights
  4. Spring-based applications

Conclusion


The article discusses architectural approaches to authorization, presented by Easy-ABAC Framework.
Among the advantages of the developed framework are:


  1. Declarative authorization style
  2. Handling configuration errors at compile time
  3. Simple and straightforward configuration
  4. Flexibility

Conclusion


Today we’ve shown why new frameworks are sometimes necessary in order to meet your project needs. We’ve also demonstrated the pros and cons of a variety of these frameworks and introduced you to our new one: the Easy-ABAC framework, which provides:


  1. Declarative authorization style
  2. Handling configuration errors at compile time
  3. Simple and straightforward configuration
  4. Flexibility

If you have any further questions about how the Easy-ABAC framework could work for you, please contact us.

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое