# Command Sovereignty โ€” Production Hardening Implementation Plan

**Date:** 2026-07-13 ยท **Scope:** all 20 audit findings ยท **Stack:** Flask + SQLite + React SPA, systemd user service on port 5003 behind Cloudflare Tunnel.

Verified facts from recon (used throughout this plan):

- App factory: `app/__init__.py` (184 lines). Limiter at L28โ€“31 uses `memory://`. Session config L50โ€“53 (`SESSION_COOKIE_SECURE` already env-driven, **but systemd unit sets it to `false`**). No `MAX_CONTENT_LENGTH`, no security headers, no global JSON error handler (only 404/429 handlers at L104โ€“119).
- `require_csrf` decorator exists at `app/routes/api_proxy.py` L50โ€“75.
- IDOR: `app/routes/dashboard.py` โ€” `company_id = request.args.get('company_id', companies[0].id)` at L93, L116, L164, L185, L208 with **no membership check**; `/api/kpis/<company_id>` (L237โ€“242) and `/api/forecast/<company_id>` (L245โ€“254) take path param with **no check at all**.
- Systemd unit: `~/.config/systemd/user/command-sovereignty.service` โ€” runs `.venv/bin/python run.py` (Werkzeug dev server), plaintext `SECRET_KEY`, `CONNECTOR_ENCRYPTION_KEY`, `MAIL_PASSWORD`, `SMTP_PASSWORD` inline, `SESSION_COOKIE_SECURE=false`, `REDIS_URL` already set.
- `gunicorn==22.0.0` already in `requirements.txt` L11. Redis running locally.
- `UserSession` model exists (`app/models.py` L698โ€“714) and is *written* on login (`auth_api.py` L104โ€“127) and listed/deleted in `security_api.py` L365โ€“459 โ€” but deletion does **not** revoke the actual Flask session cookie.
- Scheduler: `app/scheduler.py` (406 lines) โ€” `threading.Timer`-based, in-process.
- Twilio: `app/routes/sms_webhooks.py` L85โ€“109 โ€” `connector.settings.get('verify_webhook_signature', True)` lets a tenant disable verification (fails open at L92โ€“93).
- Logo upload: `app/routes/enterprise.py` L641โ€“692 โ€” accepts `image/svg+xml`, saves raw bytes, trusts client `Content-Type`, no size/dimension checks โ†’ stored XSS.
- Auth: `app/routes/auth_api.py` โ€” signup L172+ (only `len >= 8`), change-password L419+. **No forgot/reset routes exist.**
- Password hashing: werkzeug `generate_password_hash` (default scrypt on 3.12 โ€” fine).
- Billing/webhooks: `app/routes/billing.py` โ€” trial-abuse guard exists at L140โ€“142 (checkout side) but webhook processing `_process_stripe_event` (L267+) syncs tier from event metadata with no out-of-order/downgrade protection.
- `.all()` counts per routes file: api_proxy 37, analytics 32, admin_api 18, dashboard 11, sms 7, multi_market 6, email_campaigns 5, business_api 5, security_api 4, auth_api 4 (โ‰ˆ139 total).
- `docker-compose.yml` exists at repo root (dead config).
- Note: two venvs exist (`venv/` py3.13 and `.venv/` py3.12). Systemd uses `.venv` โ€” **all installs below go into `.venv`**.

Global rollback primitive (applies to every phase): the repo is git-managed โ€” tag before each phase:
```bash
cd /home/vincent/projects/command-sovereignty
git tag pre-phase-N && git push --tags   # rollback: git checkout pre-phase-N -- <files> && systemctl --user restart command-sovereignty
```
DB rollback primitive: `python seed.py --backup-only` before any migration (already hardened; creates `instance/auth.db.backup.TIMESTAMP`).

---

# PHASE 1 โ€” LAUNCH BLOCKERS (est. 1.5โ€“2 days)

| # | Task | Est. | Depends on |
|---|------|------|-----------|
| 1.1 | Tenancy decorator + IDOR fix | 3โ€“4 h | โ€” |
| 1.2 | Gunicorn behind systemd | 1โ€“2 h | โ€” |
| 1.3 | Password reset flow | 4โ€“6 h | mail config (exists); migration |
| 1.4 | WAL mode + automated backups | 1โ€“2 h | โ€” |

## 1.1 Cross-tenant IDOR (Finding #1) โ€” CRITICAL

**New file:** `app/utils/tenancy.py`
```python
"""Company membership enforcement."""
from functools import wraps
from flask import request, jsonify, abort
from flask_login import current_user
from ..models import UserCompany

def user_in_company(user_id, company_id) -> bool:
    if not company_id:
        return False
    return UserCompany.query.filter_by(
        user_id=user_id, company_id=str(company_id)
    ).first() is not None

def resolve_company_id(default=None):
    """company_id from args/json/view_args, validated against membership.
    Returns validated id or aborts 403/404."""
    cid = (request.view_args or {}).get('company_id') \
        or request.args.get('company_id') \
        or (request.get_json(silent=True) or {}).get('company_id') \
        or default
    if cid is None:
        abort(404)
    if not user_in_company(current_user.id, cid):
        abort(403, description='Not a member of this company')
    return str(cid)

def require_company_access(f):
    """Decorator for routes with <company_id> path param or ?company_id."""
    @wraps(f)
    def wrapper(*args, **kwargs):
        cid = kwargs.get('company_id') or request.args.get('company_id')
        if cid and not user_in_company(current_user.id, cid):
            return jsonify({'error': 'Forbidden'}), 403
        return f(*args, **kwargs)
    return wrapper
```

**Changes in `app/routes/dashboard.py`:**
- L93, L116, L164, L185, L208 โ€” replace:
  ```python
  company_id = request.args.get('company_id', companies[0].id)
  ```
  with:
  ```python
  company_id = request.args.get('company_id', companies[0].id)
  if not user_in_company(current_user.id, company_id):
      flash('Access denied', 'error'); return redirect(url_for('dashboard.index'))
  ```
  (or use `resolve_company_id(default=companies[0].id)`).
- L237โ€“242 and L245โ€“254 โ€” add `@require_company_access` under `@login_required`.

**Sweep the rest of the app** (this is the audit finding's tip of the iceberg):
```bash
grep -rn "request.args.get('company_id'\|request.args.get(\"company_id\"\|<company_id>" app/routes/ | grep -v tenancy
```
Apply `@require_company_access` / `resolve_company_id` to every hit in `api_proxy.py`, `analytics.py`, `business_api.py`, `multi_market.py`, `coaching.py`, `email_campaigns.py`, `sms.py`, `portfolios.py`. Exempt: super-admin routes already gated by `require_super_admin()` (admin_api.py).

**Testing:**
```bash
# Two users in different companies (seed data). Login as user A, request user B's company:
curl -b cookies_a.txt "http://localhost:5003/dashboard/api/kpis/<companyB_id>"   # expect 403
curl -b cookies_a.txt "http://localhost:5003/dashboard/forecast?company_id=<companyB_id>"  # expect redirect/403
curl -b cookies_a.txt "http://localhost:5003/dashboard/api/kpis/<companyA_id>"   # expect 200
```
Add pytest `tests/test_tenancy.py` with two seeded users asserting 403/200 matrix.

**Rollback:** revert dashboard.py + delete tenancy.py; no DB change.

## 1.2 Gunicorn (Finding #2) โ€” CRITICAL

**New file:** `gunicorn.conf.py` (repo root)
```python
bind = "127.0.0.1:5003"          # Cloudflare Tunnel connects locally; no 0.0.0.0
workers = 2                       # SQLite: keep low to limit write contention
threads = 4
worker_class = "gthread"
timeout = 60
graceful_timeout = 30
keepalive = 5
accesslog = "logs/gunicorn-access.log"
errorlog = "logs/gunicorn-error.log"
loglevel = "info"
preload_app = False               # IMPORTANT: scheduler must not fork-duplicate; see 3.5
```

**New file:** `wsgi.py` (repo root)
```python
from dotenv import load_dotenv
import os
load_dotenv(os.path.join(os.path.dirname(__file__), '.env'))
from app import create_app
app = create_app()
```

**Edit systemd unit** `~/.config/systemd/user/command-sovereignty.service`:
```ini
ExecStart=/home/vincent/projects/command-sovereignty/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
ExecReload=/bin/kill -HUP $MAINPID
```
(secrets lines are removed in 2.3 โ€” can be batched together).

**Dependency note:** with `workers=2` the Threading.Timer scheduler would run **twice**. Until 3.5 lands, gate scheduler startup: only start when `os.environ.get('RUN_SCHEDULER') == '1'`, and set that on exactly one worker โ€” or simpler, keep `workers=1, threads=8` until Phase 3.5, then bump to 2. **Recommended: workers=1/threads=8 now, revisit in 3.5.**

**Deploy/test:**
```bash
cd /home/vincent/projects/command-sovereignty && .venv/bin/pip show gunicorn  # already pinned 22.0.0
systemctl --user daemon-reload && systemctl --user restart command-sovereignty
curl -s http://localhost:5003/health   # {"status":"healthy",...}
ps -ef | grep gunicorn                 # master + worker visible
```
**Rollback:** restore `ExecStart=... python run.py`, daemon-reload, restart.

## 1.3 Password reset flow (Finding #3) โ€” CRITICAL

**Migration** โ€” new table. New file `scripts/migrate_password_reset.py` (follow pattern of `scripts/migrate_company_flags.py`):
```python
CREATE TABLE IF NOT EXISTS password_reset_tokens (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
    token_hash TEXT NOT NULL,          -- sha256 of token; never store raw
    created_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL,
    used_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
```
Run: `python seed.py --backup-only && .venv/bin/python scripts/migrate_password_reset.py`

**Model** โ€” append to `app/models.py` (after UserSession, ~L715):
```python
class PasswordResetToken(db.Model):
    __tablename__ = 'password_reset_tokens'
    id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
    user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
    token_hash = db.Column(db.String(64), nullable=False, index=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    expires_at = db.Column(db.DateTime, nullable=False)
    used_at = db.Column(db.DateTime, nullable=True)
```

**Routes** โ€” add to `app/routes/auth_api.py` (uses existing `mail` ext + limiter):
```python
@auth_api_bp.route('/api/auth/forgot-password', methods=['POST'])
@limiter.limit('5 per hour')
def api_forgot_password():
    email = (request.get_json(silent=True) or {}).get('email', '').strip().lower()
    generic = jsonify({'message': 'If that email exists, a reset link has been sent.'})
    user = User.query.filter_by(email=email).first()
    if user:
        raw = secrets.token_urlsafe(32)
        db.session.add(PasswordResetToken(
            user_id=user.id,
            token_hash=hashlib.sha256(raw.encode()).hexdigest(),
            expires_at=datetime.now(timezone.utc) + timedelta(hours=1)))
        db.session.commit()
        send_email(user.email, 'Reset your password',
                   f'https://app.commandsovereignty.com/reset-password?token={raw}\nLink expires in 1 hour.')
    return generic, 200   # same response either way โ€” no user enumeration

@auth_api_bp.route('/api/auth/reset-password', methods=['POST'])
@limiter.limit('10 per hour')
def api_reset_password():
    data = request.get_json(silent=True) or {}
    raw, new_pw = data.get('token', ''), data.get('password', '')
    err = validate_password_strength(new_pw)   # from 3.1; until then: len>=8 check
    if err: return jsonify({'error': err}), 400
    h = hashlib.sha256(raw.encode()).hexdigest()
    rec = PasswordResetToken.query.filter_by(token_hash=h, used_at=None).first()
    if not rec or rec.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
        return jsonify({'error': 'Invalid or expired reset link'}), 400
    user = db.session.get(User, rec.user_id)
    user.set_password(new_pw)
    rec.used_at = datetime.now(timezone.utc)
    UserSession.query.filter_by(user_id=user.id).delete()   # revoke all sessions
    db.session.commit()
    return jsonify({'message': 'Password updated. Please log in.'})
```
Use existing `app/utils/mail.py` / `email_service.py` helper for `send_email` (check exact function name there). Frontend: add `/forgot-password` + `/reset-password` SPA pages (new routes โ†’ no breaking change).

**Testing:**
```bash
curl -X POST localhost:5003/api/auth/forgot-password -H 'Content-Type: application/json' -d '{"email":"seeduser@example.com"}'
sqlite3 instance/auth.db "SELECT * FROM password_reset_tokens;"   # token row exists, hash not raw
# take raw token from mail log / test hook, then:
curl -X POST localhost:5003/api/auth/reset-password -d '{"token":"...","password":"N3w-Passw0rd!"}' -H 'Content-Type: application/json'
# verify: old password fails login, new works, reusing token โ†’ 400, expired token โ†’ 400
```
**Rollback:** routes are additive; drop table if needed. No existing endpoint touched.

## 1.4 WAL mode + automated backups (Finding #4) โ€” CRITICAL

**Edit `app/__init__.py` L20โ€“25** โ€” extend pragma listener:
```python
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
    cursor = dbapi_connection.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.execute("PRAGMA journal_mode=WAL")
    cursor.execute("PRAGMA synchronous=NORMAL")
    cursor.execute("PRAGMA busy_timeout=5000")
    cursor.close()
```
(WAL is persistent once set, but setting on connect is idempotent and safe. `busy_timeout` also mitigates gunicorn-thread write contention.)

**New file:** `scripts/backup_db.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
DB=/home/vincent/projects/command-sovereignty/instance/auth.db
DEST=/home/vincent/backups/command-sovereignty
mkdir -p "$DEST"
STAMP=$(date +%Y%m%d_%H%M%S)
OUT="$DEST/auth.db.$STAMP"
sqlite3 "$DB" ".backup '$OUT'"                     # online backup API โ€” WAL-safe
sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -q '^ok$' || { echo "BACKUP CORRUPT"; rm -f "$OUT"; exit 1; }
gzip "$OUT"
find "$DEST" -name 'auth.db.*.gz' -mtime +14 -delete   # 14-day retention
echo "OK $OUT.gz"
```
**Cron:** `crontab -e` โ†’
```
17 */6 * * * /home/vincent/projects/command-sovereignty/scripts/backup_db.sh >> /home/vincent/backups/command-sovereignty/backup.log 2>&1
```
(Optionally rsync `$DEST` off-box โ€” single-server constraint noted, but at least copy to a second disk/remote if available.)

**Testing:**
```bash
chmod +x scripts/backup_db.sh && ./scripts/backup_db.sh && ls ~/backups/command-sovereignty/
sqlite3 instance/auth.db "PRAGMA journal_mode;"   # โ†’ wal (after service restart)
# restore drill:
gunzip -k ~/backups/command-sovereignty/auth.db.<stamp>.gz
sqlite3 ~/backups/command-sovereignty/auth.db.<stamp> "SELECT COUNT(*) FROM profiles;"
```
**Rollback:** `PRAGMA journal_mode=DELETE` to revert; remove cron line.

---

# PHASE 2 โ€” SECURITY HARDENING (est. 2โ€“2.5 days)

| # | Task | Est. | Depends |
|---|------|------|---------|
| 2.1 | CSRF coverage sweep | 3โ€“4 h | โ€” |
| 2.2 | Session security + revocation | 3 h | 1.2 (restart cadence) |
| 2.3 | Secrets externalization | 1 h | 1.2 (unit edit batched) |
| 2.4 | Security headers | 1 h | โ€” |
| 2.5 | Rate limiter โ†’ Redis | 30 min | Redis (running) |
| 2.6 | SVG sanitization | 2 h | 2.7 |
| 2.7 | MAX_CONTENT_LENGTH | 15 min | โ€” |

## 2.1 CSRF coverage (Finding #5)

Move `require_csrf` from `api_proxy.py` L50โ€“75 into a shared module to avoid circular imports:
- **New:** `app/utils/csrf.py` (copy decorator verbatim).
- `api_proxy.py`: `from ..utils.csrf import require_csrf` (keep name re-exported for back-compat).

**Audit command:**
```bash
grep -rn "methods=\[.*\(POST\|PUT\|DELETE\|PATCH\)" app/routes/ -A3 | grep -B2 -L require_csrf
# more precise: python one-off that walks app.url_map at startup
```
Better โ€” add a **startup assertion** in `create_app()` (dev only):
```python
if app.debug or os.environ.get('CSRF_AUDIT'):
    from .utils.csrf import CSRF_EXEMPT
    for rule in app.url_map.iter_rules():
        if rule.methods & {'POST','PUT','DELETE','PATCH'}:
            fn = app.view_functions[rule.endpoint]
            if not getattr(fn, '_csrf_protected', False) and rule.endpoint not in CSRF_EXEMPT:
                app.logger.warning(f'CSRF GAP: {rule.rule} [{rule.endpoint}]')
```
(mark decorator: set `decorated._csrf_protected = True` inside `require_csrf`.)

Apply `@require_csrf` to all authenticated state-changing routes in: `auth_api.py` (update-user, change-password, logout, invite, settings), `business_api.py`, `connectors.py`, `analytics.py`, `multi_market.py`, `coaching.py`, `billing.py` (checkout/portal โ€” **NOT** `/stripe/webhook`), `portfolios.py`, `email_campaigns.py`, `sms.py`, `sms_call.py`, `enterprise.py` (incl. upload-logo), `admin_api.py`, `security_api.py`.

**Exempt list (`CSRF_EXEMPT`):** login, signup, forgot/reset-password, demo-request, `/stripe/webhook`, `/webhooks/sms/*` (Twilio), SAML ACS/SLO.

**Test:** authenticated POST without header โ†’ 403; with token from `/api/csrf-token` โ†’ 200. Run SPA smoke test (frontend already sends `X-CSRF-Token` via Axios interceptor โ€” no frontend change).

**Rollback:** decorators are additive; remove per-route if a legit client breaks.

## 2.2 Session security + revocation (Finding #6)

**Config (`app/__init__.py` L50โ€“53):**
```python
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', 'true').lower() in ('true','1','yes')
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 * 7    # 30d โ†’ 7d
app.config['SESSION_REFRESH_EACH_REQUEST'] = True       # sliding window
```
**Systemd unit:** change `SESSION_COOKIE_SECURE=false` โ†’ delete the line (default true). Cloudflare Tunnel serves HTTPS, so secure cookies work; local curl tests must use `-k https` via tunnel or set the env var in a dev shell only.

**Revocation** โ€” the gap: deleting a `UserSession` row doesn't kill the cookie. Fix by binding Flask session to the row:

`auth_api.py` login (~L104โ€“127): after creating `new_session`, store its id in the cookie session:
```python
flask_session['us_id'] = new_session.id
```
**New `before_request` in `create_app()`:**
```python
@app.before_request
def enforce_session_revocation():
    from flask import session as fs
    from flask_login import current_user, logout_user
    if current_user.is_authenticated:
        us_id = fs.get('us_id')
        if us_id:
            from .models import UserSession
            rec = db.session.get(UserSession, us_id)
            if rec is None:                      # revoked from security page
                logout_user(); fs.clear()
                if request.path.startswith('/api/'):
                    return jsonify({'error': 'Session revoked'}), 401
```
Back-compat: sessions without `us_id` (issued pre-deploy) stay valid until natural expiry โ€” acceptable, or force logout-all on deploy.
Perf note: one PK lookup per request on SQLite โ‰ˆ microseconds; acceptable.

`security_api.py` L391โ€“441 already deletes rows โ†’ now actually revokes. Also update `last_seen_at` opportunistically (throttle to 1/min per session to avoid write churn).

**Test:** login in browser A; from browser B (same account) revoke A's session via security page; next request in A โ†’ 401/redirect to login. Verify cookie has `Secure; HttpOnly; SameSite=Lax` in devtools over the tunnel domain.

## 2.3 Secrets externalization (Finding #7)

**New file:** `/home/vincent/projects/command-sovereignty/.env.production` โ€” `chmod 600`, owner vincent:
```
SECRET_KEY=<ROTATE โ€” new 64-hex value; old one is burned, it sat in the unit file>
CONNECTOR_ENCRYPTION_KEY=eF50...   # CANNOT rotate blindly โ€” Fernet-encrypted connector creds
MAIL_PASSWORD=...
SMTP_PASSWORD=...
DATABASE_URL=sqlite:////home/vincent/projects/command-sovereignty/instance/auth.db
REDIS_URL=redis://localhost:6379/0
... (all current Environment= lines)
```
**Systemd unit:** delete every `Environment=` secret line; add:
```ini
EnvironmentFile=/home/vincent/projects/command-sovereignty/.env.production
```
Ensure `.env.production` is in `.gitignore`. To rotate `CONNECTOR_ENCRYPTION_KEY` later use `app/utils/migrate_encryption.py` (exists) with old+new keys โ€” do NOT just swap it or all connector creds become undecryptable.

**Rotating SECRET_KEY invalidates all sessions/CSRF** โ€” deploy together with 2.2 and announce forced re-login.

**Test:** `systemctl --user daemon-reload && restart`, `/health` OK, login works, `systemctl --user show command-sovereignty -p Environment` shows no secrets.
**Rollback:** restore old unit from git/backup copy.

## 2.4 Security headers (Finding #8)

Add to `create_app()` (after error handlers):
```python
@app.after_request
def security_headers(resp):
    resp.headers.setdefault('X-Content-Type-Options', 'nosniff')
    resp.headers.setdefault('X-Frame-Options', 'DENY')
    resp.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
    resp.headers.setdefault('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
    resp.headers.setdefault('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
    resp.headers.setdefault('Content-Security-Policy',
        "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
        "img-src 'self' data: blob:; font-src 'self' data:; "
        "connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com https://checkout.stripe.com; "
        "object-src 'none'; base-uri 'self'; frame-ancestors 'none'")
    if request.path.startswith('/uploads/'):
        resp.headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
        resp.headers['Content-Disposition'] = resp.headers.get('Content-Disposition', 'inline')
    return resp
```
**CSP tuning required:** Vite SPA may need `'unsafe-inline'` for styles (kept) and Stripe.js sources โ€” start with `Content-Security-Policy-Report-Only` for 24 h, watch console, then enforce. The `/uploads/` sandbox CSP is the second layer of the SVG-XSS fix (2.6).

**Test:** `curl -sI localhost:5003/ | grep -Ei 'content-security|strict-trans|x-frame'`; click through the SPA with devtools console open (no CSP violations).

## 2.5 Rate limiter โ†’ Redis (Finding #9)

`app/__init__.py` L28โ€“31:
```python
limiter = Limiter(
    key_func=get_remote_address,
    storage_uri=os.environ.get('REDIS_URL', 'memory://'),   # REDIS_URL already in unit
    storage_options={'socket_connect_timeout': 2},
)
```
Add `redis` to `requirements.txt` if missing; `.venv/bin/pip install redis limits[redis]`.
Also: behind Cloudflare Tunnel `get_remote_address` sees the tunnel IP. Use CF header:
```python
def real_ip():
    return request.headers.get('CF-Connecting-IP') or get_remote_address()
limiter = Limiter(key_func=real_ip, ...)
```
**Test:** hammer login: `for i in $(seq 12); do curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:5003/api/auth/login -d '{}' -H 'Content-Type: application/json'; done` โ†’ 429 appears; `redis-cli keys 'LIMITS*'` shows entries; restart service and confirm counters survive.
**Rollback:** revert storage_uri to `memory://`.

## 2.6 SVG upload sanitization (Finding #10)

`app/routes/enterprise.py` L641โ€“692 (`upload_logo`). Fixes:
1. Don't trust client `Content-Type`; sniff bytes.
2. For raster: validate with Pillow, re-encode (strips payloads), cap dimensions.
3. For SVG: **simplest safe option โ€” drop SVG support** (PNG/JPEG/WebP only). If SVG must stay, sanitize:

```python
# pip install Pillow defusedxml
ALLOWED_EXT = {'.png', '.jpg', '.jpeg', '.webp', '.svg'}
MAX_DIM = 2000

def sanitize_svg(data: bytes) -> bytes:
    from defusedxml import ElementTree as DET
    import xml.etree.ElementTree as ET
    root = DET.fromstring(data)          # rejects DTD/entities (XXE/billion-laughs)
    BAD_TAGS = {'script', 'foreignObject', 'animate', 'set', 'iframe', 'embed', 'object'}
    for parent in root.iter():
        for child in list(parent):
            tag = child.tag.split('}')[-1].lower()
            if tag in BAD_TAGS:
                parent.remove(child); continue
            for attr in list(child.attrib):
                a = attr.split('}')[-1].lower()
                if a.startswith('on') or (a in ('href','xlink:href') and
                        child.attrib[attr].strip().lower().startswith(('javascript:','data:'))):
                    del child.attrib[attr]
    # also scrub root element attribs the same way
    return ET.tostring(root)
```
In the route, replace the naive save block (L658โ€“672):
```python
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXT: return jsonify({'error': 'Invalid file type'}), 400
data = file.read()
if len(data) > 2 * 1024 * 1024: return jsonify({'error': 'Logo must be under 2MB'}), 400
if ext == '.svg':
    if b'<svg' not in data[:4096].lower(): return jsonify({'error': 'Not a valid SVG'}), 400
    try: data = sanitize_svg(data)
    except Exception: return jsonify({'error': 'Could not parse SVG'}), 400
else:
    from PIL import Image
    import io
    try:
        img = Image.open(io.BytesIO(data)); img.verify()
        img = Image.open(io.BytesIO(data))
        if max(img.size) > MAX_DIM: return jsonify({'error': f'Max {MAX_DIM}px'}), 400
        buf = io.BytesIO(); img.save(buf, format=img.format); data = buf.getvalue()  # re-encode
    except Exception: return jsonify({'error': 'Invalid image'}), 400
filename = f'{uuid.uuid4().hex}{ext}'
...
with open(filepath, 'wb') as fh: fh.write(data)
```
Also add `@require_csrf` (2.1) and serve `/uploads/` with sandbox CSP (2.4).
`.venv/bin/pip install Pillow defusedxml` + pin in requirements.txt.

**Test:** upload `<svg onload="alert(1)"><script>alert(1)</script></svg>` โ†’ stored file has no script/onload; fetch it, confirm `Content-Security-Policy: sandbox` header; upload 50MB file โ†’ 413 (2.7); upload PNG-with-.svg-name โ†’ 400.

## 2.7 MAX_CONTENT_LENGTH (Finding #11)

`app/__init__.py` after L53:
```python
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024   # 10MB global cap
@app.errorhandler(413)
def too_large(e):
    return jsonify({'error': 'Request too large (max 10MB)'}), 413
```
**Test:** `head -c 11M /dev/zero > /tmp/big && curl -F file=@/tmp/big -b cookies.txt localhost:5003/enterprise/branding/upload-logo` โ†’ 413 JSON.

---

# PHASE 3 โ€” RELIABILITY (est. 3โ€“4 days)

| # | Task | Est. | Depends |
|---|------|------|---------|
| 3.1 | Password policy + lockout | 3โ€“4 h | migration |
| 3.2 | Datetime consistency | 2โ€“3 h | โ€” |
| 3.3 | Pagination | 1โ€“1.5 d (139 sites; triage) | โ€” |
| 3.4 | Webhook trial/tier races | 3 h | โ€” |
| 3.5 | APScheduler | 3โ€“4 h | 1.2 |
| 3.6 | Twilio verification mandatory | 30 min | โ€” |
| 3.7 | Invite fix + email send | 2โ€“3 h | mail helper |

## 3.1 Password policy + account lockout (Finding #12)

**New:** `app/utils/passwords.py`
```python
import hashlib, re, requests

def validate_password_strength(pw: str) -> str | None:
    if len(pw) < 12: return 'Password must be at least 12 characters'
    if len(pw) > 128: return 'Password too long'
    checks = [r'[a-z]', r'[A-Z]', r'\d']
    if sum(bool(re.search(c, pw)) for c in checks) < 3:
        return 'Password must include upper, lower, and a digit'
    if is_breached(pw): return 'This password appears in known breaches; choose another'
    return None

def is_breached(pw: str) -> bool:
    """HIBP k-anonymity; fail-open on network error."""
    sha = hashlib.sha1(pw.encode()).hexdigest().upper()
    try:
        r = requests.get(f'https://api.pwnedpasswords.com/range/{sha[:5]}', timeout=2)
        return any(line.split(':')[0] == sha[5:] for line in r.text.splitlines())
    except requests.RequestException:
        return False
```
Wire into `auth_api.py` signup (L196โ€“198), change-password (L431), reset-password (1.3). **Back-compat:** enforce only on new/changed passwords; existing users unaffected at login.

**Lockout** โ€” migration adds columns to `profiles`:
```sql
ALTER TABLE profiles ADD COLUMN failed_login_count INTEGER DEFAULT 0;
ALTER TABLE profiles ADD COLUMN locked_until DATETIME;
```
`api_auth_login` (L92โ€“130):
```python
if user and user.locked_until and as_utc(user.locked_until) > now_utc():
    return jsonify({'error': 'Account temporarily locked. Try again later.'}), 423
# on bad password:
user.failed_login_count += 1
if user.failed_login_count >= 8:
    user.locked_until = now_utc() + timedelta(minutes=15)
    user.failed_login_count = 0
db.session.commit()
# on success: user.failed_login_count = 0; user.locked_until = None
```
Keep `@limiter.limit` on login as the first layer (per-IP); lockout is per-account.

**Test:** 8 bad logins โ†’ 423 even with correct password; wait/clear `locked_until` in sqlite3 โ†’ login OK. Signup with `password123` โ†’ 400 (breached).

## 3.2 Datetime consistency (Finding #13)

Root cause: SQLite `DateTime` columns come back **naive** even when stored from aware `datetime.now(timezone.utc)`; comparisons against aware datetimes raise `TypeError` (breaks invite expiry).

**New helpers** in `app/utils/time.py`:
```python
from datetime import datetime, timezone
def now_utc(): return datetime.now(timezone.utc)
def as_utc(dt):
    if dt is None: return None
    return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
```
**Sweep:**
```bash
grep -rn "datetime.utcnow\|datetime.now()" app/ --include='*.py'      # replace with now_utc()
grep -rn "expires_at [<>]\|< datetime\|> datetime" app/ --include='*.py'  # wrap column side in as_utc()
```
Priority fix sites: invite accept flow (`auth_api.py` L348+), trial checks (`billing.py`), token expiry (1.3 code already uses the pattern), session expiry (`security_api.py`), scheduler.

**Test:** unit test comparing a stored-then-loaded `expires_at` against `now_utc()` โ€” must not raise; invite created โ†’ accepted after reload works.

## 3.3 Pagination (Finding #14) โ€” triage, don't boil the ocean

139 `.all()` sites; most are already bounded by `company_id` filter + small tables. **Paginate only unbounded/user-facing list endpoints:**

Priority targets (grep `\.all()` per file):
- `admin_api.py` (18): super-admin lists over ALL companies/users โ€” highest risk.
- `analytics.py` (32): raw KPI/event rows over time ranges.
- `api_proxy.py` (37): activity logs, audit logs, notifications lists.

**Standard pattern (backward compatible โ€” same shape when params absent is NOT possible if we wrap; so keep response shape, add envelope only when `page` param present):**
```python
def paginated(query, default_per_page=50, max_per_page=200):
    page = request.args.get('page', type=int)
    per_page = min(request.args.get('per_page', default_per_page, type=int), max_per_page)
    if page is None:
        return query.limit(1000).all(), None      # legacy path, hard cap 1000
    p = query.paginate(page=page, per_page=per_page, error_out=False)
    return p.items, {'page': p.page, 'pages': p.pages, 'total': p.total, 'per_page': per_page}
```
Legacy callers (SPA today) get identical arrays capped at 1000; SPA can adopt `?page=` incrementally. Frontend unaffected โ†’ constraint satisfied.

**Test:** `curl 'localhost:5003/api/activity?page=1&per_page=10'` returns envelope; without params returns plain array (existing SPA tests pass); seed 2000 rows โ†’ legacy response is 1000, no OOM.

## 3.4 Stripe webhook races (Finding #15)

`app/routes/billing.py` `_process_stripe_event` (L267+). Three fixes:

1. **Trial abuse (webhook side):** on `checkout.session.completed`/`customer.subscription.created`, record permanently:
   ```sql
   ALTER TABLE companies ADD COLUMN has_had_subscription BOOLEAN DEFAULT 0;  -- migration
   ```
   Set `company.has_had_subscription = True` on first subscription event; change L140โ€“142 checkout guard to check this flag (survives subscription deletion, unlike querying live subs).
2. **Out-of-order events:** Stripe doesn't guarantee ordering. Store `event.created` timestamp:
   ```sql
   ALTER TABLE companies ADD COLUMN tier_synced_at DATETIME;  -- migration (or on subscription table)
   ```
   ```python
   evt_ts = datetime.fromtimestamp(event['created'], tz=timezone.utc)
   if company.tier_synced_at and as_utc(company.tier_synced_at) >= evt_ts:
       return  # stale event; idempotency table already dedupes exact retries
   ... apply tier ...
   company.tier_synced_at = evt_ts
   ```
3. **Downgrade authority:** derive tier from `subscription.status` + price/plan id fetched fresh via `stripe.Subscription.retrieve(sub_id)` rather than trusting event metadata; on `customer.subscription.deleted` โ†’ downgrade to `'launch'` (DEFAULT_PLAN in config.py L31).

**Test:** `stripe listen --forward-to localhost:5003/api/billing/stripe/webhook`; `stripe trigger customer.subscription.updated` then replay an older event fixture โ†’ tier unchanged; cancel sub in test mode โ†’ company tier drops to launch; re-checkout โ†’ `trial_period_days=0`.

## 3.5 Scheduler โ†’ APScheduler (Finding #16)

Keep single-process constraint, remove Threading.Timer fragility. `.venv/bin/pip install APScheduler` (+pin).

**Rewrite `app/scheduler.py`** core (keep public API `start_scheduler(app)` / job funcs so callers don't change):
```python
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger

_sched = None
def start_scheduler(app):
    global _sched
    if _sched: return _sched
    if os.environ.get('RUN_SCHEDULER', '1') != '1': return None
    _sched = BackgroundScheduler(daemon=True, job_defaults={
        'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 300})
    _sched.add_job(lambda: _sync_all(app), IntervalTrigger(minutes=5), id='sync_realtime')
    _sched.add_job(lambda: _refresh_tokens(app), IntervalTrigger(minutes=15), id='token_refresh')
    _sched.start()
    return _sched
```
Each job wraps work in `with app.app_context():` and try/except with logging (Timer chains die silently on exception โ€” APScheduler doesn't).

**Gunicorn interplay (from 1.2):** stay at `workers=1` and start scheduler in-app, **or** (cleaner) move to a separate systemd unit `command-sovereignty-scheduler.service` running `.venv/bin/python -m app.scheduler_main` with `RUN_SCHEDULER=1`, and set web unit `RUN_SCHEDULER=0` โ†’ then bump gunicorn to `workers=2`. **Recommended: separate unit.**

**Test:** start service, `grep -i 'scheduler\|sync' logs/app.log` shows jobs firing at expected cadence; kill worker mid-job โ†’ job resumes on next tick; raise an exception in a job โ†’ next run still fires.
**Rollback:** old scheduler.py from git; both expose `start_scheduler(app)`.

## 3.6 Twilio verification mandatory (Finding #17)

`app/routes/sms_webhooks.py` L85โ€“93 โ€” delete the opt-out:
```python
def verify_twilio_signature(connector, sms_conn) -> bool:
    # Signature verification is ALWAYS enforced (removed per-connector opt-out, audit #17)
    signature = request.headers.get('X-Twilio-Signature', '')
    if not signature:
        logger.warning('Missing X-Twilio-Signature header')
        return False
    ...
```
Also verify the provider's `verify_webhook_signature` fails closed (returns False on missing auth token) โ€” check `app/connectors/sms.py`; if auth token absent, return False, never True. Remove/ignore the `verify_webhook_signature` key from connector settings UI (leave key harmless in stored settings โ€” back-compat).

**Test:** `curl -X POST localhost:5003/webhooks/sms/<connector_id> -d 'Body=hi'` (no header) โ†’ 401; replay a captured valid Twilio request โ†’ 200; connector with `verify_webhook_signature: false` in settings โ†’ still 401 without valid signature.

## 3.7 Invite system (Finding #18)

Locate bug: `app/routes/auth_api.py` `api_auth_invite` (L275+) / accept (L348+).
```bash
grep -n "user_id" app/routes/auth_api.py | sed -n '1,40p'
```
Fix pattern (typical form of this bug โ€” invite row created with `user_id=None` and never linked, plus lookup by wrong field on accept):
1. On invite create: `user_id` legitimately None (invitee has no account) โ€” but ensure `email`, `company_id`, `role`, `token`, `expires_at` are set and **status filter on accept matches** (`status='pending'`, token match, `as_utc(expires_at) > now_utc()` โ€” depends on 3.2).
2. On accept: create/find user by invite email, create `UserCompany(user_id=user.id, company_id=invite.company_id, role=invite.role)`, set `invite.user_id = user.id`, `invite.status='accepted'`.
3. **Actually send the email** (currently missing):
```python
from ..utils.mail import send_email   # confirm helper name in app/utils/mail.py
send_email(invite_email, f'You are invited to {company.name} on Command Sovereignty',
           f'Accept your invite: https://app.commandsovereignty.com/accept-invite?token={raw_token}\n'
           f'This link expires in 7 days.')
```
Wrap send in try/except โ†’ on failure return 502 with 'Invite created but email failed' + keep invite row so it can be resent; add `POST /api/auth/invite/<id>/resend` (with `@require_csrf`).

**Test:** invite fresh email โ†’ row has correct company/email, mail log shows send; accept with token โ†’ user created, `UserCompany` row exists, `invite.user_id` populated, invite unusable twice; expired invite โ†’ 400.

---

# PHASE 4 โ€” CLEANUP (est. 2โ€“3 h)

## 4.1 docker-compose.yml (Finding #19)

Deployment is systemd; the compose file exposes a Postgres with default creds and misleads operators.
```bash
git rm docker-compose.yml && git commit -m 'Remove dead docker-compose (deployment is systemd, audit #19)'
```
Also delete/ignore stale root-level cruft spotted during recon if unused: `app.py`, `fix_duplicate.py`, `integrate_security.py`, `security_routes_addition.py`, `auth_cookies.txt` (**contains cookies โ€” delete and git-filter if ever committed**). Verify each with `git log --oneline -1 -- <file>` + grep for imports before removal.

**Test:** `systemctl --user restart command-sovereignty && curl localhost:5003/health`.

## 4.2 Global JSON error handler (Finding #20)

`app/__init__.py` โ€” replace/extend handlers at L104โ€“119:
```python
from werkzeug.exceptions import HTTPException

@app.errorhandler(HTTPException)
def handle_http_error(e):
    if not request.path.startswith('/api/') and e.code == 404:
        return send_from_directory(SPA_DIR, 'index.html')     # keep SPA fallback
    return jsonify({'error': e.description or e.name, 'code': e.code}), e.code

@app.errorhandler(Exception)
def handle_unexpected(e):
    app.logger.exception('Unhandled exception on %s %s', request.method, request.path)
    if app.debug:
        raise e
    return jsonify({'error': 'Internal server error', 'code': 500}), 500
```
This standardizes 400/401/403/405/413/429/500 to `{"error": ..., "code": ...}` and guarantees no stack traces/HTML leak to clients. Keep the existing 429 message text for back-compat.

**Test:** `curl localhost:5003/api/nonexistent` โ†’ JSON 404; force an exception (temp route) โ†’ JSON 500, full traceback only in `logs/app.log`; `curl -X PUT localhost:5003/health` โ†’ JSON 405.

---

# DEPENDENCY GRAPH (summary)

```
1.4 WAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
1.2 Gunicorn โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€ 2.3 secrets (same unit edit) โ”€โ”€ SECRET_KEY rotation โ”€โ”€ 2.2 sessions
               โ””โ”€โ”€ 3.5 APScheduler (worker count decision)
1.1 IDOR (independent, do first)
1.3 reset flow โ”€โ”€ needs migration; strength check upgraded by 3.1
2.1 CSRF โ† csrf.py extraction (do before adding decorators elsewhere)
2.4 headers โ”€โ”€ /uploads sandbox supports 2.6 SVG
2.7 MAX_CONTENT_LENGTH before 2.6 (upload tests)
3.2 datetime helpers โ† used by 3.1, 3.4, 3.7, 1.3
```
Suggested order inside phases: 1.1 โ†’ 1.4 โ†’ 1.2 โ†’ 1.3, then 2.7 โ†’ 2.5 โ†’ 2.4 โ†’ 2.1 โ†’ 2.6 โ†’ 2.3+2.2 (one restart, forced re-login), then 3.2 โ†’ 3.1 โ†’ 3.7 โ†’ 3.4 โ†’ 3.6 โ†’ 3.5 โ†’ 3.3, then Phase 4.

# MIGRATIONS (all follow scripts/migrate_company_flags.py pattern; each preceded by `python seed.py --backup-only`)

| Migration | Tables/columns | Needed by |
|---|---|---|
| migrate_password_reset.py | `password_reset_tokens` table | 1.3 |
| migrate_lockout.py | `profiles.failed_login_count`, `profiles.locked_until` | 3.1 |
| migrate_billing_flags.py | `companies.has_had_subscription`, `companies.tier_synced_at` | 3.4 |

# VERIFICATION GATE (end of each phase)

```bash
cd /home/vincent/projects/command-sovereignty
.venv/bin/python -m pytest tests/ -x -q
curl -s localhost:5003/health | jq .
systemctl --user status command-sovereignty --no-pager | head -5
# manual SPA smoke: login, dashboard, forecast, connectors page, billing page
```