Skip to content

Commit fd1bdec

Browse files
Align CMS with use case: own-content editors, team-enforced galleries, StaticPage
Product decisions (Dylan, 2026-08-04): - Editors edit only their own content: content/0004 revokes the editor group's tree-wide change_page; add_page + Wagtail's owner model grant edit on owned pages, publish_page applies only to those. migrate_tiptap gains --owners "auth0|sub=email,..." to set Page.owner from the legacy author column (Auth0 subjects) so pre-cutover content stays editable. - group_only galleries enforced via Teams: JWT gains a map_groups claim (user's teams' MapGroup slugs); the gallery API requires the gallery's map_group in that claim or the admin role — any-valid-login no longer opens restricted galleries. - StaticPage type (+ StaticIndexPage) with /api/content/static/... served by a Next.js /[slug] catch-all; hardcoded routes take precedence, so static pages migrate into the CMS one at a time. - District-comments 403 for tag-scoped reviewers: decided to keep as-is. FOLLOWUPS doc updated; cms 220 tests, backend suite, typecheck + build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ac5018b commit fd1bdec

12 files changed

Lines changed: 359 additions & 72 deletions

File tree

.agents/WAGTAIL-CUTOVER-FOLLOWUPS.md

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,34 +20,24 @@ replaced with real `Model(**row._asdict())` instances.
2020

2121
---
2222

23-
## 1. Product decisions needed (blockers for *deciding*, not for shipping)
24-
25-
### 1.1 Editor scope: all pages vs. own-content-only
26-
The legacy FastAPI CMS let editors modify only content they authored
27-
(`update:content` vs `update:update-all` scope split). The Wagtail replacement
28-
grants editor/admin **full-tree** add/change/publish on the page root
29-
([cms/content/migrations/0002_grant_page_permissions.py](../cms/content/migrations/0002_grant_page_permissions.py)
30-
— docstring records this decision as deferred).
31-
32-
- Wagtail-native way to restore own-content-only: grant `add_page` **without**
33-
`change_page` — owners get implicit edit rights on pages they created.
34-
- If chosen, `migrate_tiptap` should also set `Page.owner` from the legacy
35-
`author` column during import (currently dropped).
36-
37-
### 1.2 group_only galleries: who actually gets access?
38-
Today any **valid Districtr token** opens a `group_only` gallery — the
39-
`Gallery.map_group` FK is an unenforced placeholder
40-
([cms/galleries/api.py](../cms/galleries/api.py), documented simplification).
41-
Real enforcement needs:
42-
- a user→MapGroup assignment model in `authapi` (mirror `ReviewTagAssignment`),
43-
- a claim in the JWT (e.g. `map_groups`) minted in
44-
[cms/authapi/serializers.py](../cms/authapi/serializers.py),
45-
- the gallery API checking claim ∩ `gallery.map_group`.
46-
Also decide whether the unit of scoping is `MapGroup` at all, or the Django
47-
auth group (the `roles` claim already exists) — if the latter, repoint the FK
48-
before anyone stores data against it.
49-
50-
### 1.3 Refresh-token security posture
23+
## 1. Product decisions — RESOLVED 2026-08-04 (session with Dylan)
24+
25+
### 1.1 ✅ Editor scope: **own-content-only** (+ teams for manual control)
26+
Shipped: `content/0004_editor_own_content_only` revokes the editor group's
27+
tree-wide `change_page`; editors keep `add_page` (Wagtail's owner model grants
28+
edit on owned pages) + `publish_page` (applies only to editable, i.e. own,
29+
pages). `migrate_tiptap --owners "auth0|xx=email,..."` sets `Page.owner` from
30+
the legacy `author` column (Auth0 subjects — the sub→email mapping must be
31+
supplied at cutover; there are two distinct authors in the data).
32+
33+
### 1.2 ✅ group_only galleries: **enforced via Teams**
34+
Shipped: the JWT carries a `map_groups` claim (slugs across the user's teams,
35+
minted in [cms/authapi/serializers.py](../cms/authapi/serializers.py));
36+
[cms/galleries/api.py](../cms/galleries/api.py) requires the gallery's
37+
`map_group` slug in that claim, or the `admin` role. A merely-valid login no
38+
longer opens group_only galleries. Scoping unit confirmed as `MapGroup`.
39+
40+
### 1.3 Refresh-token security posture (still open, low priority)
5141
`BLACKLIST_AFTER_ROTATION` was turned **off**
5242
([cms/config/settings/base.py](../cms/config/settings/base.py)) because Next.js
5343
RSCs cannot persist rotated cookies — single-use tokens deterministically
@@ -62,7 +52,7 @@ or make middleware the *only* refresher and re-enable blacklisting.
6252

6353
| Item | Where | Notes |
6454
|---|---|---|
65-
| **NEEDS DECISION** — District comments for tag-scoped reviewers | [backend/app/comments/main.py](../backend/app/comments/main.py) | Blanket 403 today (district comments are tag-less). Either tag district comments at sync time or add per-document scoping. Menu link already hidden for scoped reviewers. |
55+
| **DECIDED 2026-08-04: leave as-is** — District comments for tag-scoped reviewers | [backend/app/comments/main.py](../backend/app/comments/main.py) | Blanket 403 stays: scoped reviewers moderate community comments only; full reviewers/admins handle district comments. Menu link already hidden for scoped reviewers. |
6656
|**DONE**`/places` "N map modules" count | [app/src/app/(static)/places/page.tsx](../app/src/app/(static)/places/page.tsx) | Restored: card shows `N map module(s)` from the `districtr_map_slugs` the list endpoint returns. |
6757
|**DONE** — GET `/auth/logout` CSRF | [app/src/app/auth/logout/route.ts](../app/src/app/auth/logout/route.ts) | Guarded with the Fetch-Metadata `Sec-Fetch-Site` header — an explicit `cross-site` GET bounces home WITHOUT signing out; same-origin/same-site/direct nav still log out. Chose this over the auto-submit-form approach: lower risk, no coupling to NextAuth CSRF internals, no redirect flash. |
6858
|**DEFERRED** (long-term) — PermissionGuard reads raw JWT client-side | [app/src/app/admin/components/PermissionGuard.tsx](../app/src/app/admin/components/PermissionGuard.tsx) | Now base64url-safe via shared `decodeJwtPayload`, but long-term the access token shouldn't need to reach the client at all — pass roles/scopes as typed session fields and keep the token server-side. Larger auth-session refactor; left as-is. |
@@ -137,7 +127,13 @@ or make middleware the *only* refresher and re-enable blacklisting.
137127
3. Staging rehearsal on the `-dev` Fly apps first (full sequence below, plus a
138128
backend `alembic revision --autogenerate` afterward proving an empty diff).
139129
4. Merge → CI deploys api/app/cms (release commands run both migration systems).
140-
5. `manage.py migrate_tiptap --dry-run` → review report → real run.
130+
5. `manage.py migrate_tiptap --dry-run` → review report → real run **with
131+
`--owners "auth0|<sub>=<email>,..."`** (map the two legacy author subjects
132+
to provisioned users so their pages stay editable under own-content-only).
133+
5b. In the Wagtail admin, add a "Static pages" index page under Home
134+
(StaticPage type, new 2026-08-04): static site pages migrate into the CMS
135+
one at a time — delete the hardcoded Next.js route, publish a StaticPage
136+
with the same slug (the `/[slug]` catch-all serves it).
141137
6. `manage.py provision_users users.csv` (CSV: email,name,group) — sends
142138
password-setup emails.
143139
7. Smoke: Wagtail login, edit+publish a page, comment moderation at
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {LanguagePicker} from '@/app/components/LanguagePicker/LanguagePicker';
2+
import StreamRenderer from '@/app/components/RichTextRenderer/StreamRenderer';
3+
import {getCMSContent} from '@/app/utils/api/cmsContent';
4+
import {Flex, Heading} from '@radix-ui/themes';
5+
import {cookies} from 'next/headers';
6+
import {notFound} from 'next/navigation';
7+
8+
// Catch-all for CMS-authored static pages. Hardcoded routes (about/, rules/,
9+
// ...) take precedence in Next.js routing, so pages migrate into the CMS one
10+
// at a time: delete the hardcoded route and publish a StaticPage of the same
11+
// slug.
12+
export const revalidate = 3600;
13+
14+
export async function generateMetadata({params}: {params: Promise<{slug: string}>}) {
15+
const [{slug}, userCookies] = await Promise.all([params, cookies()]);
16+
const language = userCookies.get('language')?.value ?? 'en';
17+
const cmsData = await getCMSContent('static', slug, language).catch(() => null);
18+
const title = cmsData?.content?.title;
19+
return title ? {title} : {};
20+
}
21+
22+
export default async function Page({params}: {params: Promise<{slug: string}>}) {
23+
const [{slug}, userCookies] = await Promise.all([params, cookies()]);
24+
const language = userCookies.get('language')?.value ?? 'en';
25+
const cmsData = await getCMSContent('static', slug, language).catch(() => null);
26+
27+
if (!cmsData?.content) {
28+
notFound();
29+
}
30+
31+
return (
32+
<Flex direction="column" width="100%">
33+
<Heading as="h1" size="6" mb="4">
34+
{cmsData.content.title}
35+
</Heading>
36+
<LanguagePicker
37+
preferredLanguage={language}
38+
availableLanguages={cmsData.available_languages}
39+
/>
40+
<StreamRenderer body={cmsData.content.body} className="my-4" />
41+
</Flex>
42+
);
43+
}

app/src/app/utils/api/cmsContent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const CMS_API_URL =
1515
? (process.env.CMS_URL ?? process.env.NEXT_PUBLIC_CMS_URL)
1616
: process.env.NEXT_PUBLIC_CMS_URL;
1717

18-
export type CmsContentTypes = 'tags' | 'places';
18+
export type CmsContentTypes = 'tags' | 'places' | 'static';
1919

2020
/** StreamField blocks returned in `content.body` */
2121
export interface RichTextBlock {
@@ -87,10 +87,13 @@ export interface TagsCMSContent extends CMSContent {
8787
export interface PlacesCMSContent extends CMSContent {
8888
districtr_map_slugs: string[] | null;
8989
}
90+
/** Static site pages (about, rules, ...) carry no map association. */
91+
export type StaticCMSContent = CMSContent;
9092

9193
interface CmsContentTypesEnum {
9294
tags: TagsCMSContent;
9395
places: PlacesCMSContent;
96+
static: StaticCMSContent;
9497
}
9598

9699
export interface CMSContentResponseWithLanguages<

cms/authapi/serializers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
)
55

66
from authapi.scopes import scopes_for_user
7+
from authapi.teams import map_group_slugs_for_user
78
from authapi.tokens import KidRefreshToken
89

910

@@ -42,6 +43,12 @@ def get_token(cls, user):
4243
token["roles"] = group_names
4344
if review_tags:
4445
token["review_tags"] = review_tags
46+
# MapGroup slugs across the user's teams: the galleries API matches
47+
# this claim against Gallery.map_group for group_only galleries
48+
# (admins bypass via the roles claim). Absent when team-less.
49+
map_groups = sorted(map_group_slugs_for_user(user))
50+
if map_groups:
51+
token["map_groups"] = map_groups
4552
return token
4653

4754

cms/content/api.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@
2828
from django.conf import settings
2929
from django.views.decorators.http import require_GET
3030

31-
from content.models import PlacePage, TagPage
31+
from content.models import PlacePage, StaticPage, TagPage
3232
from core.api import MAX_PAGE_SIZE, _json, pagination
3333

3434
CONTENT_TYPE_PAGES = {
3535
"tags": TagPage,
3636
"places": PlacePage,
37+
"static": StaticPage,
3738
}
3839

3940
DEFAULT_LANGUAGE = "en"
@@ -60,7 +61,7 @@ def _serialize_page(page, content_type):
6061
}
6162
if content_type == "tags":
6263
content["districtr_map_slug"] = page.districtr_map_slug or None
63-
else:
64+
elif content_type == "places":
6465
content["districtr_map_slugs"] = page.districtr_map_slugs or None
6566
return content
6667

@@ -146,7 +147,7 @@ def content_list(request, content_type):
146147
# modules per place without fetching each page.
147148
if content_type == "tags":
148149
item["districtr_map_slug"] = page.districtr_map_slug or None
149-
else:
150+
elif content_type == "places":
150151
item["districtr_map_slugs"] = page.districtr_map_slugs or None
151152
results.append(item)
152153
return _json(results)

cms/content/management/commands/migrate_tiptap.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
- Re-running upserts by (type, slug, locale): existing pages are updated in
1717
place (and skipped entirely when already identical), never duplicated.
1818
19-
Not carried over: legacy `author` (an Auth0 subject string with no Django
20-
user to map to) and created_at/updated_at (Wagtail manages its own
21-
first/last_published_at via publish()).
19+
Legacy `author` is an Auth0 subject string with no automatic Django user
20+
mapping; pass --owners "auth0|xx=email@x.org,..." to set Page.owner (which
21+
is what keeps pre-cutover content editable by its authors under the
22+
editors-own-content-only permission model). created_at/updated_at are not
23+
carried (Wagtail manages its own first/last_published_at via publish()).
2224
2325
--dry-run converts everything without writing and emits a per-row report:
2426
input node-type counts, output block-type counts, and a
@@ -29,6 +31,7 @@
2931
import json
3032
from collections import Counter, defaultdict
3133

34+
from django.contrib.auth import get_user_model
3235
from django.core.management.base import BaseCommand, CommandError
3336
from django.db import connection, transaction
3437
from wagtail.models import Locale, Page, Site
@@ -103,9 +106,38 @@ def add_arguments(self, parser):
103106
metavar="PATH",
104107
help="Write the full per-row report to PATH as JSON.",
105108
)
109+
parser.add_argument(
110+
"--owners",
111+
metavar="SUB=EMAIL[,SUB=EMAIL...]",
112+
help=(
113+
"Map legacy author Auth0 subjects to provisioned users by "
114+
"email; matched pages get that user as Page.owner (editors "
115+
"hold add-only page permission, so ownership is what keeps "
116+
"pre-cutover content editable by its authors). Unmapped or "
117+
"unknown authors are warned and left ownerless."
118+
),
119+
)
120+
121+
def _parse_owners(self, spec):
122+
"""``sub=email,...`` -> {sub: User}; warns on unknown emails."""
123+
owners = {}
124+
if not spec:
125+
return owners
126+
User = get_user_model()
127+
for pair in spec.split(","):
128+
sub, _, email = pair.partition("=")
129+
user = User.objects.filter(email=email.strip()).first()
130+
if user is None:
131+
self.stderr.write(
132+
self.style.WARNING(f"--owners: no user with email '{email}'")
133+
)
134+
else:
135+
owners[sub.strip()] = user
136+
return owners
106137

107138
def handle(self, *args, **options):
108139
dry_run = options["dry_run"]
140+
self.owners = self._parse_owners(options.get("owners"))
109141
report = []
110142
failed_rows = 0
111143
pages_created = 0
@@ -308,6 +340,12 @@ def _upsert_page(self, config, row, entry, canonical_page, is_canonical):
308340
created = True
309341

310342
changed = self._apply_row(config, page, row, entry)
343+
owner = self.owners.get(row.get("author") or "")
344+
if owner is not None and page.owner_id != owner.pk:
345+
page.owner = owner
346+
page.save(update_fields=["owner"])
347+
changed = True
348+
311349
if created:
312350
return page, "created"
313351
return page, ("updated" if changed else "unchanged")
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Editors edit only their own content (product decision 2026-08-04, resolving
3+
the deferral documented in 0002): revoke the editor group's tree-wide
4+
`change_page`. Wagtail's owner model does the rest — a group holding only
5+
`add_page` on a tree may still edit pages it owns — and the retained
6+
`publish_page` applies only to pages the user can edit, i.e. their own.
7+
Admins keep full-tree permissions. migrate_tiptap sets Page.owner from the
8+
legacy author column so pre-cutover content stays editable by its authors.
9+
10+
Team scoping (authapi/teams.py) composes with this: it further narrows which
11+
pages are *visible/actionable*; ownership now governs which are *editable*.
12+
"""
13+
14+
from django.db import migrations
15+
16+
17+
def _editor_change_page(apps):
18+
Group = apps.get_model("auth", "Group")
19+
GroupPagePermission = apps.get_model("wagtailcore", "GroupPagePermission")
20+
return GroupPagePermission.objects.filter(
21+
group=Group.objects.get(name="editor"),
22+
permission__content_type__app_label="wagtailcore",
23+
permission__content_type__model="page",
24+
permission__codename="change_page",
25+
)
26+
27+
28+
def revoke_editor_change_page(apps, schema_editor):
29+
_editor_change_page(apps).delete()
30+
31+
32+
def restore_editor_change_page(apps, schema_editor):
33+
Group = apps.get_model("auth", "Group")
34+
Page = apps.get_model("wagtailcore", "Page")
35+
Permission = apps.get_model("auth", "Permission")
36+
GroupPagePermission = apps.get_model("wagtailcore", "GroupPagePermission")
37+
GroupPagePermission.objects.get_or_create(
38+
group=Group.objects.get(name="editor"),
39+
page=Page.objects.get(pk=1),
40+
permission=Permission.objects.get(
41+
content_type__app_label="wagtailcore",
42+
content_type__model="page",
43+
codename="change_page",
44+
),
45+
)
46+
47+
48+
class Migration(migrations.Migration):
49+
dependencies = [
50+
("content", "0003_alter_placepage_body_alter_tagpage_body"),
51+
]
52+
53+
operations = [
54+
migrations.RunPython(revoke_editor_change_page, restore_editor_change_page),
55+
]

0 commit comments

Comments
 (0)