Skip to content

Commit 269aeb2

Browse files
Team is the tenant; MapGroup returns to a listing facet
The org/access boundary moves off MapGroup (districtr_v2-i06): Team gains a slug (authapi.0008) minted as the JWT `teams` claim; galleries get a required, real `team` FK (galleries.0003 — structurally closing the ownerless group_only gallery hole, districtr_v2-rqz); and teams own map modules directly through TeamDistrictrMap, with existing TeamMapGroup ownership expanded one row per group member map at migration. The scoping engine (authapi/teams.py) now keys on team ids/slugs; galleries, pages, map modules, and the moderation add-to-gallery flow follow through their per-resource lookups. MapGroup and its junction stay as the product listing facet only — no access control reads them anymore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 656ba8c commit 269aeb2

15 files changed

Lines changed: 452 additions & 274 deletions

File tree

.agents/WAGTAIL-CUTOVER-FOLLOWUPS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ minted in [cms/authapi/serializers.py](../cms/authapi/serializers.py));
6666
`map_group` slug in that claim, or the `admin` role. A merely-valid login no
6767
longer opens group_only galleries. Scoping unit confirmed as `MapGroup`.
6868

69+
> **Superseded 2026-08-05 (districtr_v2-i06): Team is the tenant now.**
70+
> MapGroup reverted to a pure listing facet. Team gained a `slug`
71+
> (authapi.0008), the JWT claim is `teams` (team slugs), Gallery has a
72+
> required real `team` FK (galleries.0003 — also closes the
73+
> silently-inaccessible NULL-group gallery bug, districtr_v2-rqz), and
74+
> teams own map modules directly via TeamDistrictrMap (existing
75+
> TeamMapGroup ownership auto-expanded per map at migration). Scoping
76+
> engine: authapi/teams.py (`team_ids_for_user` / `team_slugs_for_user`).
77+
6978
### 1.3 Refresh-token security posture (still open, low priority)
7079
`BLACKLIST_AFTER_ROTATION` was turned **off**
7180
([cms/config/settings/base.py](../cms/config/settings/base.py)) because Next.js
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Team becomes the tenant (2026-08-05, districtr_v2-i06): give Team a slug —
3+
the stable identifier minted into the JWT `teams` claim — and assign map
4+
modules to teams DIRECTLY (TeamDistrictrMap) instead of through MapGroup,
5+
which returns to being a pure listing facet.
6+
7+
Existing ownership carries over: each TeamMapGroup row expands to one
8+
TeamDistrictrMap row per map in that group (via the backend's
9+
districtrmaps_to_groups table, guarded for isolated cms databases where the
10+
public tables don't exist). TeamMapGroup is then deleted; the reverse cannot
11+
reconstruct group ownership from map ownership, so it only restores schema.
12+
"""
13+
14+
import django.db.models.deletion
15+
import modelcluster.fields
16+
from django.db import migrations, models
17+
from django.utils.text import slugify
18+
19+
from core.migration_utils import ensure_permissions, model_permissions
20+
21+
22+
def backfill_team_slugs(apps, schema_editor):
23+
Team = apps.get_model("authapi", "Team")
24+
seen = set()
25+
for team in Team.objects.order_by("pk"):
26+
base = slugify(team.name) or f"team-{team.pk}"
27+
slug, suffix = base, 2
28+
while slug in seen:
29+
slug, suffix = f"{base}-{suffix}", suffix + 1
30+
seen.add(slug)
31+
team.slug = slug
32+
team.save(update_fields=["slug"])
33+
34+
35+
def migrate_group_ownership_to_maps(apps, schema_editor):
36+
TeamMapGroup = apps.get_model("authapi", "TeamMapGroup")
37+
TeamDistrictrMap = apps.get_model("authapi", "TeamDistrictrMap")
38+
rows = list(TeamMapGroup.objects.values_list("team_id", "map_group_id"))
39+
if not rows:
40+
return
41+
with schema_editor.connection.cursor() as cursor:
42+
# to_regclass: NULL (no error, no aborted transaction) when the
43+
# backend-owned table is absent — e.g. the Django test database.
44+
cursor.execute("SELECT to_regclass('public.districtrmaps_to_groups')")
45+
if cursor.fetchone()[0] is None:
46+
return
47+
cursor.execute(
48+
"SELECT group_slug, districtrmap_uuid FROM districtrmaps_to_groups"
49+
)
50+
group_to_maps = {}
51+
for group_slug, map_uuid in cursor.fetchall():
52+
group_to_maps.setdefault(group_slug, []).append(map_uuid)
53+
for team_id, group_slug in rows:
54+
for map_uuid in group_to_maps.get(group_slug, []):
55+
TeamDistrictrMap.objects.get_or_create(
56+
team_id=team_id, districtr_map_id=map_uuid
57+
)
58+
59+
60+
def grant_admin_permissions(apps, schema_editor):
61+
ensure_permissions("authapi", apps, schema_editor)
62+
Group = apps.get_model("auth", "Group")
63+
Group.objects.get(name="admin").permissions.add(
64+
*model_permissions(apps, "authapi", model="teamdistrictrmap")
65+
)
66+
67+
68+
def revoke_admin_permissions(apps, schema_editor):
69+
Group = apps.get_model("auth", "Group")
70+
Group.objects.get(name="admin").permissions.remove(
71+
*model_permissions(apps, "authapi", model="teamdistrictrmap")
72+
)
73+
74+
75+
class Migration(migrations.Migration):
76+
dependencies = [
77+
("authapi", "0007_consolidate_partner_groups"),
78+
("datastore", "0001_initial"),
79+
("contenttypes", "0002_remove_content_type_name"),
80+
]
81+
82+
operations = [
83+
migrations.AddField(
84+
model_name="team",
85+
name="slug",
86+
# db_index=False: the final AlterField below creates the unique
87+
# index; an interim plain index would collide on the generated
88+
# *_like index name.
89+
field=models.SlugField(max_length=255, null=True, db_index=False),
90+
),
91+
migrations.RunPython(backfill_team_slugs, migrations.RunPython.noop),
92+
migrations.AlterField(
93+
model_name="team",
94+
name="slug",
95+
field=models.SlugField(
96+
max_length=255,
97+
unique=True,
98+
help_text=(
99+
"Stable identifier, minted into members' JWT `teams` claim. "
100+
"Changing it revokes group_only gallery access until re-login."
101+
),
102+
),
103+
),
104+
migrations.CreateModel(
105+
name="TeamDistrictrMap",
106+
fields=[
107+
(
108+
"id",
109+
models.BigAutoField(
110+
auto_created=True,
111+
primary_key=True,
112+
serialize=False,
113+
verbose_name="ID",
114+
),
115+
),
116+
(
117+
"team",
118+
modelcluster.fields.ParentalKey(
119+
on_delete=django.db.models.deletion.CASCADE,
120+
related_name="districtr_maps",
121+
to="authapi.team",
122+
),
123+
),
124+
(
125+
"districtr_map",
126+
models.ForeignKey(
127+
db_constraint=False,
128+
on_delete=django.db.models.deletion.DO_NOTHING,
129+
related_name="team_links",
130+
to="datastore.districtrmap",
131+
),
132+
),
133+
],
134+
options={
135+
"unique_together": {("team", "districtr_map")},
136+
},
137+
),
138+
migrations.RunPython(
139+
migrate_group_ownership_to_maps, migrations.RunPython.noop
140+
),
141+
migrations.RunPython(grant_admin_permissions, revoke_admin_permissions),
142+
migrations.DeleteModel(name="TeamMapGroup"),
143+
]

cms/authapi/models.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,29 @@ def __str__(self):
5757

5858

5959
class Team(ClusterableModel):
60-
"""A group of CMS users that owns one or more MapGroups.
60+
"""A partner organization — the access-control boundary of the CMS.
6161
6262
Team membership scopes a non-admin user's Wagtail admin to their teams'
63-
map groups: they see/edit only the galleries, tag pages, and Districtr map
64-
modules tied to those groups (authapi/teams.py). Admins and superusers are
65-
never scoped, nor are non-admin users with no team. Managed by admins in
66-
the "Teams" snippet (authapi/wagtail_hooks.py).
63+
resources: the galleries a team owns (Gallery.team), the Districtr map
64+
modules assigned to it (TeamDistrictrMap), and the tag/place pages tied
65+
to those modules (authapi/teams.py). Admins and superusers are never
66+
scoped, nor are non-admin users with no team. Managed by admins in the
67+
"Teams" snippet (authapi/wagtail_hooks.py).
68+
69+
The slug is minted into the JWT `teams` claim at login and matched by
70+
the galleries API for group_only galleries — renaming a team is safe,
71+
changing its slug invalidates members' access until re-login.
6772
"""
6873

6974
name = models.CharField(max_length=255, unique=True)
75+
slug = models.SlugField(
76+
max_length=255,
77+
unique=True,
78+
help_text=(
79+
"Stable identifier, minted into members' JWT `teams` claim. "
80+
"Changing it revokes group_only gallery access until re-login."
81+
),
82+
)
7083

7184
class Meta:
7285
ordering = ["name"]
@@ -92,24 +105,24 @@ def __str__(self):
92105
return f"{self.user.get_username()}{self.team.name}"
93106

94107

95-
class TeamMapGroup(models.Model):
96-
"""A MapGroup owned by a Team (InlinePanel child of Team).
108+
class TeamDistrictrMap(models.Model):
109+
"""A Districtr map module assigned to a Team (InlinePanel child of Team).
97110
98-
db_constraint=False because MapGroup is a managed=False mirror of a
99-
backend-owned table in the public schema — mirrors
100-
datastore.DistrictrMapsToGroups.group.
111+
Direct assignment — MapGroup is a listing facet, not an access boundary.
112+
db_constraint=False because DistrictrMap is a managed=False mirror of a
113+
backend-owned table in the public schema.
101114
"""
102115

103-
team = ParentalKey(Team, on_delete=models.CASCADE, related_name="map_groups")
104-
map_group = models.ForeignKey(
105-
"datastore.MapGroup",
116+
team = ParentalKey(Team, on_delete=models.CASCADE, related_name="districtr_maps")
117+
districtr_map = models.ForeignKey(
118+
"datastore.DistrictrMap",
106119
on_delete=models.DO_NOTHING,
107120
db_constraint=False,
108-
related_name="+",
121+
related_name="team_links",
109122
)
110123

111124
class Meta:
112-
unique_together = [("team", "map_group")]
125+
unique_together = [("team", "districtr_map")]
113126

114127
def __str__(self):
115-
return f"{self.team.name}{self.map_group_id}"
128+
return f"{self.team.name}{self.districtr_map_id}"

cms/authapi/serializers.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
)
77

88
from authapi.scopes import scopes_for_user
9-
from authapi.teams import map_group_slugs_for_user
9+
from authapi.teams import team_slugs_for_user
1010
from authapi.tokens import KidAccessToken, KidRefreshToken
1111

1212

@@ -41,12 +41,12 @@ def set_user_claims(token, user) -> None:
4141
token["roles"] = group_names
4242
if review_tags:
4343
token["review_tags"] = review_tags
44-
# MapGroup slugs across the user's teams: the galleries API matches
45-
# this claim against Gallery.map_group for group_only galleries
46-
# (admins bypass via the roles claim). Absent when team-less.
47-
map_groups = sorted(map_group_slugs_for_user(user))
48-
if map_groups:
49-
token["map_groups"] = map_groups
44+
# Slugs of the user's teams: the galleries API matches this claim
45+
# against Gallery.team for group_only galleries (admins bypass via the
46+
# roles claim). Absent when team-less.
47+
teams = sorted(team_slugs_for_user(user))
48+
if teams:
49+
token["teams"] = teams
5050

5151

5252
def mint_user_access_token(user, lifetime_minutes: int = 5) -> str:

0 commit comments

Comments
 (0)