This document presents a production-ready low-level design for an authorization service that implements both role-based and attribute-based access control models, providing a scalable foundation for managing fine-grained permissions across distributed systems.
1. Problem Statement & Requirements
Clarifying questions
- What scale expectations are there for this authorization service? Will it handle millions of concurrent requests and thousands of users/roles?
- What consistency requirements exist for authorization decisions? Should they be strongly consistent or eventual consistency acceptable?
- What are the key entities in the system and their relationships? Specifically, how do users, roles, permissions, and resources relate to each other?
- What's the read/write ratio for authorization checks? Is it predominantly reads (e.g., 95% of traffic) or more balanced?
- What latency sensitivity do we have for authorization decisions? For example, should all checks complete under 50ms?
- What authentication model is in place? Is it JWT-based, OAuth2, or session-based?
- Are there any external systems that this authorization service integrates with? For example, SSO providers or identity management systems.
Functional requirements
- User Role Assignment: Assign users to roles and manage role hierarchies.
- Permission Management: Define, update, and delete permissions for roles.
- Resource-Based Access Control: Support fine-grained access control based on resource types and identifiers.
- ABAC Support: Evaluate policies based on user attributes, resource attributes, and environment context.
- Authorization Decision Engine: Make authorization decisions for incoming requests.
- Audit Logging: Log all authorization decisions with context and outcome.
- Policy Management API: CRUD operations for managing ABAC policies.
Non-functional requirements
- Latency Targets:
- p50: < 20ms
- p95: < 50ms
- p99: < 100ms
- Throughput: Support at least 50,000 authorization checks per second.
- Availability: 99.9% uptime (5 minutes of downtime/month).
- Scalability: Support horizontal scaling to handle peak loads.
- Security: All authorization decisions must be auditable and tamper-proof.
Constraints and assumptions
- Assumption: The system will be used within a single organization or application ecosystem.
- Assumption: Users and roles are managed outside this service (e.g., via an identity provider).
- Constraint: Authorization decisions must be made within 100ms to avoid impacting user experience.
- Constraint: The service will be deployed in a microservices architecture with multiple clients.
- Assumption: All authorization policies can fit into memory for fast evaluation.
Out of scope
- User/Role Management: Creating or deleting users and roles outside the scope of this service.
- Identity Provider Integration: Authentication integration (e.g., SAML, OAuth2) is out of scope.
- Policy Language Implementation: Full policy engine implementation (e.g., XACML) is out of scope.
- Cross-service Authorization: Authorization across different microservices or domains.
- Real-time Policy Updates: Immediate propagation of policy changes to all instances is not required.
2. API Design
API Style Selection
REST is selected for this authorization service due to its simplicity, wide adoption, and clear semantics. It provides well-defined HTTP methods (GET, POST, PUT, DELETE) that align naturally with CRUD operations on authorization resources. REST also enables better tooling support and easier debugging compared to GraphQL or gRPC.
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /v1/roles | Create a new role with permissions | JWT + Admin |
| GET | /v1/roles/{roleId} | Retrieve role details | JWT + Admin |
| PUT | /v1/roles/{roleId} | Update role permissions | JWT + Admin |
| DELETE | /v1/roles/{roleId} | Delete a role | JWT + Admin |
| POST | /v1/users/{userId}/roles | Assign role to user | JWT + Admin |
| DELETE | /v1/users/{userId}/roles/{roleId} | Remove role from user | JWT + Admin |
| POST | /v1/permissions | Create new permission | JWT + Admin |
| GET | /v1/permissions/{permissionId} | Retrieve permission details | JWT + Admin |
| GET | /v1/users/{userId}/permissions | List user's permissions | JWT + User |
| GET | /v1/users/{userId}/roles | List user's roles | JWT + User |
| POST | /v1/evaluate | Evaluate if user has permission | JWT + User |
| GET | /v1/policies | List all ABAC policies | JWT + Admin |
Request and Response Schemas
Create Role Request
{
"name": "admin",
"description": "Administrator role with full access",
"permissions": ["read:user", "write:user", "delete:user"]
}
Create Role Response
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "admin",
"description": "Administrator role with full access",
"permissions": ["read:user", "write:user", "delete:user"],
"createdAt": "2023-10-01T10:00:00Z",
"updatedAt": "2023-10-01T10:00:00Z"
}
Evaluate Permission Request
{
"userId": "user-123",
"permission": "read:user"
}
Evaluate Permission Response
{
"userId": "user-123",
"permission": "read:user",
"granted": true,
"reason": "User has role admin which includes read:user permission"
}
HTTP Status Codes and Error Format
Standard HTTP status codes will be used:
- 200 OK - Successful GET, PUT, PATCH
- 201 Created - Successful POST
- 400 Bad Request - Invalid request body or parameters
- 401 Unauthorized - Missing or invalid auth token
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource does not exist
- 500 Internal Server Error - Unexpected server error
Standardized error response:
{
"error": {
"code": "INVALID_INPUT",
"message": "Permission name must be unique and not empty",
"details": {
"field": "name",
"value": ""
}
}
}
API Versioning Strategy
Versioned using path prefix (/v1/) to ensure backward compatibility and allow for future breaking changes. This approach is simple, explicit, and widely adopted in FAANG services.
Pagination Design
Cursor-based pagination will be used for listing endpoints to handle large datasets efficiently and provide stable results even when data changes between requests.
Authentication and Authorization
All endpoints will require JWT tokens for authentication. Admin-level access (creating roles, assigning permissions) will require a special "admin" scope in the token. User-level access (checking permissions) can be granted to any authenticated user.
Rate Limiting
- 1000 requests per minute for admin endpoints
- 5000 requests per minute for user permission check endpoints
- Rate limit enforced via Redis with token bucket algorithm
Idempotency Design
Mutation endpoints will support idempotency keys via X-Idempotency-Key header:
POST /v1/roles HTTP/1.1
X-Idempotency-Key: user-123-role-admin-uuid
If the same key is used, the system will return the previous response without re-executing the operation.
3. Data Model Design
Entity-Relationship Diagram
erDiagram
USER ||--o{ USER_ROLE : assigns
ROLE ||--o{ USER_ROLE : grants
ROLE ||--o{ ROLE_PERMISSION : contains
PERMISSION ||--o{ ROLE_PERMISSION : grants
USER ||--o{ USER_PERMISSION : inherits
USER_PERMISSION ||--o{ USER_PERMISSION_ASSIGNMENT : applies
ROLE ||--o{ ROLE_ASSIGNMENT : assigns
USER ||--o{ ROLE_ASSIGNMENT : gets
RESOURCE ||--o{ PERMISSION : protects
USER ||--o{ USER_ATTRIBUTE : has
ATTRIBUTE ||--o{ USER_ATTRIBUTE : describes
ROLE ||--o{ ROLE_ATTRIBUTE : has
ATTRIBUTE ||--o{ ROLE_ATTRIBUTE : describes
USER ||--o{ USER_SESSION : maintains
USER {
string id PK
string email
string status
timestamp created_at
timestamp updated_at
}
ROLE {
string id PK
string name
string description
timestamp created_at
timestamp updated_at
}
PERMISSION {
string id PK
string name
string resource
string action
timestamp created_at
timestamp updated_at
}
RESOURCE {
string id PK
string name
string type
timestamp created_at
timestamp updated_at
}
ATTRIBUTE {
string id PK
string name
string value_type
timestamp created_at
timestamp updated_at
}
USER_ROLE {
string user_id FK
string role_id FK
timestamp created_at
}
ROLE_PERMISSION {
string role_id FK
string permission_id FK
timestamp created_at
}
USER_PERMISSION {
string user_id FK
string permission_id FK
timestamp created_at
}
USER_PERMISSION_ASSIGNMENT {
string user_permission_id FK
string resource_id FK
string attribute_value
timestamp created_at
}
ROLE_ASSIGNMENT {
string user_id FK
string role_id FK
timestamp created_at
}
USER_ATTRIBUTE {
string user_id FK
string attribute_id FK
string value
timestamp created_at
}
ROLE_ATTRIBUTE {
string role_id FK
string attribute_id FK
string value
timestamp created_at
}
USER_SESSION {
string user_id FK
string session_id PK
timestamp created_at
timestamp expires_at
}
Table / Collection Schema Definitions
Users Table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_users_email (email),
INDEX idx_users_status (status)
);
Roles Table
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_roles_name (name)
);
Permissions Table
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
resource VARCHAR(255),
action VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_permissions_resource_action (resource, action),
INDEX idx_permissions_name (name)
);
Resources Table
CREATE TABLE resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
type VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_resources_name (name),
INDEX idx_resources_type (type)
);
Attributes Table
CREATE TABLE attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
value_type VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_attributes_name (name)
);
User-Roles Assignment Table
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
INDEX idx_user_roles_user_id (user_id),
INDEX idx_user_roles_role_id (role_id)
);
Role-Permissions Assignment Table
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (role_id, permission_id),
INDEX idx_role_permissions_role_id (role_id),
INDEX idx_role_permissions_permission_id (permission_id)
);
User-Permissions Assignment Table
CREATE TABLE user_permissions (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, permission_id),
INDEX idx_user_permissions_user_id (user_id),
INDEX idx_user_permissions_permission_id (permission_id)
);
User-Resource Attribute Assignment Table
CREATE TABLE user_resource_attributes (
user_permission_id UUID REFERENCES user_permissions(id) ON DELETE CASCADE,
resource_id UUID REFERENCES resources(id) ON DELETE CASCADE,
attribute_value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_permission_id, resource_id),
INDEX idx_user_resource_attributes_user_permission_id (user_permission_id),
INDEX idx_user_resource_attributes_resource_id (resource_id)
);
Role-Attribute Assignment Table
CREATE TABLE role_attributes (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
attribute_id UUID REFERENCES attributes(id) ON DELETE CASCADE,
value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (role_id, attribute_id),
INDEX idx_role_attributes_role_id (role_id),
INDEX idx_role_attributes_attribute_id (attribute_id)
);
User-Attribute Assignment Table
CREATE TABLE user_attributes (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
attribute_id UUID REFERENCES attributes(id) ON DELETE CASCADE,
value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, attribute_id),
INDEX idx_user_attributes_user_id (user_id),
INDEX idx_user_attributes_attribute_id (attribute_id)
);
User Sessions Table
CREATE TABLE user_sessions (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
session_id VARCHAR(255) PRIMARY KEY,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_sessions_user_id (user_id),
INDEX idx_user_sessions_expires_at (expires_at)
);
Primary Key Strategy
UUID is selected as the primary key strategy for all tables due to:
- Global uniqueness across distributed systems
- No need for coordination between services when generating IDs
- Better performance in distributed environments compared to auto-increment
- Security benefits by preventing enumeration attacks
Index Design
| Table | Index Name | Fields | Type | Justification |
|---|---|---|---|---|
| users | idx_users_email | Single | For fast user lookups by email | |
| users | idx_users_status | status | Single | For filtering active/inactive users |
| roles | idx_roles_name | name | Single | For fast role lookups by name |
| permissions | idx_permissions_resource_action | resource, action | Composite | For efficient permission checks by resource and action |
| permissions | idx_permissions_name | name | Single | For fast permission lookups by name |
| resources | idx_resources_name | name | Single | For fast resource lookups |
| resources | idx_resources_type | type | Single | For filtering by resource type |
| attributes | idx_attributes_name | name | Single | For fast attribute lookups |
| user_roles | idx_user_roles_user_id | user_id | Single | For fast role lookups by user |
| user_roles | idx_user_roles_role_id | role_id | Single | For fast user lookups by role |
| role_permissions | idx_role_permissions_role_id | role_id | Single | For fast permission lookups by role |
| role_permissions | idx_role_permissions_permission_id | permission_id | Single | For fast role lookups by permission |
| user_permissions | idx_user_permissions_user_id | user_id | Single | For fast permission lookups by user |
| user_permissions | idx_user_permissions_permission_id | permission_id | Single | For fast user lookups by permission |
| user_resource_attributes | idx_user_resource_attributes_user_permission_id | user_permission_id | Single | For fast resource attribute lookups |
| user_resource_attributes | idx_user_resource_attributes_resource_id | resource_id | Single | For fast permission lookups by resource |
| role_attributes | idx_role_attributes_role_id | role_id | Single | For fast attribute lookups by role |
| role_attributes | idx_role_attributes_attribute_id | attribute_id | Single | For fast role lookups by attribute |
| user_attributes | idx_user_attributes_user_id | user_id | Single | For fast attribute lookups by user |
| user_attributes | idx_user_attributes_attribute_id | attribute_id | Single | For fast user lookups by attribute |
| user_sessions | idx_user_sessions_user_id | user_id | Single | For fast session lookups by user |
| user_sessions | idx_user_sessions_expires_at | expires_at | Single | For efficient session cleanup |
Data Partitioning / Sharding Strategy
Partitioning will be implemented using sharding by user ID:
- Hash-based sharding on the user ID to distribute users evenly across shards
- Each shard will contain a subset of users, their roles, permissions, and attributes
- Cross-shard queries will be handled via a centralized authorization gateway that routes requests to appropriate shards
Soft Delete vs. Hard Delete Decision
Soft delete is chosen for the following reasons:
- Auditability: Historical access patterns and role assignments need to be preserved
- Recovery capability: In case of accidental deletions, data can be recovered
- Compliance: Regulatory requirements often mandate retention of audit trails
- Data integrity: Prevents cascading deletion issues in complex role hierarchies
Audit Trail Design
All data modification operations will be logged in an audit trail table:
CREATE TABLE audit_trail (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operation VARCHAR(20) NOT NULL, -- CREATE, UPDATE, DELETE
table_name VARCHAR(100) NOT NULL,
record_id UUID NOT NULL,
user_id UUID REFERENCES users(id),
changes JSONB,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_trail_timestamp (timestamp),
INDEX idx_audit_trail_user_id (user_id),
INDEX idx_audit_trail_table_name (table_name)
);
Data Archival Strategy and Retention Policy
- Active data: Users, roles, permissions, assignments remain in primary storage
- Audit logs: Retained for 7 years (compliance requirement)
- Session data: Retained for 30 days (security and analytics)
- Archival process: Daily batch job to move audit logs older than 7 years to cold storage (e.g., AWS Glacier)
- Index maintenance: Periodic reindexing of archived tables to maintain query performance
4. Class Design & Service Layer
classDiagram
class AuthorizationService {
+authorize(user, resource, action) boolean
+hasPermission(user, resource, action) boolean
+getPermissions(user, resource) Set~Permission~
+assignRole(user, role) void
+revokeRole(user, role) void
+assignPermission(role, permission) void
+revokePermission(role, permission) void
}
class RBACAuthorizationService {
+authorize(user, resource, action) boolean
+hasPermission(user, resource, action) boolean
+getPermissions(user, resource) Set~Permission~
}
class ABACAuthorizationService {
+authorize(user, resource, action) boolean
+hasPermission(user, resource, action) boolean
+getPermissions(user, resource) Set~Permission~
}
class Permission {
-id: UUID
-name: String
-description: String
-resource: String
-action: String
+equals() boolean
+hashCode() int
}
class Role {
-id: UUID
-name: String
-description: String
-permissions: Set~Permission~
+equals() boolean
+hashCode() int
}
class User {
-id: UUID
-username: String
-email: String
-roles: Set~Role~
+equals() boolean
+hashCode() int
}
class Resource {
-id: UUID
-name: String
-type: String
-attributes: Map~String, Object~
+equals() boolean
+hashCode() int
}
class Policy {
-id: UUID
-name: String
-description: String
-conditions: List~Condition~
-effect: Effect
}
class Condition {
-attribute: String
-operator: Operator
-value: Object
}
class AuthorizationContext {
-user: User
-resource: Resource
-action: String
-requestTime: Instant
}
class PolicyEvaluator {
+evaluate(policy, context) boolean
}
class PermissionResolver {
+resolvePermissions(user, resource) Set~Permission~
}
AuthorizationService <|-- RBACAuthorizationService
AuthorizationService <|-- ABACAuthorizationService
RBACAuthorizationService --> User : "user"
RBACAuthorizationService --> Role : "roles"
RBACAuthorizationService --> Permission : "permissions"
ABACAuthorizationService --> User : "user"
ABACAuthorizationService --> Resource : "resource"
ABACAuthorizationService --> Policy : "policies"
PermissionResolver --> User : "user"
PermissionResolver --> Role : "roles"
PermissionResolver --> Permission : "permissions"
PolicyEvaluator --> Policy : "policy"
PolicyEvaluator --> AuthorizationContext : "context"
stateDiagram-v2
[*] --> Idle
Idle --> CheckingPermission : checkPermission()
CheckingPermission --> Granted : Permission granted
CheckingPermission --> Denied : Permission denied
Granted --> [*]
Denied --> [*]
Service Class Structure and Responsibilities
The authorization service follows a layered architecture with clear separation of concerns:
- AuthorizationService - Main interface defining authorization operations
- RBACAuthorizationService - Implements Role-Based Access Control logic
- ABACAuthorizationService - Implements Attribute-Based Access Control logic
- PermissionResolver - Resolves effective permissions for a user/resource combination
- PolicyEvaluator - Evaluates ABAC policies against authorization context
Interface and Abstract Class Design
public interface AuthorizationService {
boolean authorize(User user, Resource resource, String action);
boolean hasPermission(User user, Resource resource, String action);
Set<Permission> getPermissions(User user, Resource resource);
void assignRole(User user, Role role);
void revokeRole(User user, Role role);
void assignPermission(Role role, Permission permission);
void revokePermission(Role role, Permission permission);
}
public abstract class AbstractAuthorizationService implements AuthorizationService {
protected final PermissionResolver permissionResolver;
protected final PolicyEvaluator policyEvaluator;
public AbstractAuthorizationService(PermissionResolver resolver, PolicyEvaluator evaluator) {
this.permissionResolver = resolver;
this.policyEvaluator = evaluator;
}
@Override
public boolean hasPermission(User user, Resource resource, String action) {
return getPermissions(user, resource).stream()
.anyMatch(p -> p.getAction().equals(action));
}
}
Business Logic Encapsulation Patterns
Strategy Pattern Implementation:
public interface AuthorizationStrategy {
boolean authorize(AuthorizationContext context);
}
public class RBACAuthorizationStrategy implements AuthorizationStrategy {
private final PermissionResolver permissionResolver;
public RBACAuthorizationStrategy(PermissionResolver resolver) {
this.permissionResolver = resolver;
}
@Override
public boolean authorize(AuthorizationContext context) {
Set<Permission> permissions = permissionResolver.resolvePermissions(
context.getUser(),
context.getResource()
);
return permissions.stream()
.anyMatch(p -> p.getAction().equals(context.getAction()));
}
}
public class ABACAuthorizationStrategy implements AuthorizationStrategy {
private final PolicyEvaluator policyEvaluator;
public ABACAuthorizationStrategy(PolicyEvaluator evaluator) {
this.policyEvaluator = evaluator;
}
@Override
public boolean authorize(AuthorizationContext context) {
// Evaluate all applicable policies for the resource and action
return policyEvaluator.evaluateAll(context);
}
}
Critical-Path Method Implementation
public class RBACAuthorizationService extends AbstractAuthorizationService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final EventPublisher eventPublisher;
private final TransactionManager transactionManager;
public RBACAuthorizationService(
UserRepository userRepository,
RoleRepository roleRepository,
PermissionRepository permissionRepository,
EventPublisher eventPublisher,
TransactionManager transactionManager) {
super(new PermissionResolverImpl(roleRepository, permissionRepository),
new PolicyEvaluatorImpl());
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.permissionRepository = permissionRepository;
this.eventPublisher = eventPublisher;
this.transactionManager = transactionManager;
}
public boolean authorize(User user, Resource resource, String action) {
// Transaction boundary - all operations in single transaction
return transactionManager.executeInTransaction(() -> {
// Validate inputs
if (user == null || resource == null || action == null) {
throw new IllegalArgumentException("User, resource and action must not be null");
}
// Fetch user with roles and permissions (optimized eager load)
User userWithRoles = userRepository.findByIdWithRoles(user.getId());
if (userWithRoles == null) {
return false;
}
// Check direct permissions first (fast path)
Set<Permission> effectivePermissions = permissionResolver.resolvePermissions(
userWithRoles, resource);
boolean hasPermission = effectivePermissions.stream()
.anyMatch(p -> p.getAction().equals(action));
// Log authorization decision
AuthorizationEvent event = new AuthorizationEvent(
userWithRoles.getId(),
resource.getId(),
action,
hasPermission,
Instant.now()
);
eventPublisher.publish(event);
return hasPermission;
});
}
public void assignRole(User user, Role role) {
transactionManager.executeInTransaction(() -> {
// Validate that user and role exist
User existingUser = userRepository.findById(user.getId());
Role existingRole = roleRepository.findById(role.getId());
if (existingUser == null || existingRole == null) {
throw new ResourceNotFoundException("User or Role not found");
}
// Add role to user (with locking to prevent race conditions)
User userWithLock = userRepository.lockById(user.getId());
userWithLock.addRole(existingRole);
// Update in database
userRepository.save(userWithLock);
// Publish role assignment event for audit trail
RoleAssignmentEvent event = new RoleAssignmentEvent(
userWithLock.getId(),
existingRole.getId(),
Instant.now()
);
eventPublisher.publish(event);
});
}
}
Transaction Boundary Design
All authorization operations that modify state are wrapped in single database transactions to ensure atomicity:
- Role Assignment: User update + event publishing in single transaction
- Permission Check: Read operations with consistent snapshot isolation
- Policy Evaluation: Read-only operations that don't require transaction boundaries
Service Interface Contracts
public interface AuthorizationService {
/**
* Main authorization check - returns true if user has permission for resource/action
*/
boolean authorize(User user, Resource resource, String action);
/**
* Fast path check - returns true if user has specific permission
*/
boolean hasPermission(User user, Resource resource, String action);
/**
* Returns all effective permissions for a user/resource combination
*/
Set<Permission> getPermissions(User user, Resource resource);
/**
* Assigns a role to a user - requires admin privileges
*/
void assignRole(User user, Role role);
/**
* Revokes a role from a user - requires admin privileges
*/
void revokeRole(User user, Role role);
/**
* Assigns a permission to a role - requires admin privileges
*/
void assignPermission(Role role, Permission permission);
/**
* Revokes a permission from a role - requires admin privileges
*/
void revokePermission(Role role, Permission permission);
}
Saga / Orchestration Pattern for Distributed Transactions
For distributed authorization systems, we implement a saga pattern:
public class AuthorizationSaga {
private final List<SagaStep> steps;
public void executeAuthorizationSaga(AuthorizationRequest request) {
try {
for (SagaStep step : steps) {
step.execute();
}
} catch (Exception e) {
// Compensate all previous steps
compensateSteps();
throw new AuthorizationException("Authorization failed", e);
}
}
private void compensateSteps() {
// Reverse the order of executed steps and call compensate()
for (int i = steps.size() - 1; i >= 0; i--) {
steps.get(i).compensate();
}
}
}
Domain Object / Value Object Design
public class User {
private final UUID id;
private final String username;
private final String email;
private final Set<Role> roles;
// Constructor, getters, equals, hashCode
}
public class Role {
private final UUID id;
private final String name;
private final String description;
private final Set<Permission> permissions;
// Constructor, getters, equals, hashCode
}
public class Permission {
private final UUID id;
private final String name;
private final String description;
private final String resource;
private final String action;
// Constructor, getters, equals, hashCode
}
public class Resource {
private final UUID id;
private final String name;
private final String type;
private final Map<String, Object> attributes;
// Constructor, getters, equals, hashCode
}
public class AuthorizationContext {
private final User user;
private final Resource resource;
private final String action;
private final Instant requestTime;
// Constructor, getters
}
5. Concurrency & Thread Safety
Concurrency Model
The authorization service will use a thread-safe, single-threaded event loop model for processing requests. This approach is chosen to minimize contention and simplify state management, particularly for concurrent access to permission checks. Each request will be handled by a dedicated thread from a fixed-size thread pool, ensuring predictable resource usage and avoiding thread starvation.
Race Condition Prevention Strategy
Race conditions in authorization checks will be prevented through:
- Immutable data structures - All permission and policy objects are immutable once created
- Read-locks on shared resources - When checking policies, read locks are acquired to prevent concurrent modifications
- Atomic operations for counters and metrics - Using atomic references for tracking authorization decisions
Optimistic vs. Pessimistic Locking Selection
Optimistic locking is selected for the authorization service because:
- Authorization checks are read-heavy operations with minimal write contention
- The system prioritizes throughput over strict consistency in permission evaluation
- Policy updates are infrequent and can be handled with conflict resolution
- The risk of concurrent policy modification is low since policies are typically updated by admin users in batches
Deadlock Prevention Strategy
Deadlock prevention is implemented through:
- Fixed ordering of resource acquisition - All lock acquisitions follow a consistent order (e.g., by policy ID, then role ID)
- Timeout-based lock acquisition - Locks are acquired with a 50ms timeout to prevent indefinite blocking
- Hierarchical lock management - Locks are organized in a hierarchy to prevent circular dependencies
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is handled at the service layer by:
- Request deduplication using a cache - Requests with identical parameters are cached with TTL of 10 seconds
- Idempotency key validation - Clients can provide an idempotency key in the request header that is used to identify and deduplicate requests
- Atomic operation boundaries - Authorization decisions are wrapped in atomic operations to ensure consistent state
public class AuthorizationService {
private final Map<String, AuthorizationResult> requestCache = new ConcurrentHashMap<>();
private final ReadWriteLock policyLock = new ReentrantReadWriteLock();
public AuthorizationResult checkPermission(
String userId,
String resource,
String action,
String idempotencyKey) {
// Idempotency check
if (idempotencyKey != null) {
AuthorizationResult cached = requestCache.get(idempotencyKey);
if (cached != null) {
return cached;
}
}
// Acquire read lock for policy access
policyLock.readLock().lock();
try {
// Perform authorization check
AuthorizationResult result = performAuthorizationCheck(userId, resource, action);
// Cache result for idempotency
if (idempotencyKey != null) {
requestCache.put(idempotencyKey, result);
// Remove from cache after TTL
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.schedule(() -> requestCache.remove(idempotencyKey), 10, TimeUnit.SECONDS);
}
return result;
} finally {
policyLock.readLock().unlock();
}
}
private AuthorizationResult performAuthorizationCheck(String userId, String resource, String action) {
// Implementation details for policy evaluation
return new AuthorizationResult(true); // Simplified for example
}
}
6. Sequence Diagrams for Key Flows
Happy Path Sequence Diagram
sequenceDiagram
participant Client
participant AuthzService
participant AuthService
participant DB
Client->>AuthzService: Request with JWT token
AuthzService->>AuthService: Validate token
AuthService-->>AuthzService: Token valid, user_id=123
AuthzService->>DB: Fetch user roles and permissions
DB-->>AuthzService: Roles: [admin, editor], Permissions: [read, write]
AuthzService->>DB: Fetch resource policies (ABAC)
DB-->>AuthzService: Policy: user.department == resource.department
AuthzService->>DB: Fetch resource details (if needed)
DB-->>AuthzService: Resource: {department: "engineering"}
AuthzService->>AuthzService: Evaluate RBAC + ABAC rules
AuthzService-->>Client: 200 OK / 403 Forbidden
Error / Edge Case Sequence Diagram
sequenceDiagram
participant Client
participant AuthzService
participant AuthService
participant DB
Client->>AuthzService: Request with invalid token
AuthzService->>AuthService: Validate token
AuthService-->>AuthzService: Token invalid, throw exception
AuthzService-->>Client: 401 Unauthorized
Client->>AuthzService: Request with valid token but no permissions
AuthzService->>AuthService: Validate token
AuthService-->>AuthzService: Token valid, user_id=123
AuthzService->>DB: Fetch user roles and permissions
DB-->>AuthzService: Roles: [viewer], Permissions: [read]
AuthzService->>DB: Fetch resource policies (ABAC)
DB-->>AuthzService: Policy: user.department == resource.department
AuthzService->>DB: Fetch resource details (if needed)
DB-->>AuthzService: Resource: {department: "marketing"}
AuthzService->>AuthzService: Evaluate RBAC + ABAC rules
AuthzService-->>Client: 403 Forbidden
Client->>AuthzService: Request with expired token
AuthzService->>AuthService: Validate token
AuthService-->>AuthzService: Token expired, throw exception
AuthzService-->>Client: 401 Unauthorized
Async Flow Sequence Diagram
sequenceDiagram
participant Client
participant AuthzService
participant EventPublisher
participant EventConsumer
participant DB
Client->>AuthzService: Create user request
AuthzService->>DB: Create user in DB
DB-->>AuthzService: User created
AuthzService->>EventPublisher: Publish user.created event
EventPublisher-->>AuthzService: Event published
AuthzService->>EventConsumer: Handle user.created event (assign roles)
EventConsumer->>DB: Assign default roles to user
DB-->>EventConsumer: Roles assigned
AuthzService-->>Client: 201 Created
7. Caching Design
Cache Strategy Selection
For the authorization service, we'll use a cache-aside pattern with LRU eviction policy. This approach provides the best balance between performance and consistency for authorization decisions, where freshness is more important than absolute cache hit rates.
Cache Key Design
Cache keys will follow a structured namespace pattern:
auth:{resource_type}:{resource_id}:{user_id}:{permission_name}
Example key for checking if user 123 has "read" permission on resource 456:
auth:document:456:123:read
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| User permissions | 5 minutes | Frequent updates during role changes |
| Role assignments | 10 minutes | Less frequent than permissions |
| Permission trees | 1 hour | Stable and expensive to compute |
| Policy evaluations | 30 seconds | High cardinality, low volatility |
Cache Eviction Policy
LRU (Least Recently Used) with probabilistic early expiry:
- Primary eviction: LRU-based
- Secondary: 10% probabilistic early expiry to prevent cache stampede
- Cache size: 100K entries per node
Cache Stampede Prevention
Implement probabilistic early expiry with 10% chance of expiring entries 2 minutes before TTL:
public class CacheStampedePrevention {
private static final double EXPIRY_PROBABILITY = 0.1;
public boolean shouldExpireEarly() {
return Math.random() < EXPIRY_PROBABILITY;
}
}
Distributed Cache Architecture
Using Redis Cluster with:
- 3 master nodes for high availability
- 3 replica nodes per master for read scaling
- Redis Sentinel for automatic failover
- Cache sharding by user ID hash to ensure even distribution
Cache Invalidation Strategy
Event-driven invalidation with explicit purging:
- Immediate invalidation: On role/permission changes
- Batch invalidation: Periodic cleanup of stale entries (every 5 minutes)
- Graceful degradation: Return cached value with warning header if cache is unavailable
@EventListener
public void handleRoleAssignmentChanged(RoleAssignmentChangedEvent event) {
String cacheKey = buildCacheKey(event.getResourceType(),
event.getResourceId(),
event.getUserId(),
event.getPermission());
cacheManager.evict(cacheKey);
}
Cache Read-Through Pattern
public boolean hasPermission(String userId, String resourceType,
String resourceId, String permission) {
String cacheKey = buildCacheKey(resourceType, resourceId, userId, permission);
Boolean cachedResult = cache.get(cacheKey);
if (cachedResult != null) {
return cachedResult;
}
// Cache miss - compute and store
boolean result = computePermission(userId, resourceType, resourceId, permission);
cache.put(cacheKey, result, TTL_FOR_PERMISSIONS);
return result;
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The authorization service follows the repository pattern to abstract data access logic and provide a clean interface for domain operations. Each entity has its own repository interface with standardized CRUD operations and specialized queries.
public interface UserRepository {
Optional<User> findById(UUID userId);
List<User> findAll();
User save(User user);
void delete(UUID userId);
List<User> findByRole(String role);
boolean existsById(UUID userId);
}
public interface RoleRepository {
Optional<Role> findById(UUID roleId);
List<Role> findAll();
Role save(Role role);
void delete(UUID roleId);
List<Role> findByUser(UUID userId);
Set<String> findRoleNamesByUser(UUID userId);
}
public interface PermissionRepository {
Optional<Permission> findById(UUID permissionId);
List<Permission> findAll();
Permission save(Permission permission);
void delete(UUID permissionId);
Set<String> findPermissionsByRole(UUID roleId);
}
public interface UserPermissionRepository {
void assignPermissionToUser(UUID userId, UUID permissionId);
void revokePermissionFromUser(UUID userId, UUID permissionId);
Set<UUID> findPermissionsByUser(UUID userId);
boolean hasPermission(UUID userId, String permissionName);
}
public interface RolePermissionRepository {
void assignPermissionToRole(UUID roleId, UUID permissionId);
void revokePermissionFromRole(UUID roleId, UUID permissionId);
Set<UUID> findPermissionsByRole(UUID roleId);
boolean hasPermission(UUID roleId, String permissionName);
}
ORM vs Raw Query Decisions
For the authorization service, we use JPA/Hibernate for most entities due to its maturity and ability to handle complex relationships, but we use raw SQL for performance-critical permission checks that involve large datasets.
@Repository
public class PermissionRepositoryImpl implements UserPermissionRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public boolean hasPermission(UUID userId, String permissionName) {
// Raw SQL for performance - avoids object mapping overhead
String sql = """
SELECT EXISTS (
SELECT 1 FROM user_permissions up
JOIN permissions p ON up.permission_id = p.id
WHERE up.user_id = :userId AND p.name = :permissionName
)
""";
Query query = entityManager.createNativeQuery(sql);
query.setParameter("userId", userId);
query.setParameter("permissionName", permissionName);
return ((Number) query.getSingleResult()).intValue() == 1;
}
// Other methods...
}
Read vs Write Separation
We implement CQRS pattern for the authorization service to separate read and write operations:
// Write side - for mutations (roles, permissions, assignments)
@Repository
public class AuthorizationWriteRepository {
@PersistenceContext
private EntityManager entityManager;
public void saveUser(User user) {
entityManager.persist(user);
}
public void assignRoleToUser(UUID userId, UUID roleId) {
// Implementation for role assignment
}
public void assignPermissionToRole(UUID roleId, UUID permissionId) {
// Implementation for permission assignment
}
}
// Read side - optimized for queries (user permissions, role hierarchies)
@Repository
public class AuthorizationReadRepository {
@PersistenceContext
private EntityManager entityManager;
public Set<String> getUserPermissions(UUID userId) {
// Optimized query for fetching user permissions
String sql = """
SELECT p.name FROM permissions p
JOIN user_permissions up ON p.id = up.permission_id
WHERE up.user_id = :userId
""";
Query query = entityManager.createNativeQuery(sql);
query.setParameter("userId", userId);
return query.getResultList().stream()
.map(String.class::cast)
.collect(Collectors.toSet());
}
public Set<String> getUserRoles(UUID userId) {
// Optimized query for fetching user roles
String sql = """
SELECT r.name FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = :userId
""";
Query query = entityManager.createNativeQuery(sql);
query.setParameter("userId", userId);
return query.getResultList().stream()
.map(String.class::cast)
.collect(Collectors.toSet());
}
}
Connection Pool Configuration
We configure connection pooling to handle concurrent authorization requests efficiently:
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
N+1 Query Prevention Strategy
We implement eager loading and batch fetching to prevent N+1 queries:
@Entity
@Table(name = "users")
@NamedEntityGraph(
name = "User.withRolesAndPermissions",
attributeNodes = @NamedAttributeNode(value = "roles", subgraph = "roles-subgraph"),
subgraphs = @NamedSubgraph(
name = "roles-subgraph",
attributeNodes = @NamedAttributeNode(value = "permissions", subgraph = "permissions-subgraph"),
subgraphs = @NamedSubgraph(
name = "permissions-subgraph",
attributeNodes = @NamedAttributeNode("name")
)
)
)
public class User {
@Id
private UUID id;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
// Other fields...
}
// Repository method that uses fetch joins to prevent N+1
@Repository
public class UserRepositoryImpl {
@PersistenceContext
private EntityManager entityManager;
public User findUserWithRolesAndPermissions(UUID userId) {
String jpql = """
SELECT u FROM User u
LEFT JOIN FETCH u.roles r
LEFT JOIN FETCH r.permissions p
WHERE u.id = :userId
""";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("userId", userId);
return query.getSingleResult();
}
}
Batch Operation Design
For bulk operations like role assignment, we implement batch processing:
@Repository
public class BatchRoleAssignmentRepository {
@PersistenceContext
private EntityManager entityManager;
public void assignRolesToUsers(List<UUID> userIds, List<UUID> roleIds) {
// Batch insert for role assignments
String sql = """
INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)
""";
try (Connection connection = entityManager.unwrap(Session.class).connection()) {
PreparedStatement stmt = connection.prepareStatement(sql);
for (UUID userId : userIds) {
for (UUID roleId : roleIds) {
stmt.setObject(1, userId);
stmt.setObject(2, roleId);
stmt.addBatch();
}
}
stmt.executeBatch();
} catch (SQLException e) {
throw new DataAccessException("Failed to batch assign roles", e);
}
}
}
Transaction Management
All repository operations are properly wrapped in transaction boundaries:
@Service
@Transactional
public class AuthorizationService {
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private UserPermissionRepository userPermissionRepository;
public void assignRoleToUser(UUID userId, UUID roleId) {
// Transaction boundary ensures consistency
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
Role role = roleRepository.findById(roleId)
.orElseThrow(() -> new RoleNotFoundException(roleId));
// This operation is atomic within the transaction
user.getRoles().add(role);
userRepository.save(user);
}
}
Query Optimization
We implement query optimization strategies including:
- Composite indexes on frequently queried columns
- Covering indexes for read-heavy operations
- Partitioned tables for large datasets
-- Indexes for authorization queries
CREATE INDEX idx_user_permissions_user_id ON user_permissions(user_id);
CREATE INDEX idx_user_permissions_permission_id ON user_permissions(permission_id);
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
-- Covering index for permission lookup
CREATE INDEX idx_permissions_covering
ON permissions(name, id) INCLUDE (description);
The repository layer is designed for high performance under concurrent load while maintaining data consistency and providing clean separation between domain logic and data access concerns.
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The authorization service is primarily synchronous for real-time access control decisions, but certain operations benefit from asynchronous processing:
- Policy change propagation - When a policy is updated, we need to invalidate caches and notify dependent services asynchronously
- Audit logging - Authorization decisions are logged for compliance and monitoring, which can be done asynchronously to avoid impacting decision latency
- User role assignment updates - When a user's roles change, we may want to notify downstream systems for cache invalidation or re-authentication
The core authorization decision flow (checkPermission) remains synchronous to ensure low-latency response for access control checks.
Message schema design
{
"version": "1.0",
"event_type": "policy.changed",
"timestamp": "2023-10-15T14:30:00Z",
"source": "auth-service",
"payload": {
"policy_id": "policy-123",
"policy_name": "read-user-data",
"updated_by": "admin@company.com",
"changes": {
"effect": "allow",
"conditions": [
{
"attribute": "user.department",
"operator": "equals",
"value": "engineering"
}
]
}
},
"correlation_id": "req-abc-123"
}
Dead letter queue strategy and poison message handling
Poison messages are handled through a dead-letter queue (DLQ) pattern:
- Messages that fail more than 3 times are moved to a DLQ topic
- DLQ messages are logged with full context for manual inspection
- A separate monitoring dashboard alerts on DLQ message volume
- Failed messages are retried with exponential backoff (1s, 5s, 10s) before moving to DLQ
At-least-once vs. exactly-once delivery
At-least-once delivery is chosen for authorization events because:
- Authorization decisions are idempotent - reprocessing a policy change event won't cause harm
- The system can handle duplicate events gracefully through cache invalidation and state reconciliation
- Implementation complexity is reduced compared to exactly-once semantics
Retry policy for consumers
public class AuthorizationEventConsumer {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_DELAY_MS = 1000;
private static final long MAX_DELAY_MS = 30000;
public void processEvent(AuthorizationEvent event) {
int attempts = 0;
while (attempts < MAX_RETRIES) {
try {
handleEvent(event);
return;
} catch (Exception e) {
attempts++;
if (attempts >= MAX_RETRIES) {
moveMessageToDLQ(event, e);
break;
}
long delay = Math.min(INITIAL_DELAY_MS * (1L << attempts), MAX_DELAY_MS);
Thread.sleep(delay + randomJitter(delay));
}
}
}
private long randomJitter(long baseDelay) {
return (long) (Math.random() * baseDelay * 0.1);
}
}
Outbox pattern for reliable event publishing
The outbox pattern is implemented for authorization events to ensure eventual consistency:
@Entity
@Table(name = "authorization_events_outbox")
public class AuthorizationEventOutbox {
@Id
private UUID id;
@Column(name = "event_type")
private String eventType;
@Column(name = "payload")
private String payload;
@Column(name = "status")
private String status; // PENDING, PROCESSED, FAILED
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "processed_at")
private Instant processedAt;
}
Sequence diagram for async flows
sequenceDiagram
participant A as Auth Service
participant O as Outbox Table
participant M as Message Broker
participant C as Cache Service
participant L as Audit Logger
A->>O: Insert event (policy.changed)
O-->>A: Acknowledge
A->>M: Publish to policy-changes topic
M-->>A: Acknowledge
M->>C: Notify policy change
C-->>M: Acknowledge
M->>L: Log audit event
L-->>M: Acknowledge
10. Error Handling Design
Error Classification Hierarchy
The authorization service implements a three-tier error classification system:
public enum ErrorType {
VALIDATION_ERROR, // Input validation failures
AUTHORIZATION_ERROR, // Permission denied
INFRASTRUCTURE_ERROR, // Database, cache, or external service failures
DOMAIN_ERROR, // Business logic violations (e.g., invalid role)
SYSTEM_ERROR // Unexpected internal failures
}
Error Propagation Strategy
- Validation errors: Caught at API boundary and converted to 400 responses
- Authorization errors: Translated to 403 responses at service boundary
- Infrastructure errors: Bubble up to centralized error handler with retry logic where appropriate
- Domain errors: Handled at service layer and converted to appropriate HTTP status codes
- System errors: Logged with full stack trace and returned as 500 responses
Retry Strategy
For transient infrastructure errors (e.g., database timeouts, cache unavailability):
public class RetryableOperation {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 100;
public <T> T executeWithRetry(Supplier<T> operation) {
int attempts = 0;
while (true) {
try {
return operation.get();
} catch (InfrastructureException e) {
if (++attempts >= MAX_RETRIES) {
throw new SystemException("Operation failed after " + MAX_RETRIES + " attempts", e);
}
long delay = BASE_DELAY_MS * Math.pow(2, attempts) +
ThreadLocalRandom.current().nextLong(100);
Thread.sleep(delay);
}
}
}
}
Circuit Breaker Design
public class CircuitBreaker {
private final int failureThreshold = 5;
private final long timeoutMs = 60000;
private final int resetTimeoutMs = 30000;
private volatile CircuitState state = CircuitState.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
public <T> T execute(Supplier<T> operation) {
switch (state) {
case CLOSED:
try {
T result = operation.get();
failureCount = 0;
return result;
} catch (Exception e) {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN;
}
throw e;
}
case OPEN:
if (System.currentTimeMillis() - lastFailureTime > resetTimeoutMs) {
state = CircuitState.HALF_OPEN;
} else {
throw new ServiceUnavailableException("Circuit breaker is open");
}
case HALF_OPEN:
try {
T result = operation.get();
state = CircuitState.CLOSED;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
throw e;
}
}
}
}
Fallback Behavior Design
For critical authorization checks, fallback to a safe default:
public class AuthorizationFallback {
private final boolean allowByDefault;
public AuthorizationResult checkPermissionWithFallback(
String userId, String resource, String action) {
try {
return authorizationService.checkPermission(userId, resource, action);
} catch (Exception e) {
// Log the failure for monitoring
logger.warn("Authorization check failed for user {}, resource {}, action {}. Using fallback.",
userId, resource, action, e);
// Fallback to deny if configured as such
return new AuthorizationResult(false, "Fallback: permission denied");
}
}
}
Error Logging Format
Structured logging with trace context:
public class AuthorizationErrorLogger {
private final ObjectMapper objectMapper;
public void logAuthorizationError(String traceId, String userId,
String resource, String action, Exception error) {
Map<String, Object> logEntry = new HashMap<>();
logEntry.put("timestamp", Instant.now().toString());
logEntry.put("trace_id", traceId);
logEntry.put("user_id", userId);
logEntry.put("resource", resource);
logEntry.put("action", action);
logEntry.put("error_type", error.getClass().getSimpleName());
logEntry.put("error_message", error.getMessage());
logEntry.put("stack_trace", getSanitizedStackTrace(error));
logger.error(objectMapper.writeValueAsString(logEntry));
}
private String getSanitizedStackTrace(Exception error) {
// Remove sensitive data from stack trace
return Arrays.stream(error.getStackTrace())
.map(ste -> ste.getClassName() + ":" + ste.getMethodName() + ":" + ste.getLineNumber())
.collect(Collectors.joining("\n"));
}
}
Alerting Thresholds
| Error Type | Alert Threshold | Severity |
|---|---|---|
| AUTHORIZATION_ERROR | 100 errors/minute | Warning |
| INFRASTRUCTURE_ERROR | 50 errors/minute | Critical |
| SYSTEM_ERROR | 10 errors/minute | Critical |
Alerts are sent to on-call engineers via PagerDuty with escalation policies for critical errors.
11. Security Design
Input Validation and Sanitization Strategy
All inputs are validated using a combination of:
- Regex patterns for format validation (e.g., email, UUID)
- Length limits to prevent buffer overflow attacks
- Whitelist-based validation for allowed characters and values
- Custom validators for complex business rules
For example, role names are validated with regex ^[a-zA-Z0-9_-]{1,64}$ to prevent injection and ensure proper naming.
SQL Injection Prevention
- All database queries use parameterized prepared statements exclusively
- No dynamic query building with user input
- ORM is configured to prevent raw SQL injection (e.g., Hibernate with proper parameter binding)
- Input sanitization is applied as an additional layer
Authentication Middleware Design
Authentication middleware intercepts all requests and:
- Extracts JWT from Authorization header
- Validates token signature using public key (RSA-2048)
- Verifies expiration and audience claims
- Extracts user ID and roles from token payload
- Stores authenticated context in request scope
Authorization Enforcement (RBAC/ABAC)
RBAC Implementation:
- Role-based access control with hierarchical role inheritance
- Users are assigned to roles, roles have permissions
- Permissions are grouped into scopes (e.g.,
user:read,user:write)
ABAC Implementation:
- Attribute-based access control for dynamic policies
- Policies are evaluated using JEXL expression engine (e.g.,
user.department == resource.department) - Context-aware permissions with time-based conditions
// Example policy evaluation logic
public boolean evaluatePolicy(String policyExpression, Map<String, Object> context) {
JexlEngine jexl = new JexlBuilder().create();
JexlExpression expression = jexl.createExpression(policyExpression);
return (Boolean) expression.evaluate(new MapContext(context));
}
Sensitive Data Encryption at Rest and in Transit
At Rest:
- AES-256-GCM encryption for sensitive data fields (e.g., user passwords, tokens)
- Key rotation every 90 days using AWS KMS or Vault
- Data is encrypted before storage in database
In Transit:
- TLS 1.3 enforced for all API endpoints (minimum)
- All internal service-to-service communication uses mTLS
- JWT tokens are signed with RS256 (RSA 2048-bit keys)
PII Handling and Masking Strategy
- PII fields (user emails, phone numbers) are encrypted at rest
- Masking in logs: Email addresses shown as
user***@domain.com - Redaction in responses: Sensitive fields like passwords are never returned
- Audit logging includes only necessary PII with proper masking
Secrets Management
- Vault (HashiCorp) for secrets lifecycle management:
- API keys, database passwords, JWT signing keys
- Secrets rotated every 30 days with automatic renewal
- Environment-based secrets:
- Production secrets stored in AWS Secrets Manager or GCP Secret Manager
- Development/staging use local Vault instances
Audit Logging Design
Events Logged:
- User login/logout
- Role assignment/revoke
- Policy creation/update/delete
- Permission grant/deny operations
Log Format:
{
"timestamp": "2023-10-27T14:30:00Z",
"trace_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"user_id": "user-uuid-12345",
"action": "ROLE_ASSIGNMENT",
"resource_type": "user",
"resource_id": "user-uuid-67890",
"details": {
"assigned_role": "admin",
"requesting_user": "admin-uuid"
},
"ip_address": "10.0.0.1",
"user_agent": "Mozilla/5.0..."
}
Retention:
- Audit logs retained for 3 years
- Logs are stored in secure, immutable storage (e.g., AWS S3 with versioning)
- Compliance reporting generated monthly from audit logs
12. Testing Strategy
Unit Testing Scope and Coverage Targets
Unit tests will cover 85%+ of business logic and service layer code with 90%+ line coverage. Focus on:
- Core authorization decision logic (RBAC and ABAC evaluation)
- Policy rule parsing and validation
- Permission assignment and revocation workflows
- Role hierarchy traversal and inheritance resolution
- Context attribute processing and matching logic
Integration Testing Strategy
Database Integration Tests:
- Use testcontainers for PostgreSQL to ensure schema compatibility and query correctness
- Test transaction boundaries and concurrent access scenarios
- Validate data consistency across multi-table operations (roles, permissions, policies)
External Service Integration Tests:
- Mock external auth providers (OAuth2, SAML) using WireMock or embedded servers
- Test integration with audit logging services and metrics collectors
- Validate event publishing to message brokers (e.g., Kafka/Redis) in integration tests
End-to-End Test Scenarios:
- Complete authorization flow from request to decision with real policy evaluation
- Test cascading role inheritance and attribute-based condition matching
- Validate policy conflict resolution and precedence handling
Contract Testing
Implement contract testing using Pact for:
- API boundary contracts between authorization service and client applications
- Message format contracts with downstream consumers (e.g., API gateway, microservices)
- Version compatibility testing for policy schema changes
Performance Testing Tool and Scenarios
Load Testing Framework: k6 (JavaScript-based, scalable for high-concurrency scenarios)
Key Performance Scenarios:
- Concurrent authorization requests (500-1000 concurrent users)
- Policy evaluation under high load (10,000+ policies in system)
- Complex ABAC attribute matching performance (50+ attributes per request)
- Cache hit vs. miss performance comparison
Target Metrics:
- p50 latency < 10ms for 95% of requests
- p95 latency < 50ms for 95% of requests
- Throughput: 20,000+ req/sec for basic RBAC checks
- Error rate < 0.1% under load
Test Data Management Strategy
Test Data Generation:
- Use factory patterns to generate realistic test data for:
- Users (1000+ with various role combinations)
- Roles (hierarchical structures up to 5 levels deep)
- Permissions (1000+ with different resource types and actions)
- Policies (ABAC conditions, RBAC role assignments)
Fixtures and Seeding:
- Pre-populate test database with common authorization scenarios
- Use fixtures for edge case testing (circular role references, policy conflicts)
- Implement data seeding scripts that can be run in CI/CD pipeline
Test Data Isolation:
- Use database transaction rollback per test method to ensure clean state
- Implement test-specific schema or database instances for parallel test execution
- Separate test data from production data with distinct namespaces and prefixes
// Example unit test for policy evaluation logic
@Test
public void testPolicyEvaluationWithAttributeMatch() {
// Given: User with specific attributes and policy with attribute conditions
User user = createUserWithAttributes("department", "engineering", "location", "sf");
Policy policy = createPolicyWithAttributeCondition("department", "engineering");
// When: Authorization check is performed
boolean result = authorizationService.canAccess(user, "read", "resource1");
// Then: Should grant access based on matching attributes
assertTrue(result);
}
13. Observability Design
Structured Logging Schema
All logs must follow a consistent schema to enable effective search, filtering, and analysis:
{
"timestamp": "2023-10-27T14:30:45.123Z",
"level": "INFO|WARN|ERROR",
"service": "auth-service",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user-123",
"entity_id": "resource-456",
"operation": "checkPermission",
"request_id": "req-789",
"message": "Permission check completed",
"duration_ms": 45,
"error_code": "PERMISSION_DENIED",
"error_message": "User does not have required permission",
"stack_trace": "..."
}
PII masking rules:
- User IDs and emails are masked in logs when not required for debugging
- Token values are truncated (first 4, last 4 characters shown)
- Sensitive attribute values (e.g., passwords) are never logged
Distributed Tracing Instrumentation
Using OpenTelemetry for end-to-end tracing:
Custom spans to add:
auth.check_permission- measures the entire permission check flowauth.evaluate_policy- for ABAC policy evaluation logicauth.fetch_user_roles- database fetch for user role mappingsauth.validate_token- JWT validation and claims extraction
Attributes to capture per key operation:
Span currentSpan = Span.current();
currentSpan.setAttribute("user.id", userId);
currentSpan.setAttribute("resource.type", resourceType);
currentSpan.setAttribute("permission.action", action);
currentSpan.setAttribute("policy.evaluation.result", evaluationResult);
Metrics Design at Service Level
RED metrics for monitoring service health:
| Metric Name | Type | Description | Labels |
|---|---|---|---|
auth_requests_total | Counter | Total authorization requests | method, status, user_id |
auth_request_duration_seconds | Histogram | Request processing time | method, status |
auth_errors_total | Counter | Authorization errors | error_type, user_id |
auth_cache_hit_ratio | Gauge | Cache hit rate | cache_type |
auth_active_sessions | Gauge | Current active sessions | user_id |
Health Check Endpoint Design
GET /health
GET /ready
GET /live
GET /startup
/health - Comprehensive health check:
{
"status": "healthy",
"checks": {
"database": {"status": "healthy", "duration_ms": 15},
"cache": {"status": "healthy", "duration_ms": 8},
"auth_service": {"status": "healthy", "duration_ms": 2}
}
}
/ready - Service ready for traffic:
{
"status": "ready",
"database": "connected",
"cache": "connected"
}
/live - Service alive:
{
"status": "alive",
"uptime_seconds": 3600,
"version": "v1.2.3"
}
/startup - Startup completion check:
{
"status": "completed",
"components_initialized": ["database", "cache", "policy_engine"]
}
Alerting Thresholds per Key Metric
| Metric | Warning Level | Critical Level | Escalation Path |
|---|---|---|---|
auth_request_duration_seconds (p99) | > 500ms | > 2s | On-call engineer |
auth_errors_total (rate) | > 10/minute | > 100/minute | SRE team |
auth_cache_hit_ratio | < 70% | < 50% | SRE team |
auth_requests_total (rate) | > 10k/second | > 50k/second | SRE team |
auth_active_sessions (rate) | > 10k | > 50k | SRE team |
Alerts should include:
- Service degradation notifications
- Cache miss rate spikes
- Authentication failure bursts
- Database connection pool exhaustion
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Policy enforcement granularity: Should authorization checks be at the API endpoint level, resource level, or field level? This decision impacts performance vs. flexibility trade-offs.
- Cross-service authorization: How do we handle authorization across microservices? Should each service independently validate permissions or rely on a centralized auth service?
- User vs. Application permissions: Should the service support both user-based and application-based authorization models, or focus on one primary use case?
Known trade-offs and justifications
- Performance vs. flexibility: Using ABAC for fine-grained authorization provides maximum flexibility but introduces performance overhead from policy evaluation. RBAC offers better performance with less flexibility.
- Complexity vs. maintainability: ABAC introduces significant complexity in policy definition and evaluation, but provides better extensibility for evolving access requirements.
- Centralized vs. distributed auth: Centralizing authorization logic provides consistency but creates a single point of failure and potential performance bottleneck.
- Real-time vs. cached decisions: Real-time policy evaluation ensures accuracy but increases latency; caching improves performance at risk of stale permissions.
Risks and mitigation strategies
- Policy evaluation performance: Complex ABAC policies can cause significant latency. Mitigation includes policy optimization, caching, and pre-computing common access patterns.
- Permission drift: Authorization policies may become outdated as system evolves. Mitigation includes regular policy audits, automated compliance checks, and clear ownership of authorization rules.
- Race conditions in concurrent access: Multiple simultaneous permission checks could lead to inconsistent results. Mitigation includes proper locking mechanisms and atomic operations.
- Cache invalidation storms: When policies change, cache invalidation can cause performance degradation. Mitigation includes smart cache invalidation strategies and TTL-based approaches.
Technical debt acknowledged
- Policy language complexity: The ABAC implementation uses a custom policy language that may require future refactoring to support more sophisticated rule engines like Open Policy Agent (OPA).
- Audit logging overhead: Comprehensive audit logging increases storage and processing costs. Future improvements could include configurable audit levels or sampling strategies.
- Service coupling: The authorization service becomes a critical dependency for all other services. This creates a single point of failure that requires careful monitoring and redundancy planning.
- Schema evolution complexity: As authorization rules become more complex, maintaining backward compatibility in the data model and API becomes increasingly difficult. This debt will compound over time as more granular permissions are added.
- Testing coverage gaps: The complexity of authorization logic makes comprehensive testing challenging. While unit tests cover basic cases, end-to-end integration testing with various policy combinations is difficult to maintain at scale.