PoutineAI — Comprehensive Architecture & Security Review
Date: 2026-02-11
Reviewer: Friday (AI Agent)
Repo: /root/clawd/repos/poutineai (~927 files)
Stack: Python 3.11 / FastAPI + React 18 / TypeScript + PostgreSQL 17.6
Deployment: Docker Compose on Hetzner VPS, Caddy reverse proxy, Ansible provisioning
1. Architecture Overview
System Diagram
┌─────────┐
Email (Mailgun) ──┤ │
│ Caddy │──── Static React SPA
WhatsApp (Twilio)─┤ :80/443│
└────┬────┘
│ reverse_proxy
┌────▼────┐ ┌──────────┐
│ API │◄──────►│ Postgres │
│ FastAPI │ │ 17.6 │
│ :8000 │ └──────────┘
└────┬────┘
│ filesystem queue
┌────▼────┐
│ Worker │ (polls local_queue/)
│ Gemini │
│ 2.5Flash│
└─────────┘
Components
| Component | Role | Notes |
|---|---|---|
| Caddy | TLS termination, static file serving, reverse proxy | Auto HTTPS, security headers, CSP |
| API (FastAPI) | REST API, webhook receiver, auth, SPA serving | 14 routers, slowapi rate limiting |
| Worker | Filesystem queue poller, AI extraction, price history | Gemini 2.5 Flash primary, OpenRouter fallback |
| PostgreSQL 17.6 | Primary datastore | 15 tables, Alembic migrations |
| Local Filesystem | File storage + message queue | local_storage/uploads/, local_queue/ |
Data Flow
- Invoice arrives via email webhook (Mailgun), WhatsApp webhook (Twilio), or manual upload
- File saved to local filesystem, Invoice record created in DB (status=PROCESSING)
- Message written to filesystem queue (
local_queue/doc.ingested/) - Worker polls queue, sends file to Gemini 2.5 Flash for extraction
- Structured data persisted (InvoiceExtraction + InvoiceLines), status → AWAITING_APPROVAL
- User reviews/approves in React UI
- Export to Excel, QuickBooks IIF, Xero CSV, hledger journal, or universal CSV
Multi-Tenancy
- Full tenant isolation via
tenant_idFK on Invoice, enforced throughrequire_tenant_iddependency - Each tenant gets a unique Mailgun inbound alias email
- WhatsApp sender allowlist maps phone → tenant
- Roles:
global_admin(cross-tenant),admin(tenant),member(tenant)
Multi-Brand / Multi-Region
- Brand system:
poutineai(CA-QC),sundayroast(UK),vetbooks(US future) - Region-aware tax config (GST/QST, VAT, Sales Tax)
- Per-instance
.envconfig for brand/region/locale
2. Security Audit
2.1 Authentication & Authorization ✅ Generally Good
Auth flow: Passwordless magic links (email) → JWT in httpOnly cookie
Strengths:
- Magic link tokens hashed (SHA-256) before storage
- Single-use tokens with 15-min expiry
- Per-email rate limiting (3 links per 15 min)
- JWT in httpOnly, secure (prod), samesite=lax cookie
- Timing-attack resistant constant-time comparison for Mailgun HMAC
- Dev-only JWT fallback explicitly blocked in production (_is_production_env())
- Revoked users properly blocked
- Primary super admin protected from demotion/revocation
Concerns:
- P1: auth/login endpoint allows passwordless login with just an email — no magic link required. This is effectively an auth bypass for any known email. It issues a full session cookie. This endpoint should be removed or restricted to dev only.
- P2: JWT secret falls back to "dev-ephemeral-secret" in dev mode — acceptable but logged as warning.
- P2: Session TTL is 7 days (SESSION_TTL_MINUTES = 60 * 24 * 7). Consider shorter sessions with refresh.
2.2 Hardcoded Secrets ✅ Clean
- No hardcoded API keys or secrets found in code
- All secrets loaded from environment variables
.env.examplecontains only placeholder values- Super admin email in docker-compose is configurable via env var (not hardcoded in code logic)
2.3 SQL Injection ✅ Safe
- All queries use SQLAlchemy ORM — no raw SQL anywhere (only
text("SELECT 1")for health check) - Parameterized queries throughout
- No string interpolation in queries
2.4 CORS ✅ Properly Configured
- CORS origins loaded from environment variable (
POUTINEAI_CORS_ORIGINS) - Not wildcard — explicitly listed origins only
allow_credentials=Truewith explicit origin list (not*)
2.5 Input Validation ⚠️ Mixed
Good:
- File upload: magic byte validation prevents spoofed extensions
- File size limit: 20MB enforced
- Allowed types whitelist (PDF, JPEG, PNG, HEIC/HEIF)
- Pydantic models for request bodies with EmailStr validation
- Path traversal protection on document serving (uploads_dir prefix check)
- SSRF protection on Twilio media URLs (domain allowlist)
- Batch upload limited to 10 files
Gaps:
- P1: UpdateLinesRequest.lines accepts list[dict] with no field validation — any keys/values pass through
- P2: Supplier name in URL path (/suppliers/{supplier_name}) is URL-decoded but not sanitized for display
- P2: sort and order query params have whitelist for sort but order only checks "desc" vs anything else
2.6 Webhook Security ✅ Good
- Mailgun: HMAC-SHA256 signature verification with replay protection (5-min timestamp window)
- Twilio: Official
RequestValidatorfrom twilio SDK - Both can be disabled ONLY in non-production environments (env check)
- Unknown email recipients are silently dropped (prevents spam intake)
2.7 Dependency Vulnerabilities ⚠️ Review Needed
requirements-docker.txt — no version pins at all:
fastapi
uvicorn[standard]
requests
pydantic
...
pip install pulls latest — risk of supply chain attacks or breaking changes in production
- oci package included but Oracle Document AI doesn't appear actively used (dead dependency?)
- twilio>=9.0.0 in pyproject.toml but unpinned in docker requirements
Frontend (web/package.json): Versions look reasonable and recent.
2.8 Docker Security ⚠️ Runs as Root
- P1: Both
Dockerfile.apiandDockerfile.workerusepython:3.11-slimbase and run as root (noUSERdirective) - P2: API container exposes port 8010 to host (
ports: "8010:8000") — should be internal-only if Caddy is the entry point - DB password defaults to
change-mein docker-compose (acceptable since it's overridden by.env) - Host volumes mounted at
/var/lib/poutineai/— ensure proper permissions on host
2.9 Rate Limiting ✅ Good
- slowapi integrated at app level and per-router
- Per-user rate limiting (tenant+email identity, fallback to IP)
- Configurable limits via environment variables
- Separate limits for webhooks, reprocessing, and general API
- Magic link requests have per-email rate limiting (3/15min)
2.10 File Upload Security ✅ Good
- Magic byte validation (PDF, JPEG, PNG, HEIC signatures)
- Content hash deduplication
- Files stored with UUID prefix (prevents name collisions)
- Date-partitioned directory structure
- Path traversal protection on retrieval
2.11 Security Headers ✅ Excellent (Caddy)
- HSTS with preload
- X-Frame-Options: SAMEORIGIN
- X-Content-Type-Options: nosniff
- CSP policy (default-src 'self')
- Permissions-Policy (camera/mic/geo/payment disabled)
- Referrer-Policy: strict-origin-when-cross-origin
2.12 Error Handling in Webhooks ⚠️
- P2: WhatsApp webhook catches all exceptions and returns
500withdetail=f"Internal error: {str(e)}"— leaks internal error messages to Twilio. Should return generic error.
3. Code Quality
3.1 Strengths
- Clean separation: API routers / security / services / models / worker
- Consistent use of SQLAlchemy ORM with context managers (
with SessionLocal() as db) - Good audit logging throughout (AuditEvent for all invoice state changes)
- Comprehensive extraction post-processing (
_validate_and_fix) - Smart price history tracking with alert detection
- Multi-region/brand abstraction is clean
3.2 Issues
Dead Code / Unused:
- oci dependency — Oracle Document AI not actively used
- _run_pubsub() function in worker — just logs a warning
- common/gcp_secrets.py referenced from mailgun.py — GCP Secret Manager for self-hosted?
- jinja2 in dependencies — not used anywhere visible
- Legacy gst_total/qst_total fields maintained alongside v4 tax fields — tech debt
Inconsistencies:
- _audit() in invoices.py has parameter order (db, invoice_id, event, actor, meta) but some calls pass positional args as (db, invoice_id, "edited", {dict}) — skipping actor, passing meta as actor
- Actually looking closer: _audit signature is _audit(db, invoice_id, event, actor="system", meta=None) but the update_extraction call does _audit(db, invoice_id, "edited", {"changed_fields": ...}) — this passes the dict as actor, not meta. This is a bug.
- Boolean columns use Integer type (active, is_active) with comments "for SQLite simplicity" — but the project is Postgres-only now
Missing Error Handling:
- Batch upload silently swallows exceptions in the loop with bare except Exception
- Worker _write_logs has no error handling — filesystem write failure could crash extraction
Test Coverage:
- P1: Zero test files found. No unit tests, no integration tests. Only e2e Playwright config exists (playwright.config.js) but no test files.
3.3 Code Duplication
_display_status()defined identically ininvoices.pyandsuppliers.py- Status filter parsing (approved_paid/unpaid/rejected) duplicated across invoices and suppliers
- Confidence calculation logic duplicated between
_persist_extractionand_write_logsin worker
4. Feature Map
API Endpoints
| Prefix | Endpoint | Method | Auth | Description |
|---|---|---|---|---|
/health |
/health |
GET | None | Health check |
/api/config |
/config |
GET | None | Instance config (region, brand, tax) |
| Auth | ||||
/api/auth |
/magic/request |
POST | None | Request magic link email |
/verify |
GET | None | Verify magic link token | |
/login |
POST | None | Direct login (passwordless!) | |
/me |
GET | User | Current user info | |
/logout |
POST | Any | Clear session cookie | |
| Invoices | ||||
/api/invoices |
/ |
GET | Tenant | List with filters, search, sort, pagination |
/{id} |
GET | Tenant | Detail with extraction + lines | |
/{id} |
DELETE | Tenant | Delete invoice + related | |
/{id}/document |
GET | Tenant | Stream original file | |
/{id}/reprocess |
POST | Tenant | Re-queue for extraction | |
/{id}/approve |
POST | Tenant | Approve invoice | |
/{id}/reject |
POST | Tenant | Reject invoice | |
/{id}/mark-paid |
PATCH | Tenant | Toggle paid status | |
/{id}/extraction |
PATCH | Tenant | Edit extracted fields | |
/{id}/lines |
PATCH | Tenant | Replace line items | |
/{id}/audit-events |
GET | Tenant | Audit history | |
/poll-status |
POST | Tenant | Batch status polling | |
/bulk-approve |
POST | Tenant | Bulk approve | |
/bulk-reject |
POST | Tenant | Bulk reject | |
/bulk-mark-paid |
POST | Tenant | Bulk mark paid | |
| Upload | ||||
/api/upload |
/ |
POST | Tenant | Single file upload |
/batch |
POST | Tenant | Multi-file upload (max 10) | |
| Exports | ||||
/api/exports |
/excel |
GET | Tenant | Excel download |
/journal |
GET | Tenant | hledger journal | |
/quickbooks |
GET | Tenant | QuickBooks IIF | |
/xero |
GET | Tenant | Xero CSV | |
/universal |
GET | Tenant | Generic CSV | |
/summary |
GET | Tenant | Export preview/totals | |
| Suppliers | ||||
/api/suppliers |
/ |
GET | Tenant | List with stats |
/{name}/invoices |
GET | Tenant | Supplier's invoices | |
| Stats | ||||
/api/stats |
/overview |
GET | Tenant | Dashboard stats |
/api/whatsapp |
/senders |
GET/POST | Tenant | Manage sender allowlist |
/senders/{id} |
PATCH/DELETE | Tenant | Update/remove sender | |
/status |
GET | Tenant | Connection status | |
| Settings | ||||
/api/settings |
/email-alias |
GET | Tenant | Mailgun alias |
/users |
GET | Tenant | Tenant user list | |
| Onboarding | ||||
/api/onboarding |
/status |
GET | User | Check if onboarding needed |
/complete |
POST | User | Create tenant + complete | |
/checklist |
GET | Admin | Checklist progress | |
/checklist/alias-copied |
POST | Admin | Mark alias copied | |
| Invitations | ||||
/api/invitations |
Various | POST | Admin+ | Invite users to tenant |
| Admin (global) | ||||
/api/admin |
/allowlist |
GET/POST/DELETE | GlobalAdmin | Email allowlist |
/waitlist |
GET | GlobalAdmin | Waitlist management | |
/waitlist/{id}/approve |
POST | GlobalAdmin | Approve waitlist entry | |
/tenants |
GET | GlobalAdmin | List all tenants | |
/tenants/{id} |
DELETE | GlobalAdmin | Soft-delete tenant | |
/users |
GET/PATCH | GlobalAdmin | Manage all users | |
| Webhooks | ||||
/webhooks/email |
/email |
POST | Mailgun HMAC | Inbound email |
/webhooks/wa |
/wa |
POST | Twilio sig | Inbound WhatsApp |
Frontend Pages (React SPA)
- Login / Magic Link Verify
- Onboarding (create restaurant)
- Dashboard (stats overview)
- Invoices list (with filters, search, bulk actions)
- Invoice detail (extraction view, edit, approve/reject, payment tracking)
- Upload page
- Suppliers list + supplier detail
- Exports page
- Settings (email alias, users)
- Admin panel (allowlist, waitlist, tenants, users) — global admin only
Worker Features
- Gemini 2.5 Flash extraction with OpenRouter fallback
- Versioned XML prompts (
prompt/live/prompt-v*.xml) - Document validity detection (non-financial document rejection)
- Low confidence auto-rejection
- Arithmetic validation & auto-correction
- Date inference (issue_date from invoice number or received date)
- Due date inference (immediate payment policy)
- Price history tracking with hike detection (>5% threshold)
- Retry with exponential backoff (rate limiting aware)
- Automatic cleanup of rejected invoices (configurable retention)
- Per-extraction cost tracking
5. Recommendations
P0 — Critical (Fix Before Next Deploy)
| # | Issue | Impact | Fix |
|---|---|---|---|
| 1 | Unpinned Docker dependencies | Supply chain attack, broken builds | Pin all versions in requirements-docker.txt with hashes. Use pip-compile or uv lock. |
| 2 | /api/auth/login is an auth bypass |
Any known email can get a full session without magic link | Remove this endpoint or restrict to dev environment only. It bypasses the entire magic link security model. |
P1 — Important (Fix This Sprint)
| # | Issue | Impact | Fix |
|---|---|---|---|
| 3 | Zero test coverage | Regressions, no confidence in changes | Add pytest tests for auth, invoices CRUD, webhook signature verification, worker extraction. Start with auth and tenant isolation. |
| 4 | Docker containers run as root | Container escape → host compromise | Add RUN useradd -r appuser && USER appuser to Dockerfiles |
| 5 | _audit() bug in update_extraction |
Dict passed as actor instead of meta |
Fix call to _audit(db, invoice_id, "edited", actor="system", meta={...}) |
| 6 | UpdateLinesRequest.lines unvalidated |
Arbitrary data stored in DB | Define a proper LineItem Pydantic model with typed fields |
| 7 | API port 8010 exposed to host | Bypasses Caddy security headers/TLS | Change to expose: 8000 (internal only) or bind to 127.0.0.1 |
| 8 | WhatsApp webhook leaks error details | Information disclosure to Twilio | Return generic 500 message, log details server-side only |
P2 — Nice to Have (Backlog)
| # | Issue | Impact | Fix |
|---|---|---|---|
| 9 | Remove dead oci, jinja2 dependencies |
Smaller attack surface, cleaner builds | Remove from requirements |
| 10 | Migrate Boolean columns from Integer to Boolean | Code clarity, Postgres-native types | Alembic migration |
| 11 | Extract _display_status() to shared utility |
DRY | Move to common/ |
| 12 | Shorter session TTL with refresh | Security best practice | 24h TTL + silent refresh on /auth/me |
| 13 | Add ON DELETE CASCADE to foreign keys |
Simplify deletion logic | Alembic migration, remove manual cascade deletes |
| 14 | Structured logging (JSON) | Better log aggregation | Use structlog or JSON formatter |
| 15 | Add health check endpoint for worker | Monitoring, Docker healthcheck | Simple HTTP server on worker (already has FastAPI import) |
| 16 | Content-Disposition header injection | Filenames in inline; filename= not sanitized |
Sanitize or quote filename |
6. Summary
PoutineAI is a well-architected, cleanly structured invoicing SaaS with thoughtful security measures throughout. The magic link auth, webhook verification, SSRF protection, file upload validation, and Caddy security headers demonstrate security-conscious development.
Critical gaps are the untyped /auth/login endpoint (effectively an auth bypass), unpinned dependencies, and zero test coverage. The Docker-as-root issue is standard for early-stage projects but should be fixed before handling sensitive financial data at scale.
The codebase is clean and readable with good separation of concerns. The multi-tenant isolation is consistently enforced. The worker's extraction pipeline is robust with retry logic, fallback providers, and intelligent validation.
Overall assessment: Solid foundation, needs the P0/P1 fixes before production scale-up.