We state the split plainly. The architecture Actionary runs on today satisfies the SOC 2 Trust Service Criteria that matter for a customer-facing platform. The Type II attestation — the auditor sitting with the evidence for twelve months — is on the compliance roadmap. Everything below distinguishes the two.
A SOC 2 auditor reads controls. Every control on this page maps to a row in a table, a query against audit_log, a policy enforced by Postgres, or a file in services/core_api/. Nothing here is aspirational.
The five load-bearing claims
Application code never touches secrets. Credentials resolve through SDK default chains (boto3, OAuth providers), sit in environment variables, or attach to compute via IAM roles. The application source has zero credential strings. PLATFORM_MASTER_KEY is the single root secret; every subsystem key — MFA, SSO, integration tokens, LLM provider keys, MCP OAuth, JWT signing — is HKDF-derived at boot.
Per-tenant isolation at every layer. tenant_id sits on every multi-tenant row, every S3 path, every search index, every audit log entry. Postgres row-level security enforces the boundary at the database — a query without the request-scoped GUC set returns zero rows. See tenant isolation for the RLS + four-role runtime.
Audit-everything, mask-nothing. Every administrative action and every entity mutation writes an audit_log row with actor, before, after, and timestamp. Errors surface with their root cause; no except Exception: return 500 pattern exists in any router.
Fail-loudly capture as a compliance control. Every uncaught exception lands in server_errors with a full traceback; every browser-side failure lands in frontend_errors. Both surfaces triage from /platform, and every triage transition writes an audit_log row.
Server-side sessions carry the Common Criteria. Revocation is one UPDATE on auth_sessions. Idle clocks tick per session kind (staff 30 min, tenant 4 h). staff_users sits physically separate from users — operator identity never carries through a tenant click. This maps directly onto CC6.1, CC6.2, CC6.3, and CC6.7.
Public-surface SOC 2 mapping
Twenty-three surfaces a security reviewer probes, mapped to the criterion each satisfies.
| # | Surface | Mechanism | Criterion |
|---|---|---|---|
| 1 | Tenant browser session | __Host-acr_tenant_session, opaque 32-byte id, HttpOnly, SameSite=Lax |
CC6.1 |
| 2 | Staff console session | __Host-acr_staff_session on separate origin |
CC6.1 |
| 3 | Support-access grant | staff_grant kind, one-shot Redis ticket, bounded by expires_at |
CC6.2 |
| 4 | CSRF defence | Session-bound token, in-memory store, constant-time compare | CC6.1 |
| 5 | MFA (TOTP) | users.totp_secret + recovery codes, encrypted with derived subsystem key |
CC6.1 |
| 6 | Passkeys (WebAuthn) | webauthn_credentials, strictly-increasing sign_count, self-only management |
CC6.1 |
| 7 | Step-up on destructive actions | require_step_up dep, 5-minute window, policy toggle audit-gated |
CC6.1 |
| 8 | Enterprise SSO | OIDC PKCE + SAML 2.0, signature verified, JIT provisioning | CC6.2 |
| 9 | Password storage | scrypt:N=32768:r=8:p=1$salt$digest, constant-time verify |
CC6.1, CC7.1 |
| 10 | Session revocation | Single UPDATE auth_sessions SET revoked_at = now() |
CC6.3 |
| 11 | Session-event audit | auth_session_events row on every create / rotate / revoke / idle-expire |
CC6.7 |
| 12 | Tenant isolation (DB) | Postgres RLS keyed on app.current_tenant_id GUC, four-role runtime |
CC6.1, CC6.6 |
| 13 | Tenant isolation (HTTP) | Four-layer resolver in get_tenant, cross-tenant probes return 404 generic |
CC6.6 |
| 14 | Repository-layer tenant filter | Every read WHERE tenant_id = X, every write sets tenant_id from context |
CC6.6 |
| 15 | S3 object keys | <tenant_id>/<asset_id> — namespaces do not overlap |
CC6.6 |
| 16 | Encryption at rest | SSE-S3, pgp_sym_encrypt on MFA / SSO / OAuth / LLM-key columns |
CC6.7 |
| 17 | Encryption in transit | TLS everywhere via Caddy, HSTS max-age=63072000; includeSubDomains |
CC6.7 |
| 18 | Audit log | audit_log table, CRUD + admin + workflow + MCP token events |
CC7.2, CC7.3 |
| 19 | Server error capture | server_errors (migration 195), global FastAPI handler, no router silent-catch |
CC7.2 |
| 20 | Frontend error capture | frontend_errors (migration 170), window.onerror + React componentDidCatch |
CC7.2 |
| 21 | Token logging hygiene | Bearer credentials never appear in logs; token_hash = sha256(token)[:12] |
CC6.1 |
| 22 | Operator runtime probes | /platform/operations live probes, banner on degraded / down |
CC7.1, CC7.2 |
| 23 | Rate limiting | fastapi-limiter on every state-changing endpoint |
CC6.6, CC7.2 |
The twenty-control audit summary
The controls a Type II auditor reads as positive evidence, condensed.
- Tenant isolation at every layer of the stack, enforced by Postgres RLS at the database and by repository discipline in the code.
- RBAC gated at the FastAPI dep layer with per-permission rows in
role_permissions. - Row-level
_permissionsblock on every list response; CI gate attests/test_row_permission_contract.py. - Encryption at rest with subsystem keys derived from one master via HKDF-SHA256.
- Encryption in transit via TLS + HSTS on every origin.
audit_logcapturing entity mutations withbefore/afterpayloads.- Session-event audit lineage in
auth_session_events. - Fail-loudly error capture on both tiers, triaged with per-transition audit rows.
- Passkey enrolment with monotonic
sign_countreplay defence. - Staff / tenant identity split — two tables, two origins, two cookies.
- Support-access grants as the only staff-to-tenant path, per-grant audit.
- Step-up MFA on destructive actions with policy-toggle audit chain.
- Password storage at the OWASP-recommended
scrypttier, stdlib-native. - Rate limiting on every sensitive endpoint via Redis-backed limiter.
- Cross-tenant probes return 404 generic — no existence enumeration.
- Secrets outside the DB they protect:
PLATFORM_MASTER_KEYin env, IAM roles for S3. - Continuous PITR on managed Postgres, 5-minute granularity, 7-day retention.
- Object storage versioning enabled for per-object recovery.
- Operator runtime probes at
/platform/operationswith degraded-state banner. - OpenTelemetry pipeline across
core-api,web-app,mcp-serverwith W3C trace context.
What we deliberately ruled out
Storing storage credentials in the application database. Widens the secret surface to backups, replicas, SQL injection, and any operator with read access. Documented as an anti-pattern in docs/13_storage/storage_admin_panel_spec.md §2.
An is_root flag on any user record. SOC 2 CC6.1 invariant. session_kind === "staff_platform" is the only platform-admin signal. Bearer JWTs are stripped of session_kind at the request boundary so a captured external token cannot claim staff elevation. See design principles.
Silent exception catches in router code. The except Exception: return api_error("server_error", str(exc), 500) pattern is structurally absent across every router file. Exceptions bubble unconditionally to the global handler.
Third-party log shippers, ingestion delays, sampling. Every error lives in the same Postgres a compliance reviewer can SELECT against. No vendor login. No ingestion lag.
The honest gaps
Formal Type II attestation. The 12-month evidence window is the remaining engagement — the automation vendors that drive continuous evidence collection sit against controls that already run.
Append-only audit_log partition-by-month. The table is append-only at the row level. The partition + cold-storage lifecycle triggers on volume; the create_audit_log_partition(year, month) helper is ready.
Persistent OTEL sink. The trace shape is captured across every hop. The persistent trace backend is the wiring item.
Automated GDPR Article 17 deletion. Manual against a documented workflow. Crypto-shredding for backup data is the target shape.
Cross-region S3 replication and SSE-KMS with customer-managed keys. Provisioned in-region with SSE-S3 and all four Block Public Access settings on. The enterprise-tier upgrade path is scoped.
For CISOs evaluating risk against a control matrix, the security substrate reads the way the CISO brief frames it: the controls are the code, and the code is the evidence.
The takeaway
SOC 2 is the auditor’s confidence that controls exist, run, and leave evidence. Actionary’s controls live as code in this repository. The security posture and tenant isolation pages read the same way an auditor’s fieldwork report does, because the substrate is the same substrate. The attestation follows the evidence. The evidence is already accumulating.