-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture Security
Authentication, authorization, secrets, encryption, OWASP coverage, threat-model summary, secure coding practices. Companion to Architecture-Overview, Threat-Modeling, Architecture-Resilience. Detailed threat-model per service in Threat-Modeling.
- Zero trust - every request authenticated, every call authorized, even within the cluster.
- Defense in depth - multiple layers (TLS + auth + authz + invariants + audit + SIEM).
- Least privilege - services and humans get exactly the permissions they need, nothing more.
- Auditable by default - every state-changing action logged with actor, timestamp, intent.
- Secrets out of code - never in repos, never in env vars, always via secret store.
- Cryptographic agility - algorithms are config, rotation is built-in.
- PII minimization - store only what's required, encrypt what's stored, log only references.
Identity Provider: Keycloak 26.6.1 bundled in OSS docker-compose; production deployments use external OIDC (Auth0, Okta, Cognito, internal Keycloak HA).
Realm: fincore
Clients:
| Client | Type | Use |
|---|---|---|
fincore-gateway |
confidential bearer-only | Internal validation of JWTs |
fincore-cli |
public | CLI tool (PKCE flow) |
fincore-dashboard |
public | Operator UI (PKCE flow) |
fincore-api-client |
confidential | Adopter machine-to-machine (client_credentials) |
Authentication flows:
- OAuth2 client_credentials: machine-to-machine API access
- OAuth2 authorization_code + PKCE: human users via dashboard/CLI
- OAuth2 refresh_token: long-lived sessions
Access tokens: RS256-signed JWTs with TTL = 5 minutes:
{
"iss": "https://keycloak.example.com/realms/fincore",
"aud": ["fincore-api"],
"sub": "01HX...user-id",
"iat": 1714050000,
"exp": 1714050300,
"azp": "fincore-api-client",
"scope": "openid ledger:read ledger:write payments:initiate",
"realm_access": {
"roles": ["LEDGER_WRITER", "PAYMENTS_INITIATOR"]
},
"resource_access": {
"fincore-api": {
"roles": ["LEDGER_WRITER"]
}
},
"tenant": null,
"actor_id": "01HX...service-id"
}Refresh tokens: TTL 30 days, rotation on each use.
Performed at the API Gateway, cached per request to avoid duplicate work:
@Component
class JwtValidator(
private val jwksClient: JwksClient,
@Qualifier("jwksCache") private val jwksCache: Cache<String, JWK>,
) {
suspend fun validate(token: String): Principal {
val jwt = SignedJWT.parse(token)
val kid = jwt.header.keyID
val jwk = jwksCache.getIfPresent(kid)
?: jwksClient.fetchKey(kid).also { jwksCache.put(kid, it) }
val verifier = RSASSAVerifier(jwk.toRSAKey().toRSAPublicKey())
require(jwt.verify(verifier)) { "JWT signature invalid" }
val claims = jwt.jwtClaimsSet
require(claims.expirationTime.after(Date())) { "JWT expired" }
require(claims.issuer == EXPECTED_ISSUER) { "JWT issuer wrong" }
require(EXPECTED_AUDIENCE in claims.audience) { "JWT audience wrong" }
return Principal(
subject = claims.subject,
scopes = claims.getStringClaim("scope").split(" "),
roles = claims.getStringListClaim("realm_access.roles"),
actorId = claims.getStringClaim("actor_id"),
)
}
}- Keycloak rotates signing keys every 30 days
- Gateway pre-fetches keys with KID; expired keys evicted from cache
- During rotation: both old and new keys present in JWKS, no downtime
- Compromised key revocation: realm-level rollover, all tokens invalidated within 5 min (TTL)
Roles map to business functions, not individual permissions:
| Role | Allowed actions |
|---|---|
LEDGER_READER |
GET accounts, balances, transactions, entries |
LEDGER_WRITER |
LEDGER_READER + create/update accounts |
LEDGER_POSTER |
LEDGER_READER + post transactions |
LEDGER_REVERSER |
LEDGER_READER + reverse transactions |
LEDGER_ADMIN |
All ledger actions |
PAYMENTS_INITIATOR |
Initiate payments |
PAYMENTS_VIEWER |
View payments |
PAYMENTS_ADMIN |
Cancel, manual override |
COMPLIANCE_VIEWER |
View KYC sessions, AML alerts, cases |
COMPLIANCE_RESOLVER |
+ resolve cases |
COMPLIANCE_ADMIN |
+ manage rules, approve manual overrides |
DECISION_VIEWER |
View rules, evaluations |
DECISION_ADMIN |
Manage rules + activate |
WEBHOOK_ADMIN |
Manage subscriptions |
SYSTEM_OPERATOR |
Operational endpoints (replay, reconciliation, runbooks) |
SYSTEM_AUDITOR |
Read-only access to all logs and audit trails (cannot modify state) |
For machine-to-machine (M2M) calls, OAuth2 scopes refine permissions:
| Scope | Purpose |
|---|---|
ledger:read |
GET ledger resources |
ledger:write |
All ledger writes (combined with role check) |
payments:initiate |
Initiate payments |
compliance:resolve |
Resolve cases |
decision:evaluate |
Call decision engine |
decision:admin |
Manage rules |
webhook:subscribe |
Manage own subscriptions |
Both role AND scope must match for an endpoint to authorize. This is intentional: scopes limit what a token can do, roles limit what a user may do, and a third-party with a delegated token can't escalate.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
.csrf { it.disable() } // Stateless API, no session
.cors { it.configurationSource(corsConfig()) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/v1/health/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/v1/webhooks/**").permitAll() // signature-checked separately
.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt ->
jwt.jwtAuthenticationConverter(JwtAuthenticationConverter().also {
it.setJwtGrantedAuthoritiesConverter(rolesConverter())
})
}
}
.sessionManagement { it.sessionCreationPolicy(STATELESS) }
.headers { headers ->
headers
.frameOptions { it.deny() }
.contentSecurityPolicy { it.policyDirectives(CSP_POLICY) }
.strictTransportSecurity { it.maxAgeInSeconds(31536000).preload(true) }
.xssProtection { it.disable() } // CSP supersedes
.referrerPolicy { it.policy(STRICT_ORIGIN_WHEN_CROSS_ORIGIN) }
}
.build()
}Method-level checks:
@RestController
class TransactionController(private val service: TransactionService) {
@PostMapping("/v1/transactions")
@PreAuthorize("hasRole('LEDGER_POSTER') and hasAuthority('SCOPE_ledger:write')")
suspend fun post(@Valid @RequestBody req: PostTransactionRequest): ResponseEntity<TransactionResponse> { ... }
@PostMapping("/v1/transactions/{id}/reverse")
@PreAuthorize("hasRole('LEDGER_REVERSER') and hasAuthority('SCOPE_ledger:write')")
suspend fun reverse(@PathVariable id: UUID, @Valid @RequestBody req: ReverseRequest): ResponseEntity<TransactionResponse> { ... }
}| Boundary | Protocol | Notes |
|---|---|---|
| Public internet ↔ Ingress | TLS 1.3 (mandatory), ECDSA P-256 cert | Let's Encrypt or commercial CA |
| Ingress ↔ Gateway | TLS 1.2+ (in-cluster) | mTLS recommended via service mesh |
| Gateway ↔ Services | TLS optional in v0.1, mTLS in v1.0+ | service mesh (Istio) recommended |
| Services ↔ DB | TLS mandatory | self-signed CA OK if cluster-internal |
| Services ↔ Kafka | SASL_SSL (Kafka native) | TLS + SASL/SCRAM authentication |
| Services ↔ Redis | TLS (rediss://) |
mandatory in production |
| Services ↔ External providers | TLS 1.2+ + cert pinning where supported | per-provider config |
- TLS 1.3 only for new connections (where supported)
- TLS 1.2 fallback with whitelisted ciphers:
ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256 - No SSL 3.0, no TLS 1.0/1.1, no static RSA, no MD5, no SHA-1, no CBC ciphers
- HSTS header:
max-age=31536000; includeSubDomains; preload
Default-deny, with explicit allows:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-default
namespace: fincore-engine
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ledger-allow
namespace: fincore-engine
spec:
podSelector:
matchLabels: { app: ledger-service }
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels: { app: api-gateway }
ports: [{ port: 8080 }]
- from:
- podSelector:
matchLabels: { app: payment-service }
ports: [{ port: 8080 }]
egress:
- to:
- podSelector:
matchLabels: { app: postgres-primary }
ports: [{ port: 5432 }]
- to:
- namespaceSelector:
matchLabels: { name: kube-system }
ports: [{ port: 53, protocol: UDP }] # DNS| Tier | Use |
|---|---|
| Vault / AWS Secrets Manager / GCP Secret Manager / K8s Secrets with KMS | All production secrets |
.env files |
Dev only, gitignored |
| Plain env vars | Forbidden in production for sensitive values |
| Code constants | Forbidden always |
| Secret | Where | Rotation |
|---|---|---|
| DB credentials | Vault | 30 days |
| Kafka SASL credentials | Vault | 90 days |
| Redis password | Vault | 90 days |
| Keycloak admin password | Vault, sealed | manual, audited |
| Keycloak client secrets | Vault, per-client | 90 days |
| Webhook subscription HMAC keys | DB (encrypted column with KMS-DEK) | client-controlled |
| External provider API keys (KYC, bank, sanctions, LLM) | Vault | per provider policy |
| TLS private keys | cert-manager + cluster issuer | 90 days (Let's Encrypt) |
spring:
cloud:
vault:
authentication: KUBERNETES
kubernetes:
role: fincore-ledger
uri: ${VAULT_ADDR}
kv:
enabled: true
backend: secret
default-context: fincore/ledger
# values pulled by name
spring.datasource.password: ${vault.fincore.ledger.db_password}- Postgres: TDE via filesystem-level encryption (LUKS) and/or column-level encryption for PII
- Keycloak DB: same as above
- Kafka topic data: Redpanda native encryption-at-rest enabled
- Redis: encryption-at-rest if backed by ElastiCache/MemoryStore; minimum TLS in transit
For columns containing PII (KYC evidence references, customer notes, encrypted API tokens):
@Entity
@Table(name = "kyc_documents")
class KycDocumentRecord(
@Id val id: UUID,
@Column(name = "external_ref")
@Convert(converter = AesGcmConverter::class)
val externalRef: String, // encrypted at rest with DEK from Vault
val createdAt: Instant,
)AesGcmConverter uses AES-256-GCM, DEK fetched from Vault per service, KEK rotated quarterly.
| # | OWASP | FinCore mitigation |
|---|---|---|
| A01 | Broken Access Control | RBAC + scope-based + method-level @PreAuthorize + integration tests asserting deny |
| A02 | Cryptographic Failures | TLS 1.3, AES-256-GCM, RS256 JWT, no MD5/SHA-1, secrets in Vault |
| A03 | Injection | JPA prepared statements only (no native string concat), input validation via Jakarta Bean Validation, CSP headers |
| A04 | Insecure Design | Threat-modeling done per service (Threat-Modeling), invariants enforced at multiple layers |
| A05 | Security Misconfiguration | Security headers via Spring Security, TLS-only, default-deny NetworkPolicy, non-root containers, distroless base images |
| A06 | Vulnerable Components | Dependabot weekly, OWASP Dependency-Check in CI, Trivy scan on Docker images, monthly base image refresh |
| A07 | Auth & Session Failures | Stateless JWT, short TTLs, refresh rotation, no plaintext credentials, account lockout via Keycloak |
| A08 | Software & Data Integrity Failures | Signed Docker images (cosign), signed git commits, SBOM (CycloneDX), npm/Maven dependency lock |
| A09 | Logging & Monitoring Failures | Structured logging, MDC correlation IDs, Prometheus metrics, alert on auth failures, audit log retention 7 years |
| A10 | Server-Side Request Forgery | Outbound calls only to allowlisted domains, no user-supplied URLs in /v1/* (except webhook subscriptions which are explicitly user-owned and rate-limited) |
Subscriptions allow user-supplied URLs. To prevent SSRF:
fun validateWebhookUrl(url: HttpUrl): Boolean {
require(url.scheme == "https") { "https only" }
val host = url.host
// Block private IPs
val ip = InetAddress.getByName(host)
require(!ip.isLoopback && !ip.isSiteLocalAddress && !ip.isAnyLocalAddress && !ip.isLinkLocalAddress) {
"private IP not allowed"
}
require(!isMulticast(ip)) { "multicast not allowed" }
// Block metadata services
require(host !in BLOCKED_METADATA_HOSTS) { "metadata host blocked" }
require(!ip.hostAddress.startsWith("169.254.")) { "metadata IP blocked" }
// Optional: allowlist if extra paranoid
return true
}
val BLOCKED_METADATA_HOSTS = setOf(
"metadata.google.internal",
"instance-data.ec2.internal",
"169.254.169.254",
)- Bean validation at controller (Jakarta Bean Validation):
data class PostTransactionRequest(
@field:NotBlank @field:Size(max = 255) val reference: String,
@field:Size(max = 2048) val description: String?,
@field:NotEmpty @field:Size(min = 2, max = 1000)
val entries: List<EntryRequest>,
@field:Valid val metadata: Map<String, String>?,
)
data class EntryRequest(
@field:NotNull val accountId: UUID,
@field:NotNull @field:DecimalMin("-1000000000000") @field:DecimalMax("1000000000000")
val amount: BigDecimal,
@field:NotBlank @field:Pattern(regexp = "^[A-Z]{3}$") val currency: String,
@field:NotNull val direction: EntryDirection,
)- Domain invariant in entity constructor (last line of defense):
class Transaction(...) {
init {
require(reference.length in 1..255)
require(entries.size in 2..1000)
// SUM=0 check ...
}
}- DB constraints: NOT NULL, CHECK, FK, deferred trigger
- JSON depth limit: max 10 nested levels (anti-billion-laughs)
- Request body size: max 1 MB (configurable per route)
- Query string length: max 8 KB
- Header size: max 32 KB
- Header count: max 100
Spring Boot defaults adjusted:
server:
max-http-header-size: 32KB
spring:
servlet:
multipart:
max-file-size: 1MB
max-request-size: 1MB
mvc:
async:
request-timeout: 30000| Tier | Limit | Per | Storage |
|---|---|---|---|
| Per-IP global | 100 req/sec, 1000 burst | IP | Redis |
| Per-authenticated-user | 1000 req/sec | user_id | Redis |
| Per-endpoint global | varies | endpoint+IP | Redis |
| Per-IP webhook | 10 req/sec | IP | Redis |
- Token bucket for normal endpoints (smoother)
- Sliding window log for sensitive endpoints (auth, webhooks)
spring:
cloud:
gateway:
default-filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@principalKeyResolver}"HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714050300
Content-Type: application/problem+json
{
"type": "https://docs.fincore.dev/errors/rate-limit",
"title": "Too many requests",
"status": 429,
"detail": "Rate limit exceeded for principal abc-123"
}
| Event | Where stored | Retention |
|---|---|---|
| All state-changing API calls | structured log + audit_events table |
7 years |
| All authentication events (success + failure) | Keycloak audit + log | 1 year |
| All authorization decisions (allow/deny) | log | 1 year |
| All decision engine evaluations | decision_logs |
7 years |
| All compliance case actions |
case_notes + compliance_events
|
7 years |
| All webhook deliveries | webhook_deliveries |
1 year |
| All admin actions (rule changes, manual overrides) |
audit_events table |
forever |
| All ledger writes |
transactions + entries (immutable journal) |
forever |
{
"id": "audit_01HX...",
"occurredAt": "2026-04-25T10:00:00Z",
"actor": {
"type": "USER",
"id": "01HX...",
"displayName": "user@example.com",
"ipAddress": "203.0.113.42",
"userAgent": "Mozilla/5.0..."
},
"action": "transaction.posted",
"resourceType": "Transaction",
"resourceId": "tx_01HX...",
"result": "SUCCESS",
"context": {
"correlationId": "01HX...",
"requestId": "01HX...",
"method": "POST",
"path": "/v1/transactions",
"statusCode": 201
},
"diff": {
"before": null,
"after": {
"id": "tx_01HX...",
"reference": "demo-001",
"..."
}
}
}All security-relevant events ship to SIEM (Splunk, Sumo Logic, Elastic SIEM, in-cluster Wazuh):
- Authentication failures
- Authorization denials
- Rate limit hits beyond threshold
- Anomalous transaction patterns
- Admin actions
- Network policy violations (from Falco/Cilium)
-
Distroless Java image (
gcr.io/distroless/java21-debian12:nonroot) - No shell, no package manager, no curl, minimal CVE surface
- USER nonroot (UID 65532)
- Read-only root filesystem (writable mounts only for tmp, logs)
FROM gradle:8.14-jdk21 AS builder
WORKDIR /app
COPY --chown=gradle:gradle . .
RUN ./gradlew :services:ledger:bootJar --no-daemon
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/services/ledger/build/libs/*.jar /app/app.jar
EXPOSE 8080
USER nonroot
ENTRYPOINT ["java", "-jar", "/app/app.jar"]- cosign signs every published image (keyless via Sigstore OIDC)
- CycloneDX SBOM generated and attached to image
- Trivy scan in CI fails on HIGH/CRITICAL vulnerabilities
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: ledger
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
requests: { memory: "512Mi", cpu: "200m" }
limits: { memory: "2Gi", cpu: "2" }PSA-restricted enforced at namespace level.
- Endpoint published per provider (
/v1/webhooks/payments/{providerId}) - HMAC-SHA256 signature in
X-Provider-Signatureheader - Per-provider shared secret in Vault, rotated quarterly
- Replay protection: timestamp in signed payload, reject events older than 5 min
-
processed_webhookstable dedups byprovider_event_id
- Subscriber-supplied secret stored encrypted at rest (column-level KEK from Vault)
- HMAC-SHA256 signature:
X-Signature: sha256=<hex>over raw body - Timestamp header:
X-Timestamp: <unix ms>for replay protection - Subscriber re-computes HMAC and verifies (sample code in API-Webhooks)
Production deployments can require webhook URLs match allowlist:
fincore:
webhook:
outbound:
url-allowlist:
- "https://*.example.com/**"
- "https://api.partner.example.com/**"If allowlist set, URLs not matching are rejected at subscription creation.
FinCore is not a PCI DSS environment by itself (no card data processing).
- Card data never enters FinCore - adopters using card processors handle PCI at their tokenization boundary.
- We support PCI Self-Assessment Questionnaire A (SAQ-A) for the rare adopter that uses redirect-based card collection.
- For SAQ-D adopters, network segregation is the adopter's responsibility (FinCore in non-CDE).
The architecture supports SOC 2 trust services criteria:
| Criterion | FinCore mechanism |
|---|---|
| Security | All of Section 1-10 above |
| Availability | SLOs (Architecture-SLA-SLI-SLO), DR plan (Architecture-Resilience), incident response runbook |
| Processing Integrity | Ledger invariants, idempotency, outbox correctness, audit trail |
| Confidentiality | Encryption at rest, encryption in transit, RBAC, secret management |
| Privacy | PII minimization, encryption, data deletion (right-to-be-forgotten in Y1 H2) |
| GDPR right | FinCore mechanism |
|---|---|
| Right of access |
GET /v1/users/{id}/data-export (Y1 H2) |
| Right to rectification | Update endpoints with audit log |
| Right to erasure | Soft delete + DEK rotation (data unrecoverable, audit-preserved) - Y1 H2 |
| Right to data portability | Same as access, JSON/CSV formats - Y1 H2 |
| Right to restriction | Account FROZEN state |
| Audit log of consent |
consent_log table (Y1 H2) |
FinCore provides the infrastructure for AML/KYC compliance - rule engine, case management, audit logs, screening orchestration. The regulatory ownership sits with the adopter / their banking partner / their compliance team. We are not a registered AML reporting entity.
Detailed threat model per service in Threat-Modeling. Top threats and mitigations:
| Threat | Mitigation |
|---|---|
| Compromised JWT signing key | Short TTL (5 min), JWKS rotation, revocation via realm rollover |
| Compromised service account | Vault-issued short-lived creds, audit on credential use |
| Insider with prod DB access | Audit log of every DB query, DB user audit, separate roles for app vs DBA |
| Webhook subscriber URL pivot to internal services | URL validation (anti-SSRF), allowlist option |
| Replay attack on webhook | Timestamp + dedup table |
| MITM on external provider call | TLS + cert pinning where supported |
| DoS via expensive endpoints | Rate limiting + circuit breaker on downstream |
| Data exfiltration via API | Per-user rate limiting, pagination caps, audit log review |
| Cache poisoning | Cache key includes principal context where relevant; signed cache entries for sensitive data |
| Race condition exploit | Optimistic locking + invariant triggers + idempotency |
Security policy in SECURITY.md:
We accept reports via:
- security@fincore.dev (PGP key: ...)
- GitHub Security Advisories (private)
Out of scope:
- Findings on dependencies (report to upstream)
- Issues in sandbox/dev configurations
- DoS via volumetric attacks
- Social engineering of maintainers
Response SLA:
- Acknowledgment within 48 hours
- Triage within 5 business days
- Fix within 30 days for HIGH/CRITICAL, 90 days for MEDIUM
- Bounties: case-by-case via GitHub Sponsors90-day public disclosure timeline if unfixed.
We pin and audit:
- Spring Boot - bi-weekly Dependabot + manual major version review
- Keycloak - monthly version review
- PostgreSQL JDBC driver - version-locked, tested
- Hibernate ORM - bumped with Spring Boot
- Liquibase - manually reviewed before bump
- Kafka client - version-locked with broker
- jackson-databind - Dependabot daily
- Apache Commons - known offender, minimal use, locked
Daily Dependabot scan + weekly OWASP Dependency-Check in CI.
- Annual external pentest (Y1 H2 target - recommended cadence for production fintech adopters)
- Quarterly internal red team (Y2+)
- Continuous fuzzing of API surface (planned via Schemathesis from OpenAPI spec - Y1 H2)
Findings ranked CVSS, tracked as security issues with strict SLA.
For operator UI / admin actions:
- Mandatory MFA for all admin roles (enforced at Keycloak level)
- Session TTL 30 minutes for admin UI; auto-logout
- Re-authentication for sensitive actions (rule activation, case escalation, production secret rotation)
- Privileged Access Management (PAM) integration for credentials with full DB access
- Audit log of every admin click - operator UI tracks UI actions in addition to API actions
Before tagging any v0.x.0:
- No secrets in repo (
gitleaksscan) - All dependencies up to date or pinned with reason
- Trivy scan clean (no HIGH/CRITICAL CVEs)
- CodeQL scan clean
- OWASP Top 10 coverage matrix updated
- All endpoints have
@PreAuthorize(orpermitAllwith explicit reason) - All controller methods have integration test asserting auth/authz
- All entities with PII columns have
@Convertencryption - No
println, no debug logs in production code paths - No hardcoded URLs to production services
- SBOM generated and attached
- Image signed with cosign
- SECURITY.md current (PGP key valid)
- Threat model reviewed for any new aggregate
- Penetration test done before v1.0
- Architecture-Overview - high-level architecture
- Threat-Modeling - STRIDE per service
- Architecture-Resilience - caching, sagas, fault tolerance
- Architecture-SLA-SLI-SLO - SLOs and error budgets
- ADR-0005 - Keycloak adoption rationale
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO