Skip to content

Commit 4255074

Browse files
authored
Merge pull request #3930 from allegro/dont-remove-extra-permissions-automatically
Dont remove extra permissions automatically
2 parents 156b712 + 928700f commit 4255074

File tree

10 files changed

+1017
-38
lines changed

10 files changed

+1017
-38
lines changed

src/ralph/__init__.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1 @@
1-
from django.db.models.options import Options
2-
31
__version__ = "3.0.0"
4-
5-
6-
def monkey_options_init(self, meta, app_label):
7-
self._old__init__(meta, app_label)
8-
self.default_permissions = ("add", "change", "delete", "view")
9-
10-
11-
Options._old__init__ = Options.__init__
12-
Options.__init__ = lambda self, meta, app_label=None: monkey_options_init(
13-
self, meta, app_label
14-
)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Base command class and utilities for permissions management.
3+
4+
Provides shared functionality like psql-style table output formatting.
5+
"""
6+
7+
import re
8+
from typing import Callable
9+
10+
from django.core.management.base import BaseCommand
11+
12+
# Pre-compiled regex for ANSI escape codes
13+
ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
14+
15+
16+
def strip_ansi(text: str) -> str:
17+
"""Remove ANSI escape codes from text."""
18+
return ANSI_ESCAPE_RE.sub("", text)
19+
20+
21+
def calculate_column_widths(headers: list[str], rows: list[list]) -> list[int]:
22+
"""Calculate the maximum width needed for each column."""
23+
widths = [len(h) for h in headers]
24+
for row in rows:
25+
for i, val in enumerate(row):
26+
plain_val = strip_ansi(str(val))
27+
widths[i] = max(widths[i], len(plain_val))
28+
return widths
29+
30+
31+
def format_separator(widths: list[int]) -> str:
32+
"""Format the horizontal separator line."""
33+
return "+-" + "-+-".join("-" * w for w in widths) + "-+"
34+
35+
36+
def format_header(headers: list[str], widths: list[int]) -> str:
37+
"""Format the header row."""
38+
cells = (h.ljust(widths[i]) for i, h in enumerate(headers))
39+
return "| " + " | ".join(cells) + " |"
40+
41+
42+
def format_row(
43+
row: list,
44+
widths: list[int],
45+
row_idx: int,
46+
style_map: dict[tuple[int, int], Callable],
47+
) -> str:
48+
"""Format a single data row with optional styling."""
49+
formatted = []
50+
for col_idx, val in enumerate(row):
51+
cell = str(val).ljust(widths[col_idx])
52+
style_fn = style_map.get((row_idx, col_idx))
53+
formatted.append(style_fn(cell) if style_fn else cell)
54+
return "| " + " | ".join(formatted) + " |"
55+
56+
57+
def format_table(
58+
headers: list[str],
59+
rows: list[list],
60+
style_map: dict[tuple[int, int], Callable] = None,
61+
) -> list[str]:
62+
"""
63+
Format data as psql-style table lines.
64+
65+
Args:
66+
headers: Column header names.
67+
rows: List of rows, each row is a list of values.
68+
style_map: Optional dict mapping (row_idx, col_idx) to style function.
69+
70+
Returns:
71+
List of formatted lines ready to print.
72+
"""
73+
if not rows:
74+
return ["(0 rows)"]
75+
76+
style_map = style_map or {}
77+
widths = calculate_column_widths(headers, rows)
78+
separator = format_separator(widths)
79+
80+
lines = [
81+
separator,
82+
format_header(headers, widths),
83+
separator,
84+
*[format_row(row, widths, idx, style_map) for idx, row in enumerate(rows)],
85+
separator,
86+
f"({len(rows)} rows)",
87+
]
88+
return lines
89+
90+
91+
def format_summary(active: int, orphaned: int) -> str:
92+
"""Format permission summary counts."""
93+
return (
94+
f"\n Active permissions: {active}\n"
95+
f" Orphaned permissions: {orphaned}\n"
96+
f" Total in database: {active + orphaned}\n"
97+
)
98+
99+
100+
class PermissionBaseCommand(BaseCommand):
101+
"""Base command with table output utilities."""
102+
103+
def print_table(
104+
self,
105+
headers: list[str],
106+
rows: list[list],
107+
style_map: dict[tuple[int, int], Callable] = None,
108+
):
109+
"""Print data in psql-style table format."""
110+
for line in format_table(headers, rows, style_map):
111+
self.stdout.write(line)
112+
113+
def print_summary(self, active: int, orphaned: int):
114+
"""Print permission summary counts."""
115+
self.stdout.write(format_summary(active, orphaned))
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Management command to list and audit extra view permissions.
3+
4+
This command provides an overview of all extra view permissions,
5+
showing which are active, orphaned, and their group assignments.
6+
"""
7+
8+
import json
9+
10+
from ralph.accounts.management.commands._base import PermissionBaseCommand
11+
from ralph.lib.permissions.utils import collect_permission_info
12+
13+
14+
class Command(PermissionBaseCommand):
15+
help = "List and audit all extra view permissions."
16+
17+
def add_arguments(self, parser):
18+
parser.add_argument(
19+
"--show-groups",
20+
action="store_true",
21+
help="Show which groups have each permission.",
22+
)
23+
parser.add_argument(
24+
"--orphaned-only",
25+
action="store_true",
26+
help="Show only orphaned permissions.",
27+
)
28+
parser.add_argument(
29+
"--format",
30+
choices=["table", "json"],
31+
default="table",
32+
help="Output format (default: table).",
33+
)
34+
35+
def handle(self, *args, **options):
36+
show_groups = options["show_groups"]
37+
orphaned_only = options["orphaned_only"]
38+
output_format = options["format"]
39+
40+
data, active_count, orphaned_count = collect_permission_info(
41+
include_groups=show_groups,
42+
orphaned_only=orphaned_only,
43+
)
44+
45+
if output_format == "json":
46+
self._output_json(data, active_count, orphaned_count)
47+
else:
48+
self._output_table(data, active_count, orphaned_count, show_groups)
49+
50+
def _output_table(self, data, active_count, orphaned_count, show_groups):
51+
"""Output permissions in psql-style table format."""
52+
self.print_summary(active_count, orphaned_count)
53+
54+
if not data:
55+
self.stdout.write("(0 rows)\n")
56+
return
57+
58+
# Build headers and rows
59+
headers = ["status", "codename", "name", "content_type"]
60+
if show_groups:
61+
headers.append("groups")
62+
63+
rows = []
64+
style_map = {}
65+
66+
for idx, info in enumerate(data):
67+
row = [info.status, info.codename, info.name, info.content_type]
68+
if show_groups:
69+
groups_str = ", ".join(info.groups) or "(none)"
70+
row.append(groups_str)
71+
rows.append(row)
72+
73+
# Style the status column
74+
if info.is_orphaned:
75+
style_map[(idx, 0)] = self.style.ERROR
76+
else:
77+
style_map[(idx, 0)] = self.style.SUCCESS
78+
79+
self.print_table(headers, rows, style_map)
80+
81+
def _output_json(self, data, active_count, orphaned_count):
82+
"""Output permissions in JSON format."""
83+
output = {
84+
"summary": {
85+
"active_count": active_count,
86+
"orphaned_count": orphaned_count,
87+
"total_count": active_count + orphaned_count,
88+
},
89+
"permissions": [
90+
{
91+
"codename": info.codename,
92+
"name": info.name,
93+
"content_type": info.content_type,
94+
"status": info.status,
95+
"groups": info.groups,
96+
}
97+
for info in data
98+
],
99+
}
100+
101+
self.stdout.write(json.dumps(output, indent=2))
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Management command to clean up orphaned extra view permissions.
3+
4+
Orphaned permissions are those that start with 'can_view_extra_' but are not
5+
associated with any currently registered view. This can happen when views are
6+
removed or when conditionally-loaded views (e.g., DNSView) are not available
7+
in the current environment.
8+
"""
9+
10+
import logging
11+
12+
from ralph.accounts.management.commands._base import PermissionBaseCommand
13+
from ralph.lib.permissions.views import get_orphaned_extra_view_permissions
14+
from ralph.lib.permissions.utils import get_permission_groups
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class Command(PermissionBaseCommand):
20+
help = (
21+
"Clean up orphaned extra view permissions that are no longer "
22+
"associated with any registered view."
23+
)
24+
25+
def add_arguments(self, parser):
26+
parser.add_argument(
27+
"--dry-run",
28+
action="store_true",
29+
help="Show what would be deleted without actually deleting.",
30+
)
31+
parser.add_argument(
32+
"--force",
33+
action="store_true",
34+
help="Skip confirmation prompt.",
35+
)
36+
parser.add_argument(
37+
"--exclude",
38+
action="append",
39+
default=[],
40+
metavar="CODENAME",
41+
help="Exclude permission codename from deletion (can be repeated).",
42+
)
43+
44+
def handle(self, *args, **options):
45+
dry_run = options["dry_run"]
46+
force = options["force"]
47+
exclude = set(options["exclude"])
48+
49+
orphaned = get_orphaned_extra_view_permissions()
50+
51+
if exclude:
52+
orphaned = orphaned.exclude(codename__in=exclude)
53+
54+
if not orphaned.exists():
55+
self.stdout.write(
56+
self.style.SUCCESS("No orphaned extra view permissions found.")
57+
)
58+
return
59+
60+
# Display orphaned permissions in table format
61+
count = orphaned.count()
62+
self.stdout.write(
63+
self.style.WARNING(f"\nFound {count} orphaned permission(s):\n")
64+
)
65+
66+
headers = ["codename", "content_type", "groups"]
67+
rows = []
68+
style_map = {}
69+
70+
for idx, perm in enumerate(orphaned):
71+
groups = get_permission_groups(perm)
72+
groups_str = ", ".join(groups) or "(none)"
73+
rows.append([perm.codename, str(perm.content_type), groups_str])
74+
75+
self.print_table(headers, rows, style_map)
76+
77+
if dry_run:
78+
self.stdout.write(
79+
self.style.NOTICE("\nDry run - no permissions were deleted.")
80+
)
81+
return
82+
83+
if not force:
84+
self.stdout.write(
85+
self.style.WARNING(
86+
"\nWARNING: Deleting will remove permissions from all groups!"
87+
)
88+
)
89+
confirm = input("Are you sure you want to delete? [y/N]: ")
90+
if confirm.lower() != "y":
91+
self.stdout.write(self.style.NOTICE("Aborted."))
92+
return
93+
94+
deleted_count, _ = orphaned.delete()
95+
96+
self.stdout.write(
97+
self.style.SUCCESS(f"Deleted {deleted_count} orphaned permission(s).")
98+
)
99+
logger.info(
100+
"Deleted %d orphaned permission(s) via management command.", deleted_count
101+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
Management command to export group permissions to a JSON file.
3+
"""
4+
5+
import json
6+
7+
from django.contrib.auth.models import Group
8+
from django.core.management.base import BaseCommand
9+
10+
11+
def export_all_group_permissions() -> dict[str, list[str]]:
12+
"""Export all group permissions as {group_name: ["app_label.codename", ...]}."""
13+
result = {}
14+
for group in Group.objects.prefetch_related("permissions__content_type").all():
15+
if group.permissions.exists():
16+
result[group.name] = [
17+
f"{perm.content_type.app_label}.{perm.codename}"
18+
for perm in group.permissions.all()
19+
]
20+
return result
21+
22+
23+
class Command(BaseCommand):
24+
help = "Export group permissions to a JSON file."
25+
26+
def add_arguments(self, parser):
27+
parser.add_argument(
28+
"file",
29+
help="Output JSON file path.",
30+
)
31+
32+
def handle(self, *args, **options):
33+
mappings = export_all_group_permissions()
34+
35+
with open(options["file"], "w") as f:
36+
json.dump(mappings, f, indent=2)
37+
38+
self.stdout.write(
39+
self.style.SUCCESS(
40+
f"Exported permissions for {len(mappings)} groups to {options['file']}"
41+
)
42+
)

0 commit comments

Comments
 (0)