Productive IT logo PRODUCTIVE IT · BrandGrowth OS docs

SECURITY.md — security posture, controls and known gaps

Rule 75: this document describes what the code actually does, with file pointers. Anything not implemented is listed under Known gaps — no claim here is aspirational. Verified by reading the code paths named below plus the live checks recorded in docs/STATE.md (Playwright E2E on a production build, CSP fix, backup/restore drill).

1. Scope & deployment shape

2. Authentication

Control Implementation
Password hashing Argon2id via @node-rs/argon2, memoryCost 19456, timeCost 2, parallelism 1 — app/src/lib/password.ts
Password policy min 8 / max 128 chars, control characters rejected — app/src/lib/validation.ts
Session token 32 random bytes, base64url; only the sha256 hash is stored (sessions.token_hash) — app/src/lib/tokens.ts, lib/sessions.ts
Session cookie bg_session; httpOnly, SameSite=Lax, Secure when APP_ORIGIN starts with https://, path=/, TTL SESSION_TTL_DAYS (default 30)
Session validation hash lookup + revoked_at IS NULL + expires_at > now(); suspended user ⇒ no session; revoked membership ⇒ org treated as null; suspended org ⇒ session rejected (lib/sessions.ts)
Email verification one-time token, sha256 at rest, 24 h expiry, consumed atomically (auth/verify-email)
Password reset six-digit email OTP, sha256 at rest, 10-minute expiry, five attempts, single use; SMS is reserved for a future delivery adapter. On success all of the user's sessions are revoked (auth/reset-password → revokeAllUserSessions)
Logout revokes the current session row + clears the cookie (auth/logout)
Suspension admin PATCH /admin/users/[id] sets status='suspended' and revokes all that user's live sessions
Login enumeration identical error for unknown email and wrong password; argon2 verification runs for existing users to keep timing flat (auth/login)
Auth brute force Postgres-backed limiter (lib/ratelimit.ts, 0018_rate_limits.sql). One rate_limit_buckets row per action+IP holds the window counter and the lock deadline together, so state survives restarts and is shared across app instances. Login / reset-password / verify-email: 8 failed attempts per IP per minute ⇒ 15-minute lock (success clears the key). Register: 20 attempts / 15 min / IP (every call). Forgot-password: 5 / 15 min / IP (every call — it queues mail).
Invitations 32-byte opaque token, sha256 at rest, 7-day TTL, single use; bound to the invited email so redemption by another account is 403 EMAIL_MISMATCH (lib/invites.ts, auth/accept-invite)
Social sign-in Google/LinkedIn OAuth2 + OpenID, GitHub OAuth, and Microsoft Entra ID OIDC with provider-verified email linking, state CSRF token, creds-gated (buttons hidden until env vars exist) — docs/OAUTH.md
Provider unlink DELETE /api/v1/account/providers: password accounts confirm their password; an OAuth-only account cannot unlink its last sign-in method (409 LAST_SIGNIN_METHOD) — a first password can be set via PUT /api/v1/account/password, which revokes all sessions
Two-factor authentication Optional TOTP (RFC 6238) after password or social sign-in; encrypted AES-256-GCM secret, five-minute one-use challenge (body token for password login, short-lived httpOnly cookie for the OAuth callback), five failed-code attempts per challenge, setup/disable require the current password + code — lib/totp.ts, lib/twofactor.ts, /api/v1/account/2fa, /api/v1/auth/2fa/verify
2FA recovery codes Eight single-use codes issued at activation, regenerable (PUT /api/v1/account/2fa, password + live factor). sha256-hashed at rest, unambiguous 8-char alphabet, atomic single-use claim, usable as the second factor for sign-in and disable — so a lost phone costs one code, not the account

3. Request hardening

4. Authorization (RBAC)

Org roles rank viewer < member < admin < owner; platform admins are users.role_global = 'admin' (lib/guards.ts).

Endpoint group Requirement
GET /business-profiles, GET /business-profiles/[id] org role ≥ viewer
POST /business-profiles, PATCH /business-profiles/[id] org role ≥ admin
DELETE /business-profiles/[id] org role owner only
GET /campaign-profiles, GET /campaign-profiles/[id] org role ≥ viewer
POST /campaign-profiles, PATCH /campaign-profiles/[id], DELETE /campaign-profiles/[id] org role ≥ admin
GET /orgs/members, GET /orgs/mine, POST /orgs/switch session + active membership (switch: any workspace the user is an active member of)
PATCH /orgs/members/[userId], DELETE /orgs/members/[userId] org role owner (self-actions and removing the last owner are 400 INVALID_ACTION)
POST /orgs/invites, GET /orgs/invites, DELETE /orgs/invites/[id] org role ≥ admin, and the target role must be strictly below the actor (403 FORBIDDEN)
POST /auth/accept-invite session + the invite's email must match the session (403 EMAIL_MISMATCH)
/admin/* requirePlatformAdmin() (403 FORBIDDEN otherwise; the admin UI additionally redirects non-admins)
GET /auth/me session required (401 UNAUTHENTICATED otherwise)
GET /visibility/scans, GET /visibility/scans/[id] org role ≥ viewer; SQL org_id from the session
POST /visibility/scans, POST /visibility/scans/[id]/share, POST /leads org role ≥ member; visibility_scan flag on scan POST
GET /visibility/shared/[token], GET /r/[token] public; token is 24 random bytes base64url, unique, unguessable; response has no org_id

Ownership is enforced in SQL (WHERE id = $1 AND org_id = $2), so a cross-tenant id resolves to 404 NOT_FOUND rather than leaking existence. Campaigns additionally re-check that the linked business_profile_id belongs to the same org on create and on every re-link (400 VALIDATION otherwise).

Inviting is the only path that mints access for a non-member, so the limits live in one place (lib/invites.ts) as server-side truth that both the routes and the UI read: ownership is never grantable by invite, admin cannot invite an admin (peers), and an org always keeps ≥1 owner. Removing a member tears down their live sessions for that org (sessions.org_id), so an ex-member's next request is 401 UNAUTHENTICATED instead of silently retaining access.

5. Auditing & observability

6. Secrets & configuration

7. Data protection & recovery

8. Ingestion / third-party policy

Rule 10/99 — only ToS- and robots-permitted acquisition.

9. Known gaps and accepted launch decisions

  1. Accepted V1 trade-off — feature flags use a bounded local cache. lib/flags.ts reads the authoritative feature_flags rows from Postgres and caches them per Next process for 60 seconds. An admin PATCH invalidates the cache on the process handling that request; another instance, if one is introduced later, may therefore observe the old state for at most 60 seconds. We are deliberately not moving every flag read to Postgres for V1: the current deployment shape is one Next process, and feature flags govern availability/rollout rather than authentication, tenant isolation, billing authorization, or brute-force protection. Those security controls remain request-scoped or database-backed. A multi-instance deployment that needs immediate flag convergence must replace this with shared invalidation (for example Postgres LISTEN/NOTIFY or a shared cache), not rely on a shorter TTL. The bounded staleness is acceptable for the current public-launch shape.

    The rate limiter is Postgres-backed (0018_rate_limits.sql): counters and lockouts survive restarts and are shared across instances; expired buckets are pruned by cron (scripts/prune-ratelimits.mjs, DEPLOY.md §4).

  2. Email transport is coded but unconfigured: with EMAIL_TRANSPORT=console (the default) verification, reset and invite links appear in logs, and POST /orgs/invites returns acceptUrl to compensate. Setting EMAIL_TRANSPORT=smtp removes acceptUrl automatically (P-022); real SMTP credentials are still needed (P-031).

  3. CSRF relies on the Origin/Referer check + SameSite=Lax; no double-submit token, so a misconfigured proxy that rewrites Origin would weaken it.

  4. TOTP 2FA is enforced on both password and social (OAuth) sign-in, and lockout counters persist in Postgres (see gap 1). There is no password breach-list check.

  5. Docker Compose still publishes Postgres on host 5434. On a VPS, drop that port mapping: docker compose -f docker-compose.yml -f docker-compose.vps.yml does exactly that (P-025), and docs/DEPLOY.md §1.1 says how to verify.

  6. TLS certificates and DNS are operator-owned. The committed Nginx template (deploy/nginx.conf.example) is not a live public site — this repo has no remote and no VPS credentials (docs/DEPLOY.md §6).

  7. Offsite copies: the operator's Mac pulls dumps and the WAL archive daily via the in.vir.brandgrowth-offsite-sync launchd agent (checksum-verified, 30-day local retention — scripts/offsite-sync-mac.sh). The physical PITR base-backup archive is not yet in that pull; recovery is not valid if the archive stops advancing or the pair is incomplete.

  8. Destructive account/workspace actions are password- or name-confirmed and audited, but there is no retention hold: DELETE /api/v1/orgs/current removes tenant rows immediately, so a mistaken owner deletion is recoverable only from a dump (docs/DISASTER_RECOVERY.md).

  9. Billing is a request surface only — no payment provider is wired and no plan is granted without an operator (P-032). Nothing here charges a card.

  10. Accepted gap — master cannot be protected by GitHub, so it is watched instead of enforced. master on the Productive-iT org repo is the branch everything deploys from, and the repo is private on a free org plan, so GitHub refuses both branch protection and rulesets (403; the two ways out are a Team plan or making the repo public, neither of which is a settings toggle — scripts/setup-branch-protection.sh reports the blocker and is idempotent once a plan allows it). "No force pushes, no deletions" therefore has no server-side enforcement. What the free plan does allow is detection, and that is what is deployed: a nightly operator-Mac check (scripts/repo-integrity-check.mjs, launchd in.vir.brandgrowth-repo-integrity, wired in DEPLOY.md §5.1.4) that remembers the last-known-good tip of master and pages the operator over the direct-SMTP ops-alert channel when that tip is no longer an ancestor of the live one (history rewritten) or the branch has vanished. It cannot prevent a rewrite; it only makes one loud, and it depends on the Mac being awake and on the witness state file surviving. Removing this gap means paying for the plan.

10. Reporting a problem

Contact the repository owner (founder) directly. Do not open a public issue for a suspected vulnerability; include reproduction steps, affected endpoint and timestamps so the audit_logs trail can be correlated.

11. Operator runbook: 2FA enrollment, recovery & credential files

Procedures only — no secret values live in this document. Secret values are stored exclusively in operator-local credential files (§11.2) and, for the production environment file, in the git-ignored repo backup (§6).

11.1 Credential-file convention (~/.bgo-*)

Every credential family the operator handles gets one plaintext file on the operator's own machine — never on a shared host, never in the repo:

11.2 Enrolling 2FA on an operator account

Done for the founder account on 2026-09-19; repeat verbatim for any new operator. Schema: user_two_factor (migration 0016, secret encrypted at rest with MFA_ENCRYPTION_KEY, §6) and two_factor_recovery_codes (0017).

  1. Sign in → Settings → Two-factor authentication → Begin setup.
  2. The setup screen shows a QR code (scan with Google Authenticator, Authy, or any TOTP app), plus the manual secret key and the otpauth:// URI as fallback. The QR is rendered by the app's own local encoder (app/src/lib/qr.ts) from the server-issued URI — the enrollment secret is never sent to a third-party image/chart service. (Scanability of this exact pipeline can be proven offline, without enrolling anything — see §11.3 step 4.)
  3. Before confirming, copy into the operator credential file ~/.bgo-2fa-credentials (create with 0600): the account email, issuer, the manual secret, and the otpauth URI. This file is the only record of the secret if the authenticator app is lost before enrollment completes.
  4. Confirm with a live 6-digit code from the app. The screen then shows 8 single-use recovery codes exactly once — append them to the same file.
  5. Prove it works: sign out, sign in with password → the 2FA prompt appears → accept an authenticator code. Then sign out/in once more using a recovery code to confirm the backup path (that code is now consumed; 7 remain).

Rotating codes later: Settings → Two-factor authentication → Regenerate recovery codes (requires password + a live authenticator code); update the credential file in the same sitting.

11.3 Lost phone / recovery path

  1. At the 2FA prompt, use a recovery code (each works once, any order).
  2. Once signed in, immediately regenerate recovery codes (see §11.2) if fewer than 3 remain, and re-pair a new authenticator app via Settings if the old one is gone for good.
  3. Wrong-code attempts are rate limited per IP (twoFactorFail: 8 per minute, 15-minute lockout) — a locked-out legitimate user should wait out the lock or attempt from a different network; break-glass (§11.4) does not clear rate-limit rows, they expire on their own.
  4. Prove the enrollment QR pipeline itself is healthy without enrolling anything: node scripts/verify-2fa-qr.mjs renders the same production URI shape through the same production encoder into a PNG and prints the TOTP codes any authenticator app should show for it. When a re-pairing (step 2) keeps failing, this one command separates "our QR/encoder is broken" from "the phone or app is the problem". The generated secret is never enrolled and grants nothing — the PNG is safe to keep as a fixture or delete; the compiled build dir is cleaned up automatically.

11.4 Break-glass: operator-disabled 2FA (last resort)

Use only when every recovery code is spent AND the authenticator is unrecoverable. Requires direct VPS access (§1) and is fully audited.

  1. Verify the requester's identity out-of-band (the account email alone is not proof — an attacker who controls the mailbox must not be able to talk an operator into this).
  2. Snapshot first: the nightly dump (docs/DISASTER_RECOVERY.md) covers it; note the timestamp before touching anything.
  3. In one transaction, disable and audit:
BEGIN;
UPDATE user_two_factor SET enabled_at = NULL, updated_at = now()
 WHERE user_id = (SELECT id FROM users WHERE email = '<account email>');
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id,
                        details, created_at)
SELECT id, 'auth.2fa_break_glass_disabled', 'user', id,
       to_jsonb('operator break-glass; re-enrollment required'::text), now()
  FROM users WHERE email = '<account email>';
COMMIT;
  1. The next sign-in skips the TOTP challenge; the account must re-enroll (§11.2) in the same session. Unused recovery codes stay valid and should be regenerated during re-enrollment.
  2. The previous secret becomes inert (enabled_at = NULL keeps the ciphertext row), so a phone that reappears later cannot silently sign the account in.