Skip to content

Commit f48188c

Browse files
devGregAclaude
andcommitted
feat(authorization): add preview_legacy_authorization_migration command
Read-only dry-run companion to dojo.0267_backfill_authorized_users. Customers run this before upgrading so they can audit: * authorized_users rows that would be added per Product / Product_Type, broken down by source (direct member rows vs flattened group members). * is_superuser / is_staff flag flips driven by Global_Role(Owner) / Global_Role(Maintainer | API_Importer) assignments, with the affected user_ids listed. * Role-granularity counts that the legacy model cannot preserve (Reader / Writer / Maintainer / Owner per-product distinctions, plus group-based authorization rows). Both human-readable tables and ``--json`` output are supported. Idempotent introspection guard mirrors the migration: fresh OS installs (no dojo_role table) report a clean no-op and exit. Verified against bare_bones fixture with the migration already applied: zero new pairs / flag flips reported (no-op state), with 1 Owner-role Product_Member, 1 Owner-role Product_Type_Member, and 2 group-based authorization rows surfaced as granularity that would be lost on downgrade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e041bac commit f48188c

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""
2+
Pre-flight preview of the legacy authorization migration.
3+
4+
Dry-run companion to ``dojo.0267_backfill_authorized_users``. Prints what
5+
the data migration *would* change without writing anything to the database
6+
so customers can audit the impact before they upgrade.
7+
8+
Usage::
9+
10+
python manage.py preview_legacy_authorization_migration # tabular
11+
python manage.py preview_legacy_authorization_migration --json # JSON
12+
13+
Reports:
14+
15+
* Per-product / per-product-type ``authorized_users`` rows that would be
16+
added (broken down by source: direct member rows vs flattened group
17+
members).
18+
* Users that would be flipped to ``is_superuser=True`` (Global_Role.Owner).
19+
* Users that would be flipped to ``is_staff=True`` (Global_Role.Maintainer
20+
/ API_Importer).
21+
* Counts of role granularity that the legacy model cannot preserve
22+
(Reader vs Writer vs Maintainer per product, group membership as a
23+
permission-bearing entity, configuration permissions per codename).
24+
25+
Read-only. The RBAC tables themselves are never modified by either this
26+
command or the data migration; running it on a fresh OS install is a no-op.
27+
"""
28+
import json
29+
30+
from django.core.management.base import BaseCommand
31+
from django.db import connection
32+
33+
34+
class Command(BaseCommand):
35+
help = "Preview the legacy authorization migration's impact without applying it."
36+
37+
def add_arguments(self, parser):
38+
parser.add_argument(
39+
"--json",
40+
action="store_true",
41+
dest="emit_json",
42+
help="Emit the report as JSON instead of human-readable tables.",
43+
)
44+
45+
def handle(self, *args, emit_json=False, **options):
46+
if "dojo_role" not in connection.introspection.table_names():
47+
self.stdout.write(
48+
self.style.SUCCESS(
49+
"No RBAC tables present — the legacy authorization migration "
50+
"would be a no-op on this database.",
51+
),
52+
)
53+
return
54+
55+
report = self._build_report()
56+
if emit_json:
57+
self.stdout.write(json.dumps(report, indent=2, default=str))
58+
else:
59+
self._render_tables(report)
60+
61+
# ------------------------------------------------------------------
62+
# Data collection
63+
# ------------------------------------------------------------------
64+
65+
def _build_report(self):
66+
# Imported lazily so the command imports cleanly even when the
67+
# legacy shells are eventually deleted from dojo/authorization/
68+
# models.py (Track B step #13).
69+
from dojo.authorization.models import ( # noqa: PLC0415
70+
Dojo_Group_Member,
71+
Global_Role,
72+
Product_Group,
73+
Product_Member,
74+
Product_Type_Group,
75+
Product_Type_Member,
76+
)
77+
from dojo.models import Dojo_User, Product, Product_Type # noqa: PLC0415
78+
79+
product_member_pairs = set(Product_Member.objects.values_list("product_id", "user_id"))
80+
product_type_member_pairs = set(Product_Type_Member.objects.values_list("product_type_id", "user_id"))
81+
82+
product_group_pairs = set()
83+
for product_id, group_id in Product_Group.objects.values_list("product_id", "group_id"):
84+
product_group_pairs.update((product_id, user_id) for user_id in Dojo_Group_Member.objects.filter(group_id=group_id).values_list("user_id", flat=True))
85+
86+
product_type_group_pairs = set()
87+
for product_type_id, group_id in Product_Type_Group.objects.values_list("product_type_id", "group_id"):
88+
product_type_group_pairs.update((product_type_id, user_id) for user_id in Dojo_Group_Member.objects.filter(group_id=group_id).values_list("user_id", flat=True))
89+
90+
# Already-existing authorized_users rows (so we report incremental adds).
91+
existing_product_pairs = set(Product.authorized_users.through.objects.values_list("product_id", "dojo_user_id"))
92+
existing_product_type_pairs = set(Product_Type.authorized_users.through.objects.values_list("product_type_id", "dojo_user_id"))
93+
94+
new_product_pairs = (product_member_pairs | product_group_pairs) - existing_product_pairs
95+
new_product_type_pairs = (product_type_member_pairs | product_type_group_pairs) - existing_product_type_pairs
96+
97+
owner_user_ids = set(
98+
Global_Role.objects.filter(role__name="Owner", user__isnull=False).values_list("user_id", flat=True),
99+
)
100+
owner_group_ids = list(
101+
Global_Role.objects.filter(role__name="Owner", group__isnull=False).values_list("group_id", flat=True),
102+
)
103+
owner_user_ids |= set(
104+
Dojo_Group_Member.objects.filter(group_id__in=owner_group_ids).values_list("user_id", flat=True),
105+
)
106+
new_superuser_ids = set(
107+
Dojo_User.objects.filter(id__in=owner_user_ids, is_superuser=False).values_list("id", flat=True),
108+
)
109+
110+
elevated_user_ids = set(
111+
Global_Role.objects.filter(
112+
role__name__in=("Maintainer", "API_Importer"),
113+
user__isnull=False,
114+
).values_list("user_id", flat=True),
115+
)
116+
elevated_group_ids = list(
117+
Global_Role.objects.filter(
118+
role__name__in=("Maintainer", "API_Importer"),
119+
group__isnull=False,
120+
).values_list("group_id", flat=True),
121+
)
122+
elevated_user_ids |= set(
123+
Dojo_Group_Member.objects.filter(group_id__in=elevated_group_ids).values_list("user_id", flat=True),
124+
)
125+
new_staff_ids = set(
126+
Dojo_User.objects.filter(id__in=elevated_user_ids, is_staff=False).values_list("id", flat=True),
127+
)
128+
129+
# Granularity that legacy cannot preserve.
130+
per_role_member_counts = _count_by_role_name(Product_Member)
131+
per_role_member_type_counts = _count_by_role_name(Product_Type_Member)
132+
group_role_count = Product_Group.objects.count() + Product_Type_Group.objects.count()
133+
134+
return {
135+
"authorized_users_additions": {
136+
"product": {
137+
"from_direct_members": len(product_member_pairs - existing_product_pairs),
138+
"from_group_expansion": len(product_group_pairs - product_member_pairs - existing_product_pairs),
139+
"total_new_pairs": len(new_product_pairs),
140+
},
141+
"product_type": {
142+
"from_direct_members": len(product_type_member_pairs - existing_product_type_pairs),
143+
"from_group_expansion": len(product_type_group_pairs - product_type_member_pairs - existing_product_type_pairs),
144+
"total_new_pairs": len(new_product_type_pairs),
145+
},
146+
},
147+
"global_role_flag_flips": {
148+
"is_superuser_count": len(new_superuser_ids),
149+
"is_superuser_user_ids": sorted(new_superuser_ids),
150+
"is_staff_count": len(new_staff_ids),
151+
"is_staff_user_ids": sorted(new_staff_ids),
152+
},
153+
"granularity_lost": {
154+
"product_member_role_counts": per_role_member_counts,
155+
"product_type_member_role_counts": per_role_member_type_counts,
156+
"group_based_authorization_rows": group_role_count,
157+
"note": (
158+
"Legacy collapses Reader / Writer / Maintainer / Owner per-product "
159+
"distinction to membership-only. Group structure as a permission-"
160+
"bearing entity is also lost; only individual user memberships "
161+
"remain after the migration."
162+
),
163+
},
164+
}
165+
166+
# ------------------------------------------------------------------
167+
# Rendering
168+
# ------------------------------------------------------------------
169+
170+
def _render_tables(self, report):
171+
adds = report["authorized_users_additions"]
172+
flags = report["global_role_flag_flips"]
173+
lost = report["granularity_lost"]
174+
175+
self.stdout.write(self.style.MIGRATE_HEADING("authorized_users additions"))
176+
self.stdout.write(
177+
f" Product : +{adds['product']['total_new_pairs']:>6} pairs "
178+
f"({adds['product']['from_direct_members']} direct, "
179+
f"{adds['product']['from_group_expansion']} from group expansion)",
180+
)
181+
self.stdout.write(
182+
f" Product_Type : +{adds['product_type']['total_new_pairs']:>6} pairs "
183+
f"({adds['product_type']['from_direct_members']} direct, "
184+
f"{adds['product_type']['from_group_expansion']} from group expansion)",
185+
)
186+
187+
self.stdout.write("")
188+
self.stdout.write(self.style.MIGRATE_HEADING("Global_Role flag flips"))
189+
self.stdout.write(f" is_superuser <- True : {flags['is_superuser_count']} user(s)")
190+
if flags["is_superuser_user_ids"]:
191+
self.stdout.write(f" user_ids: {flags['is_superuser_user_ids']}")
192+
self.stdout.write(f" is_staff <- True : {flags['is_staff_count']} user(s)")
193+
if flags["is_staff_user_ids"]:
194+
self.stdout.write(f" user_ids: {flags['is_staff_user_ids']}")
195+
196+
self.stdout.write("")
197+
self.stdout.write(self.style.MIGRATE_HEADING("Granularity not preserved"))
198+
for label, counts in (
199+
("Product_Member by role", lost["product_member_role_counts"]),
200+
("Product_Type_Member by role", lost["product_type_member_role_counts"]),
201+
):
202+
self.stdout.write(f" {label}:")
203+
if not counts:
204+
self.stdout.write(" (none)")
205+
for role, count in counts.items():
206+
self.stdout.write(f" {role:>14}: {count}")
207+
self.stdout.write(
208+
f" Group-based authorization rows (lost): {lost['group_based_authorization_rows']}",
209+
)
210+
self.stdout.write("")
211+
self.stdout.write(self.style.WARNING(" " + lost["note"]))
212+
213+
214+
def _count_by_role_name(model):
215+
from django.db.models import Count # noqa: PLC0415
216+
217+
return {
218+
row["role__name"]: row["count"]
219+
for row in model.objects.values("role__name").annotate(count=Count("id"))
220+
}

0 commit comments

Comments
 (0)