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:
16
scripts/backup-cron.sh
Normal file
16
scripts/backup-cron.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
|
||||
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
|
||||
# cron daemon — so it works in a bare postgres:16-alpine image.
|
||||
set -eu
|
||||
|
||||
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
|
||||
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
|
||||
|
||||
# Take one backup shortly after start so a freshly-deployed stack has an
|
||||
# immediate restore point instead of waiting a whole interval.
|
||||
sleep 20
|
||||
while true; do
|
||||
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
5
scripts/backup.Dockerfile
Normal file
5
scripts/backup.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
|
||||
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
|
||||
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
|
||||
FROM postgres:16-alpine
|
||||
RUN apk add --no-cache openssl
|
||||
58
scripts/db-backup.sh
Normal file
58
scripts/db-backup.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
|
||||
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
|
||||
#
|
||||
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
|
||||
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
|
||||
# the database holds customer IP — otherwise the dump (and every offsite copy) is
|
||||
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
|
||||
# possible); losing it means the backups are unrecoverable.
|
||||
#
|
||||
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
|
||||
set -eu
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
KEEP="${BACKUP_KEEP:-14}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
ENC="${BACKUP_ENC_PASSPHRASE:-}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
ts="$(date -u +%Y%m%d-%H%M%SZ)"
|
||||
if [ -n "$ENC" ]; then
|
||||
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
|
||||
else
|
||||
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
|
||||
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
|
||||
fi
|
||||
tmp="$out.partial"
|
||||
|
||||
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
|
||||
if [ -n "$ENC" ]; then
|
||||
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
|
||||
| gzip -c \
|
||||
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
|
||||
mv "$tmp" "$out"
|
||||
else
|
||||
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
|
||||
fi
|
||||
else
|
||||
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
|
||||
mv "$tmp" "$out"
|
||||
else
|
||||
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
|
||||
|
||||
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
|
||||
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
|
||||
if [ "$count" -gt "$KEEP" ]; then
|
||||
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
|
||||
echo "[db-backup] pruning $f"
|
||||
rm -f "$f"
|
||||
done
|
||||
fi
|
||||
33
scripts/db-restore.sh
Normal file
33
scripts/db-restore.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
|
||||
#
|
||||
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
|
||||
# recreates objects before loading. Take a fresh backup first if in doubt.
|
||||
#
|
||||
# Usage (from the project root):
|
||||
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
|
||||
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
|
||||
# backup container's environment).
|
||||
set -eu
|
||||
|
||||
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
|
||||
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
|
||||
|
||||
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
|
||||
case "$FILE" in
|
||||
*.enc)
|
||||
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
|
||||
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||
;;
|
||||
*)
|
||||
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||
;;
|
||||
esac
|
||||
echo "[db-restore] done."
|
||||
Reference in New Issue
Block a user