Skip to content

Security & Identity

Full detail lives in Security Posture and Keycloak & Auth - this page is the map.

Trust boundary

Browser / client
    | HTTPS + Authorization Code + PKCE
    v
Keycloak (issuer) --JWT (access + refresh)--> client
    |
    v
API Gateway --validate JWT via realm JWKS--> strip client identity headers,
                                              inject X-User-Id / X-User-Roles from the verified token
    | HTTP inside the cluster (gateway-behind-trust)
    v
Downstream services --trust the forwarded identity headers--> @PreAuthorize / mediator AuthorizationRule

Keycloak is the identity provider and the only JWT issuer. No service mints or refreshes tokens. identity-service manages users/roles/permissions through the Keycloak Admin API and owns app-specific authorization data plus its own audit trail - it is explicitly not a token issuer (ADR-011).

Realm and roles

Realm telco-crm, defined in infra/docker/keycloak/realm/realm-export.json, imported on container startup for local development.

Realm role Maps to persona
SUBSCRIBER End-user subscriber
CALL_CENTER_AGENT Support agent
DEALER Field / retail dealer
MARKETING_MANAGER Marketing manager
BILLING_OPERATOR Billing / finance operator
ADMIN Platform administrator
SERVICE Internal service-to-service caller (dev only)

Roles are exposed as a flat roles claim (the telco-roles client scope) that both the gateway and starter-security read for RBAC. Two clients: telco-web (public, Authorization Code + PKCE, the browser flow) and telco-gateway (confidential, service accounts for dev-only service-to-service testing).

Authorization

Role/permission-based via @PreAuthorize and the mediator's AuthorizationRule, applied to admin and privileged endpoints. Both Spring Security's and the platform's own AccessDeniedException map to HTTP 403 through the shared GlobalExceptionHandler.

PII protection

  • At rest: Customer TCKN/VKN is AES-256-GCM encrypted (random 12-byte IV per record) via a JPA attribute converter; the database column stores ciphertext only. Payment card data is never stored at all - payment-service delegates to a PSP.
  • In telemetry (ADR-021): a @Sensitive annotation drives a masking ObjectMapper for structured JSON logs (the primary control), with a Logback pattern-converter as a free-text backstop covering email, IBAN, PAN, MSISDN, and TCKN. Traces and metrics never carry PII in span attributes or metric labels. Masking applies only to logs and persisted request/exception logs - it never touches outbox/Kafka payloads, API responses, or a service's own database rows, which is the whole point: wire data stays correct, only observability surfaces are masked.

Rate limiting

Redis-backed fixed-window limiter at the gateway: 100 requests/minute, keyed by JWT subject (or client IP for unauthenticated allowlisted routes), via an atomic INCR+EXPIRE Lua script. Exceeding the limit returns HTTP 429 in the standard ApiResult error envelope. It fails open on a Redis outage - a deliberate availability-over-strictness tradeoff, the mirror image of starter-lock's fail-closed behavior.

Service-to-service trust (mTLS)

The MVP ran internal traffic over plain HTTP inside the cluster under the gateway-behind-trust model, with the residual risk (an in-cluster attacker forging identity headers to bypass the gateway) explicitly accepted and compensated by NetworkPolicies and namespace isolation. Sprint 19 closes this gap: Linkerd (edge channel, chosen over Istio and bare SPIFFE/SPIRE) provides automatic sidecar mTLS on all in-cluster traffic plus default-deny NetworkPolicies, as a second, independent trust layer alongside the existing JWT/gateway model - neither layer replaces the other. See ADR-026.

Secrets

Local development uses committed-default Kubernetes Secrets. Sprint 18 introduces HashiCorp Vault (in-cluster, standalone Raft storage) delivered via the Secrets Store CSI Driver syncing into native Kubernetes Secrets - chosen specifically so no application or Dockerfile code has to change, since services keep consuming secrets via envFrom.secretRef exactly as before. See ADR-025.

Error handling

The platform's GlobalExceptionHandler maps every exception to a status and an ApiResult error envelope carrying a stable code, traceId, and a server-side logId - never a stack trace or raw exception message. Full detail is logged server-side and referenced by logId only.

For the complete picture (key rotation, audit logging scope, production hardening checklist), read Security Posture in full.