Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email

Brings the Work Package Suite from a browser-local prototype to a
multi-tenant, SQL-backed deployment hardened for customer IP.

Auth & access control
- Local username/password login (bcrypt + JWT in an HttpOnly cookie),
  admin-managed users, per-project membership, and project-scoped API access.
- Admin console: change user roles, view the audit trail, manage settings.

Security hardening
- CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via
  X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping.
- Login lockout, token_version session revocation, stronger password policy,
  fail-closed secret loading, encrypted (AES-256) database backups.

Persistence & schema
- SOPs and Work Packages are now DB-backed and shared across users, written
  through a durable client sync outbox that queues offline edits.
- Alembic migrations applied automatically on container start.

New capabilities
- Phase 2 dashboard (progress, gating, pagination, archive).
- Phase 3 PWA "Field View" with offline caching and auth fallback.
- WP owner assignment with OPTIONAL email notifications, OFF by default and
  toggled from the admin console. SMTP password is read only from the
  SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link,
  never customer IP.

Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:51:15 -07:00
parent dd37f1f551
commit 39b48055ff
48 changed files with 2867 additions and 507 deletions

View File

@@ -30,7 +30,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from .db import get_db
from .db import get_db, DATABASE_URL
from . import models
log = logging.getLogger("wpsuite.auth")
@@ -40,6 +40,29 @@ JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {
@@ -55,13 +78,24 @@ def _load_secret() -> str:
s = os.getenv("AUTH_SECRET_KEY")
if s:
return s
# No secret configured: generate an ephemeral one so the app still runs in
# dev. Sessions won't survive a restart, and this is unsafe across multiple
# workers — production must set AUTH_SECRET_KEY.
# No key configured. In production (a real database is configured via
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
# sessions with a throwaway key that silently rotates on every restart. In
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
# runs zero-config.
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
# condition db.py uses to pick Postgres, so we don't wrongly block a
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
is_prod = not str(DATABASE_URL).startswith("sqlite")
if is_prod:
raise RuntimeError(
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
"(see server/.env.example / DEPLOYMENT.md)."
)
log.warning(
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
"Logins will reset on restart and break across multiple workers. "
"Set AUTH_SECRET_KEY in the environment for production."
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
)
return secrets.token_urlsafe(48)
@@ -92,6 +126,7 @@ def create_token(user: "models.User") -> str:
"sub": user.id,
"username": user.username,
"role": user.role,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(hours=SESSION_HOURS),
}
@@ -163,6 +198,10 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user