Skip to content

Commit 81b6872

Browse files
The Districtr CMS: a Wagtail service for content, users, and data admin
New top-level cms/ — a Django 5.2 / Wagtail 7 project sharing the backend's Postgres, with every Django-owned table confined to an 'admin' schema (search_path=admin,public + bootstrap_schema). It is the JWT issuer (RS256 with kid headers over /.well-known/jwks.json), the CMS for portal/place/static pages, the moderation surface for comment and map submissions, and the admin for map modules and overlays. Nothing routes to it yet: the frontend still authenticates against Auth0 and the backend still verifies Auth0's JWKS, so this is additive and schema-confined. Deploy it to dev and exercise it there before the cutover PR. Shape: three roles (admin / partner / super_partner) as Django groups; Team as the tenant boundary, scoping galleries, pages, and modules; review scope derived from a team's portals and minted as the review_tags claim PR3 enforces; legacy TipTap content converted to StreamField by migrate_tiptap, wrapped in a reversible migration. 238 tests, and CI also runs alembic against the same database to prove the managed=False datastore mirrors still match the backend schema. Reviewer: cms/README-less by design — start at config/settings/base.py (the search_path pinning and SIMPLE_JWT block), then authapi/{jwks, tokens,serializers,scopes,teams}.py, which is where the whole security model lives. content/0003_import_legacy_content runs migrate_tiptap during migrate and refuses to do so unattended without an owner mapping.
1 parent 88c0751 commit 81b6872

107 files changed

Lines changed: 11769 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-cms.yml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: Test CMS
2+
3+
on:
4+
push:
5+
paths: &paths
6+
- "cms/**"
7+
- ".github/workflows/test-cms.yml"
8+
# The datastore mirror drift check depends on the backend schema:
9+
- "backend/app/models.py"
10+
- "backend/app/alembic/**"
11+
# Also on PRs, so the suite runs against the MERGE RESULT rather than only
12+
# the branch tip.
13+
pull_request:
14+
paths: *paths
15+
16+
jobs:
17+
container-job:
18+
runs-on: ubuntu-latest
19+
20+
container: python:3.12.6
21+
22+
services:
23+
postgres:
24+
image: postgis/postgis:16-3.4
25+
env:
26+
POSTGRES_USER: postgres
27+
POSTGRES_PASSWORD: postgres
28+
POSTGRES_DB: postgres
29+
ports:
30+
- 5432:5432
31+
options: >-
32+
--health-cmd pg_isready
33+
--health-interval 10s
34+
--health-timeout 5s
35+
--health-retries 5
36+
37+
env:
38+
DJANGO_SETTINGS_MODULE: config.settings.dev
39+
DJANGO_SECRET_KEY: test-only-secret-key
40+
POSTGRES_USER: postgres
41+
POSTGRES_PASSWORD: postgres
42+
POSTGRES_DB: postgres
43+
POSTGRES_SERVER: postgres
44+
POSTGRES_PORT: 5432
45+
46+
steps:
47+
- name: Checkout repo code
48+
uses: actions/checkout@v4
49+
50+
- name: Install dependencies
51+
run: |
52+
python -m pip install --upgrade pip
53+
pip install -r requirements.txt --no-cache-dir
54+
working-directory: cms
55+
56+
- name: Django system checks
57+
run: python manage.py check
58+
working-directory: cms
59+
60+
- name: Bootstrap schema and migrate
61+
run: |
62+
python manage.py bootstrap_schema
63+
python manage.py migrate --noinput
64+
working-directory: cms
65+
66+
- name: Check for missing migrations
67+
run: python manage.py makemigrations --check --dry-run
68+
working-directory: cms
69+
70+
- name: Run tests
71+
run: python manage.py test
72+
working-directory: cms
73+
74+
# Drift guard: datastore mirrors the Alembic-owned `public` tables with
75+
# managed=False models. Run the backend's migrations against the same
76+
# database, then verify the mirrors still match the live schema.
77+
- name: Install backend dependencies (for Alembic)
78+
run: |
79+
pip install -r requirements.txt --no-cache-dir
80+
working-directory: backend
81+
82+
- name: Run backend (Alembic) migrations
83+
run: alembic upgrade head
84+
working-directory: backend
85+
env:
86+
DOMAIN: postgres
87+
ENVIRONMENT: test
88+
PROJECT_NAME: Districtr v2 backend
89+
BACKEND_CORS_ORIGINS: "http://localhost,http://localhost:5173"
90+
SECRET_KEY: mysupersecretkey
91+
DATABASE_URL: postgresql+psycopg://postgres:postgres@postgres:5432/postgres
92+
POSTGRES_SCHEME: postgresql+psycopg
93+
POSTGRES_USER: postgres
94+
POSTGRES_PASSWORD: postgres
95+
POSTGRES_DB: postgres
96+
POSTGRES_SERVER: postgres
97+
POSTGRES_PORT: 5432
98+
AUTH_JWKS_URL: http://localhost:8001/.well-known/jwks.json
99+
AUTH_AUDIENCE: http://localhost:8000/
100+
AUTH_ISSUER: http://localhost:8001
101+
AUTH_ALGORITHMS: RS256
102+
103+
- name: Check mirror drift
104+
run: python manage.py check_mirror_drift
105+
working-directory: cms

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.PHONY: dev prod playwright
22

33
dev:
4-
docker-compose up db backend frontend
4+
docker-compose up db backend frontend cms
55

66
prod:
77
docker-compose up db backend frontend-prod

cms/.env.docker.example

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Districtr CMS (Wagtail) — local dev via docker-compose
2+
DJANGO_SETTINGS_MODULE=config.settings.dev
3+
DJANGO_SECRET_KEY=django-insecure-dev-only-key
4+
5+
# Postgres (same database as the FastAPI backend; Django tables live in the
6+
# `admin` schema via search_path)
7+
POSTGRES_USER=postgres
8+
POSTGRES_PASSWORD=postgres
9+
POSTGRES_DB=districtr
10+
POSTGRES_SERVER=db
11+
POSTGRES_PORT=5432
12+
13+
WAGTAILADMIN_BASE_URL=http://localhost:8001
14+
DEFAULT_FROM_EMAIL=noreply@districtr.org
15+
16+
# FastAPI backend (GeoPackage import + thumbnail triggers; compose-internal)
17+
BACKEND_API_URL=http://backend:8000
18+
19+
# Object storage for GeoPackage and overlay uploads — mirrors the backend's
20+
# env contract (backend/app/core/config.py). Set ACCOUNT_ID for Cloudflare
21+
# R2, or AWS_S3_ENDPOINT for a custom S3 endpoint; leave both unset for
22+
# plain AWS S3.
23+
#AWS_ACCESS_KEY_ID=
24+
#AWS_SECRET_ACCESS_KEY=
25+
#R2_BUCKET_NAME=
26+
#ACCOUNT_ID=
27+
#AWS_S3_ENDPOINT=
28+
29+
# Public base URL stored as Overlay.source for uploaded overlays — the CDN
30+
# fronting the bucket (https://tilesets1.cdn.districtr.org in prod). When
31+
# unset, the raw s3://bucket/key path is stored instead.
32+
#OVERLAY_PUBLIC_URL_BASE=

cms/Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Production image for the Districtr CMS (Wagtail) on Fly.io
2+
FROM python:3.12-slim
3+
4+
ENV PYTHONUNBUFFERED=1 \
5+
PYTHONDONTWRITEBYTECODE=1 \
6+
DJANGO_SETTINGS_MODULE=config.settings.production
7+
8+
WORKDIR /districtr-cms
9+
10+
COPY requirements.txt .
11+
RUN pip install --no-cache-dir -r requirements.txt
12+
13+
COPY . .
14+
15+
# collectstatic needs settings to load; the real secret comes from Fly at runtime.
16+
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput
17+
18+
EXPOSE 8080
19+
20+
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8080", "--workers", "2", "--timeout", "60"]

cms/Dockerfile.dev

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Dev image for the Districtr CMS (Wagtail); used by docker-compose.
2+
FROM python:3.12.7
3+
4+
ENV PYTHONUNBUFFERED=1 \
5+
DJANGO_SETTINGS_MODULE=config.settings.dev
6+
7+
WORKDIR /districtr-cms
8+
9+
COPY requirements.txt .
10+
RUN pip install --no-cache-dir -r requirements.txt
11+
12+
COPY . .
13+
14+
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

cms/authapi/__init__.py

Whitespace-only changes.

cms/authapi/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AuthapiConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "authapi"

cms/authapi/jwks.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
JWK construction for the verifying key(s), including the RFC 7638 thumbprint
3+
used as `kid`.
4+
5+
The FastAPI backend verifies our tokens with PyJWT's PyJWKClient, which
6+
selects the signing key by matching the JWT header's `kid` against the JWKS.
7+
SimpleJWT does not emit a `kid` header by default, so authapi.tokens adds
8+
one using the same thumbprint computed here.
9+
"""
10+
11+
import base64
12+
import hashlib
13+
import json
14+
from functools import lru_cache
15+
16+
from django.conf import settings
17+
from jwt.algorithms import RSAAlgorithm
18+
19+
20+
def _b64url(data: bytes) -> str:
21+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
22+
23+
24+
def jwk_from_public_pem(public_pem: str) -> dict:
25+
"""Build a JWK dict (with RFC 7638 `kid`) from an RSA public key PEM."""
26+
public_key = RSAAlgorithm(RSAAlgorithm.SHA256).prepare_key(public_pem)
27+
jwk = json.loads(RSAAlgorithm.to_jwk(public_key))
28+
# RFC 7638: thumbprint over the lexicographically ordered required members.
29+
canonical = json.dumps(
30+
{"e": jwk["e"], "kty": jwk["kty"], "n": jwk["n"]},
31+
separators=(",", ":"),
32+
sort_keys=True,
33+
)
34+
jwk["kid"] = _b64url(hashlib.sha256(canonical.encode("utf-8")).digest())
35+
jwk["use"] = "sig"
36+
jwk["alg"] = "RS256"
37+
return jwk
38+
39+
40+
@lru_cache(maxsize=1)
41+
def current_jwk() -> dict:
42+
"""JWK for the active verifying key."""
43+
return jwk_from_public_pem(settings.SIMPLE_JWT["VERIFYING_KEY"])
44+
45+
46+
def current_kid() -> str:
47+
return current_jwk()["kid"]
48+
49+
50+
def all_jwks() -> list[dict]:
51+
"""Active key plus, during rotation, the next key (JWT_NEXT_VERIFYING_KEY)."""
52+
keys = [current_jwk()]
53+
next_pem = getattr(settings, "JWT_NEXT_VERIFYING_KEY", "")
54+
if next_pem:
55+
keys.append(jwk_from_public_pem(next_pem))
56+
return keys

cms/authapi/management/__init__.py

Whitespace-only changes.

cms/authapi/management/commands/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)