Every load-bearing security control in Actionary CRM lives in the substrate. The same Postgres. The same request path. The same audit primitive that every other feature uses.
The controls below run against real production traffic. A compliance reviewer asking “show me the evidence” runs one indexed database query. A pen-tester asking “what stops this class of attack” reads one control.
Three architectural commitments drive every decision. Application code never touches secrets — credentials resolve through SDK default chains, IAM roles, and environment configuration. Per-tenant isolation runs at every layer — origin, HTTP, repository, RLS, object-storage key, audit row. Audit everything, mask nothing — every mutation writes an audit-log row and every uncaught failure lands in a forensic table platform admins triage from the same SPA they use for every other administrative task.
Five-layer XSS defence, A through E
Every path from user-supplied bytes to a rendered DOM node passes through five independent controls. Any one of the five closes the class; running all five closes it under simultaneous failure.
A — session cookies unreachable to JavaScript. __Host-prefixed, HttpOnly, Secure, SameSite=Lax. An XSS in a tenant page cannot read document.cookie; the browser refuses to expose the value. B — write-time server-side sanitisation on every rich-text write. Fourteen-tag allowlist (p, br, strong, em, u, ul, ol, li, code, pre, h2, h3, blockquote, a[href]); URL filter rejects javascript:, data:, vbscript:, protocol-relative, and backslash-escaped variants. C — client-side DOMPurify with matching allowlist plus a force-rel="noopener noreferrer" hook on every anchor. D — CSP blocks inline scripts as a third backstop. E — lint gate structurally forbids document.cookie reads and dangerouslySetInnerHTML in the SPA source.
Stdlib-native password storage
New hashes are OWASP-recommended scrypt at parameters N=32768, r=8, p=1, 64-byte output, 16-character alphanumeric salt per hash. Verification also accepts PBKDF2-SHA256 so a parameter rotation does not force a mass reset. Constant-time comparison. Implementation uses the Python standard library’s cryptographic primitives — zero external hashing dependency, zero supply-chain surface on the credential-store hot path.
Passkeys, MFA, step-up
Three independent authentication paths — password+TOTP, enterprise SSO (OIDC + SAML), and passkeys (WebAuthn) — on both the tenant and staff surfaces. Face ID, Touch ID, Windows Hello, FIDO2 hardware keys. Public key plus monotonic sign count; the private key never leaves the device’s Secure Enclave or TPM. Relying-party id equals the request hostname, so a passkey enrolled on the tenant origin cannot be released to the staff origin.
MFA step-up self-gates. A per-action policy demands a fresh second-factor within a 5-minute window on every destructive admin action. The policy toggle at /platform/security lets a platform admin suspend per-action step-up during sequences of admin work — disabling the policy itself requires a fresh step-up. Every toggle writes to the audit log; every request that passes only because the policy is off emits a step-up-skipped log line. The knob cannot weaken the substrate without proving possession of the operator’s factor on the same request.
Machine identity — OAuth controls
The MCP server ships an OAuth authorisation server with full discovery metadata. Authorisation code and refresh token only — implicit, password, and client-credentials grants are structurally rejected. PKCE mandatory on every flow, S256 only. Audience-bound tokens (RFC 8707) cryptographically pin every access token to the specific MCP endpoint it was minted for. Refresh-token rotation with reuse detection. Short-lived access tokens; single-use authorisation codes deleted on consumption. Least-privilege scope. Hashed client secrets with constant-time comparison. Dynamic Client Registration (RFC 7591) opt-in per tenant, off by default, per-tenant rate-limited. Hard tenant isolation on every token, refresh row, client, and consent record. Defence-in-depth: rate limits on every sensitive endpoint, no-store cache-control headers, single-use codes. Full audit on every authorise, mint, refresh, revoke, DCR, and secret-rotation event. Centralised revocation cascades to every dependent token. Bearer-token mode available for service-to-service integrations with the same scope vocabulary. And — the SOC 2 invariant — bearer JWTs are stripped of any staff-platform session-kind claim at the request boundary. A captured external token cannot claim platform-admin privilege regardless of what the payload says.
MCP defensive design — ten constraints
The MCP tool surface enforces ten constraints against the common failure modes agent platforms ship with. No tool accepts a credential parameter. Zero auth tools — login, get-token, refresh, and set-credentials tools are absent from the catalogue. Every tool is stateless and idempotent on its inputs. Concrete parameter types with structured per-parameter descriptions instead of untyped bags. Constraints live in the parameter schema itself, machine-readable at call time. One-line capability statements — no implementation leakage. A single canonical MCP server with a single tool catalogue. Live runtime observability under /platform/agent surfaces protocol misuse as a latency or error-rate anomaly per tenant, per tool.
One master key, HKDF-derived subsystems
A single platform master key is the sole internal-encryption secret. HKDF-SHA256 with a labelled info parameter derives one 32-byte key per subsystem — seven of them: MFA (TOTP secrets and recovery codes), SSO config (OIDC client secrets and SAML IdP certs), LLM provider keys (per-tenant BYOK LLM API keys), integration tokens (outbound OAuth and connector tokens), MCP OAuth session (MCP refresh tokens), third-party secrets (the generic tenant secret store), and JWT signing (HMAC signing key for machine-identity JWTs). Same master + same label = deterministic same bytes, so cross-environment dumps round-trip cleanly. Rotation is one ceremony, one in-app operator surface, two modes — change the master, set the previous value as the old master, run the rotation, drop the old master. Every column is re-encrypted in one pass, idempotent, composite-PK aware. The master lives outside the DB it protects with a clean path to AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault.
Fail-loudly substrate
Every uncaught failure on both tiers is a forensic row. One global server-side exception handler captures every 5xx into a server-errors table with full traceback, request id, exception type, tenant, and actor. One browser capture path wired into the standard error events lands every client failure into a frontend-errors table with stack, route, build id, and user-agent. Five properties make it audit-friendly: no silent catch-all in router code; error envelopes stay implementation-detail-free at the client boundary; both capture paths are best-effort so a broken endpoint cannot cascade into another error trying to capture itself; every triage transition writes an audit-log row; the carve-outs are named (HTTP-level rejections and request-validation rejections).
The compliance answer to “demonstrate that your team acknowledges and resolves application errors” is one indexed database query joining server errors, frontend errors, and audit log. The evidence lives inside the tenant database, resolves synchronously, and stays fresh as of the last request.
Five-layer agent safety perimeter
Every agent turn passes five composing defences. Structural tool framing lands results as typed tool-result blocks the model treats as data. System-prompt directive names the common injection vectors. Untrusted-content markers wrap text-shaped content with an explicit treat as data, not instructions boundary the model is trained to recognise. Tenant tool policy gates destructive dispatch with per-tenant allow-destructive and require-confirm-destructive settings. Tenant content policy — blocked-phrase list, profanity filter, response token cap, tool allow-list — filters the assistant’s final text before persistence; a per-turn log entry tags which rule fired without writing the un-redacted content.
Meeting audio — tenant-controlled retention
The in-browser recorder transcribes through OpenAI Whisper and summarises with Anthropic. The audio itself is treated as the blast radius most vendors ignore. Three retention modes chosen per-tenant under /settings/uploads: delete after transcription (the platform default — the transcript is the permanent record, the audio is gone the moment it lands), retain for N days (bounded 1–3650, for coaching or dispute-evidence windows), and keep indefinitely (an explicit legal-hold shape for regulated industries, audit-trailed on every change). A dedicated background worker wakes every minute, purges audio whose window has elapsed, and reports its heartbeat on /platform/operations. Every policy change writes one audit-log row with before/after JSON.
Deleting a tenant — reversible for thirty days, then gone
Deletion moves a tenant to a trash it can be restored from intact. It resolves nowhere meanwhile — not by slug, alias, vanity host, or an already-issued token — and every session bound to it is revoked. Thirty days later a scheduled worker removes every row it owns, permanently and without further instruction. A platform operator can order the removal sooner; a customer deleting their own tenant cannot, because the window exists to protect the decision most likely to be regretted. Once the worker takes a tenant, restore is refused — a removal part-way through cannot hand back something that looks whole and is not.
Removal runs in committed batches, so a tenant of any size clears without holding locks or interrupting service, and resumes where it stopped if interrupted. What survives is deliberate and minimal: the append-only audit trail, which records what was done in the tenant and by whom, and a single tombstone row naming the tenant, when it was trashed, when it was removed, and which operator decided each. deleted_at beside purged_at is the evidence the retention window was honoured. The tombstone grants insert and select only — a record of a removal that can itself be edited is not a record. Of the customer it keeps a name and nothing more.
No superuser boolean. Anywhere.
The staff-platform session kind is the only platform-admin signal. A superuser-style boolean is absent from users, from JWT payloads, from the auth-context response, from bearer-token rows, from the SPA, and from every repository. Staff identity lives in a physically separate table that carries no path to tenant-scoped data. A user cannot be elevated to staff by toggling a flag — there is no flag. This is the SOC 2 CC6.1 invariant the substrate makes structural. See the design principles page for the industry-standard reasoning; see tenant isolation for how the same invariant runs true at the database with Postgres row-level security under a purpose-scoped runtime role model.
One audit primitive
Every state change on every entity writes one row to a single audit-log table through one shared write helper. Same shape for record CRUD, schema migration applies, workflow definition changes, permission grants, MCP token issuance, error-triage transitions, and session lifecycle events. Append-only enforced by database triggers that reject any update or delete on the audit table regardless of role, ownership, or definer-security context. Every row carries session id, actor user id, actor email, actor IP, actor user-agent, and (when the actor entered under a support grant) the impersonating staff-user id. “Every action under grant X” resolves as one indexed query.
The takeaway
Security in Actionary is the substrate. The controls a SOC 2 auditor reads as positive evidence — tenant isolation, RBAC, encryption at rest and in transit, phishing-resistant authentication, append-only audit, fail-loudly capture, agent safety perimeter — already run in production against real customer data. The remaining compliance work (SOC 2 Type II evidence collection, automated GDPR deletion) extends this baseline; the underlying controls are already in production and inspectable. Read the mapping onto the trust criteria at SOC 2 readiness, or the CISO-shaped summary at for CISOs.