Apache Shiro: Implementing complex permissions autonomously

Although Apache Shiro includes a powerful and flexible tool for fine-grained allocation of authorizations with its WildcardPermissions, one is faced with its limitations when it comes to complex scenarios. This article shows how any authorization checks can be implemented autonomously using the Permission interface.

Authorization with Apache Shiro

With Apache Shiro, Java developers have a comprehensive and perfected security framework with the core features authentication, authorization, session management and cryptography at their disposal. This article focuses on the topic of authorization and demonstrates how Shiro’s permission concept can be taken advantage of to implement complex authorizations. A general introduction to Shiro is available at http://shiro.apache.org/. A complete example of the code for the solution presented here can be viewed at https://github.com/triologygmbh/complex-shiro-permissions.

Shiro permissions

Shiro offers two concepts for authorization: Roles and permissions. Role-based authorization checks are suited to simple, straightforward scenarios. In contrast, permissions provide the option of defining and assigning authorizations with flexibility and as finely grained as is needed. Here, everything which is not explicitly allowed, is prohibited.

With WildcardPermissions, Shiro already provides a powerful permission implementation which is sufficient for most applications. WildcardPermissions can be defined as strings. It would, for example, be possible to inquire whether the current user is authorized to modify the product with the ID 123 using the string “product:update:123“. Inversely, we can assign the authorization “product:update:*” to a user. This allows him to modify any product. “product:*” would allow the user to perform any action for any product, “product:*:123” would allow any action for product 123, and so on. The number of elements a WildcardPermission has is limitless. To restrict the authorization to a certain product line, the permission ”productline:hardware:product:update:*”, for example, would be conceivable.

Shiro fundamentals

Let us now look at the basic operation of Shiro before we consider the limitations of string based WildcardPermissions. The interface for client code is quite straightforward:

Subject user = SecurityUtils.getSubject();
user.login(new UsernamePasswordToken("user", "password"));
user.checkRole("admin");
user.checkPermission("product:update:123");
user.logout();

In Shiro, the so-called Realm forms the bridge to the domain of the developer’s own application. Application-specific issues such as integrating a user administration or assigning authorizations are delegated to a previously registered implementation of this interface by Shiro. If a client code attempts to log in the Subject, then the defined AuthenticationToken (a UsernamePasswordToken in this example) is passed on to the Realm to be tested. In a similar way, the Realm is asked for the roles and permissions of the current user when the user’s authorizations are being tested.
So that developers only need concentrate on these two aspects, Shiro provides the class AuthorizingRealm, which defines two template methods for this purpose. We are extending it as follows:

public class ComplexPermissionRealm extends AuthorizingRealm {

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        Collection<Permission> permissions = loadPermissions(principalCollection);
        authorizationInfo.addObjectPermissions(permissions);
        return authorizationInfo;
    }

    private Collection<Permission> loadPermissions(PrincipalCollection principalCollection) {
        // In the real world we would most likely load the user's permissions from a database.
        // We keep it simple for demonstration purposes. 
       return Arrays.asList(
               new WildcardPermission("user:create:*"),
               new WildcardPermission("user:update:*"),
               new WildcardPermission("user:delete:*"));
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
              throws AuthenticationException {
        checkCredentials(authenticationToken);
        return new SimpleAuthenticationInfo(authenticationToken.getPrincipal(), authenticationToken.getCredentials(), getName());
    }

    private void checkCredentials(AuthenticationToken authenticationToken) {
        // implement authentication mechanism here using
        // authenticationToken.getPrincipal()
        // authenticationToken.getCredentials()
    }

}

If the method doGetAuthenticationInfo returns an AuthenticationInfo instance after the test of the transmitted AuthenticationToken without throwing an exception, Shiro recognises the user as authenticated.
When authorizations are checked, doGetAuthorizationInfo is called. The method returns the authorizations and the roles of a user bundled in an AuthorizationInfo instance.
In the example, we define a series of WildcardPermissions. This class implements the Permission interface, which only defines one method:

boolean implies(Permission permission);

When user.checkPermission(“product:update:123”) is called, Shiro first converts the requested Permission string into a WildcardPermission. With this instance, the implies methods of the Permissions assigned to the user are called until one of them returns true. In our example this would be
new WildcardPermission(“user:update:*”). If none of the Permissions of the user implies the requested Permission, then the user is not authorized.

Requirements of complex Permissions

With the string-based WildcardPermissions, developers have a simple tool at their disposal which can be used to implement rather complex scenarios. This tool should be used whenever possible.
However, this type of authorization definition is not appropriate in every case. Let us assume, for example, that we want to model authorizations on a file tree as follows:

  • File access can be authorized for reading or writing.
  • Files that have writing access also have reading access.
  • When access to a file is permitted reading access to all superordinate directories must also be permitted in order to be able to navigate to the file.
  • If a directory is authorized for reading or writing, this authorization is inherited by all subordinate directories and files.

With this definition, there are three conditions that result in a user having access to a file:

1. The user has authorization for the requested file assigned directly.
2. The user requests reading access to the file and the user has access to a subordinate file in the tree.
3. The user has access to a file which is above the requested file in the tree hierarchy.

We will implement the tests for each of these three constellations in their own custom implementations of the Permission interface.

Implementation of complex PermissionS

Because the implies method to be implemented also expects a Permission, we need yet another implementation which depicts the requested access to the file:

public class RequestedFileAccess implements Permission {

    private final Path requestedFile;
    private final FileOperation operation;

    public RequestedFileAccess(Path file, FileOperation operation) {
        this.requestedFile = file;
        this.operation = operation;
    }

    public Path getRequestedFile() {
        return requestedFile;
    }

    public FileOperation getOperation() {
        return operation;
    }

    @Override
    public boolean implies(Permission permission) {
        return false;
    }
}

All three authorization checks to be implemented have mutual basic functions: They only imply RequestedFileAccess PermissionS. They also have to ensure that the requested FileOperation (READ or WRITE) matches the configured authorization. For this purpose, we define the base class BaseFilePermission which tests the type of the requested Permission and the requested operation. Subsequently, it calls a template method so that specific tests can be implemented in its subclasses.

abstract class BaseFilePermission implements Permission {

    protected final Path ownFile;
    protected final FileOperation operation;

    public BaseFilePermission(Path file, FileOperation operation) {
        this.ownFile = file;
        this.operation = operation;
    }

    @Override
    final public boolean implies(Permission permission) {
        return isFileAccessRequest(permission) && fileAccessIsImplied((RequestedFileAccess) permission);
    }

    private boolean isFileAccessRequest(Permission permission) {
        return permission instanceof RequestedFileAccess;
    }

    private boolean fileAccessIsImplied(RequestedFileAccess requestedFileAccess) {
        return requestedOperationIsImplied(requestedFileAccess) &&   
		accessIsAllowed(requestedFileAccess.getRequestedFile());
    }

    private boolean requestedOperationIsImplied(RequestedFileAccess requestedFileAccess) {
        return requestsRead(requestedFileAccess) || writeIsPermitted();
    }

    private boolean requestsRead(RequestedFileAccess requestedFileAccess) {
        return requestedFileAccess.getOperation() == FileOperation.READ;
    }

    private boolean writeIsPermitted() {
        return this.operation == FileOperation.WRITE;
    }

    /**
     * Template method called during permission check that allows subclasses to check access permissions.
     *
     * @param requestedFile Path
     * @return true if the RequestedFileAccess is granted
     */
    protected abstract boolean accessIsAllowed(Path requestedFile);

    
}

In the following, we will implement the defined tests 1., 2. and 3. in custom Permissions. Here, we are using the following file tree as an example:

/departments
    /development
        /employee_789
        /employee_987
    /finance
        /employee_123
        /employee_456

Let us now implement the first test, the direct authorization for a file. As BaseFilePermission does most of the work for us, we only have to ensure that the requested file is the same file there is a direct authorization for. We delegate the testing of two files for equality and for their relative position to one another in the file tree to the FileRelations helping class.

class FileRelations {

    private final Path file;

    private FileRelations(Path file) {
        this.file = file;
    }

    static FileRelations is(Path file) {
        return new FileRelations(file);
    }

    boolean descendantOf(Path potentialAncestor) {
        try {
            return isDescendantOf(potentialAncestor);
        } catch (IOException e) {
            // wrap checked exception to avoid cluttering up the example code
            throw new RuntimeException(e);
        }
    }

    private boolean isDescendantOf(Path potentialAncestor) throws IOException {
        return Files.walk(potentialAncestor)
                .anyMatch(descendant -> descendant.equals(this.file));
    }

    boolean theSameAs(Path otherFile) {
        return this.file.equals(otherFile);
    }

}

Using a static import of the is method, we can expressively formulate the test of whether the requested file is the same as that which is configured in the Permission. This takes place in the PermissionForTheFileItself class:

class PermissionForTheFileItself extends BaseFilePermission {

    public PermissionForTheFileItself(Path file, FileOperation operation) {
        super(file, operation);
    }

    @Override
    protected boolean accessIsAllowed(Path requestedFile) {
        return is(requestedFile).theSameAs(ownFile);
    }
}

In order to try all of this out, we assign the new Permission to the user in the Realm:

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    authorizationInfo.addObjectPermissions(createFilePermissions());
    return authorizationInfo;
}

private Collection<Permission> createFilePermissions() {
    return Arrays.asList(
            new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ));
}

This way, the user has reading access to the file departments/development/employee_987. Writing access and access to other files is not possible. For a demonstration, please refer to the test class Demo.java which can be found in the GitHub repository mentioned at the beginning.

The reading authorization for all superordinate files in the tree is also implemented in a similarly simply manner:

public class ReadPermissionForAncestors extends BaseFilePermission {

    public ReadPermissionForDirectAncestors(Path file) {
        super(file, FileOperation.READ);
    }

    @Override
    protected boolean accessIsAllowed(Path requestedFile) {
        return is(ownFile).descendantOf(requestedFile);
    }
}

If we assign the permission woth the same file to the user in the Realm,

private Collection<Permission> createFilePermissions() {
    return Arrays.asList(
            new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ),
            new ReadPermissionForDirectAncestors(filePath("departments/development/employee_987")));
}

then reading access is possible to directories above the file employee_987.

In the same manner, we can implement the third and last Permission – the one which allows access to all the subordinate files in a directory:

class PermissionForAllDescendantFiles extends BaseFilePermission {

    public PermissionForAllDescendantFiles(Path file, FileOperation operation) {
        super(file, operation);
    }

    @Override
    protected boolean accessIsAllowed(Path requestedFile) {
       return is(requestedFile).descendantOf(ownFile);
    }

}

With the assignment in the realm,

private Collection<Permission> createFilePermissions() {
    return Arrays.asList(
            new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ),
            new ReadPermissionForAncestors(filePath("departments/development/employee_987")),
            new PermissionForTheFileItself(filePath("departments/finance"), FileOperation.WRITE),
            new ReadPermissionForAncestors(filePath("departments/finance")),
            new PermissionForAllDescendantFiles(filePath("departments/finance"), FileOperation.WRITE));
}

we now have access to all files under departments/finance.

Conclusion

This article shows how any authorizations can be implemented with Apache Shiro using the simple Permission interface. Instead of implementing one complex Permission which covers every case, practice has shown that it is preferable to distribute the tests among multiple Permissions. This is how fine-grained authorizations can be assigned in the Realm.

Note, however, that Shiro always calls every Permission a user has until one is found which allows the requested access. Expensive operations such as data base accesses should therefore be avoided when possible or be reduced with the use of caching. The file access demonstrated here could also have a negative effect on performance if large file hierarchies are searched through or if the issue is a remote file system. Such cases lend themselves to executing the extensive operations only once before testing the authorizations. In this example, the structure of the file tree could, e.g., be loaded in advance and kept in the memory instead of navigating within the file system for every request.

The question of how a user has access to their authorizations is not handled in this article. In general, what a user is allowed to do is stored in a data base with a role and authorization concept. When Permissions are being assigned in the Realm, this information is used to generate the appropriate Permissions. Caution is advised here as well: If nothing else is done, Shiro asks for the Permissions a user has every time a test regarding rights is executed. This could mean an unnecessary burden. In such cases, the use of Caching API provided by Shiro is advisable.

Share this article

Daniel Behrwind
Software Development
As a passionate software developer and clean code advocate, he is fascinated by creative solutions for complex problems which appear so obvious that they may have simply emerged all on their own.