Building a Flexible Permission System

tags

In the main article Securing Spring Boot Applications - From Requirements to Implementation, we introduced how our Construction Project Diary system uses authorities to control access. Now, let’s dive deeper into how we designed and implemented these authorities to create a flexible, maintainable, and type-safe permission system.

Understanding Permission Architecture

When designing a security system, we need to balance complexity with flexibility. We want to be able to express all our security requirements whilst not overwhelming the administrators managing the roles and authorities.

Our solution addresses these challenges through a hierarchical system of permissions, built from three main components: Permission Categories, Permission Scopes, and the final Permissions themselves. Let’s examine each of these components and understand how they work together.

Permission Scopes: Actions and Hierarchies

Defining Scopes means defining what actions users can perform. Think of scopes as verbs in our security language - they represent operations like “read,” “write,” or more specific actions like “close” or “sign.” However, these actions often have natural relationships with each other. For instance, if someone can write to a document, they typically should also be able to read it.

public interface PermissionScope {  
  
    String name();  
    PermissionScope getParent();  
  
}

To represent these relationships, we implemented a hierarchical scope system. Each scope can have a parent scope, creating natural permission inheritance. This hierarchy serves several purposes:

  1. It enforces logical relationships between permissions
  2. It reduces redundancy in permission assignments
  3. It makes it easier to understand and manage permission structures
@Getter  
@RequiredArgsConstructor  
public enum DomainPermissionScope implements PermissionScope {  
  
    READ(null),  
        LIST(READ),  
        DETAIL(READ),  
    WRITE(null),  
        CREATE(WRITE),  
        UPDATE(WRITE),  
        DELETE(WRITE);  
  
    private final PermissionScope parent;  
  
}

For example, in our domain-specific scopes, we define basic READ and WRITE operations as root-level scopes. More specific operations like LIST and DETAIL inherit from READ, while CREATE and UPDATE inherit from WRITE. This mirrors how we naturally think about these operations - listing items is a type of reading, and creating items is a type of writing.

Permission Categories: Organizing by Domain

While scopes define what can be done, categories define where these actions apply. Categories represent different modules or domains in our application, such as journals, entries, or user management. Like scopes, categories can also be hierarchical. For example, the “Journal Entry” category might have sub-categories for specific types of entries like “BOZP” (safety) or “Timesheet.”

This hierarchical structure allows us to:

  1. Organize permissions logically by application domain
  2. Group related permissions together
  3. Apply permissions at different levels of granularity
@Getter  
@RequiredArgsConstructor  
public enum PermissionCategory implements LabeledEnum<PermissionCategory> {  
  
    PRIMARY_JOURNAL("Hlavný denník"),  
        USER_ASSIGNMENT_PRIMARY_JOURNAL(PRIMARY_JOURNAL, "Priradenie osôb k hlavnému denníku"),  
  
    JOURNAL_ENTRY("Denný záznam"),  
        JOURNAL_ENTRY_BOZP(JOURNAL_ENTRY, "BOZP"),  
        JOURNAL_ENTRY_MECHANISM(JOURNAL_ENTRY, "Mechanizmus denného záznamu"),  
        JOURNAL_ENTRY_TIMESHEET(JOURNAL_ENTRY, "Odpracované hodiny"),  
    
    PROFESSION("Profesia"),  
    
    USER("Užívateľ"),  
    ROLE("Rola"),  
    
    private PermissionCategory parent = null;  
    private final String label;  
  
    PermissionCategory(PermissionCategory parent, String label) {  
        this.parent = parent;  
        this.label = label;  
    }  
}

Bringing It All Together: The Permission Enum

The final piece of our permission system combines categories and scopes into concrete permissions. Each permission represents a specific action that can be performed in a specific part of the system. By using an enum to define these permissions, we gain several advantages:

  1. Compile-time safety - we can’t accidentally reference permissions that don’t exist
  2. IDE support - we get auto-completion when working with permissions
  3. Documentation - we can attach human-readable labels and descriptions to each permission
  4. Type safety - the compiler ensures we’re using permissions correctly
@Getter  
@AllArgsConstructor  
public enum Permission implements LabeledEnum<Permission> {  
  
    PRIMARY_JOURNAL_LIST(PRIMARY_JOURNAL, LIST, "Prehľad hlavných denníkov", "Oprávnenie umožňujúce zobraziť prehľad hlavných denníkov"),  
    PRIMARY_JOURNAL_READ_ASSIGNED(PRIMARY_JOURNAL, PrimaryJournalPermissionScope.READ_ASSIGNED, "Prehľad priradených hlavných denníkov", "Doplňujúce oprávnenie obmedzujúce prehľad hlavných denníkov len na svoje hlavné denníky"),  
    PRIMARY_JOURNAL_DETAIL(PRIMARY_JOURNAL, DETAIL, "Detail hlavného denníka", "Oprávnenie umožňujúce zobraziť detail hlavného denníka"),
    // ...
 
	private final PermissionCategory category;  
	private final PermissionScope scope;  
	private final String label;  
	private final String description;
	  
	Permission(PermissionCategory category, PermissionScope scope, String label, String description) {  
	    this(category, scope, label, description);  
	}
}

The standard format of representing the permissions is defined in the article Scope Best Practices, where the format is represented as a string, with a format module:scope such as “primary-journal:list”

Design Considerations and Trade-offs

While this design has served us well, it’s worth noting some of the trade-offs we made:

  1. Flexibility vs. Complexity: A hierarchical system is more complex than a flat list of permissions, but the benefits of proper permission inheritance outweigh this complexity.
  2. Enum vs. String-based Permissions: Using enums gives us compile-time safety but makes it harder to add new permissions dynamically. For our use case, the safety benefits were more important than runtime flexibility.
  3. Granularity: We chose to create fairly fine-grained permissions (like separating LIST from DETAIL) rather than broader permissions. This gives us more control but requires more careful permission management.

In the next article, we’ll explore how we use these permissions to secure our endpoints and ensure comprehensive security coverage across our application.

References

Defining Scopes Scope Best Practices