Security

JWT authentication flow, role-based access control, RolePermissionValidator internals, and the @AllowedRoles annotation.

Authentication Flow

The admin-service acts as an OAuth2 Resource Server. It never issues tokens — it validates JWTs issued by Keycloak.


JWT Claims Processing

Keycloak tokens include roles under the realm_access claim:

Json code-highlight
{
  "realm_access": {
    "roles": ["ADMIN", "offline_access", "uma_authorization"]
  }
}

The JwtAuthenticationConverter in SecurityConfig maps each role to a Spring GrantedAuthority by prepending ROLE_:

Text code-highlight
"ADMIN"            → "ROLE_ADMIN"
"INTERNAL_SERVICE" → "ROLE_INTERNAL_SERVICE"

Role-to-Operation Matrix

Operation KeyRequired RoleEndpoints
create-clientROLE_ADMINPOST /api/admin/inter-service-clients
query-realmROLE_ADMINGET/POST /api/admin/realm-roles, GET /api/admin/realm-role
vault-opsROLE_ADMINAll /api/admin/vault-* endpoints
(path-based)ROLE_INTERNAL_SERVICE/internal/**
⚠️ (missing)NoneDELETE /api/admin/vault-secrets
The DELETE /api/admin/vault-secrets endpoint is missing @PreAuthorize. Any authenticated user — not just ROLE_ADMIN — can delete a Vault secret path. This must be patched.

RolePermissionValidator

Bean name: rolePermissionValidator. This is the central enforcement point for operation-level RBAC across all controllers.

Java code-highlight
@Component
@RequiredArgsConstructor
public class RolePermissionValidator {

    private final ApiProperties apiProperties;

    public Boolean hasAnyRole(Authentication authentication, String operation) {
        List<String> allowedRoles = apiProperties.getApiRoles().get(operation);

        if (CommonUtils.isEmpty(allowedRoles)) return false;

        Set<String> userRoles = authentication.getAuthorities()
            .stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toSet());

        boolean hasRole = allowedRoles.stream().anyMatch(userRoles::contains);

        if (!hasRole) {
            throw new UnAuthorizedException("User does not have valid access for this operation");
        }
        return true;
    }
}

Flow for @PreAuthorize("@rolePermissionValidator.hasAnyRole(authentication, 'vault-ops')"):

  1. Spring AOP intercepts the controller method call.
  2. Evaluates the SpEL expression — calls rolePermissionValidator.hasAnyRole(...).
  3. The validator looks up "vault-ops" in ApiProperties.apiRoles → resolves to ["ROLE_ADMIN"].
  4. Compares against the JWT-extracted GrantedAuthority set.
  5. If no match: throws UnAuthorizedException (HTTP 403). If match: returns true and the call proceeds.

@AllowedRoles Annotation

A meta-annotation that wraps the SpEL expression in a cleaner, declarative form:

Java code-highlight
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@rolePermissionValidator.hasAnyRole(authentication, #allowedRoles.value())")
public @interface AllowedRoles {
    String value();
}

Usage (intended):

Java code-highlight
@AllowedRoles("vault-ops")
@GetMapping("/vault-approle")
public ResponseEntity<?> getAppRoles() { ... }
@AllowedRoles is defined but not yet used in any controller. All controllers currently use inline @PreAuthorize(...) expressions. Migrating to @AllowedRoles would improve consistency and reduce boilerplate.

Adding a New Operation Role

To protect a new endpoint with a custom role mapping, no code changes are required — only config:

Step 1 — Add the operation mapping in application.yaml:

Yaml code-highlight
security:
  api-roles:
    my-new-operation:
      - ROLE_ANALYST
      - ROLE_ADMIN

Step 2 — Annotate the controller method:

Java code-highlight
@PreAuthorize("@rolePermissionValidator.hasAnyRole(authentication, 'my-new-operation')")
@GetMapping("/my-endpoint")
public ResponseEntity<?> myEndpoint() { ... }

The validator reads the updated config at runtime — no restart required if using Spring Cloud Config with refresh scope.

securityjwtkeycloakrbacoauth2