13 KiB
Denya OneCare — Production Hardening & Review Checklist
Audience: Abiba (and any agent working the Denya OneCare repo) Status: Demo → Production hardening Context: WhatsApp integration lands within a week (once Denya provides credentials). We are moving past "demo" toward the final product. This doc is the concrete, ordered punch-list to get there. Each item is grounded in the current codebase (verified against
mainand the live deployment onscottdenya). How to use: work top-down. P0 items are hard blockers for any real data. When a P0/P1 item is done, mark it[x]and PR it with ano-mistakes(review)pass.
0. Current state (verified 2026-08-03)
- Working & verified: auth (JWT 30m/7d, bcrypt, RBAC via
require_roles), ticket CRUD with 16-statusVALID_TRANSITIONSstate machine, SLA engine, photo uploads, category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with legacy-schema self-heal. pytest suite passes. - Live: container
denya-onecareon LXCscottdenya(192.168.68.75:8000), image built 2026-08-02,restart: unless-stopped. - Demo-only posture resolved (PR #10 + P0 batch):
SECRET_KEYnow fails closed without a real key, CORS is an explicit origin allow-list, and open self-registration was removed —POST /api/auth/register→ 404, users are admin-managed only (see AGENTS.md). - Known demo-only posture (must change): SQLite backend; WhatsApp webhook code is present but no real credentials wired.
1. P0 — Security blockers (do these FIRST, before any real data)
P0.1 Hardcode-safe secrets; never ship the default key — DONE (PR #10)
- Files:
docker-compose.yml,app/core/config.py - Replace the hardcoded
SECRET_KEY=change-me-in-productiondefault with a fail-closed default: ifSECRET_KEYis unset/empty or still the well-known placeholder string, refuse to boot (raise inSettingsvalidation or lifespan). docker-compose.ymlmust NOT carry a literal secret. Reference an.env(git-ignored) or a runtime secret source. AddSECRET_KEY+WHATSAPP_*to.gitignore.- Acceptance: starting the app without a real key fails loudly; container env
contains a strong random key (≥32 bytes, e.g.
openssl rand -hex 32).
P0.2 Lock down CORS — DONE (PR #10)
- Files:
app/main.py,docker-compose.yml CORS_ORIGINS=*+allow_credentials=Trueis an invalid/unsafe combo (browsers reject*with credentials anyway). Replace with an explicit origin allow-list of the real web origins (e.g.https://denya.sysloggh.net, your NetBird/nomad domain + localhost for dev).- If credentials are used, origins MUST be explicit — never
*. - Acceptance:
settings.CORS_ORIGINSis a comma-separated explicit list; the middleware builds an allow-list, not["*"].
P0.3 Gate user registration — DONE (P0 batch, 2026-09)
- Open
POST /api/auth/registerwas removed entirely (404) — there is no sign-up UI. - Users are admin-managed:
POST /api/auth/users(Admin/Jerome + Admin/Wahab only) creates users with a forced canonical role (unknown roles → 422);PATCH /api/auth/users/{id}changes role / deactivates (self-modification → 400);DELETE /api/auth/users/{id}is guarded (users referenced by tickets/timeline/escalations → 409); duplicate email → 409. - Emails are normalized (strip + lowercase) on every write path; a startup
self-heal lowercases legacy mixed-case rows so pre-P0 accounts can't be locked
out of login. Unified role model lives in
app/core/roles.py. - Regression tests:
tests/test_p0_auth_admin_batch.py. - If a public self-service resident/tenant signup is genuinely required later (Phase 2 QR/self-service), it must be a SEPARATE endpoint with a role default of the least-privilege role and rate-limiting — never able to mint admin/FM roles.
P0.4 Reconsider SQLite for the final product
- Files:
docker-compose.yml,app/core/database.py,app/core/config.py, PRD §16 - PRD Phase 1 calls for PostgreSQL. SQLite is fine for POC but is a write-lock bottleneck and a data-integrity risk under concurrent FM/CS/WhatsApp writes.
- Recommended: switch
DATABASE_URLto Postgres via async driver (postgresql+asyncpg://). SQLAlchemy 2.0 + SQLAlchemy models are portable — the migration is mostly: new driver dependency,DATABASE_URL, and re-running Alembic against Postgres. Keep SQLite as the default for local dev/tests only. - Acceptance:
pytestgreen against Postgres (tests param via conftest), Alembic applies cleanly on a fresh Postgres DB.
P0.5 WhatsApp webhook auth + hardening — PARTIAL (P0 batch, 2026-09)
- File:
app/routers/whatsapp.py - Done: the
GEThandshake validateshub.verify_tokenwith a constant-time compare and returns 403 on mismatch; inboundPOSTs are gated by theX-Webhook-Secretheader matchingWHATSAPP_WEBHOOK_SECRET(fail-closed 403 when the env var is unset — see.env.example); the debugGET /api/whatsapp/mock-lognow requires Bearer auth. - Still open: Meta request-signature validation (
X-Hub-Signature-256HMAC over the raw body with the app secret — the shared-secret header above is the interim gate); per-sender rate limiting / dedupe idempotency keyed onwa_message_id(retries can still double-create tickets); redacting the raw access token insend_whatsapp_replyerror paths. - Acceptance (open items): a forged POST without the Meta signature is
rejected; duplicate
wa_message_iddoes not create a second ticket; verify-token mismatch returns 403 (done).
2. P1 — Operational hardening (before/just after go-live)
P1.1 Secrets handling & git hygiene
- Ensure
SECRET_KEY,WHATSAPP_*, and any DB credentials are not in the repo or in the committeddocker-compose.yml..envis git-ignored. - On this fleet: align with Syslog's key-off-disk doctrine — inject secrets at runtime (Infisical) rather than baking into image or compose if feasible.
- Rotate the seed demo users'
denya123password before production.seed_usersis idempotent but the default password is inapp/services/seed.py— forced-rotate on first prod login or at seed time.
P1.2 Reverse proxy + TLS
- Do not expose the raw uvicorn :8000 behind
CORS_ORIGINS=*on the WAN. Terminate TLS at a reverse proxy (Caddy/Traefik/nginx) with a proper domain (e.g.denya.sysloggh.net). - Configure gunicorn/workers +
--proxy-headers(or keep uvicorn but behind TLS). - Acceptance:
https://denya.sysloggh.netserves the app with a valid cert;:8000is not directly reachable from the internet.
P1.3 DB backups & persistence
- Postgres change (P0.4) enables sane backups. Wire nightly
pg_dump(or PBS / Syslog backup cron) of the persistent volume. The compose already mountsapp-datavolume — make sure it's on backed-up storage. - Add an Alembic upgrade step to the deploy runbook (never rely only on
Base.metadata.create_all+ self-heal for schema changes in prod).
P1.4 Logging & observability
- Add structured request logging; route to a location you can actually check
(stdout + a file/volume). Correlate with
ticket_number. - Add a minimal
/healthreadiness that checks DB connectivity (currently it returns OK without touching the DB).
P1.5 Photo upload hardening
- File:
app/routers/tickets.py - Uploads already validate MIME + extension and use UUID filenames — good.
- Add: max file-size limit (e.g. 10 MB) and content sniffing (validate magic
bytes, not just
content_typewhich is client-supplied). - Ensure uploaded files are never executable and are served with
X-Content-Type-Options: nosniff.
P1.6 API hardening & rate limiting
- Done (P0 batch):
POST /api/auth/loginis rate-limited in-process — ~5 failures / 15 min per IP+email → 429 (env-tunableLOGIN_RATE_LIMIT_*, a successful login resets the window). Open: ticket-creation rate limiting (spam / mass-creation). - Done (P0 batch): ticket-list pagination —
page/page_size(default 50, cap 200),limitalias forpage_sizealso capped; passing both with different values → 422. Tie-breakerid DESCpresent.
3. WhatsApp integration (this week) — concrete wiring runbook
Assumes Denya provides: phone number ID, access token, verify token, app secret.
- Add env vars (
WHATSAPP_PHONE_NUMBER_ID,WHATSAPP_ACCESS_TOKEN,WHATSAPP_VERIFY_TOKEN,WHATSAPP_APP_SECRET,META_GRAPH_BASE) to.env(git-ignored) and inject at runtime. Never commit. - Webhook handshake: in Meta dashboard point the webhook URL at
<domain>/api/whatsapp/webhook. The GET verify path echoeshub.challengewhen the verify token matches (constant-time compare; mismatch → 403). The still-open P0.5 work is the Meta signature validation in step 3. - Verify incoming signature (P0.5) — use
X-Hub-Signature-256= HMAC-SHA256 of the raw body with your app secret, compared withcompare_digest. - Reply flow: confirm
send_whatsapp_replyposts correctly tograph.facebook.com/v18.0/<PHONE_NUMBER_ID>/messages. The reply template currently builds a JS string manually — prefer sending the nested object as a proper JSON body rather than a hand-built string ({\"body\":\"...\"}) to avoid escaping bugs. Test with the Meta "send a test message" tool. - Idempotency: guard ticket creation on
wa_message_id(P0.5) to prevent double-creation on retries. - Standalone test: use the
mock-logendpoint to confirm webhook → ticket → auto-reply path end-to-end in the demo env before pointing Meta's production webhook at it.
4. Review recommendations (for the no-mistakes(review) pass and final QA)
- RBAC coverage: audit every route for the correct dependency. Currently:
POST /api/tickets,PATCH,POST /{id}/status,POST /{id}/photos→ any authenticated user. Confirm role intent (should a CS Rep push a ticket to "On-Field Verification"? or only FM/Tech?).GET /api/tickets,GET /{id},/transitions,/sla,/photosare unauthenticated. For a facilities tool this may be intentional (resident view), but confirm you're comfortable with public reads of ticket details (which include reporter/phone). If not, add auth.
- Phone/tenant data exposure: ticket detail returns
phone. Decide who can see phone numbers and enforce at the API, not just the UI. - Test coverage gaps to add:
- Auth: expired token, malformed token, RBAC denial per role
- WhatsApp: signature validation (valid/invalid/forged), verify-token mismatch,
duplicate
wa_message_ididempotency - Pagination boundary: page > last page returns empty items, tie-breaker stable
- Photo upload: bad MIME spoofing, oversize file,
is_beforeflag - SLA: breach boundary exactly at deadline (not just past it)
- Schema/migration hygiene: the
ensure_legacy_schemaself-heal inapp/main.pyexists because of create_all DBs. Once you move to Alembic-only (P1.3), this becomes dead weight — plan a deprecation. - Concurrency: ticket-number generation reads
max()then+1— fine at current scale, but under concurrent Postgres writes this can race. If tickets ever originate from WhatsApp + web + dashboard simultaneously at volume, move to a sequenced/unique constraint approach.
5. Definition of Done (production-ready)
- No default
SECRET_KEY; app fails closed without a real key (PR #10) - CORS is an explicit origin allow-list (PR #10)
- Self-registration removed (register → 404); users are admin-managed only with forced canonical roles (P0 batch)
- Postgres backend; Alembic applies cleanly on fresh DB; nightly backups
- TLS-terminated reverse proxy with real domain; no raw :8000 on WAN
- Webhook POST gated by
X-Webhook-Secret(fail-closed); verify-token mismatch → 403;mock-logrequires auth - WhatsApp webhook: Meta
X-Hub-Signature-256HMAC validated; idempotent onwa_message_id; real credentials injected at runtime - Login rate limiting in place (~5 fails / 15 min per IP+email → 429)
- Ticket-creation rate limiting in place
- Photo uploads size-limited and content-sniffed
- RBAC audited per-route; phone data access controlled
- Expanded test suite (auth, WhatsApp, SLA boundary, uploads) — all green
- Secrets out of repo; demo password rotated
- Structured logs + DB-aware health check
Prepared by Mumuni (Syslog Falcon) — 2026-08-03, from a hands-on review of the denya-onecare repo and the live scottdenya deployment.