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

  1. One authority - Single source of truth for all authorization decisions
  2. Stable resource names - Predictable, hierarchical naming scheme
  3. Explicit access levels - Clear action semantics (READ, UPDATE, EXECUTE, etc.)
  4. Default deny - Nothing is permitted unless explicitly allowed
  5. 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:12345
  • LEDGER:POST
  • BATCH:END_OF_DAY
  • CUSTOMER: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 resource
  • UPDATE - Modify the resource
  • EXECUTE - Run an operation
  • DELETE - Remove the resource
  • ADMIN - 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:

SubjectResourceActionEffect
ROLE:TELLERACCOUNT:*READAllow
ROLE:SUPERVISORACCOUNT:*UPDATEAllow
USER:aliceLEDGER:POSTEXECUTEAllow
GROUP:BATCH_OPERATORSBATCH:*EXECUTEAllow

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 ConceptKeycloak Equivalent
SubjectUser / Service Account / Client
ResourceResource (with stable name)
ActionScope / Permission
PolicyAuthorization Policy
AuditAdmin Events + Custom Logs

Setting Up Resources in Keycloak

  1. Define Resources:

    • ACCOUNT (with scopes: READ, UPDATE, DELETE)
    • LEDGER (with scopes: READ, EXECUTE)
    • BATCH (with scopes: EXECUTE)
  2. 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”
  3. 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:

AspectRACF/KeycloakSAP
ModelSubject-Resource-ActionUser-Role-Object-Activity
EnforcementCentral API or JWTEmbedded in app/module code
GranularityFine-grained, hierarchicalCoarse to fine (authorization objects)
Policy StorageData-driven, dynamicRole assignments; admin-heavy
Audit TrailCentral logs + eventsLogs + table-driven checks
FlexibilityHigh - dynamic resourcesLimited - adding objects requires admin
Cloud IntegrationNative, standards-basedLimited 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 AuthorizationService interface
  • 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