| Threat | Mitigation |
|---|---|
| DB dump exposes customer ad-platform tokens | Envelope encryption; attacker needs DB and KMS. |
| Compromised user account → unlimited ad spend | Hard budget caps per connection; step-up auth for high-value mutations; anomaly alerts. |
| Malicious tenant reads another tenant's data | Postgres RLS enforced at DB layer; every query sets app.current_org. |
| OAuth callback CSRF | state parameter signed + single-use; PKCE for all flows. |
| Leaked refresh token used externally | Bind tokens to organization_id; rotate on suspicious use; IP allowlist optional. |
| Worker runs malicious rule payload | Rule DSL is declarative (not code-exec); bounded action set. |
| XSS pastes ad copy with script tags | Sanitise + render as text in previews; CSP on frontend. |
plaintext_token
│
▼
AES-GCM (per-org DEK) ──► ciphertext stored in `connections.access_token_enc`
▲
│
DEK (32 bytes, random, per-org)
│
▼
KMS KEK (Azure Key Vault / AWS KMS) ──► wrapped DEK stored in `organizations.wrapped_dek`
- To read a token the API: (1) fetches the wrapped DEK, (2) asks KMS to unwrap, (3) decrypts the ciphertext. DEK is held in memory for the request only.
- Key rotation: generate new DEK, re-encrypt all tokens for the org, update
wrapped_dek. Can be done live. - Local dev falls back to a static Fernet key from env (clearly labelled as insecure) so engineers don't need KMS to run the app.
Implementation lives in apps/api/app/core/encryption.py.
Every tenant table:
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON campaigns
USING (organization_id = current_setting('app.current_org')::uuid);Middleware sets the GUC at the start of each request after auth. Background jobs set it explicitly before each org's work. Integration tests assert that cross-tenant queries return zero rows.
Every write (API mutation, rule action, admin action) records:
actor_type: user | rule | system | adminactor_idorganization_idaction: e.g.campaign.create,connection.revoke,rule.executetarget_type+target_iddiff: JSONB before/afterip,user_agent,request_idts
Audit log is append-only (no UPDATE/DELETE permission for app role).
- Per-IP on auth endpoints (5/min for login).
- Per-org on platform-write endpoints (prevents runaway costs from bugs).
- Global per-platform SDK rate limits handled in connector with token-bucket + retry on
RESOURCE_EXHAUSTED.
- Never commit credentials.
.env.exampleis the only tracked env file. - Production: Azure Key Vault / AWS Secrets Manager. Pulled at container start into env.
pre-commithook runsdetect-secretsto block accidental commits.
pip-audit+npm auditrun in CI.- Dependabot / Renovate for PR auto-updates.
- Pin exact versions in lock files.
On account deletion we (a) revoke OAuth at each platform, (b) delete tokens, (c) anonymise audit-log actor fields but retain events (30-day grace, then hard delete). Export bundle offered before deletion.