Introduction
Modern applications often struggle with authorization complexity—scattered @RolesAllowed annotations, hardcoded role checks, and no clear audit trail of why a decision was made. Yet decades ago, mainframe systems solved this problem elegantly with RACF (Resource Access Control Facility).
This post explores how to bring RACF-style authorization to modern applications, creating centralized, auditable, resource-based access control that’s both powerful and maintainable. Since I’ve used SAP extensively, I wanted to compare it with RACF as well.
What RACF Actually Did
RACF wasn’t just “login + roles.” It was fundamentally different from typical RBAC systems:
A centralized policy engine that decides whether a subject can perform an action on a named resource—consistently, auditably, and outside application code.
Key Properties
- One authority - Single source of truth for all authorization decisions
- Stable resource names - Predictable, hierarchical naming scheme
- Explicit access levels - Clear action semantics (READ, UPDATE, EXECUTE, etc.)
- Default deny - Nothing is permitted unless explicitly allowed
- Audited decisions - Every authorization check is logged
Applications asked, RACF decided. The application never made authorization decisions itself.
RACF vs. Typical Modern App Security
Let’s contrast the approaches:
Typical Modern Monolith (Problematic)
@RestController
public class AccountController {
@RolesAllowed("ADMIN") // Hardcoded role
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id, @RequestBody WithdrawRequest req) {
// Authorization mixed with business logic
if (!currentUser.hasRole("ADMIN") && !currentUser.owns(id)) {
throw new ForbiddenException();
}
accountService.withdraw(id, req.getAmount());
}
}
Problems:
- Role names hardcoded throughout codebase
- Authorization logic scattered and duplicated
- No global visibility into who can do what
- Difficult to audit why something was allowed
- Can’t answer: “Who could withdraw from account X last Tuesday?”
RACF-Style Monolith (Better)
@RestController
public class AccountController {
private final AccountService accountService;
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id, @RequestBody WithdrawRequest req) {
// Just delegate to service - authorization happens there
accountService.withdraw(currentSubject(), id, req.getAmount());
}
}
@Service
public class AccountService {
private final AuthorizationService auth;
public void withdraw(Subject subject, String accountId, BigDecimal amount) {
// Authorization at use-case boundary
auth.check(subject, Resource.account(accountId), Action.UPDATE);
// Business logic follows - clean and focused
Account account = accountRepository.findById(accountId);
account.withdraw(amount);
accountRepository.save(account);
}
}
Benefits:
- One authorization service - single point of control
- Policy is data, not code annotations
- Authorization explicit at business boundaries
- All decisions logged centrally
- Can answer audit questions easily
The RACF Mental Model
Understanding RACF requires thinking in terms of three core concepts:
1. Subject
Who or what is requesting access:
- User -
USER:alice - Service account -
SERVICE:payment-processor - Batch job -
BATCH:end-of-day
2. Resource
A stable, named entity in your system:
ACCOUNT:12345LEDGER:POSTBATCH:END_OF_DAYCUSTOMER:98765
The naming is hierarchical and predictable. Resources aren’t URLs—they’re business concepts.
3. Action
What capability is being requested:
READ- View the resourceUPDATE- Modify the resourceEXECUTE- Run an operationDELETE- Remove the resourceADMIN- Full control
RACF cared about capabilities, not screens or endpoints.
Implementing RACF-Style Authorization in Java
Step 1: Central Authorization Service
Create one place for all authorization decisions:
public interface AuthorizationService {
/**
* Check if subject can perform action on resource.
* Throws AuthorizationException if denied.
* Returns silently if allowed.
*/
void check(Subject subject, Resource resource, Action action);
/**
* Same as check() but returns boolean instead of throwing.
* Use sparingly - prefer check() for clearer semantics.
*/
boolean isAllowed(Subject subject, Resource resource, Action action);
}
public class KeycloakAuthorizationService implements AuthorizationService {
private final KeycloakAuthzClient authzClient;
private final AuditLogger auditLogger;
@Override
public void check(Subject subject, Resource resource, Action action) {
boolean allowed = evaluatePolicy(subject, resource, action);
// Always audit the decision
auditLogger.log(AuditEvent.builder()
.subject(subject)
.resource(resource)
.action(action)
.result(allowed ? "ALLOW" : "DENY")
.timestamp(Instant.now())
.build());
if (!allowed) {
throw new AuthorizationException(
"Subject %s cannot %s on %s".formatted(subject, action, resource)
);
}
}
private boolean evaluatePolicy(Subject subject, Resource resource, Action action) {
// Query Keycloak authorization service
AuthorizationRequest request = new AuthorizationRequest()
.subject(subject.getId())
.resource(resource.getName())
.scope(action.getName());
return authzClient.evaluate(request).isGranted();
}
}
Step 2: Stable Resource Naming
Resource names must be:
- Predictable - Easy to construct from business context
- Human-readable - Clear what they represent
- Stable - Don’t change with refactoring
public class Resource {
private final String name;
private Resource(String name) {
this.name = name;
}
// Factory methods for type safety and consistency
public static Resource account(String accountId) {
return new Resource("ACCOUNT:" + accountId);
}
public static Resource accountType(String accountId) {
// Hierarchical resources for broader permissions
return new Resource("ACCOUNT:*");
}
public static Resource ledgerPost() {
return new Resource("LEDGER:POST");
}
public static Resource batch(String batchName) {
return new Resource("BATCH:" + batchName);
}
public String getName() {
return name;
}
}
Bad naming:
/api/v1/accounts/withdraw // Coupled to API structure
Good naming:
ACCOUNT:WITHDRAW // Business capability
ACCOUNT:12345 // Specific resource
Step 3: Policy as Data
Store policies in Keycloak, not in code:
| Subject | Resource | Action | Effect |
|---|---|---|---|
| ROLE:TELLER | ACCOUNT:* | READ | Allow |
| ROLE:SUPERVISOR | ACCOUNT:* | UPDATE | Allow |
| USER:alice | LEDGER:POST | EXECUTE | Allow |
| GROUP:BATCH_OPERATORS | BATCH:* | EXECUTE | Allow |
This can live in:
- Keycloak Authorization Services (recommended)
- Database table
- Policy files in config repository
- External IAM system
But never in code annotations.
Step 4: Enforce at Use-Case Boundaries
Authorization happens:
- After authentication - We know who the subject is
- Before business logic - Check permission first
- At application service level - Not in controllers or repositories
@Service
public class PaymentService {
private final AuthorizationService auth;
private final AccountRepository accountRepo;
private final LedgerService ledgerService;
@Transactional
public void processPayment(Subject subject, PaymentCommand command) {
// Authorization first
auth.check(subject, Resource.account(command.fromAccount()), Action.UPDATE);
auth.check(subject, Resource.account(command.toAccount()), Action.UPDATE);
auth.check(subject, Resource.ledgerPost(), Action.EXECUTE);
// Business logic - clean and focused
Account from = accountRepo.findById(command.fromAccount());
Account to = accountRepo.findById(command.toAccount());
from.debit(command.amount());
to.credit(command.amount());
ledgerService.recordPayment(from, to, command.amount());
}
}
Where NOT to check:
- ❌ REST controllers (too early, coupled to transport)
- ❌ Repositories (too late, data layer shouldn’t know about authz)
- ❌ UI code (can’t be trusted)
Step 5: Default Deny
If no policy matches, access is denied by default:
private boolean evaluatePolicy(Subject subject, Resource resource, Action action) {
List<Policy> matchingPolicies = policyRepository.findMatching(subject, resource, action);
// No matching policy = deny
if (matchingPolicies.isEmpty()) {
return false;
}
// Check if any policy explicitly allows
return matchingPolicies.stream()
.anyMatch(policy -> policy.getEffect() == Effect.ALLOW);
}
Modern apps often default to allow (fail-open). RACF never did—it failed closed.
Step 6: Central Auditing
Every authorization decision must be logged:
{
"timestamp": "2026-01-25T14:23:45Z",
"subject": "USER:alice",
"resource": "ACCOUNT:12345",
"action": "UPDATE",
"result": "DENY",
"reason": "No matching policy",
"context": {
"ipAddress": "192.168.1.100",
"userAgent": "Mozilla/5.0...",
"requestId": "req-789"
}
}
This enables answering critical questions:
- “Who accessed account X last week?”
- “Why was user Y denied access to resource Z?”
- “Show me all failed authorization attempts in the last hour”
- “Has anyone accessed this sensitive resource today?”
Most modern apps cannot answer these questions. RACF could—instantly.
Integrating with Keycloak
Keycloak provides excellent support for RACF-style authorization through its Authorization Services feature.
Mapping RACF Concepts to Keycloak
| RACF Concept | Keycloak Equivalent |
|---|---|
| Subject | User / Service Account / Client |
| Resource | Resource (with stable name) |
| Action | Scope / Permission |
| Policy | Authorization Policy |
| Audit | Admin Events + Custom Logs |
Setting Up Resources in Keycloak
-
Define Resources:
ACCOUNT(with scopes: READ, UPDATE, DELETE)LEDGER(with scopes: READ, EXECUTE)BATCH(with scopes: EXECUTE)
-
Create Policies:
- Role-based: “Tellers can READ accounts”
- User-based: “Alice can EXECUTE ledger posts”
- Group-based: “Batch operators can EXECUTE batch jobs”
- Time-based: “Batch jobs only during maintenance window”
-
Define Permissions:
- Link resources + scopes to policies
- Support hierarchical permissions (e.g.,
ACCOUNT:*for all accounts)
Two Enforcement Patterns
Pattern 1: Runtime Policy Evaluation (Most RACF-like)
The application queries Keycloak for each authorization decision:
public class KeycloakAuthorizationService implements AuthorizationService {
@Override
public void check(Subject subject, Resource resource, Action action) {
// Query Keycloak in real-time
AuthorizationRequest request = new AuthorizationRequest()
.accessToken(subject.getAccessToken())
.resource(resource.getName())
.scope(action.getName());
AuthorizationResponse response = keycloakAuthzClient.authorize(request);
if (!response.isGranted()) {
throw new AuthorizationException("Access denied");
}
}
}
Pros:
- Most accurate - always reflects current policies
- Supports instant revocation
- Closest to RACF semantics
Cons:
- Slight latency for external call
- Requires Keycloak availability
Pattern 2: JWT-Based Claims (Faster, Less Flexible)
Keycloak issues a JWT with embedded permissions:
public class JwtAuthorizationService implements AuthorizationService {
@Override
public void check(Subject subject, Resource resource, Action action) {
// Extract permissions from JWT claims
Set<String> permissions = subject.getJwtClaims()
.get("permissions", Set.class);
String requiredPermission = resource.getName() + ":" + action.getName();
if (!permissions.contains(requiredPermission)) {
throw new AuthorizationException("Access denied");
}
}
}
Pros:
- Fast - no external call needed
- Works offline
Cons:
- Policies “cached” at token issue time
- Revocation delayed until token expiry
- Less flexible for dynamic resources
Recommendation: Use Pattern 1 for RACF-level precision, Pattern 2 for high-throughput scenarios where slight staleness is acceptable.
Comparison with SAP Authorization
SAP has a well-established authorization model that’s similar in spirit to RACF:
| Aspect | RACF/Keycloak | SAP |
|---|---|---|
| Model | Subject-Resource-Action | User-Role-Object-Activity |
| Enforcement | Central API or JWT | Embedded in app/module code |
| Granularity | Fine-grained, hierarchical | Coarse to fine (authorization objects) |
| Policy Storage | Data-driven, dynamic | Role assignments; admin-heavy |
| Audit Trail | Central logs + events | Logs + table-driven checks |
| Flexibility | High - dynamic resources | Limited - adding objects requires admin |
| Cloud Integration | Native, standards-based | Limited to SAP ecosystem |
Key Insights:
- SAP “authorization objects” ≈ RACF resources
- SAP separates “activity” from “object” (similar to Action/Resource)
- Keycloak can mimic SAP’s model but is more modern and standards-compliant
- Both are data-driven, but Keycloak integrates more cleanly with non-SAP apps
Common Anti-Patterns to Avoid
❌ Scattered Annotations
@PreAuthorize("hasRole('ADMIN')")
public void deleteAccount(String id) { ... }
@PreAuthorize("hasRole('SUPERVISOR')")
public void approveTransaction(String id) { ... }
Problem: Role names hardcoded everywhere, no audit trail, difficult to change policies.
❌ Authorization in Repositories
public Account findById(String id) {
if (!currentUser.canAccess(id)) {
throw new AccessDeniedException();
}
return accounts.get(id);
}
Problem: Data layer shouldn’t know about authorization. Violates separation of concerns.
❌ Multiple Authorization Engines
// Some endpoints use Spring Security
@PreAuthorize("hasRole('USER')")
// Others use custom checks
if (!customAuthService.check(...)) { ... }
// Others use API gateway policies
// Yet others trust the caller
Problem: No single source of truth. Impossible to audit. Security holes inevitable.
❌ No Audit Trail
public void check(Subject s, Resource r, Action a) {
boolean allowed = policy.evaluate(s, r, a);
if (!allowed) throw new AuthorizationException();
// Decision not logged!
}
Problem: Can’t answer “who did what when” questions. Fails compliance requirements.
Benefits of RACF-Style Authorization
For Development Teams
- Clearer code - Authorization explicit and centralized
- Easier testing - Mock the authorization service
- Faster changes - Policy updates don’t require code changes
- Better debugging - All decisions in one place
For Operations Teams
- Visibility - Complete audit trail
- Flexibility - Change policies without deployments
- Security - Default-deny, centralized control
- Compliance - Answer audit questions easily
For the Business
- Risk reduction - Consistent enforcement
- Faster compliance - Built-in audit trail
- Lower costs - Fewer security incidents
- Agility - Permission changes don’t require code releases
Implementation Checklist
Ready to implement RACF-style authorization? Here’s your checklist:
- Define your resource naming scheme
- Create central
AuthorizationServiceinterface - Set up Keycloak (or alternative policy engine)
- Map business resources to Keycloak resources
- Define initial policies in Keycloak
- Implement audit logging for all decisions
- Enforce at use-case boundaries (service layer)
- Remove authorization logic from controllers and repositories
- Set up default-deny behavior
- Create operational dashboards for audit data
- Document the resource naming convention
- Train team on policy management
Conclusion
RACF-style authorization isn’t just a historical curiosity—it’s a battle-tested pattern that solves real problems modern applications still face:
- Centralized control instead of scattered checks
- Auditable decisions instead of mystery denials
- Data-driven policies instead of hardcoded rules
- Resource-based permissions instead of coarse roles
By bringing these principles to modern Java applications using tools like Keycloak, we get the best of both worlds: mainframe-grade security discipline with cloud-native flexibility.
The question isn’t whether RACF-style authorization is relevant today. The question is: Why are we still building applications without it?
What patterns have you noticed in your modernization journeys? Share in the comments below!
If these architectural deep-dives help you, follow me for insights on enterprise architecture.
#MainframeModernization #SoftwareArchitecture #CloudNative #EnterpriseJava #TechLeadership