Security & Compliance
Olyron CRM is a multi-tenant SaaS that holds sensitive advisor and client data, so isolation and encryption are enforced in the data layer itself — not just the application. This page documents what the codebase actually does: Postgres row-level security for tenant isolation, AWS KMS envelope encryption for secrets at rest, session and rate-limit controls, and the GDPR data-rights and audit tables that back the compliance program.
#How security is layered
Security in Olyron CRM is defense-in-depth, with the strongest guarantees pushed as far down the stack as possible. The most important control — tenant isolation — lives in the database, so even a bug in application code cannot let one organization read another's rows.
- Database layer — Postgres Row-Level Security (RLS) is enabled on every tenant table; policies filter by the caller's active tenant memberships.
- Secret layer — third-party credentials (AI provider keys, SFTP passwords, connector tokens) are envelope-encrypted with AWS KMS before they touch Postgres.
- Request layer — Next.js middleware refreshes the Supabase session and applies per-IP rate limits before a request reaches any route.
- Audit layer — security events, GDPR data-rights requests, and autonomous AI actions are written to dedicated append-only tables.
#Multi-tenant isolation (RLS)
Every tenant-scoped table carries a tenant_id column and has ROW LEVEL SECURITY enabled (see supabase/migrations/20250101000002_rls_policies.sql). Policies never trust a tenant_id passed by the client. Instead, two SECURITY DEFINER helper functions derive access from the authenticated user.
get_user_tenant_ids()SETOF UUID- Returns the tenant_ids where the current auth.uid() has an active membership in tenant_users. Used in read/write USING clauses across CRM tables.
user_has_role_in_tenant(tenant_id, min_role)BOOLEAN- Returns true when the caller holds at least min_role in the given tenant. Roles are ranked: read_only < staff < advisor < agency_admin < org_owner (the user_role enum).
A representative policy set from crm_leads: any member can read leads in their own tenant, creating and updating a lead additionally requires at least the advisor role, and destructive deletes are gated to agency_admin or higher.
-- Read: scoped to the caller's active tenants
CREATE POLICY "Users can view leads in their tenant"
ON crm_leads FOR SELECT
USING (tenant_id IN (SELECT get_user_tenant_ids()));
-- Create: must belong to the tenant AND hold at least the advisor role
CREATE POLICY "Users can create leads in their tenant"
ON crm_leads FOR INSERT
WITH CHECK (
tenant_id IN (SELECT get_user_tenant_ids())
AND user_has_role_in_tenant(tenant_id, 'advisor')
);
-- Delete: agency_admin or higher only
CREATE POLICY "Admins can delete leads"
ON crm_leads FOR DELETE
USING (user_has_role_in_tenant(tenant_id, 'agency_admin'));Organization settings tightened this further in 20260711000001_tenant_org_admin_rls.sql: agency_admin may update the tenant record, but only org_owner may delete the tenant. Isolation is verified by live integration tests in src/__tests__/rls.live.test.ts, which sign in as two separate tenant users and assert that cross-tenant reads return null and cross-tenant updates affect zero rows.
#Encryption of secrets at rest
Third-party credentials are never stored in plaintext. Olyron CRM uses AWS KMS envelope encryption implemented in src/lib/secrets/envelope.ts. On write, KMS mints a fresh per-row Data Encryption Key (DEK); the payload is encrypted locally with AES-256-GCM; and only the wrapped DEK plus ciphertext are persisted. KMS never sees the plaintext payload, so KMS cost and rate limits scale with row count, not data size.
- 1Generate a data keyThe server calls KMS GenerateDataKey (AES_256) to get a one-time DEK plus its KMS-wrapped form (CiphertextBlob).
- 2Encrypt locallyThe plaintext secret is encrypted with AES-256-GCM using the DEK and a random 12-byte IV; the GCM auth tag is captured.
- 3Store the envelope, scrub the DEKA versioned JSON envelope is written to Postgres and the in-memory DEK buffer is zeroed. Decryption reverses this: KMS Decrypt unwraps the DEK (with the key_id pinned so a rewrapped envelope is rejected), then AES-256-GCM verifies the tag and returns plaintext.
v1- Envelope schema version, so a future KDF can coexist during migration.
key_idstring- The master KMS key ARN that wrapped the DEK; pinned on decrypt.
encrypted_dekbase64- KMS-wrapped Data Encryption Key.
ciphertextbase64- AES-256-GCM ciphertext of the secret.
ivbase64- 12-byte GCM initialization vector, unique per encryption call.
tagbase64- 16-byte GCM authentication tag.
This runs entirely server-side. The AI provider connect route (/api/settings/ai/connect) is a good example: the browser POSTs the plaintext key over TLS, the server encrypts it, and only the envelope is upserted into tenant_ai_settings.api_key_envelope (the old plaintext api_key_enc column was dropped). The same envelope helper protects Resend, SFTP, and CRM-connector credentials.
| Env var | Purpose | Default |
|---|---|---|
| OLYRON_MASTER_KEY_ARN | KMS master key ARN that wraps every DEK | required — throws if unset |
| AWS_REGION | Region for the KMS client | us-east-2 |
| AWS_ACCESS_KEY_ID | IAM credential for KMS calls | required |
| AWS_SECRET_ACCESS_KEY | IAM credential for KMS calls | required |
#Access control & session security
Authentication is handled by Supabase Auth. Requests pass through src/middleware.ts, which refreshes the session cookie on every navigation and enforces per-IP rate limiting before a route runs. A curated public-route allowlist (sign-in, auth callbacks, public form endpoints) is the only path that skips the session check; API routes additionally enforce their own auth and role checks.
| Path prefix | Rate-limit bucket |
|---|---|
| /api/terminal/* | ai |
| /api/public/forms* | forms |
| Any path with /auth/ or /sign | auth |
| All other /api/* routes | api |
When a bucket is exhausted the middleware returns HTTP 429 with code RATE_LIMIT_EXCEEDED and a Retry-After value. Security-relevant events are captured in tables introduced by 20250101000009_security_enhancements.sql.
user_mfa_settings- Per-user MFA state (mfa_enabled) with TOTP secret and backup codes stored only as encrypted columns (totp_secret_enc, backup_codes_enc). RLS restricts each row to its own user.
user_sessions- Active session tracking with device_info, ip_address, location, and expiry. Users can view and delete their own sessions (RLS FOR SELECT / FOR DELETE on user_id = auth.uid()).
login_attempts- Records email, IP, success flag, and failure_reason — the basis for brute-force detection and auth rate limiting.
security_events- Typed events (login_success, login_failed, mfa_enabled, password_changed, ...) with severity info/warning/critical, visible to the owning user and tenant admins.
#GDPR data rights & audit trails
The GDPR data-governance tables live in 20250101000011_compliance.sql. They provide the storage backing for consent, subject-access, erasure, and change history.
consent_records- Consent per type (cookies, marketing, analytics, data_processing) with the exact consent_text shown, policy version, IP, and user agent.
data_export_requests- GDPR Right to Access. Tracks status (pending → processing → completed/failed), export format (json/csv), and a time-limited download_url.
data_deletion_requests- GDPR Right to be Forgotten. Two-step with a confirmation_token, moving pending → confirmed → processing → completed/cancelled.
audit_logs- Human-readable action log (create/read/update/delete/export/login/logout) capturing resource_type, resource_id, old_values, new_values, IP, and user agent.
AI action accountability
Because Olyron CRM is AI-native, autonomous agent actions are audited separately. omnial_audit_log (migration 20251215211328) records agent_type, event_type, and confidence_score; its RLS lets a tenant read its own agent logs while inserts are restricted to the system — authenticated users cannot forge audit entries (asserted by the RLS live tests).
src/olyron/autonomy/humanOnlyActions.ts hard-codes actions the AI may never execute unattended — payment initiation and enrollment completion. These require an explicit human confirmation gate with the confirming user, role, and IP written to the audit trail.#Compliance posture & data residency
The public security and compliance pages (src/app/security, src/app/compliance, src/app/dpa, src/app/privacy) describe the formal program. The table below separates what the codebase directly implements from what is an organizational/program commitment attested through third-party audits and documents provided under NDA.
| Claim | Status in this codebase |
|---|---|
| Tenant isolation via row-level security | Implemented — RLS policies + live tests |
| AES-256 encryption at rest for secrets | Implemented — KMS envelope, AES-256-GCM |
| MFA / TOTP support | Data model present (user_mfa_settings, encrypted secrets) |
| Audit logging | Implemented — audit_logs, security_events, omnial_audit_log |
| GDPR / CCPA data-subject rights | Implemented — export & deletion request tables |
| SOC 2 Type II certification | Program commitment — report under NDA, not provable from source |
| Annual penetration testing / bug bounty | Program commitment — described on the security page |
| TLS 1.3 in transit, encrypted backups | Platform-level (Supabase/AWS), not in app source |
Data residency: the public security page states data is stored in SOC 2 audited AWS data centers, defaulting to US regions, with EU or APAC residency available to enterprise customers. In the codebase, the KMS region defaults to us-east-2 unless AWS_REGION is overridden. A signed Data Processing Agreement is available at /dpa, and SOC 2 reports can be requested from security@olyron.com.
#Operational checklist & troubleshooting
- Set all four KMS env vars (OLYRON_MASTER_KEY_ARN, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) in every deployment environment — locally in .env.local, in production via Vercel project env vars.
- Give the IAM principal only kms:GenerateDataKey and kms:Decrypt on the single master key ARN — no broader KMS access is needed.
- Never log or return decrypted secrets; routes deliberately omit the api_key_envelope from their select() responses.
- Run the RLS live tests against a staging project (set NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and the RLS_TEST_USER_A/B credentials) after any policy change.
| Symptom | Likely cause | Fix |
|---|---|---|
| Connect route returns 500 encrypt_failed | Missing or invalid KMS env vars / IAM permissions | Verify OLYRON_MASTER_KEY_ARN and AWS credentials; confirm kms:GenerateDataKey is allowed |
| Queries return empty for data you know exists | RLS filtering by tenant membership — the user has no active tenant_users row | Confirm the membership exists with status = 'active' |
| HTTP 429 RATE_LIMIT_EXCEEDED | Per-IP bucket exhausted (api/auth/ai/forms) | Honor the Retry-After header and back off; heavy jobs should batch |
| Admin action blocked by policy | Role below the required threshold (e.g. delete needs agency_admin) | Elevate the member's role in tenant_users or perform the action as an admin |