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.

0/0

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
kafka-opsROLE_ADMINGET/POST /api/admin/topics, GET /api/admin/topics/describe
(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:

> Java code-highlight
@AllowedRoles("vault-ops")
@GetMapping("/vault-approle")
public ResponseEntity<?> getAppRoles() { ... }

@AllowedRoles(KAFKA_OPS) // "kafka-ops"
@GetMapping("/topics")
public ResponseEntity<CompletableFuture<List<TopicListingDto>>> listTopics(...) { ... }

@AllowedRoles is now the standard — VaultOperationsController.updateVaultSecret was migrated from inline @PreAuthorize to @AllowedRoles(VAULT_OPS), and all KafkaAdminController endpoints use @AllowedRoles(KAFKA_OPS).


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