Status: π‘ Demo-hardened, not tenant-ready. The go-live audit shipped all launch
blockers + mediums (docs/security_audit_go_live.md Β§7), so the platform is safe for
demo / prospect use today. But the First Real Tenant gate
below is not yet cleared β several criticals are parked on "acceptable because
demo-only". Audience: GoBuild staff only. Full risk detail lives in
Known Risks & Tech Debt β this page is the operating rulebook, not the register.
Badge legend: π’ Done / enforced Β· π‘ Partial / config-gated Β· βͺ Dormant (built, not wired) Β· π΄ Must-fix before first real tenant Β· β Missing / do-not-ship.
Three layers wall a tenant off from the fleet, the internet, and each other. Know all three before you touch prod.
Every integration (Stripe, Twilio, Resend, QBOβ¦) resolves its API keys through one
choke point β CredentialResolver β so isolation is enforced in a single place and no
call site ever reads a global key directly.
app/services/credentials.py:180
The resolution order is own (encrypted) β legacy org.settings β global .env
(credentials.py:194) β but the last two steps
only apply to siloed / single-tenant orgs. A pooled org gets its OWN keys or
nothing:
secretbox and decrypted only at send time.credentials.py:129 Β· credentials.py:201_SHARED_FALLBACK set is only managedcredentials.py:79 Β· credentials.py:201None when a required field is missing,None means "this org cannot send" β nevercredentials.py:206 Β· credentials.py:219Known exceptions to the wall-off (from the register): inbound SMS webhooks
fail-open to "allow" when no Twilio token is set (H7), and the voice/Twilio path
reads global creds so pooled orgs bill GoBuild's master account (H8). See
/known-risks.
The control plane (ops.gobuild.ca) holds no client business data β it reads/writes
every hub live over a signed Admin API. Two trust boundaries:
controlplane/security.py:1
CP_SECRET), holds admin_id.issued_at.sig,controlplane/security.py:56 Β· controlplane/security.py:62jti +exp; attributed to the admin's email for the hub's logs. controlplane/security.py:84β οΈ The /_admin API is currently reachable over public HTTPS (signature-gated only),
not VPC-locked β that's critical C3, a first-tenant blocker.
Different surfaces get different gates. Know which is which:
| Tier | Who | Gate | Evidence |
|---|---|---|---|
| Staff session | owner/pm/office | Email+password β bcrypt β JWT with explicit purpose:"session"; session resolvers reject invite/device/cportal tokens |
app/auth/security.py:36 Β· app/auth/deps.py:36 |
| Field PIN / device | crew | Numeric PIN + signed device cookie (purpose:"device", 45-day TTL) |
app/auth/security.py:110 |
| Client portal | homeowners | 128-bit random link token + optional SMS-OTP / PIN gate | app/routers/client_portal.py |
| Invoice portal | anyone with link | π΄ token only β no OTP/PIN gate exists (C4) | app/routers/invoice_portal.py |
| God-mode impersonation | CP admin | 90s handoff ticket (purpose:"imp_ticket") β 30-min impersonation session; both carry impersonated_by |
app/routers/impersonation.py:35 Β· app/auth/security.py:76 |
The purpose claim is load-bearing: every token type is signed with the same key, so
the resolver checking purpose=="session" is what stops an invite/device link from being
replayed as a full login. app/auth/deps.py:36
SECRET_KEY, CP_SECRET, CP_PASSWORD, and per-clientis_production and SECRET_KEY is default/short β keep it. app/config.pysecretbox.encrypt_json;org.settings JSON. credentials.py:129/_admin. The god-mode Admin API must be reachable only over the private VPC,controlplane/totp.py:39CredentialResolver and treat None as do-not-send. credentials.py:219impersonated_by; keep theapp/routers/impersonation.py:45get_settings().<provider>_key at a call site. Always go through the resolver,credentials.py:11None means the org can'tcredentials.py:219SECRET_KEY as an API token. (The old /internal/run-alerts ?token=SECRET_KEYcompare_digest.)content_type on uploads β sniff the real MIME. (Β§2.3 of the audit.)The trigger the owner set: several criticals are explicitly parked on "acceptable
because demo-only." The moment a real paying tenant is onboarded, C1βC4 + H1 + H3
must be resolved. Treat "first real tenant" as the checklist below.
(/known-risks common gate)
This is a different, higher bar than the completed go-live audit (which cleared the
platform for demo use β see docs/security_audit_go_live.md Β§6/Β§7). Do not conflate them.
| # | Gate item | What "done" means | Evidence / where |
|---|---|---|---|
| C1 π΄ | Enroll CP TOTP | Fleet god-mode is password-only today: TOTP machinery exists and login enforces it if a secret is set, but no enrollment flow ever sets one (admins created totp_secret=""). Build enrollment; enroll every admin. |
controlplane/totp.py Β· controlplane/bootstrap.py:41 Β· controlplane/main.py:1120 |
| C2 π΄ | Offsite backups + tested restore | Today update.sh dumps only the keystone DB to the droplet's own disk; DO backups off. Not backed up at all: documenso/wiki DBs, MinIO signed contracts, chatwoot, openproject, the cp_data audit log. Losing the Fernet TENANT_SECRET_KEY = every BYO credential undecryptable. |
controlplane/do_client.py (backups:False) Β· update.sh |
| C3 π΄ | VPC-lock /_admin |
The god-mode Admin API must move off public HTTPS onto the VPC-private path (signature-gating is not enough). | app/routers/admin_api.py Β· Caddyfile |
| C4 π΄ | Build the invoice OTP gate | The invoice portal has no auth gate at all β the token alone renders the invoice and the Stripe Pay button. Owner ruling: OTP required, hard-fail if no phone. No portal_otp path is even wired here yet. |
app/routers/invoice_portal.py |
| H1 π΄ | DeepSeek β Gemini for client data | DeepSeek default api.deepseek.com is China-hosted and may train on inputs; the general text path still routes customer PII/contracts through it. Pin client data to Gemini (or migrate to a US-hosted DeepSeek) before real-tenant data flows. |
app/integrations/ai/deepseek.py:1-6 Β· docs/DECISIONS.md #1 |
| H3 π΄ | Encrypt QBO OAuth tokens at rest | QBO tokens are stored plaintext and grant write access to a Client's books. Encrypt before more orgs connect. | model docstring ("future hardening") |
Gate rule: all six must be π’ before the first paying Client logs in. The H2 client-portal
OTP-default decision (hard-fail, no fall-through) rides along with C4 β build them together.
Full register, severities, and owner decisions: Known Risks & Tech Debt.
| Control | File:line |
|---|---|
| Credential wall-off / resolver | app/services/credentials.py:180 |
| Encrypt-at-rest for tenant creds | app/services/credentials.py:129 |
Session token purpose stamp |
app/auth/security.py:36 |
| Session resolver (rejects non-session purposes) | app/auth/deps.py:36 |
| CP session HMAC + hub RS256 signing | controlplane/security.py:56 |
| CP TOTP (RFC-6238, stdlib) | controlplane/totp.py:39 |
| Impersonation handoff + exit | app/routers/impersonation.py:28 |
| Go-live audit + remediation status | docs/security_audit_go_live.md Β§6/Β§7 |
These are unresolved and need an owner decision β do not assume answers:
SECRET_KEY,CP_SECRET, the RS256 cp_private.pem/cp_public.pem pair (must update on every hub inTENANT_SECRET_KEY. How often, and who runs it?TENANT_SECRET_KEY backed up, and who can restore it?security_audit_go_live.md) the bar?impersonate.start is logged, no end/duration).jti denylisttokens_valid_after); a leaked session JWT is valid for its full TTL. Ship revocation before