Environment-variable configuration that fails fast, database-only health check, JSON request logs with a request id, security headers with a strict CSP, and a multi-stage non-root Docker image with compose file. Ticket 1.1. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
9.4 KiB
title, type, ticket, created, status, baseline_revision, route, route_source, risk, review, review_source, lenses_ran, review_loop_iteration, context
| title | type | ticket | created | status | baseline_revision | route | route_source | risk | review | review_source | lenses_ran | review_loop_iteration | context | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Project scaffold, configuration and container | feature | 1 | 2026-10-15 | built | 24de5878ed |
full | auto | medium | quick | pinned |
|
0 |
|
Intent
Problem: The repository holds only planning documents. Every later story needs a runnable, secure and configurable Django container to build on.
Approach: Create the Django 5.2 LTS project with environment-variable configuration that fails fast, a database-only health check, JSON logs with a request id, a strict security-header layer, a multi-stage Docker image with compose file, and a test setup.
Boundaries & Constraints
Always: All configuration comes from environment variables, validated at startup; one error message lists every invalid or missing variable and the process exits non-zero. Nothing in the repository names ping-mee, a domain, an OU, a group, an IP or a path. Secrets never appear in logs. One gunicorn worker with threads. The container runs as a non-root user; only /data and /backups are writable. /healthz checks the database only. Python 3.13, Django 5.2 LTS. Spine rules AD-13 and AD-18 apply. Decision: the image is built and run on the Linux host from a checkout of this repository (Docker is not installed on the development machine); no registry or pipeline in this story.
Never: No AD, OIDC, mail, share or feature-app code (later entries). No python-ldap dependency yet. No inline script or external asset anywhere. No Django admin. No commit of secrets, and no real .env.
I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|---|---|---|---|
| Valid start | All required variables valid | Migrations run, server answers | No error expected |
| Invalid config | A required variable missing or malformed | Process exits non-zero, message names every bad variable | No traceback, no secret value printed |
| Health ok | GET /healthz, database reachable | 200 {"status":"ok"} |
No error expected |
| Health down | Database unreachable | 503 {"status":"unavailable"} |
Logged without secrets |
| Headers | Any response | CSP, nosniff, frame deny, referrer policy, X-Request-ID present |
No error expected |
| Request log | Any request | One JSON log line with request id, method, path, status | Query strings and bodies not logged |
Code Map
Greenfield: only planning documents exist. New files, using the spine's source tree.
pyproject.toml,uv.lock,.python-version-- dependencies (Django, gunicorn, whitenoise; dev: pytest, pytest-django, ruff)manage.py,portal/config/--settings.py,env.py(typed parsing, collects all errors),urls.py,wsgi.py,logging.py(JSON formatter),middleware.py(request id, security headers),health.pyportal/adapters/__init__.py-- empty package for later adapters and fakesportal/tests/--conftest.py, config, health, header and logging testsDockerfile,compose.yaml,.dockerignore,docker/entrypoint.sh-- multi-stage image, non-root, migrate then gunicorn.env.example,README.md-- documented variables and run steps- Reuse: spine AD-13, AD-18. Do not change: anything under
_bmad/,_bmad-output/,.agents/,context-resources/.
Tasks & Acceptance
Execution:
pyproject.toml,.python-version-- project metadata and pinned dependencies -- repeatable installsportal/config/env.py-- typed variables (SECRET_KEY,ALLOWED_HOSTS,DATA_DIR,DEBUG,TRUSTED_PROXIES,LOG_LEVEL,DEFAULT_TIMEZONE) with defaults only for safe values -- fail fastportal/config/settings.py,urls.py,wsgi.py-- SQLite in WAL mode underDATA_DIR, secure cookies, CSRF, whitenoise, UTC storage, translation on -- spine baselineportal/config/middleware.py,logging.py,health.py-- request id, headers, JSON logs,/healthzDockerfile,docker/entrypoint.sh,compose.yaml,.dockerignore-- image and run definitionportal/tests/-- unit-test the matrix rows.env.example,README.md-- document every variable and the run steps
Acceptance Criteria:
- Given valid variables, when the app starts, then migrations apply and
/healthzreturns 200. - Given a missing
SECRET_KEYand a malformedLOG_LEVEL, when the app starts, then it exits non-zero naming both. - Given the database file is unreadable, when
/healthzis called, then it returns 503. - Given any request, when the response is sent, then the CSP forbids inline scripts and external sources and the log line has the request id.
- Given the repository, when searched, then it contains no ping-mee name, domain, IP or path.
Implementation Notes
- Implemented by a coding subagent from this plan; the diff was read and
uv run pytest(12 passed, none skipped) anduv run ruff check .were re-run by the orchestrator. - Beyond the plan:
docker/healthcheck.py(the container probe needs a Host header that matchesALLOWED_HOSTS, otherwise it gets a 400);.gitattributes(keeps LF in.shandDockerfileon Windows checkouts); extra headers (Permissions-Policy, COOP); HSTS of one year whenDEBUG=false, which needs the proxy to sendX-Forwarded-Proto. README.mdwas rewritten and no longer carries the original project name line.- Ruff excludes
_bmad,_bmad-output,.agentsandcontext-resources. - The 503 criterion is tested with a mocked unreachable database, not an unreadable file.
- Not verified: the Docker build, the read-only root file system with gunicorn's worker temp dir, the
uvimage tag and the container health check. They needdocker compose up --buildon the Linux host. compose.yamlpublishes port 8000 on all host interfaces. Bind it to a specific address if the reverse proxy runs on the same host.- Resolved versions in
uv.lock: Django 5.2.18, gunicorn 26.2.0, whitenoise 6.12.0.
Plan Change Log
Review Triage Log
Pass 1 (quick lens). Counts: high 1, medium 3, low 3, false 1, maybe-false 0.
| # | Finding | Verdict | Route | Evidence |
|---|---|---|---|---|
| 1 | docker/entrypoint.sh not executable |
high | patch | Added as mode 100644 and COPY docker ./docker has no chmod, so ENTRYPOINT on the Linux host fails with permission denied; "Valid start" would not hold. |
| 2 | Security headers missing on responses from outer middleware | medium | patch | SecurityHeadersMiddleware is innermost; a bad Host 400 from CommonMiddleware, whitenoise static files and SecurityMiddleware redirects never pass through it. The AC says any request. |
| 3 | Query string and request repr in Django's own error log lines, and a second line per 4xx |
high | patch | Reproduced: a 404 for /missing?token=supersecret writes a django.request line containing <WSGIRequest: GET '/missing?token=supersecret'>. Breaks "query strings not logged" and "one line per request". |
| 4 | TRUSTED_PROXIES parsed but unused; SECURE_PROXY_SSL_HEADER trusts any client |
medium | patch | Nothing reads it until the sign-in entry (client IP rule); docs say it restricts proxies. The fix here is wording; wiring is entry 5. The proxy-header trust has no demonstrated harm yet (low, rejected). |
| 5 | /tmp tmpfs is a third writable location |
low | rejected | Ephemeral and needed for gunicorn workers; the rule's intent (persistent writable paths) holds; changing it adds complexity. |
| 6 | Gunicorn error log is plain text on stderr | low | rejected | Server start and error lines only; app logs are JSON. A JSON gunicorn log config is more than a direct correction. |
| 7 | "No ping-mee name" not satisfiable because of the plan file | false | rejected | The criterion covers code and image; .dockerignore excludes _bmad-output and a search outside protected folders found nothing. |
| 8 | 503 criterion tested with a mock, not an unreadable file | low | rejected | Disclosed in Implementation Notes; the view returns 503 on any exception from cursor(), so a real failure cannot return 200. A real-file test needs database wrapper plumbing. |
Design Notes
CSP is a constant in middleware.py (default-src 'self', script-src 'self', frame-ancestors 'none') so story 6 can adjust the style rules for the Fluent components without weakening script rules. Dependencies were checked on the web: Django 5.2 supports Python 3.10 to 3.14 and is LTS to April 2028; gunicorn 26.x and whitenoise 6.12 support it. Python 3.13 is pinned because this machine's default 3.15 is not supported by Django 5.2.
Verification
Commands:
uv run pytest-- expected: all tests passuv run ruff check .-- expected: no findingsuv run python manage.py check --deploywith a valid test environment -- expected: no errorsuv run python manage.py checkwith missingSECRET_KEY-- expected: exit non-zero listing the variable
Manual checks (if no CLI):
- Docker is not installed on this machine. On the Linux host:
docker compose up --build, thencurl http://localhost:8000/healthzreturns{"status":"ok"}, and starting with an invalid variable shows the error and exits.