Authorization
Fine-grained access control patterns for determining what authenticated users can access and modify.
Overview
Authorization determines what an authenticated user is allowed to do. While authentication answers "who are you?", authorization answers "what can you do?". In banking applications, proper authorization prevents unauthorized access to financial data and operations.
See also: Authentication for identity verification, and Security Overview for general security principles.
Role-Based Access Control (RBAC)
RBAC assigns permissions to roles, and users are assigned roles. This is the most common authorization pattern.
Role-Based Access Control separates users from permissions through roles. This provides flexibility - modify the role once rather than updating each user individually. RBAC supports compliance by providing clear audit trails of permissions.
RBAC Architecture
Role Definition
// Define roles with clear hierarchy
public enum UserRole {
CUSTOMER(1, "Customer", "View and manage own payments"),
OPERATOR(2, "Operator", "Create and view payments for department"),
ADMIN(3, "Administrator", "Full access to payment system"),
AUDITOR(2, "Auditor", "Read-only access to audit logs"),
SUPER_ADMIN(4, "Super Administrator", "System administration");
private final int level;
private final String displayName;
private final String description;
UserRole(int level, String displayName, String description) {
this.level = level;
this.displayName = displayName;
this.description = description;
}
public boolean hasHigherPrivilegeThan(UserRole other) {
return this.level > other.level;
}
}
The role hierarchy uses numeric levels for privilege ordering. Higher numbers indicate greater privilege. The hasHigherPrivilegeThan method enables privilege comparison - admins can modify operators and customers but not other admins. AUDITOR and OPERATOR share level 2 but have different permissions - levels indicate vertical privilege, not lateral permissions.
Method-Level Authorization
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@Autowired
private PaymentService paymentService;
@Autowired
private PaymentAuthorizationService authService;
// Any authenticated user can attempt to view payment
// Additional checks inside method
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'OPERATOR', 'ADMIN', 'AUDITOR')")
public PaymentResponse getPayment(@PathVariable UUID id) {
Payment payment = paymentService.getPayment(id);
// Resource-level authorization check
if (!authService.canViewPayment(getCurrentUser(), payment)) {
throw new AccessDeniedException("Cannot access this payment");
}
return PaymentMapper.toResponse(payment);
}
// Only operators and admins can create payments
@PostMapping
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public PaymentResponse createPayment(
@Valid @RequestBody PaymentRequest request) {
// Additional business logic checks
if (!authService.canCreatePayment(getCurrentUser(), request)) {
throw new AccessDeniedException("Cannot create payment");
}
return paymentService.createPayment(request);
}
// Only admins can delete payments
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@AuditLog(action = "DELETE_PAYMENT", severity = "HIGH")
public void deletePayment(@PathVariable UUID id) {
Payment payment = paymentService.getPayment(id);
// Additional checks even for admins
if (payment.getStatus() == PaymentStatus.COMPLETED) {
throw new BusinessException("Cannot delete completed payment");
}
paymentService.deletePayment(id);
}
// Only super admins can perform system operations
@PostMapping("/admin/migrate")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public void migratePayments(@RequestBody MigrationRequest request) {
paymentService.migratePayments(request);
}
private User getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext()
.getAuthentication();
return (User) auth.getPrincipal();
}
}
The @PreAuthorize annotation performs coarse-grained role checking before method execution, throwing AccessDeniedException if conditions fail. The additional authService.canViewPayment() call provides fine-grained resource-level authorization - even users with CUSTOMER role can only view their own payments. This layered approach combines role-based gatekeeping with attribute-based resource checking.
Role Hierarchy
// Configure role hierarchy
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
// Higher roles inherit permissions of lower roles
hierarchy.setHierarchy(
"ROLE_SUPER_ADMIN > ROLE_ADMIN \n" +
"ROLE_ADMIN > ROLE_OPERATOR \n" +
"ROLE_OPERATOR > ROLE_CUSTOMER"
);
return hierarchy;
}
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
}
Role hierarchy allows higher-privilege roles to automatically inherit lower-role permissions. A SUPER_ADMIN can perform all ADMIN, OPERATOR, and CUSTOMER operations without explicitly listing all roles. The > symbol defines inheritance direction. Instead of @PreAuthorize("hasAnyRole('CUSTOMER', 'OPERATOR', 'ADMIN', 'SUPER_ADMIN')"), write @PreAuthorize("hasRole('CUSTOMER')") and higher roles automatically qualify.
Attribute-Based Access Control (ABAC)
ABAC provides fine-grained control based on attributes of the user, resource, and context.
Attribute-Based Access Control evaluates multiple dimensions beyond role membership. While RBAC asks "Does this role allow this action?", ABAC asks "Do the user's attributes, resource properties, and current context satisfy policy rules?" This enables granular control based on transaction amount, time of day, user location, and MFA status. All conditions must be met for access - any failed check denies access.
ABAC Decision Flow
ABAC Implementation
@Service
public class PaymentAuthorizationService {
@Autowired
private AuditService auditService;
public boolean canViewPayment(User user, Payment payment) {
// Admins can view everything
if (user.hasRole(UserRole.ADMIN) || user.hasRole(UserRole.SUPER_ADMIN)) {
auditLog(user, "VIEW_PAYMENT", payment.getId(), "ADMIN_ACCESS");
return true;
}
// Auditors can view everything (read-only)
if (user.hasRole(UserRole.AUDITOR)) {
auditLog(user, "VIEW_PAYMENT", payment.getId(), "AUDITOR_ACCESS");
return true;
}
// Customers can only view their own payments
if (user.hasRole(UserRole.CUSTOMER)) {
boolean isOwner = payment.getCustomerId().equals(user.getId());
auditLog(user, "VIEW_PAYMENT", payment.getId(),
isOwner ? "OWNER_ACCESS" : "ACCESS_DENIED");
return isOwner;
}
// Operators can view payments in their department
if (user.hasRole(UserRole.OPERATOR)) {
boolean sameDepartment = payment.getDepartment()
.equals(user.getDepartment());
// Operators can only view payments below threshold
boolean belowThreshold = payment.getAmount()
.compareTo(user.getAuthorizationLimit()) <= 0;
boolean canView = sameDepartment && belowThreshold;
auditLog(user, "VIEW_PAYMENT", payment.getId(),
canView ? "OPERATOR_ACCESS" : "ACCESS_DENIED");
return canView;
}
auditLog(user, "VIEW_PAYMENT", payment.getId(), "NO_ROLE");
return false;
}
public boolean canCreatePayment(User user, PaymentRequest request) {
// Customers can create small payments for themselves
if (user.hasRole(UserRole.CUSTOMER)) {
boolean isOwnAccount = request.getSourceAccountId()
.equals(user.getPrimaryAccountId());
boolean belowLimit = request.getAmount()
.compareTo(new BigDecimal("10000")) <= 0;
return isOwnAccount && belowLimit;
}
// Operators can create payments within their authorization limit
if (user.hasRole(UserRole.OPERATOR)) {
boolean withinLimit = request.getAmount()
.compareTo(user.getAuthorizationLimit()) <= 0;
boolean correctDepartment = request.getDepartment()
.equals(user.getDepartment());
return withinLimit && correctDepartment;
}
// Admins can create any payment
if (user.hasRole(UserRole.ADMIN) || user.hasRole(UserRole.SUPER_ADMIN)) {
return true;
}
return false;
}
public boolean canApprovePayment(User user, Payment payment) {
// Can't approve own payments
if (payment.getCreatedBy().equals(user.getId())) {
return false;
}
// Must have higher authorization limit than payment amount
if (payment.getAmount().compareTo(user.getAuthorizationLimit()) > 0) {
return false;
}
// Must be in same department or be admin
boolean sameDepartment = payment.getDepartment()
.equals(user.getDepartment());
boolean isAdmin = user.hasRole(UserRole.ADMIN) ||
user.hasRole(UserRole.SUPER_ADMIN);
return sameDepartment || isAdmin;
}
public boolean canModifyUser(User actor, User target) {
// Can't modify yourself (except own profile)
if (actor.getId().equals(target.getId())) {
return false;
}
// Can only modify users with lower privilege level
if (!actor.getHighestRole().hasHigherPrivilegeThan(target.getHighestRole())) {
return false;
}
// Super admins can modify anyone (except other super admins)
if (actor.hasRole(UserRole.SUPER_ADMIN)) {
return !target.hasRole(UserRole.SUPER_ADMIN);
}
// Admins can modify operators and customers
if (actor.hasRole(UserRole.ADMIN)) {
return !target.hasRole(UserRole.ADMIN) &&
!target.hasRole(UserRole.SUPER_ADMIN);
}
return false;
}
private void auditLog(User user, String action, UUID resourceId, String result) {
auditService.log(AuditEvent.builder()
.userId(user.getId())
.action(action)
.resourceId(resourceId)
.result(result)
.timestamp(LocalDateTime.now())
.build());
}
}
Context-Aware Authorization
Context-aware authorization considers environmental and temporal factors. The rule-based approach requires all conditions to evaluate true for authorization. Business hours restrictions prevent high-value transactions during off-hours. MFA requirements add second-factor authentication for sensitive operations (see MFA guidelines). Trusted network checks ensure admin operations only occur from corporate networks or VPN. This defense-in-depth approach maintains protection even if one control fails.
@Service
public class ContextAwareAuthorizationService {
public boolean canPerformSensitiveOperation(User user, Operation operation) {
List<AuthorizationRule> rules = new ArrayList<>();
// Rule 1: Must have appropriate role
rules.add(() -> user.hasAnyRole(operation.getRequiredRoles()));
// Rule 2: Must be during business hours for large transactions
if (operation.requiresBusinessHours()) {
rules.add(() -> isBusinessHours());
}
// Rule 3: Must have completed MFA for high-value operations
if (operation.requiresMfa()) {
rules.add(() -> user.hasMfaCompleted());
}
// Rule 4: Must be from trusted network for admin operations
if (operation.requiresTrustedNetwork()) {
rules.add(() -> isFromTrustedNetwork());
}
// Rule 5: Account must not be locked or flagged
rules.add(() -> !user.isLocked() && !user.isFlagged());
// All rules must pass
return rules.stream().allMatch(AuthorizationRule::evaluate);
}
private boolean isBusinessHours() {
LocalTime now = LocalTime.now();
LocalTime start = LocalTime.of(8, 0);
LocalTime end = LocalTime.of(18, 0);
DayOfWeek day = LocalDate.now().getDayOfWeek();
return !day.equals(DayOfWeek.SATURDAY) &&
!day.equals(DayOfWeek.SUNDAY) &&
now.isAfter(start) &&
now.isBefore(end);
}
private boolean isFromTrustedNetwork() {
String clientIp = getClientIpAddress();
return trustedNetworks.contains(clientIp) ||
vpnService.isVpnConnection();
}
@FunctionalInterface
private interface AuthorizationRule {
boolean evaluate();
}
}
Permission-Based Authorization
Permission Matrix
The permission matrix visualizes access control policies. Maintain this matrix in code using enums and configuration rather than hardcoding checks throughout the application. This centralization enables easier auditing, identification of over-privileged roles, and consistent enforcement. Each permission has both a machine-readable code (e.g., 'payment:create') and human-readable description for auditing and UI.
Permission Enum
public enum Permission {
// Payment permissions
PAYMENT_CREATE("payment:create", "Create payments"),
PAYMENT_READ("payment:read", "View payments"),
PAYMENT_UPDATE("payment:update", "Update payments"),
PAYMENT_DELETE("payment:delete", "Delete payments"),
PAYMENT_APPROVE("payment:approve", "Approve payments"),
// User permissions
USER_CREATE("user:create", "Create users"),
USER_READ("user:read", "View users"),
USER_UPDATE("user:update", "Update users"),
USER_DELETE("user:delete", "Delete users"),
// Report permissions
REPORT_VIEW("report:view", "View reports"),
REPORT_EXPORT("report:export", "Export reports"),
// Settings permissions
SETTINGS_READ("settings:read", "View settings"),
SETTINGS_UPDATE("settings:update", "Update settings");
private final String permission;
private final String description;
Permission(String permission, String description) {
this.permission = permission;
this.description = description;
}
public String getPermission() {
return permission;
}
}
Custom Permission Evaluator
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private PermissionService permissionService;
@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {
if (authentication == null || targetDomainObject == null) {
return false;
}
User user = (User) authentication.getPrincipal();
String targetType = targetDomainObject.getClass().getSimpleName();
return hasPrivilege(user, targetType, permission.toString());
}
@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
if (authentication == null || targetType == null) {
return false;
}
User user = (User) authentication.getPrincipal();
return hasPrivilege(user, targetType, permission.toString());
}
private boolean hasPrivilege(User user, String targetType, String permission) {
// Check if user has the specific permission
return permissionService.userHasPermission(user, targetType, permission);
}
}
// Usage in controller
@PreAuthorize("hasPermission(#payment, 'UPDATE')")
public PaymentResponse updatePayment(@PathVariable UUID id,
@RequestBody PaymentRequest request) {
Payment payment = paymentService.getPayment(id);
return paymentService.updatePayment(payment, request);
}
Resource-Level Authorization
Ownership Check
@Aspect
@Component
public class OwnershipAspect {
@Autowired
private PaymentRepository paymentRepository;
@Around("@annotation(requiresOwnership)")
public Object checkOwnership(ProceedingJoinPoint joinPoint,
RequiresOwnership requiresOwnership)
throws Throwable {
// Get current user
Authentication auth = SecurityContextHolder.getContext()
.getAuthentication();
User user = (User) auth.getPrincipal();
// Skip check for admins
if (user.hasRole(UserRole.ADMIN) || user.hasRole(UserRole.SUPER_ADMIN)) {
return joinPoint.proceed();
}
// Extract resource ID from method arguments
Object[] args = joinPoint.getArgs();
UUID resourceId = extractResourceId(args);
if (resourceId == null) {
throw new AuthorizationException("Cannot determine resource ID");
}
// Check ownership
String resourceType = requiresOwnership.resourceType();
if (!checkResourceOwnership(user, resourceType, resourceId)) {
throw new AccessDeniedException(
"User does not own " + resourceType + " " + resourceId
);
}
return joinPoint.proceed();
}
private boolean checkResourceOwnership(User user, String resourceType,
UUID resourceId) {
switch (resourceType) {
case "Payment":
return paymentRepository.findById(resourceId)
.map(payment -> payment.getCustomerId().equals(user.getId()))
.orElse(false);
case "Account":
return accountRepository.findById(resourceId)
.map(account -> account.getOwnerId().equals(user.getId()))
.orElse(false);
default:
return false;
}
}
private UUID extractResourceId(Object[] args) {
for (Object arg : args) {
if (arg instanceof UUID) {
return (UUID) arg;
}
}
return null;
}
}
// Custom annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresOwnership {
String resourceType();
}
// Usage
@GetMapping("/api/payments/{id}")
@RequiresOwnership(resourceType = "Payment")
public PaymentResponse getPayment(@PathVariable UUID id) {
Payment payment = paymentService.getPayment(id);
return PaymentMapper.toResponse(payment);
}
Authorization Testing
Test Access Control
@SpringBootTest
@AutoConfigureMockMvc
class AuthorizationTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "CUSTOMER")
void customerCanViewOwnPayment() throws Exception {
UUID paymentId = createPaymentForCurrentUser();
mockMvc.perform(get("/api/payments/" + paymentId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(paymentId.toString()));
}
@Test
@WithMockUser(roles = "CUSTOMER")
void customerCannotViewOtherUserPayment() throws Exception {
UUID otherUserPaymentId = createPaymentForOtherUser();
mockMvc.perform(get("/api/payments/" + otherUserPaymentId))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "OPERATOR")
void operatorCanCreatePayment() throws Exception {
PaymentRequest request = new PaymentRequest();
request.setAmount(new BigDecimal("1000"));
request.setCurrency("USD");
mockMvc.perform(post("/api/payments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(roles = "CUSTOMER")
void customerCannotDeletePayment() throws Exception {
UUID paymentId = createPaymentForCurrentUser();
mockMvc.perform(delete("/api/payments/" + paymentId))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeletePayment() throws Exception {
UUID paymentId = createPaymentForOtherUser();
mockMvc.perform(delete("/api/payments/" + paymentId))
.andExpect(status().isNoContent());
}
@Test
void unauthenticatedUserCannotAccessApi() throws Exception {
mockMvc.perform(get("/api/payments"))
.andExpect(status().isUnauthorized());
}
}
Related Topics
- Security Overview - Security principles and culture
- Authentication - Identity verification patterns
- Data Protection - Protecting sensitive resources
- Security Testing - Testing authorization logic
- Code Review - Authorization review checklist
Summary
Key Takeaways:
- RBAC: Use role-based access for coarse-grained control, role hierarchy for permission inheritance
- ABAC: Add attribute-based rules for fine-grained control based on context
- Least Privilege: Grant minimum permissions necessary for the task
- Defense in Depth: Multiple authorization checks (method-level + resource-level)
- Audit Everything: Log all authorization decisions for compliance and debugging
- Fail Secure: Default to deny access when rules are ambiguous
- Test Thoroughly: Test all permission combinations and edge cases
- Never Trust Client: Always validate authorization server-side
- Checking authorization only in UI (client-side), not enforcing server-side
- Allowing users to modify their own roles or permissions
- Using incremental IDs that allow enumeration attacks (use UUIDs)
- Not validating ownership of resources (IDOR vulnerability)
- Granting excessive permissions by default
- Not auditing authorization failures (miss attack patterns)
- Hardcoding authorization logic instead of using configurable rules