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 reviewed2026-09-23
Reviewed byEngineering (the ticket that introduced this document, MARK-8XBMK3)
ScopeThe 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 cadenceWhenever 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

CriterionStatusEvidence / gapOwner
CC1.1 Integrity and ethical valuesPartialNo 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 oversightMissingSingle-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 linesMissingNo org chart, no documented role definitions for security responsibilities.Founder
CC1.4 CompetenceMissingNo background checks, no security training, no onboarding checklist.Founder
CC1.5 AccountabilityMissingNo performance or accountability process tied to security responsibilities.Founder

CC2 — Communication and information

CriterionStatusEvidence / gapOwner
CC2.1 Quality informationImplementedStructured 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 responsibilitiesMissingNo 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 communicationPartial/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

CriterionStatusEvidence / gapOwner
CC3.1 ObjectivesMissingNo documented security objectives.Founder
CC3.2 Risk identificationPartialRisks are identified per change (each ticket plan carries a ## Risks section) but never aggregated into a risk register.Engineering
CC3.3 Fraud riskMissingNot assessed.Founder
CC3.4 Change riskPartialArchitecture 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

CriterionStatusEvidence / gapOwner
CC4.1 Ongoing evaluationPartialSentry 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 communicationMissingNo defined path for reporting a control failure to anyone.Founder

CC5 — Control activities

CriterionStatusEvidence / gapOwner
CC5.1 Control selectionPartialThis document is the selection. It is not reviewed on a cadence.Engineering
CC5.2 Technology controlsImplementedSee CC6 — the technical controls are the strongest part of this assessment.Engineering
CC5.3 Policy deploymentMissingControls exist in code; policies documenting them do not exist outside this file.Founder

CC6 — Logical and physical access

CriterionStatusEvidence / gapOwner
CC6.1 Logical access securityImplementedArgon2id 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 authorizationImplementedA 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 rolesImplementedThe 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 accessImplemented (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 DisposalPartialDELETE /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 protectionPartialTLS 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 movementImplementedHTTPS 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 softwarePartialDependencies 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

CriterionStatusEvidence / gapOwner
CC7.1 Vulnerability detectionMissingNo scanning of dependencies, containers or the running service.Engineering
CC7.2 Monitoring for anomaliesPartialSentry 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 evaluationMissingNo incident response plan, no severity definitions, no on-call.Founder
CC7.4 Incident responseMissingAs above. A real incident today would be handled ad hoc.Founder
CC7.5 RecoveryImplementedRPO 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

CriterionStatusEvidence / gapOwner
CC8.1 Change authorization and testingPartialEvery 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

CriterionStatusEvidence / gapOwner
CC9.1 Business disruption mitigationMissingNo business continuity plan, no cyber insurance.Founder
CC9.2 Vendor managementPartialSubprocessors 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

CriterionStatusEvidence / gapOwner
A1.1 CapacityPartialScheduled 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 recoveryImplementedDaily 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 testingPartialOne 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

CriterionStatusEvidence / gapOwner
C1.1 Identification and protectionImplementedConfidential 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 DisposalPartialRetention 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

CriterionStatusEvidence / gapOwner
PI1.1–1.3 Inputs and processingImplementedEvery 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 OutputsImplementedEvery 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 StoragePartialMoney 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

CriterionStatusEvidence / gapOwner
P1–P8PartialA 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.

SourceKindCovers
tool_invocationsRecordedEvery AI- or human-initiated tool call at every tier, including the ones a guardrail blocked.
external_actionsRecordedApprovals requested and decided, with the approver or the standing grant that released them.
api_keysDerived from row stateKey created (createdAt) and revoked (revokedAt).
audit_eventsRecordedMembers added, promoted, granted, revoked, removed; the organization renamed.
Integration connection tablesDerived from row stateConnected (createdAt) and, where the connector marks a terminal status, disconnected (updatedAt).

Known limitations, stated rather than discovered:

  1. Derived events are not immutable. An api_keys or connection row that is deleted takes its derived events with it. The recorded sources do not have this property; the derived ones do.
  2. 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.
  3. 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.
  4. 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.
  5. The record is best effort. AuditService.record logs 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 /securityWhere it lives
Passwords stored only as Argon2id hashesapps/api/src/modules/auth/auth.service.ts (argon2.hash; the argon2 package defaults to argon2id)
Fifteen-minute access token, rotating refresh tokenapps/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 productionapps/api/src/modules/auth/auth.controller.ts
Every platform credential AES-256-GCM encrypted at restapps/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 onceapps/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 ladderpackages/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 opendefaultPermissionForMethod in packages/shared/src/access.ts, applied in roles.guard.ts
Membership checked before any handler runsapps/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 membershipapi-key.entity.ts, common/guards/jwt-auth.guard.ts, org-scope.guard.ts (ADR 0001)
Risk tiers; a high-tier write always stages an approvalapps/api/src/modules/tools/ (ADR 0009)
Guardrails can only refuse, never widenADR 0011, enforced in the tool executor
Refused calls are logged tootool_invocations.status = 'blocked' with the guardrail verdict
Decision log and one-click revertapps/api/src/modules/decisions/ (ADR 0025); revert needs a captured snapshot
Structured JSON logs with a request id; credentials redactedapps/api/src/common/logging/ , packages/shared/src/redaction.ts (ADR 0003)
Error reports pass the same redaction; Sentry is off without a DSNpackages/shared/src/sentry-redaction.ts, apps/api/src/instrument.ts (ADR 0029)
Audit log downloads as CSV for owners and adminsapps/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 defaultRETENTION_* 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 filesOrganizationsService.deleteOrganization
Meta data-deletion request purges data obtained under that authorization and returns a confirmation codeapps/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 onapps/api/src/modules/users/users.service.ts, OrganizationsService.removeAccountFromOrganizations
An organization with a live paid plan cannot be deleted until it is cancelledOrganizationsService.assertNoLiveStripeSubscription
An owner or admin can export every table the organization owns as JSON, by a link that expires in 24 hours; credentials are excludedapps/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 effortapps/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 auditedGET/DELETE /auth/sessions, POST /auth/sessions/revoke-others in apps/api/src/modules/auth/; Settings → Profile
Deletion by email is answered within 30 daysDATA_REQUESTS_POLICY.md §5 (a procedure, not code)
SSRF-safe fetch for every user-supplied URLapps/api/src/common/safe-http.ts (ADR 0014)
Turnstile on signup when configuredTURNSTILE_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 memberapps/api/src/modules/auth/mfa.service.ts, common/guards/org-scope.guard.ts (ADR 0049)
No SSO, SAML or SCIMVerified by absence: nothing in apps/api/src or packages/shared/src implements any of them
No SOC 2 attestation, penetration test or bug bountyThis document
Subprocessor listThe 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:

  1. 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).
  2. Dependency scanning in CI (CC6.8, CC7.1) — an afternoon, and it is the question every questionnaire asks.
  3. An incident response plan with severity levels and a notification commitment (CC7.3, CC7.4, CC2.3).
  4. 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.
  5. A third-party penetration test (CC6.6). Only worth paying for once 1–4 are done.
  6. 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.