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.
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
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 TapGET /properties· search · featured · stats · slug lookupGET /agencies/public· slug · searchGET /lookup/**,/locations/**POST /inquiries/properties/**,POST /contact-usPOST /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 platform | global | full · cross-tenant via bypass | full | can approve / publish anything | edit · approve | full |
| AGENCY_SUPER_ADMIN tenant | own agency | full | full | approve agency content | edit own | open tickets |
| AGENCY_ADMIN tenant | own agency | full | denied | — | edit own | open tickets |
| AGENT tenant | own agency | own listings | denied | — | — | open tickets |
| PROPERTY_OWNER individual | self | own (CPLK individual agency) | pay-as-you-go | — | — | open tickets |
| PROPERTY_APPROVER platform | cross-tenant | approve/reject/permanently reject | denied | property only | — | — |
| FINANCIAL_OFFICER platform | cross-tenant | read | verify slips · process refunds | — | — | — |
| BLOG_EDITOR platform | cross-tenant | — | — | submit drafts | create/edit blogs | — |
| CUSTOMER_SUPPORT_AGENT platform | cross-tenant | read | read | — | — | full ticket management |
| PROPERTY_VIEWER public | self | read · favourite · inquire | — | — | — | — |
@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.
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
@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:
| Endpoint | Limit |
|---|---|
/auth/login | 5 / min |
/auth/register | 3 / hour |
/auth/forgot-password | 3 / hour |
/inquiries/properties/{id} | 10 / hour |
/contact-us | 5 / 15 min |
/blogs/{id}/view | 10 / min |
/blogs (public list) | 60 / min |
| Default | 100 / 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 20Mmatches 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 inPII_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)
| Threat | Control | Verification |
|---|---|---|
| Cross-tenant read/write | JWT-driven Hibernate filter on every repo call | TenantIsolationIntegrationTest · ArchUnit guard on bypass annotation |
| Privilege escalation | Role hierarchy in Role.java, method @PreAuthorize, RBAC E2E | Playwright RBAC negative tests |
| Token forgery / replay | OAuth2 resource server, JWKS validation, stateless sessions | Spring Security defaults; tested via mock JWT |
| Payment fraud / replay | Webhook signature + UNIQUE order_id + locked read | PaymentIdempotencyIntegrationTest |
| Brute force / spam | Bucket4j + reCAPTCHA + Keycloak lockout | Rate-limit unit tests |
| XSS in rich text | OWASP HTML sanitiser on body fields | Unit tests on sanitiser config |
| PII leak in logs/exports | Field-level encryption + masked toString | Code review; logs never include raw PII |
| Webhook impersonation | MD5 hash with shared secret, source-IP allowlist at Nginx | Service-level hash verification |
@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.