SOC 2 readiness assessment
This is a self-assessment, not a certification. Marketing OS holds no SOC 2 attestation — not Type I, not Type II, and no auditor is engaged. This document exists so that the gap between "we take secu
This is a self-assessment, not a certification. Marketing OS holds no SOC 2 attestation — not Type I, not Type II, and no auditor is engaged. This document exists so that the gap between "we take security seriously" and "here is the control, here is the file it lives in, and here is what is missing" is written down and can be checked by anybody who asks.
| Last reviewed | 2026-09-23 |
| Reviewed by | Engineering (the ticket that introduced this document, MARK-8XBMK3) |
| Scope | The Marketing OS production service: apps/api, apps/web, the managed Postgres/Redis/object storage behind them, and the subprocessors listed on the public /security page (apps/web/src/app/(public)/security/page.tsx). |
| Review cadence | Whenever a control on this list changes, in the same pull request. There is no scheduled review yet — that is itself a gap (CC4.1). |
Status vocabulary, used consistently below:
- Implemented — the control exists in code or configuration, and the file is named.
- Partial — something real is in place but it would not survive an audit as written.
- Missing — nothing exists. Named anyway.
1. Security (Common Criteria)
CC1 — Control environment
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC1.1 Integrity and ethical values | Partial | No written code of conduct or security policy set. The public /security page is the only committed statement of posture, and its "claim only what ships" rule is enforced by review. | Founder |
| CC1.2 Board oversight | Missing | Single-member LLC; no board, no independent oversight function. Not remediable at this size — an auditor would accept a documented compensating rationale, which does not exist yet. | Founder |
| CC1.3 Structures and reporting lines | Missing | No org chart, no documented role definitions for security responsibilities. | Founder |
| CC1.4 Competence | Missing | No background checks, no security training, no onboarding checklist. | Founder |
| CC1.5 Accountability | Missing | No performance or accountability process tied to security responsibilities. | Founder |
CC2 — Communication and information
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC2.1 Quality information | Implemented | Structured JSON logs with a request id on every line (apps/api/src/common/logging/pino-options.ts), plus the organization audit log (§ Audit trail below). | Engineering |
| CC2.2 Internal communication of responsibilities | Missing | No internal security policy is distributed because there is no second person to distribute it to. Must exist before the first hire. | Founder |
| CC2.3 External communication | Partial | /security publishes controls, subprocessors, retention and the gap list; docs/security/ holds this document; vulnerability reports go to contact@keyescode.com with a two-business-day acknowledgement commitment. No status page and no incident notification procedure. | Founder |
CC3 — Risk assessment
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC3.1 Objectives | Missing | No documented security objectives. | Founder |
| CC3.2 Risk identification | Partial | Risks are identified per change (each ticket plan carries a ## Risks section) but never aggregated into a risk register. | Engineering |
| CC3.3 Fraud risk | Missing | Not assessed. | Founder |
| CC3.4 Change risk | Partial | Architecture decisions are recorded as ADRs in .fredrin/memory/adr/, and every change ships through a reviewed pull request. No formal change-risk rating. | Engineering |
CC4 — Monitoring
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC4.1 Ongoing evaluation | Partial | Sentry reports errors from both apps when a DSN is configured (apps/api/src/instrument.ts), and /health backs the platform healthcheck. No scheduled control review, no internal audit, no vulnerability scanning. | Engineering |
| CC4.2 Deficiency communication | Missing | No defined path for reporting a control failure to anyone. | Founder |
CC5 — Control activities
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC5.1 Control selection | Partial | This document is the selection. It is not reviewed on a cadence. | Engineering |
| CC5.2 Technology controls | Implemented | See CC6 — the technical controls are the strongest part of this assessment. | Engineering |
| CC5.3 Policy deployment | Missing | Controls exist in code; policies documenting them do not exist outside this file. | Founder |
CC6 — Logical and physical access
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC6.1 Logical access security | Implemented | Argon2id password hashes (apps/api/src/modules/auth/auth.service.ts). Fifteen-minute access tokens with rotating refresh tokens, in HTTP-only SameSite=Lax cookies marked Secure in production (apps/api/src/modules/auth/auth.controller.ts). All platform credentials encrypted with AES-256-GCM at rest (apps/api/src/common/crypto.service.ts). API keys stored as SHA-256 only, shown once (apps/api/src/modules/api-keys/api-key.entity.ts). Optional TOTP two-factor authentication with single-use hashed recovery codes, which an owner can require of every member (apps/api/src/modules/auth/mfa.service.ts, apps/api/src/common/guards/org-scope.guard.ts, ADR 0049); platform administration requires it. Each person can list their sessions and sign any one, or every other one, out (GET/DELETE /auth/sessions, POST /auth/sessions/revoke-others); a signed-out device keeps its current access token until it expires, at most fifteen minutes. Gap: no SSO, SAML, SCIM, passkeys or hardware keys. | Engineering |
| CC6.2 Registration and authorization | Implemented | A user reaches an organization only through a memberships row, and a non-admin reaches a business only through a business_memberships grant. Both are enforced in apps/api/src/common/guards/org-scope.guard.ts before any handler runs. | Engineering |
| CC6.3 Access based on roles | Implemented | The five-rung permission ladder in packages/shared/src/access.ts (ADR 0013). Routes declare a permission, never a role, and an un-annotated mutation defaults to operate rather than to open. API keys are scope-limited (read/write) and optionally confined to one business. | Engineering |
| CC6.4 Physical access | Implemented (inherited) | No owned infrastructure. Physical security is Railway's and their upstream cloud provider's. We hold no attestation from them on file — a gap for an auditor, since inherited controls must be evidenced. | Founder |
| CC6.5 Disposal | Partial | DELETE /organizations/:orgId cascades the whole tenant and sweeps its object-storage keys (apps/api/src/modules/organizations/organizations.service.ts); DELETE /users/me removes one person and reuses that purge for the organizations they were the last member of. Export zips are swept hourly once their 24-hour link expires (DataExportService.sweepExpired). Meta's data-deletion callback purges data obtained under a revoked authorization (apps/api/src/modules/integrations/integrations.service.ts). Provider grants — Meta, Google, TikTok, Shopify — are revoked at the provider on deletion, and Google, TikTok and Shopify on disconnect, whenever nothing surviving still uses the grant (apps/api/src/modules/provider-grants/). Gap: provider revocation is best effort — a provider outage leaves the grant live on its side, logged but not retried. Gap: the object sweep is best effort — a bucket error leaves orphaned objects, logged but not retried, and there is no reconciliation job. Gap: no evidence of media sanitisation from the hosting provider. | Engineering |
| CC6.6 External threat protection | Partial | TLS terminated at the platform edge; no unauthenticated write route; Turnstile on signup when configured; SSRF-resistant HTTP client for every URL a user supplies (apps/api/src/common/safe-http.ts, ADR 0014). Gap: no WAF, no rate limiting on authentication, no third-party penetration test, no bug bounty. | Engineering |
| CC6.7 Transmission and movement | Implemented | HTTPS only. Credentials are never returned by an API response — an API key is shown once at creation and only its prefix thereafter. Redaction strips tokens, cookies and authorization headers from every log line and every error report (packages/shared/src/redaction.ts, packages/shared/src/sentry-redaction.ts, ADR 0003). | Engineering |
| CC6.8 Unauthorized software | Partial | Dependencies are lockfile-pinned (pnpm-lock.yaml) and the deployed image is built from the repository. Gap: no dependency vulnerability scanning in CI, no SBOM. | Engineering |
CC7 — System operations
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC7.1 Vulnerability detection | Missing | No scanning of dependencies, containers or the running service. | Engineering |
| CC7.2 Monitoring for anomalies | Partial | Sentry for errors; a spend-anomaly detector and the alert center for the product's own money risk (apps/api/src/modules/notifications/). Gap: nothing monitors for security anomalies — no failed-login alerting, no unusual-access detection. | Engineering |
| CC7.3 Incident evaluation | Missing | No incident response plan, no severity definitions, no on-call. | Founder |
| CC7.4 Incident response | Missing | As above. A real incident today would be handled ad hoc. | Founder |
| CC7.5 Recovery | Implemented | RPO 24 hours, RTO 1 hour target. Railway snapshots the Postgres volume daily (kept 6 days) and weekly (kept 27 days); the schedules were added 2026-09-23, and before then only one manual snapshot existed. A full restore was drilled on 2026-09-23: a copy of production went into a new Postgres, migrations ran, and an API on it passed /health, sign-in and the ads manager in 3 min 56 s, with row counts identical before and after. Runbook, timings and the Redis loss mode are in docs/DISASTER_RECOVERY.md; pnpm --filter @marketing-os/api dr:smoke prints the numbers a drill compares. Gap: the drill restored a logical copy, not a Railway snapshot, because Railway restores snapshots only in place on the production service. Gap: TOKEN_ENCRYPTION_KEY and JWT_SECRET are not escrowed outside Railway. Gap: the object-storage bucket and Redis have no backups. | Engineering |
CC8 — Change management
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC8.1 Change authorization and testing | Partial | Every change lands through a pull request with CI (format, typecheck, tests). Schema changes are migrations only — synchronize: false always. Architecture decisions are recorded as ADRs. Gap: no segregation of duties (the author can merge), no separate staging sign-off, no documented rollback procedure beyond reverting the pull request. | Engineering |
CC9 — Risk mitigation
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| CC9.1 Business disruption mitigation | Missing | No business continuity plan, no cyber insurance. | Founder |
| CC9.2 Vendor management | Partial | Subprocessors are enumerated on /security and in this document. Gap: no vendor security reviews on file, no DPAs executed, no advance notice commitment for subprocessor changes. | Founder |
2. Availability
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| A1.1 Capacity | Partial | Scheduled work is guarded by Postgres advisory locks so it runs once across replicas (apps/api/src/common/scheduler/cron-lock.service.ts, ADR 0004), and heavy work runs on BullMQ. Gap: no capacity planning, no load testing. | Engineering |
| A1.2 Backup and recovery | Implemented | Daily and weekly Railway snapshots of the Postgres volume (RPO 24 hours), a written restore runbook with an RTO of 1 hour (docs/DISASTER_RECOVERY.md). See CC7.5 for the gaps: the bucket and Redis are not backed up, and the two master secrets are not escrowed. | Engineering |
| A1.3 Recovery testing | Partial | One drill, 2026-09-23. It restored a production copy into a new database and passed the smoke in 3 min 56 s, recorded in the runbook's "Last drill" table. Gap: the Railway snapshot path was not drilled, and the quarterly re-drill is a written intention, not a scheduled job. | Engineering |
No uptime SLA is offered, and none should be claimed until A1.2 and A1.3 are real.
3. Confidentiality
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| C1.1 Identification and protection | Implemented | Confidential data is identified structurally: every platform credential goes through CryptoService and every organization's data is reachable only behind OrgScopeGuard. Tenancy is enforced in the guard, not in each query. | Engineering |
| C1.2 Disposal | Partial | Retention windows are enforced nightly (apps/api/src/modules/audit/retention.service.ts) and the organization delete cascades. Gap: default windows are "keep forever" for everything except bulk telemetry (raw ad metrics, social insight readings, AI usage, measurement checks, the webhook delivery log) — a deliberate choice (deleting a customer's history uninvited is worse), but an auditor will ask for a defined maximum retention period and there isn't one. | Engineering |
4. Processing integrity
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| PI1.1–1.3 Inputs and processing | Implemented | Every request body is validated against a Zod schema before a handler sees it (apps/api/src/common/zod-validation.pipe.ts). Every AI-initiated action goes through one registry with a declared risk tier; a high tier cannot execute without a persisted approver or an explicit standing grant (ADR 0009). Guardrails can only refuse, never widen (ADR 0011). | Engineering |
| PI1.4 Outputs | Implemented | Every tool call is recorded in tool_invocations with its input, outcome, guardrail verdict and the state it overwrote; the decision log renders that per entity and supports a gated revert. | Engineering |
| PI1.5 Storage | Partial | Money is integer cents throughout and metrics are derived, never stored, which removes a whole class of drift. Gap: no reconciliation job compares our mirrored metrics against the platform they came from. | Engineering |
5. Privacy
| Criterion | Status | Evidence / gap | Owner |
|---|---|---|---|
| P1–P8 | Partial | A published Privacy Policy, self-service account and organization deletes, a self-service organization export, Meta's data-deletion callback, and a written deletion-by-email procedure with a 30-day turnaround (DATA_REQUESTS_POLICY.md §5). Gap: no consent management, no self-service export of one person's own data across organizations, no data processing register, no DPAs, no EU representative, and no regional hosting option. The product is not built for protected health information or cardholder data and says so on /security. | Founder |
Audit trail — what the export actually contains
GET /organizations/:orgId/audit-log.csv?from=&to= (owner/admin only) merges five
sources into one time-ordered CSV. It is worth being precise about which of
those are recorded and which are derived, because it determines what the
log can and cannot prove.
| Source | Kind | Covers |
|---|---|---|
tool_invocations | Recorded | Every AI- or human-initiated tool call at every tier, including the ones a guardrail blocked. |
external_actions | Recorded | Approvals requested and decided, with the approver or the standing grant that released them. |
api_keys | Derived from row state | Key created (createdAt) and revoked (revokedAt). |
audit_events | Recorded | Members added, promoted, granted, revoked, removed; the organization renamed. |
| Integration connection tables | Derived from row state | Connected (createdAt) and, where the connector marks a terminal status, disconnected (updatedAt). |
Known limitations, stated rather than discovered:
- Derived events are not immutable. An
api_keysor connection row that is deleted takes its derived events with it. The recorded sources do not have this property; the derived ones do. - Two connectors lose their disconnect. A Meta ad account is disconnected by deleting its row, so only the connect survives. The same is true of any connector that hard-deletes rather than marking a status.
- Reads are not logged at the HTTP layer. A tool call that reads is logged; a person opening a dashboard is not. There is no access log of page views.
- Per-source row cap. Each source contributes at most 20,000 rows to one export; when a source is capped the CSV says so in a trailing row rather than silently returning a partial answer.
- The record is best effort.
AuditService.recordlogs and swallows a failed write so that bookkeeping never blocks the mutation a user asked for. Making completeness a guarantee means moving the write inside the caller's transaction — a deliberate, documented trade, not an oversight.
Claim inventory for the public /security page
The trust page makes factual claims about software rather than intentions
about process, so each one has to be checkable. This is that check, done
against master on the page's review date. When a row here stops being
true, the page changes in the same pull request.
Claim on /security | Where it lives |
|---|---|
| Passwords stored only as Argon2id hashes | apps/api/src/modules/auth/auth.service.ts (argon2.hash; the argon2 package defaults to argon2id) |
| Fifteen-minute access token, rotating refresh token | apps/api/src/config/env.ts — JWT_ACCESS_TTL_SECONDS defaults to 900, JWT_REFRESH_TTL_DAYS to 30 |
HTTP-only, SameSite=Lax cookies, Secure in production | apps/api/src/modules/auth/auth.controller.ts |
| Every platform credential AES-256-GCM encrypted at rest | apps/api/src/common/crypto.service.ts (aes-256-gcm), used by every connection entity's *Encrypted column |
| API keys stored as SHA-256 plus a twelve-character display prefix, shown once | apps/api/src/modules/api-keys/api-keys.service.ts (key.slice(0, 12)), api-key.entity.ts |
| Three organization roles, four per-business roles, one ordered permission ladder | packages/shared/src/access.ts, packages/shared/src/enums.ts (ADR 0013) |
| Routes declare a permission, not a role | @Requires in apps/api/src/common/decorators.ts, enforced in common/guards/roles.guard.ts |
An un-annotated mutation costs operate rather than being open | defaultPermissionForMethod in packages/shared/src/access.ts, applied in roles.guard.ts |
| Membership checked before any handler runs | apps/api/src/common/guards/org-scope.guard.ts |
| A key can be read-only and confined to one business; it stops working when its creator loses membership | api-key.entity.ts, common/guards/jwt-auth.guard.ts, org-scope.guard.ts (ADR 0001) |
Risk tiers; a high-tier write always stages an approval | apps/api/src/modules/tools/ (ADR 0009) |
| Guardrails can only refuse, never widen | ADR 0011, enforced in the tool executor |
| Refused calls are logged too | tool_invocations.status = 'blocked' with the guardrail verdict |
| Decision log and one-click revert | apps/api/src/modules/decisions/ (ADR 0025); revert needs a captured snapshot |
| Structured JSON logs with a request id; credentials redacted | apps/api/src/common/logging/ , packages/shared/src/redaction.ts (ADR 0003) |
| Error reports pass the same redaction; Sentry is off without a DSN | packages/shared/src/sentry-redaction.ts, apps/api/src/instrument.ts (ADR 0029) |
| Audit log downloads as CSV for owners and admins | apps/api/src/modules/audit/audit-log.controller.ts |
| Raw daily ad metrics and social insight readings kept 730 days, AI usage 400, measurement checks 365, webhook delivery log 30; everything else kept for the life of the organization by default | RETENTION_* in apps/api/src/config/env.ts and .env.example; enforced by apps/api/src/modules/audit/retention.service.ts |
| Organization delete is the owner's alone, needs the name typed back, cascades and sweeps stored files | OrganizationsService.deleteOrganization |
| Meta data-deletion request purges data obtained under that authorization and returns a confirmation code | apps/api/src/modules/integrations/integrations.service.ts (handleDataDeletion) |
| A person can delete their own account after re-authenticating; sole-member organizations go with it, shared ones pass ownership on | apps/api/src/modules/users/users.service.ts, OrganizationsService.removeAccountFromOrganizations |
| An organization with a live paid plan cannot be deleted until it is cancelled | OrganizationsService.assertNoLiveStripeSubscription |
| An owner or admin can export every table the organization owns as JSON, by a link that expires in 24 hours; credentials are excluded | apps/api/src/modules/data-export/ (owned-tables.ts for the table set and the excluded columns) |
| Organization and account deletion revoke Meta, Google, TikTok and Shopify grants at the provider; disconnecting Google, TikTok or Shopify does too; each only when nothing surviving uses the grant; best effort | apps/api/src/modules/provider-grants/provider-grants.service.ts, called from each disconnect path and purgeOrganization |
| A person can list their signed-in sessions and sign one, or all but the current one, out; each is audited | GET/DELETE /auth/sessions, POST /auth/sessions/revoke-others in apps/api/src/modules/auth/; Settings → Profile |
| Deletion by email is answered within 30 days | DATA_REQUESTS_POLICY.md §5 (a procedure, not code) |
| SSRF-safe fetch for every user-supplied URL | apps/api/src/common/safe-http.ts (ADR 0014) |
| Turnstile on signup when configured | TURNSTILE_SECRET_KEY in .env.example, checked in the signup path |
| TOTP two-factor authentication with single-use recovery codes; an owner can require it of every member | apps/api/src/modules/auth/mfa.service.ts, common/guards/org-scope.guard.ts (ADR 0049) |
| No SSO, SAML or SCIM | Verified by absence: nothing in apps/api/src or packages/shared/src implements any of them |
| No SOC 2 attestation, penetration test or bug bounty | This document |
| Subprocessor list | The integrations configured in .env.example plus the hosting topology in DEPLOYMENT.md |
The shortest path to an actual audit
In the order that buys the most credibility per unit of work:
Test a restore and write down the RPO/RTO it demonstrates— done 2026-09-23 (CC7.5, A1.2). Still open: drill the snapshot path and keep re-drilling (A1.3).- Dependency scanning in CI (CC6.8, CC7.1) — an afternoon, and it is the question every questionnaire asks.
- An incident response plan with severity levels and a notification commitment (CC7.3, CC7.4, CC2.3).
MFA on sign-in (CC6.1)— shipped: TOTP with recovery codes, enforceable per organization (ADR 0049). SSO/SAML is the next access control questionnaires ask for.- A third-party penetration test (CC6.6). Only worth paying for once 1–4 are done.
- Written policies — access control, change management, vendor management (CC1, CC5.3, CC9.2). Tedious, unavoidable, and the point at which engaging an auditor stops being premature.
Items 1–5 are engineering work. Item 6 is the one that cannot be shipped in a pull request, and it is why "SOC 2 in progress" would be a false claim today.
Something wrong or missing on this page? Tell us and we will fix it.