Skip to main content

Security Architecture

This page documents the current security posture of Trust Relay. The system has been through a dedicated security remediation phase (Phase 4) and a subsequent multi-tenancy implementation (Pillar 0) that established Keycloak 26 as the sole authentication mechanism with Row Level Security across all tenant-scoped tables.

Implemented Security Measures

Officer Authentication (Keycloak 26 + JWT/JWKS)

Officer-facing API endpoints are protected by JWT-based authentication via the get_current_user dependency in app/api/deps/auth.py. Authentication is provided exclusively by Keycloak 26.

How it works:

  • Every request requires a valid Authorization: Bearer <token> header
  • The JWT is validated against the Keycloak JWKS endpoint configured via auth_jwks_url
  • Issuer (iss) and audience (aud) claims are verified
  • JIT (Just-In-Time) user provisioning ensures database user records are created on first login via _ensure_db_user() using the UserRepository
# JWKS key rotation: keys are cached for 5 minutes
_JWKS_TTL_SECONDS = 300

Role-Based Access Control (RBAC) — ADR-0074, Phase 2 enforcing since 2026-06-28: Access is governed by a Permission enum and a ROLE_PERMISSIONS matrix (app/api/deps/permissions.py), not by ad-hoc role-name checks. Six roles form a permission lattice:

RolePermission set
officerCreate/investigate cases, raise SARs, make first-line decisions, interact with the copilot
mlroOfficer permissions plus SAR approve/file, four-eyes second approval, escalation sign-off
compliance_managerAlias of mlro (same permission set)
tenant_adminMLRO permissions plus tenant configuration and user management within the tenant
super_adminFull system access, cross-tenant management (see SSE impersonation below, ADR-0081)
auditorRead-only (CASE_READ) — cases, audit trails, investigation results

The require_permission(...) FastAPI dependency guards protected endpoints. The rollout was phased (ADR-0074): Phase 1 logged rbac.would_deny telemetry while allowing the request; Phase 2 (active, rbac_enforcement_enabled=True in app/config.py) returns HTTP 403 on a missing permission, and unknown or blank roles fail closed. rbac_enforcement_enabled remains as a documented kill-switch.

:::warning Endpoint coverage is not yet uniform require_permission must be attached per-router/endpoint to take effect; a handful of endpoints are still mounted with only Depends(get_current_user) and therefore accept any authenticated tenant user regardless of role. Treat router-level auth as authentication present, authorization varying until every state-changing route carries an explicit permission. See the known gaps register. :::

Multi-Tenancy (Row Level Security)

Tenant-scoped database tables carry PostgreSQL Row Level Security (RLS) policies (ADR-0023) created across Alembic migrations with FORCE ROW LEVEL SECURITY enabled. Each table has a tenant_isolation_* policy keyed on the app.current_tenant GUC and an admin_bypass_* policy keyed on app.rls_bypass. The session layer (app/db/database.py) sets the tenant context on every connection via set_config('app.current_tenant', ..., true):

  • get_session() sets the demo tenant (00000000-0000-0000-0000-000000000001) as the default RLS context
  • get_tenant_session(tid) sets an explicit tenant context (transaction-local GUC)
  • get_admin_session() sets app.rls_bypass='true' for cross-tenant administrative operations

:::tip RLS is ENFORCED as shipped (ADR-0050 / ADR-0051, since 2026-06-12) The application connects to PostgreSQL as the non-superuser role trustrelay_app — this is the database_url default in app/config.py (postgresql+asyncpg://trustrelay_app:...) and the runtime .env sets DATABASE_URL to the same role. Because trustrelay_app is NOSUPERUSER, it cannot bypass FORCE ROW LEVEL SECURITY, so the tenant_isolation_* policies are enforced by the database for every application query.

DDL runs through a separate connection URL: migration_database_url (app/config.py), which points at the superuser role and is used only by Alembic for migrations (the DDL/DML URL split, ADR-0050). The full backend test suite runs under RLS enforcement.

Deployment prerequisite: the trustrelay_app role must be provisioned (scripts/create_app_role.sql / migration 060) and DATABASE_URL pointed at it. If a deployment reverts DATABASE_URL to a superuser, RLS silently becomes advisory again — so this is a deploy-time invariant to verify, not a code default. :::

Running as trustrelay_app, these policies provide tenant isolation at the database level, not merely the application level.

Super-admin tenant impersonation over SSE (ADR-0081)

Server-Sent Events (EventSource) cannot send a custom X-Tenant-Id header, so super-admin impersonation of a tenant on SSE streams is carried by a validated tenant_id query parameter. This channel is honored only when all of: the request is an SSE endpoint, the caller is super_admin, and the value is a well-formed UUID (app/api/deps/tenant.py). Every cross-tenant access made this way is written to the immutable audit_events table, so impersonation is auditable rather than silent.

Portal Token Authentication with Expiry

Customer access to the upload portal is gated by a 192-bit cryptographically random token generated by secrets.token_urlsafe(24). Tokens use a pt_ prefix followed by 32 URL-safe characters.

Portal URL: https://portal.example.com/portal/pt_<32-url-safe-chars>

Security controls:

  • Tokens are unique per case and stored in PostgreSQL
  • Portal endpoints validate the token against the database
  • Tokens cannot be guessed or enumerated (192-bit entropy, significantly stronger than the prior 64-bit UUID hex truncation)
  • 30-day TTL: Tokens have an expires_at column, configurable via portal_token_ttl_days
  • Expired tokens return HTTP 410 Gone
  • Rate limiting applies to token validation attempts
  • The _generate_portal_token() helper is used consistently for both single-case and bulk case creation

:::info Token Entropy Upgrade (C7) Prior to the codebase hardening sweep, portal tokens were generated as uuid.uuid4().hex[:16] — a 64-bit truncated UUID. This has been replaced with secrets.token_urlsafe(24) (192-bit entropy), meeting NIST SP 800-63B guidance for session token length. See backend/app/api/case_crud.py. :::

Portal Row Level Security (Two-Phase Session)

Portal endpoints require a cross-tenant token lookup before RLS enforcement. A single-session approach would fail because the token-to-tenant mapping is not known before the first query. The solution is a two-phase session pattern:

  1. Phase 1 — Token lookup: _get_case_by_token() uses the default session (not tenant-scoped) to find the case and determine its tenant_id.
  2. Phase 2 — Tenant-scoped queries: All subsequent database operations use get_tenant_session(tenant_id), which sets the PostgreSQL RLS context and enforces row-level isolation.

This pattern is applied to all four customer-facing portal endpoints: get_portal_state, upload_document, submit_documents, and validate_document — each calls _get_case_by_token() (default session) and then switches to get_tenant_session(tenant_id). Even if a token collision occurred, subsequent queries would be scoped to the correct tenant.

This pattern's database-level guarantee is now real: as documented under Multi-Tenancy, the app runs as the non-superuser trustrelay_app role, so Phase-2 queries are rejected by the database if they reach out-of-tenant rows — the application's tenant-session selection and the database RLS policy are defence-in-depth, not a single point of trust.

See backend/app/api/portal.py.

PEPPOL API Key Protection

The PEPPOL verification API key (PEPPOL_API_KEY) is a server-side secret and must never reach the browser.

Before the hardening sweep: the key was exposed as NEXT_PUBLIC_PEPPOL_API_KEY, which Next.js inlines into the client bundle at build time.

After the hardening sweep (C4):

  • NEXT_PUBLIC_PEPPOL_API_KEY is removed from the frontend entirely
  • A Next.js Route Handler at frontend/src/app/api/peppol/verify/route.ts proxies PEPPOL verification requests server-side
  • The route handler reads PEPPOL_API_KEY from the server environment (never exposed to the browser)
  • The frontend calls /api/peppol/verify — its own Next.js backend — instead of the external PEPPOL API directly

This eliminates the API key from all browser bundles, network inspector views, and _next/static build artifacts.

PEPPOL API Key Authentication (Officer-Facing)

The PEPPOL verification service for officer-initiated lookups uses API key authentication:

  • API keys stored in the peppol_api_keys PostgreSQL table
  • Keys validated on every request via header: X-API-Key
  • Each key has a rate_limit_per_minute setting (default: 100)
  • Keys can be deactivated via the active boolean flag

IP-Based Rate Limiting

A sliding-window rate limiter (app/api/deps/rate_limiter.py) protects all endpoints:

ContextRate LimitKey
Authenticated requests100 requests/minuteIP address
Unauthenticated requests20 requests/minuteIP address
PEPPOL API (per key)Configurable per API keyAPI key

The implementation uses an in-memory sliding window. Production would use Redis or a managed API gateway for distributed rate limiting.

Dynamic CORS Configuration

CORS origins are configurable via the CORS_ORIGINS environment variable:

# Development (default)
CORS_ORIGINS=http://localhost:3001

# Production (multiple origins)
CORS_ORIGINS=https://app.trustrelay.com,https://portal.trustrelay.com

This replaces the previous hardcoded localhost:3001 configuration.

File Upload Validation

Document uploads through the portal include:

  • Content-type validation against allowed MIME types
  • File size limits enforced by the web server
  • Files stored in MinIO with case-scoped prefixes, preventing cross-case access
  • Documents are processed by Docling in a sandboxed conversion pipeline

Evidence Chain Integrity

The Belgian evidence service implements a SHA-256 hashing chain for tamper detection:

  1. Per-source hashing -- Each data source response (KBO, Gazette, NBB, Inhoudingsplicht) is hashed individually
  2. Bundle hashing -- Individual source hashes are combined into a single bundle hash
  3. Dual persistence -- Raw data stored in both PostgreSQL (belgian_evidence table) and MinIO (JSON archive)
# Per-source hash
hash = sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
# Stored as: "sha256:{hexdigest}"

# Bundle hash
bundle = sha256(json.dumps(source_hashes, sort_keys=True).encode()).hexdigest()

The PEPPOL verification service uses the same pattern with its own EvidenceService.

Post-Workflow Retrievability (2026-06-30, ADR-0082, #7)

Compliance signals must stay retrievable for the full AMLR 5-year retention window — not only while the Temporal workflow is live. The case-detail API now hydrates resolved_requirements, cross_reference_result and document_manifest from their dedicated DB columns (and additional_data) on the DB-fallback path; previously they were read only from live Temporal state and returned null once the workflow archived.

Because cross_reference_result.discrepancies is a risk-signal carrier, dropping it after archival was a never-suppress violation: a regulator re-opening an archived case would see the discrepancies silently disappear. Persisting and re-reading these fields satisfies EU AI Act Art. 11/12 (technical documentation & logging) and AMLR 5-year retention. app/api/case_crud.py.

PII Protection in Logs

KYC pipeline services follow a strict rule: log only case identifiers and non-PII metadata. Personally identifiable information — including person_name, date of birth, and national registration numbers — must not appear in log output.

This is enforced in backend/app/services/kyc_screening.py and backend/app/services/identity_verification.py. For example, the KYC screening log line records only case_id and nationality (a non-personal attribute), never the person's name.

Regulatory basis: GDPR Article 25 (data protection by design and by default).

PII Classification & Encryption at Rest

Trust Relay classifies every PII field in the database with one of six categories (ADR-0036). Fields in the DIRECT_IDENTIFIER, FINANCIAL, and CONTACT categories are encrypted at rest using AES-256-GCM via the EncryptedText SQLAlchemy TypeDecorator. The encryption is transparent to application code -- reads and writes happen with plaintext strings.

Encrypted columns:

  • users.email (CONTACT)
  • investigation_accounts.iban (FINANCIAL)
  • investigation_accounts.account_number (FINANCIAL)

JSONB PII encryption:

  • investigation_persons.identification -- document numbers encrypted within JSON
  • investigation_persons.phones -- phone numbers encrypted within JSON
  • investigation_persons.emails -- email addresses encrypted within JSON

Search hashes: HMAC-SHA256 hashes enable equality lookups (WHERE email_hash = ?) without decrypting every row. The pepper is a separate secret from the encryption key.

GDPR Data Subject Requests: Three API endpoints at /api/data-subject/ handle access (Art. 15), erasure (Art. 17), and rectification (Art. 16) with AML 5-year retention rules. See PII Classification & Encryption for full details.

Mock Mode Flags — Production Safety

Every external integration has a corresponding *_mock_mode boolean flag in app/config.py. In production, all flags default to False. If a flag is False and the real service is unavailable, the service raises NotImplementedError — preventing silent mock-in-production behavior.

FlagServicePoC default
osint_mock_modeOSINT investigation pipelinefalse
identity_verification_mock_modeIdentity verification (itsme/eIDAS) — no real path; off = raisesfalse
kyc_screening_mock_modeNatural-person sanctions/PEP screening — real OpenSanctions path existsfalse
peppol_mock_modePEPPOL/inhoudingsplicht APIfalse
kbo_mock_modeBelgian KBO registryfalse
nbb_mock_modeNational Bank of Belgiumfalse
gazette_mock_modeBelgian Official Gazettefalse
crawl4ai_mock_modeWeb scrapingfalse
graph_mock_modeNeo4j knowledge graphfalse
letta_mock_modeLetta compliance memoryfalse
brightdata_mock_modeBrightData scrapingfalse
northdata_mock_modeNorthData enrichmentfalse
mcc_mock_modeMCC classificationfalse

Set a flag to true in .env or CI to enable offline testing. Never rely on the absence of a live service to activate mock mode silently.

SSRF Protection

Website URL input is validated before scraping:

  • URL scheme validation (must be HTTP/HTTPS)
  • Domain parsing via urllib.parse
  • Scraping runs through crawl4ai which has its own URL validation

Production Security Enhancements

The following enhancements are planned for production hardening:

PII Classification

Status: Implemented (ADR-0036). The system now classifies 20 PII fields across 5 tables with a 6-category taxonomy. DIRECT_IDENTIFIER, FINANCIAL, and CONTACT fields are encrypted at rest using AES-256-GCM. Three GDPR DSR endpoints handle access, erasure, and rectification with AML retention rules. See PII Classification & Encryption for the full architecture.

Enhanced Audit Trail

Authentication is implemented (JWT with JWKS). The next phase will extend audit logging to include:

  • Per-request audit logging with caller identity
  • Document download events
  • Configuration change tracking

Secret Management

API keys are stored in .env files for local development. Production deployment will use a managed secret store (AWS Secrets Manager, HashiCorp Vault, or Kubernetes secrets).

Production Security Roadmap

PhaseItemsStatus
Phase 1 (Pre-production)Officer authentication (OIDC), token expiry, CORS configurationDone (Phase 4 remediation)
Phase 2 (Launch)Rate limiting, audit logging with identityPartially done (rate limiting complete, audit logging pending full auth integration)
Phase 3 (Post-launch)PII classification, data encryption at rest, GDPR compliance, secret managementPII + encryption + GDPR DSR done (ADR-0036). Secret management planned
Phase 4 (Maturity)SOC 2 Type II preparation, penetration testing, security monitoringPlanned

Security Configuration

All security-related configuration is managed via environment variables through pydantic-settings:

VariablePurposeDefault
AUTH_JWKS_URLJWKS endpoint for Keycloak JWT validation(Keycloak default)
AUTH_ISSUERExpected JWT issuer claim(Keycloak realm URL)
AUTH_AUDIENCEExpected JWT audience claim(Keycloak client ID)
PORTAL_TOKEN_TTL_DAYSPortal token expiry period30
CORS_ORIGINSComma-separated allowed CORS originshttp://localhost:3001
PEPPOL_API_KEYServer-side PEPPOL API key (never NEXT_PUBLIC_)(empty)
PEPPOL_MOCK_MODEDisables real PEPPOL API callsfalse
IDENTITY_VERIFICATION_MOCK_MODESimulates identity verification (itsme/eIDAS is unimplemented)false
KYC_SCREENING_MOCK_MODEDisables real natural-person sanctions/PEP screening (tests/demo only)false
BRIGHTDATA_API_TOKENBrightData MCP authentication(empty)
TAVILY_API_KEYTavily search API authentication(empty)
OPENAI_API_KEYLLM API authentication(empty)
MINIO_ACCESS_KEY / MINIO_SECRET_KEYObject storage credentialsminioadmin
DATABASE_URLPostgreSQL connection string(local default)
note

Development-only endpoints (/api/test/mock-modes) for runtime mock mode toggling are available in the development environment. These endpoints are disabled in production deployment.