Isolation by construction
The strongest control is the one you can’t forget to apply. Tenant isolation is structural, not a runtime check:- Modes are separate schemas, not a column. A
data('test')client cannot read live rows. Data model → - Every repository is tenant-scoped at construction. There’s no repository method that omits
tenantId. Request lifecycle → - Idempotency keys are per-tenant (
@@unique([tenantId, key])) — closing a cross-tenant replay vector found early in the build.
RBAC
Dashboard members have one of five roles; thePermissionGuard gates every write:
Machine credentials (sk_ keys, OAuth tokens) carry scope * — a server integration acts with full authority within its one tenant. Human members are role-gated. Sensitive roles (owner, admin) are flagged for 2FA enforcement. Team changes — invite, role change, removal — are written to an audit log, and the owner is protected (can’t be demoted or removed, can’t remove yourself).
Secrets, hashed and timing-safe
- API keys and OAuth secrets are stored as
sha256hashes; the plaintext is shown once at creation and never again. - OTP codes are stored hashed; verification is a timing-safe compare.
- Webhook signing secrets are shown once; signatures are HMAC-SHA256 with a timing-safe verify and timestamp tolerance.
- Real provider credentials (WhatsApp token, SMTP, R2) live only in gitignored
.env.local— never committed. The staff KYC key is compared timing-safe.
The audit pass — real findings, real fixes
A deliberate adversarial review found issues a casual build would ship. Each was fixed and is covered by tests.HIGH — Webhook SSRF → metadata exfiltration
HIGH — Webhook SSRF → metadata exfiltration
A webhook
url was validated only as a URL, and the delivery response body is stored and shown in the inspector. A merchant could point it at 169.254.169.254 and read cloud-metadata IAM credentials. Fixed with WebhookUrlGuard: require https, block private/loopback/link-local/CGNAT/metadata hosts (v4 + v6) at creation, and re-resolve DNS at delivery to defeat rebinding. Webhooks →MEDIUM — Blacklist was decorative
MEDIUM — Blacklist was decorative
Customers could be flagged
blacklisted but nothing enforced it. Fixed: subscription creation and checkout both reject blacklisted customers — and checkout rejects before the charge, so no money moves for a banned customer.Cross-tenant idempotency replay
Cross-tenant idempotency replay
The idempotency record was globally unique on
key. Fixed to @@unique([tenantId, key]).RBAC bypass on merchant writes
RBAC bypass on merchant writes
Members originally received a wildcard scope. Fixed: members get role-derived scopes and merchant writes are behind
PermissionGuard.Mode-scoping leaks
Mode-scoping leaks
Two request-scoped paths (the portal listing and checkout token lookup) iterated both modes. Fixed: the portal takes the current mode explicitly; checkout derives mode from the token prefix. Only background scanners loop both modes.
Performance is part of the posture
A few fixes from the same pass that keep the system honest at scale: recovered/at-risk revenue is summed in the database rather than loaded into Node; the portal listing was de-N+1’d to four batched queries; and indexes were added for the new hot paths (customer phone/email, subscriptioncustomerId). Multi-tenancy →