Platform · Keycloak · RBAC · Tenancy

Security, IAM & tenancy.

Three layers in one mental model: identity (Keycloak), authorisation (RBAC + method security), isolation (Hibernate tenant filter). Cross-tenant access is the platform's primary blast-radius risk — everything on this page is designed to reduce it.

Identity · Keycloak

Authentication is fully delegated to Keycloak. The API never sees passwords. It validates incoming JWTs against a cached JWKS and maps claims into Spring authorities.

flowchart LR classDef ext fill:#fff7ed,stroke:#f97316,color:#0f172a classDef app fill:#ecfeff,stroke:#0891b2,color:#0f172a classDef store fill:#eef2ff,stroke:#1e3a5f,color:#0f172a USER["Browser"] KC["Keycloak
auth.cplk.org
realm: cplk"]:::ext FE["Next.js
(Keycloak.js)"]:::app API["Spring API
OAuth2 resource server"]:::app JWKS["JWKS endpoint"]:::ext USER --> FE FE -- "OIDC code flow" --> KC KC -- "access_token + refresh_token" --> FE FE -- "Authorization: Bearer JWT" --> API API -- "validate signature (cached)" --> JWKS API -- "admin user/org/role mgmt" --> KC
Public URL is https://auth.cplk.org/realms/cplk; internal JWKS is fetched via the private network for low-latency signature validation.

SecurityConfig in one screen

// backend/.../config/SecurityConfig.java (excerpt)
http
  .csrf(AbstractHttpConfigurer::disable)
  .cors(c -> c.configurationSource(corsConfigSource))
  .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
  .addFilterBefore(rateLimitFilter, BearerTokenAuthenticationFilter.class)
  .addFilterAfter(tenantFilter,    BearerTokenAuthenticationFilter.class)
  .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt
      .jwkSetUri(props.jwkSetUri())
      .jwtAuthenticationConverter(jwtAuthorityConverter())))
  .authorizeHttpRequests(a -> a
      .requestMatchers(PUBLIC_GET).permitAll()
      .requestMatchers(PUBLIC_POST).permitAll()
      .anyRequest().authenticated());

Public endpoints

  • /auth/** — login, register, refresh, reset, One Tap
  • GET /properties · search · featured · stats · slug lookup
  • GET /agencies/public · slug · search
  • GET /lookup/**, /locations/**
  • POST /inquiries/properties/**, POST /contact-us
  • POST /payments/notify (PayHere webhook)
  • GET /blogs, /articles + slug routes
  • /actuator/health, /actuator/info

Claim → authority mapping

The converter reads roles from two sources and prefixes each with ROLE_:

// realm roles
jwt.claim("realm_access").asMap()
   .get("roles") .stream()
   .map(r -> "ROLE_" + r)

// custom claim emitted on token issue
jwt.claim("role") -> "ROLE_" + value

Custom claim agency_id drives tenant isolation (see below).

Roles & permission matrix

11 roles, defined in com.cplk.api.user.Role:

Role Scope Property Billing Approvals Content Support
SUPER_ADMIN platformglobalfull · cross-tenant via bypassfullcan approve / publish anythingedit · approvefull
AGENCY_SUPER_ADMIN tenantown agencyfullfullapprove agency contentedit ownopen tickets
AGENCY_ADMIN tenantown agencyfulldeniededit ownopen tickets
AGENT tenantown agencyown listingsdeniedopen tickets
PROPERTY_OWNER individualselfown (CPLK individual agency)pay-as-you-goopen tickets
PROPERTY_APPROVER platformcross-tenantapprove/reject/permanently rejectdeniedproperty only
FINANCIAL_OFFICER platformcross-tenantreadverify slips · process refunds
BLOG_EDITOR platformcross-tenantsubmit draftscreate/edit blogs
CUSTOMER_SUPPORT_AGENT platformcross-tenantreadreadfull ticket management
PROPERTY_VIEWER publicselfread · favourite · inquire
How it's enforced: method-level @PreAuthorize on controllers (hasAnyRole(...), @securityUtils.isAgencySuperAdmin(#agencyId)), plus the tenant filter at the persistence layer. Both must hold — a role check alone is never enough.

Tenant isolation

Every authenticated request goes through one tenant-aware lifecycle. Anything that bypasses it is intentional and marked with @BypassTenantFilter.

flowchart TB A["JWT claim
agency_id"] --> B["TenantFilter
(servlet)"] B --> C["TenantContext.setCurrentTenant(id)
ThreadLocal<UUID>"] C --> D["@RestController"] D --> E["@Service @Transactional"] E --> F["repository call"] F --> G["TenantFilterAspect
around advice"] G -- "@BypassTenantFilter? yes" --> I["Hibernate session
(no filter enabled)"] G -- "no" --> H["session.enableFilter(tenantFilter, agencyId)"] H --> J["SQL · WHERE agency_id = ?"] I --> J2["SQL · unrestricted"] J & J2 --> K["response builds"] K --> L["finally → TenantContext.clear()"] classDef red fill:#fef2f2,stroke:#dc2626,color:#0f172a class G,I red
The only path that skips the filter is a method annotated @BypassTenantFilter. The aspect logs every such call so that a future dashboard can alert on anomalies.

When @BypassTenantFilter is used

  • SUPER_ADMIN reading any agency's data from the platform admin UI.
  • PROPERTY_APPROVER's queue (must see properties across tenants).
  • FINANCIAL_OFFICER refund & slip queues.
  • BLOG_EDITOR draft review.
  • System jobs (recurring charges, expiry sweeps, cleanup) — no JWT present.

Rate limiting

Bucket4j buckets per (endpoint, IP). Enabled by app.rate-limit.enabled. Limits are tuned to slow brute-force and form-spam attacks without blocking real users:

EndpointLimit
/auth/login5 / min
/auth/register3 / hour
/auth/forgot-password3 / hour
/inquiries/properties/{id}10 / hour
/contact-us5 / 15 min
/blogs/{id}/view10 / min
/blogs (public list)60 / min
Default100 / min

Behind a proxy, TRUST_PROXY_HEADERS=true reads X-Forwarded-For to identify the real client IP.

Application hardening

Transport & headers

  • Nginx terminates TLS, adds HSTS (1 y), X-Frame-Options SAMEORIGIN, X-Content-Type-Options nosniff.
  • client_max_body_size 20M matches the API's upload limits.
  • Frontend ships matching headers via Next.js config for the Cloudflare Pages path.

Input & output

  • @Valid + Bean Validation on every request body.
  • OWASP HTML sanitiser on blog/article bodies (XSS).
  • EXIF metadata stripped from uploaded images.
  • Errors mapped through GlobalExceptionHandler; stack traces never leak in production responses.

Crypto & data at rest

  • PII columns encrypted by EncryptedStringConverter; key in PII_ENCRYPTION_KEY.
  • No card data persisted — only PayHere's customer_token.
  • Passwords live only in Keycloak (bcrypt by default).

CAPTCHA & bots

  • reCAPTCHA v3 on signup, password reset, public forms (RECAPTCHA_THRESHOLD=0.5).
  • Honeypot fields on contact forms.
  • Bucket4j rate limits as the second line.

Audit & financial trail

Two separate logs, both immutable, both queryable:

AuditLog

Every write goes through AuditService.log(...). Captures:

  • Actor (user, email, role, agency, IP, UA).
  • Action (CREATE, UPDATE, DELETE, LOGIN, PUBLISH, BOOST, SUBSCRIBE, ROLE_CHANGE, SYSTEM_EVENT).
  • Entity (type, id) and old/new JSONB values.
  • Severity, event category, request id, metadata.

Written @Async in a REQUIRES_NEW transaction — never rolls back business logic.

FinancialAuditLog

Separate immutable ledger for money & credit:

  • Event types: PAYMENT_SUCCESS / FAILED, CREDITS_EARNED / SPENT, BOOST_APPLIED, SUBSCRIPTION_RENEWED, REFUND_ISSUED.
  • Amount sign convention: positive = inflow, negative = outflow.
  • Indexes on entity_type + entity_id, agency_id, event_type.

Verification

  • TenantIsolationIntegrationTest · seeds two agencies, asserts every JPA query stays inside its tenant.
  • PaymentIdempotencyIntegrationTest · fires the same webhook twice, asserts a single SUCCESS row.
  • CascadeDeleteIntegrationTest · verifies cascade rules don't bleed across tenants.
  • Playwright RBAC suites · 8 spec files cover negative paths (e.g. FINANCIAL_OFFICER cannot mint a property, AGENT cannot view billing).
  • ArchUnit ApiResponse / EntityLeakage rules · prevent leaking entities directly to the client.

Threat model (brief)

ThreatControlVerification
Cross-tenant read/writeJWT-driven Hibernate filter on every repo callTenantIsolationIntegrationTest · ArchUnit guard on bypass annotation
Privilege escalationRole hierarchy in Role.java, method @PreAuthorize, RBAC E2EPlaywright RBAC negative tests
Token forgery / replayOAuth2 resource server, JWKS validation, stateless sessionsSpring Security defaults; tested via mock JWT
Payment fraud / replayWebhook signature + UNIQUE order_id + locked readPaymentIdempotencyIntegrationTest
Brute force / spamBucket4j + reCAPTCHA + Keycloak lockoutRate-limit unit tests
XSS in rich textOWASP HTML sanitiser on body fieldsUnit tests on sanitiser config
PII leak in logs/exportsField-level encryption + masked toStringCode review; logs never include raw PII
Webhook impersonationMD5 hash with shared secret, source-IP allowlist at NginxService-level hash verification
Open hardening items. 1) Structured event for every @BypassTenantFilter call with caller class/method, to feed an anomaly dashboard. 2) Daily reconciliation job comparing payments to PayHere's settlement report. 3) Automatic Keycloak session revocation on role downgrade.