Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec09470ff | ||
|
|
76d9d12b78 | ||
|
|
3ad81e2c39 | ||
|
|
aef9d90097 | ||
|
|
c9b722041c | ||
|
|
cf1d9413bb | ||
|
|
f256f1a6d2 | ||
|
|
78068c23d9 | ||
|
|
51e00a40cd | ||
|
|
1f15ca5457 |
@@ -0,0 +1,15 @@
|
||||
# Denya OneCare — runtime environment template (HARDENING.md P0.1/P1.1)
|
||||
# Copy to .env and fill in real values. NEVER commit .env.
|
||||
# Generate the secret with: openssl rand -hex 32
|
||||
|
||||
# ── Required ─────────────────────────────────────────────
|
||||
SECRET_KEY=
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
|
||||
# Explicit origin allow-list — "*" is rejected at startup (P0.2)
|
||||
CORS_ORIGINS=http://localhost:8000
|
||||
|
||||
# ── Optional (WhatsApp; needed before wiring Meta) ───────
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
WHATSAPP_ACCESS_TOKEN=
|
||||
WHATSAPP_VERIFY_TOKEN=
|
||||
META_GRAPH_BASE=https://graph.facebook.com/v18.0
|
||||
@@ -12,6 +12,7 @@ COPY pyproject.toml .
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
COPY app/ app/
|
||||
COPY apartment_mapping.json .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
# 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 `main` and the live deployment on `scottdenya`).
|
||||
> **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 a `no-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-status `VALID_TRANSITIONS` state machine, SLA engine, photo uploads,
|
||||
category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with
|
||||
legacy-schema self-heal. **32 pytest tests pass.**
|
||||
- **Live:** container `denya-onecare` on LXC `scottdenya` (192.168.68.75:8000),
|
||||
image built 2026-08-02, `restart: unless-stopped`.
|
||||
- **Known demo-only posture (must change):** `SECRET_KEY=change-me-in-production`,
|
||||
`CORS_ORIGINS=*`, open `/api/auth/register`, 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
|
||||
- **Files:** `docker-compose.yml`, `app/core/config.py`
|
||||
- Replace the hardcoded `SECRET_KEY=change-me-in-production` default with a
|
||||
fail-closed default: if `SECRET_KEY` is unset/empty or still the well-known
|
||||
placeholder string, refuse to boot (raise in `Settings` validation or lifespan).
|
||||
- `docker-compose.yml` must NOT carry a literal secret. Reference an `.env`
|
||||
(git-ignored) or a runtime secret source. Add `SECRET_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
|
||||
- **Files:** `app/main.py`, `docker-compose.yml`
|
||||
- `CORS_ORIGINS=*` + `allow_credentials=True` is 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_ORIGINS` is a comma-separated explicit list; the
|
||||
middleware builds an allow-list, not `["*"]`.
|
||||
|
||||
### P0.3 Gate user registration
|
||||
- **File:** `app/routers/auth.py` (`POST /api/auth/register`)
|
||||
- Today anyone on the network can self-register. Decide the model:
|
||||
- **Recommended:** require an admin-issued invitation token, or restrict
|
||||
registration to a seed/allowed list, or remove the open route and create
|
||||
users only via seed/admin.
|
||||
- If a public self-service resident/tenant signup is genuinely required
|
||||
(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.
|
||||
- **Acceptance:** a raw, unauthenticated register call can no longer mint an
|
||||
`Admin/*` or `Director` account.
|
||||
|
||||
### 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_URL` to 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:** `pytest` green against Postgres (tests param via conftest),
|
||||
Alembic applies cleanly on a fresh Postgres DB.
|
||||
|
||||
### P0.5 WhatsApp webhook auth + hardening (finish wiring, then lock it)
|
||||
- **File:** `app/routers/whatsapp.py`
|
||||
- The handler exists but no credentials are set. When wiring this week:
|
||||
- Verify the `hub.verify_token` check is constant-time (compare with
|
||||
`secrets.compare_digest`). **The GET verification path currently returns
|
||||
`{"error": ...}` with HTTP 200** — flip to `403` on token mismatch.
|
||||
- Validate **inbound messages only from Meta** — the webhook MUST authenticate
|
||||
Meta's request signature (X-Hub-Signature-256 HMAC over the raw body with your
|
||||
app secret) before processing, otherwise anyone who discovers the endpoint can
|
||||
forge tickets. This is the single most important WhatsApp hardening item.
|
||||
- Add per-sender rate limiting / dedupe on `wa_message_id` (webhook retries can
|
||||
double-create tickets). Create an idempotency guard keyed on `wa_message_id`.
|
||||
- Never log the raw access token; redact in `send_whatsapp_reply` error paths.
|
||||
- **Acceptance:** a forged POST without the Meta signature is rejected; duplicate
|
||||
`wa_message_id` does not create a second ticket; verify-token mismatch returns 403.
|
||||
|
||||
---
|
||||
|
||||
## 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 committed `docker-compose.yml`. `.env` is 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' `denya123` password before production. `seed_users`
|
||||
is idempotent but the default password is in `app/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.net` serves the app with a valid cert;
|
||||
`:8000` is 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 mounts
|
||||
`app-data` volume — 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 `/health` readiness 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_type` which is client-supplied).
|
||||
- Ensure uploaded files are never executable and are served with
|
||||
`X-Content-Type-Options: nosniff`.
|
||||
|
||||
### P1.6 API hardening & rate limiting
|
||||
- Add rate limiting on `POST /api/auth/login` (brute-force) — per-IP/IP+account.
|
||||
- Consider rate limits on ticket creation (spam / mass-creation).
|
||||
- Normalize/validate `page_size` (already capped `le=200`) and pagination
|
||||
tie-breaker (`id DESC` present — good).
|
||||
|
||||
---
|
||||
|
||||
## 3. WhatsApp integration (this week) — concrete wiring runbook
|
||||
|
||||
Assumes Denya provides: **phone number ID, access token, verify token, app secret.**
|
||||
|
||||
1. **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.
|
||||
2. **Webhook handshake:** in Meta dashboard point the webhook URL at
|
||||
`<domain>/api/whatsapp/webhook`. The GET verify path currently echoes
|
||||
`hub.challenge` when the verify token matches — confirm this works, then apply
|
||||
P0.5 (403 on mismatch, HMAC signature validation).
|
||||
3. **Verify incoming signature** (P0.5) — use `X-Hub-Signature-256` = HMAC-SHA256
|
||||
of the raw body with your app secret, compared with `compare_digest`.
|
||||
4. **Reply flow:** confirm `send_whatsapp_reply` posts correctly to
|
||||
`graph.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.
|
||||
5. **Idempotency:** guard ticket creation on `wa_message_id` (P0.5) to prevent
|
||||
double-creation on retries.
|
||||
6. **Standalone test:** use the `mock-log` endpoint 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`, `/photos` are
|
||||
**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_id` idempotency
|
||||
- Pagination boundary: page > last page returns empty items, tie-breaker stable
|
||||
- Photo upload: bad MIME spoofing, oversize file, `is_before` flag
|
||||
- SLA: breach boundary exactly at deadline (not just past it)
|
||||
- **Schema/migration hygiene:** the `ensure_legacy_schema` self-heal in
|
||||
`app/main.py` exists 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
|
||||
- [ ] CORS is an explicit origin allow-list
|
||||
- [ ] Self-registration cannot mint privileged roles (or is admin-gated/removed)
|
||||
- [ ] Postgres backend; Alembic applies cleanly on fresh DB; nightly backups
|
||||
- [ ] TLS-terminated reverse proxy with real domain; no raw :8000 on WAN
|
||||
- [ ] WhatsApp webhook: Meta signature validated, verify-token mismatch → 403,
|
||||
idempotent on `wa_message_id`, real credentials injected at runtime
|
||||
- [ ] Login/ticket 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.*
|
||||
+795
-687
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./denya_onecare.db"
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────
|
||||
SECRET_KEY: str = "change-me-in-production-use-a-real-secret"
|
||||
SECRET_KEY: str = ""
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 # Phase 1: raised 30 -> 60 for fewer re-logins
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────
|
||||
@@ -42,3 +42,21 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# ── Fail-closed secret validation (HARDENING.md P0.1) ─────────────────
|
||||
# Refuse to boot without a real SECRET_KEY. Devs must create a local .env
|
||||
# (see .env.example); production injects it via docker-compose env_file.
|
||||
_KNOWN_PLACEHOLDER_SECRETS = {
|
||||
"",
|
||||
"change-me-in-production",
|
||||
"change-me-in-production-use-a-real-secret",
|
||||
"changeme",
|
||||
"secret",
|
||||
}
|
||||
|
||||
if settings.SECRET_KEY in _KNOWN_PLACEHOLDER_SECRETS or len(settings.SECRET_KEY) < 32:
|
||||
raise RuntimeError(
|
||||
"SECRET_KEY is missing, a known placeholder, or shorter than 32 chars. "
|
||||
"Generate one with: openssl rand -hex 32 — and set it in .env "
|
||||
"(dev) or the runtime environment (prod). Refusing to start."
|
||||
)
|
||||
|
||||
+9
-2
@@ -83,10 +83,17 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────────
|
||||
# ── CORS (HARDENING.md P0.2 — explicit origin allow-list, never "*") ──
|
||||
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
if "*" in _origins or not _origins:
|
||||
raise RuntimeError(
|
||||
"CORS_ORIGINS must be an explicit comma-separated origin allow-list "
|
||||
"(e.g. 'https://denya.sysloggh.net,http://localhost:8000'). "
|
||||
"'*' with allow_credentials=True is invalid and unsafe. Refusing to start."
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"],
|
||||
allow_origins=_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
+4
-1
@@ -10,7 +10,10 @@ class RegisterRequest(BaseModel):
|
||||
password: str
|
||||
full_name: str
|
||||
phone: str | None = None
|
||||
role: str = "CS Rep"
|
||||
# HARDENING.md P0.3: role is NOT client-controllable. Self-registration
|
||||
# always creates the least-privilege role; privileged roles are assigned
|
||||
# by an admin directly in the DB (or a future admin-gated endpoint).
|
||||
role: str = "CS Rep" # kept for backward compat; ignored by the service
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
||||
+11
-2
@@ -16,9 +16,18 @@ from app.core.security import (
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import RegisterRequest
|
||||
|
||||
# HARDENING.md P0.3 — least-privilege default for self-registered users.
|
||||
_SELF_REGISTER_ROLE = "CS Rep"
|
||||
|
||||
|
||||
async def register(db: AsyncSession, body: RegisterRequest) -> User:
|
||||
"""Create a new user. Raises 409 if email already exists."""
|
||||
"""Create a new user. Raises 409 if email already exists.
|
||||
|
||||
HARDENING.md P0.3: unauthenticated self-registration must never mint a
|
||||
privileged role. The client-supplied ``role`` field is IGNORED — new
|
||||
self-registered users always land on the least-privilege role.
|
||||
Admins assign elevated roles directly (DB seed / admin endpoint).
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
@@ -28,7 +37,7 @@ async def register(db: AsyncSession, body: RegisterRequest) -> User:
|
||||
password_hash=hash_password(body.password),
|
||||
full_name=body.full_name,
|
||||
phone=body.phone,
|
||||
role=body.role,
|
||||
role=_SELF_REGISTER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
@@ -152,6 +152,20 @@ SEED_CATEGORIES_DATA: list[dict] = [
|
||||
{"type": "cs", "name": "Missing Item", "subs": ["Guest left items behind", "Item search request"]},
|
||||
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
|
||||
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
|
||||
# ── Phase 1 additions (maintenance) ──────────────────────────
|
||||
{"type": "maintenance", "name": "Kitchen Sink", "subs": ["Clogging"]},
|
||||
{"type": "maintenance", "name": "Painting", "subs": []},
|
||||
{"type": "maintenance", "name": "Gym", "subs": []},
|
||||
{"type": "maintenance", "name": "Swimming Pool", "subs": []},
|
||||
{"type": "maintenance", "name": "Shower Cord", "subs": []},
|
||||
{"type": "maintenance", "name": "Sliding Doors", "subs": []},
|
||||
{"type": "maintenance", "name": "Sliding Windows", "subs": []},
|
||||
{"type": "maintenance", "name": "Low Water Pressure", "subs": []},
|
||||
{"type": "maintenance", "name": "Damages", "subs": []},
|
||||
# ── Phase 1 additions (customer service) ─────────────────────
|
||||
{"type": "cs", "name": "Parking Issues", "subs": []},
|
||||
{"type": "cs", "name": "Noise Complaints", "subs": []},
|
||||
{"type": "cs", "name": "Waste Management", "subs": []},
|
||||
# ── Emergency ────────────────────────────────────────────────
|
||||
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
|
||||
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
|
||||
|
||||
+2
-3
@@ -4,10 +4,9 @@ services:
|
||||
container_name: denya-onecare
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env # git-ignored; see .env.example for required keys
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
|
||||
- SECRET_KEY=change-me-in-production
|
||||
- CORS_ORIGINS=*
|
||||
- DEBUG=false
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
|
||||
@@ -12,6 +12,10 @@ import tempfile
|
||||
|
||||
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
|
||||
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
|
||||
# HARDENING.md P0.1/P0.2: the app now fails closed without a real SECRET_KEY
|
||||
# and an explicit CORS allow-list — tests must satisfy both.
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production-0123456789abcdef")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://test")
|
||||
|
||||
import pytest_asyncio # noqa: E402 (DATABASE_URL must be set before app imports)
|
||||
from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""P0 hardening regression tests (HARDENING.md P0.1 / P0.2 / P0.3).
|
||||
|
||||
Covers:
|
||||
- P0.3: self-registration CANNOT mint a privileged role (role field ignored)
|
||||
- P0.3: duplicate email still 409s
|
||||
- P0.1: app fails to import/boot with placeholder or missing SECRET_KEY
|
||||
- P0.2: app fails to boot with CORS_ORIGINS="*"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
|
||||
# ── P0.3: registration role-escalation ────────────────────────────────
|
||||
|
||||
async def test_register_cannot_mint_admin_role(client: AsyncClient):
|
||||
"""A raw unauthenticated register call must NOT be able to mint Admin/*."""
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "attacker@example.com",
|
||||
"password": "Sup3rSecret!",
|
||||
"full_name": "Attacker",
|
||||
"role": "Admin/Jerome",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
created = resp.json()
|
||||
assert created["role"] == "CS Rep", (
|
||||
f"self-registration minted privileged role: {created['role']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_register_role_wahab_also_blocked(client: AsyncClient):
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "attacker2@example.com",
|
||||
"password": "Sup3rSecret!",
|
||||
"full_name": "Attacker Two",
|
||||
"role": "Admin/Wahab",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["role"] == "CS Rep"
|
||||
|
||||
|
||||
async def test_register_duplicate_email_conflict(client: AsyncClient):
|
||||
payload = {
|
||||
"email": "dupe@example.com",
|
||||
"password": "Sup3rSecret!",
|
||||
"full_name": "Dupe",
|
||||
}
|
||||
r1 = await client.post("/api/auth/register", json=payload)
|
||||
assert r1.status_code == 201
|
||||
r2 = await client.post("/api/auth/register", json=payload)
|
||||
assert r2.status_code == 409
|
||||
|
||||
|
||||
# ── P0.1 / P0.2: fail-closed boot validation ──────────────────────────
|
||||
|
||||
def _boot_with_env(env_overrides: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
"""Try importing app.main in a subprocess with the given env; the import
|
||||
must fail (non-zero) when fail-closed validation trips."""
|
||||
env = os.environ.copy()
|
||||
env["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
env.pop("SECRET_KEY", None)
|
||||
env.pop("CORS_ORIGINS", None)
|
||||
env.update(env_overrides)
|
||||
script = (
|
||||
"import sys; sys.path.insert(0, ''); "
|
||||
"import app.main" # noqa
|
||||
)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
def test_boot_fails_with_placeholder_secret():
|
||||
result = _boot_with_env({"SECRET_KEY": "change-me-in-production"})
|
||||
assert result.returncode != 0, "app booted with placeholder SECRET_KEY!"
|
||||
assert "SECRET_KEY" in result.stderr
|
||||
|
||||
|
||||
def test_boot_fails_with_short_secret():
|
||||
result = _boot_with_env({"SECRET_KEY": "tooshort"})
|
||||
assert result.returncode != 0, "app booted with a <32-char SECRET_KEY!"
|
||||
assert "SECRET_KEY" in result.stderr
|
||||
|
||||
|
||||
def test_boot_fails_without_secret():
|
||||
result = _boot_with_env({"SECRET_KEY": ""})
|
||||
assert result.returncode != 0, "app booted without a SECRET_KEY!"
|
||||
assert "SECRET_KEY" in result.stderr
|
||||
|
||||
|
||||
def test_boot_fails_with_wildcard_cors():
|
||||
result = _boot_with_env(
|
||||
{
|
||||
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
|
||||
"CORS_ORIGINS": "*",
|
||||
}
|
||||
)
|
||||
assert result.returncode != 0, "app booted with CORS_ORIGINS=* !"
|
||||
assert "CORS_ORIGINS" in result.stderr
|
||||
|
||||
|
||||
def test_boot_succeeds_with_valid_env():
|
||||
result = _boot_with_env(
|
||||
{
|
||||
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
|
||||
"CORS_ORIGINS": "http://test",
|
||||
}
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
Reference in New Issue
Block a user