netbox-permissions-matrix-plugin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. netbox_permissions_matrix_plugin/__init__.py +25 -0
  2. netbox_permissions_matrix_plugin/api/__init__.py +0 -0
  3. netbox_permissions_matrix_plugin/api/serializers.py +72 -0
  4. netbox_permissions_matrix_plugin/api/urls.py +11 -0
  5. netbox_permissions_matrix_plugin/api/views.py +155 -0
  6. netbox_permissions_matrix_plugin/applier.py +78 -0
  7. netbox_permissions_matrix_plugin/conf.py +21 -0
  8. netbox_permissions_matrix_plugin/core/__init__.py +4 -0
  9. netbox_permissions_matrix_plugin/core/cells.py +48 -0
  10. netbox_permissions_matrix_plugin/core/model.py +164 -0
  11. netbox_permissions_matrix_plugin/core/planner.py +259 -0
  12. netbox_permissions_matrix_plugin/core/token.py +39 -0
  13. netbox_permissions_matrix_plugin/core/yaml_io.py +200 -0
  14. netbox_permissions_matrix_plugin/loader.py +118 -0
  15. netbox_permissions_matrix_plugin/matrix.py +130 -0
  16. netbox_permissions_matrix_plugin/navigation.py +46 -0
  17. netbox_permissions_matrix_plugin/service.py +303 -0
  18. netbox_permissions_matrix_plugin/static/netbox_permissions_matrix_plugin/matrix.css +331 -0
  19. netbox_permissions_matrix_plugin/static/netbox_permissions_matrix_plugin/matrix.js +746 -0
  20. netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/inc/shell.html +115 -0
  21. netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/inc/state.html +1 -0
  22. netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/matrix.html +32 -0
  23. netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/principal_tab.html +20 -0
  24. netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/who_can.html +118 -0
  25. netbox_permissions_matrix_plugin/urls.py +9 -0
  26. netbox_permissions_matrix_plugin/version.py +1 -0
  27. netbox_permissions_matrix_plugin/views.py +102 -0
  28. netbox_permissions_matrix_plugin-0.1.0.dist-info/METADATA +91 -0
  29. netbox_permissions_matrix_plugin-0.1.0.dist-info/RECORD +32 -0
  30. netbox_permissions_matrix_plugin-0.1.0.dist-info/WHEEL +4 -0
  31. netbox_permissions_matrix_plugin-0.1.0.dist-info/entry_points.txt +2 -0
  32. netbox_permissions_matrix_plugin-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,25 @@
1
+ from .version import __version__
2
+
3
+ try:
4
+ from netbox.plugins import PluginConfig
5
+ except ImportError: # imported outside NetBox (pure unit tests, build tools)
6
+ PluginConfig = None
7
+
8
+ if PluginConfig is not None:
9
+ from .conf import DEFAULTS
10
+
11
+ class PermissionsMatrixConfig(PluginConfig):
12
+ name = "netbox_permissions_matrix_plugin"
13
+ verbose_name = "Permissions Matrix"
14
+ description = "Show and edit NetBox object permissions as a matrix of object types and groups"
15
+ version = __version__
16
+ author = "Thomas Christory"
17
+ author_email = "9317624+thomaschristory@users.noreply.github.com"
18
+ base_url = "permissions-matrix"
19
+ min_version = "4.7.0"
20
+ # No max_version: the plugin only uses the stable permission model, and CI also runs against NetBox's next
21
+ # release.
22
+ required_settings = []
23
+ default_settings = DEFAULTS
24
+
25
+ config = PermissionsMatrixConfig
File without changes
@@ -0,0 +1,72 @@
1
+ from rest_framework import serializers
2
+
3
+ from ..core.model import UNSET, CellChange, PermEdit, PrincipalRef
4
+
5
+ ACTION = r"^[a-z_][a-z0-9_]{0,29}$" # ObjectPermission.actions items are CharField(max_length=30)
6
+
7
+
8
+ class PrincipalSerializer(serializers.Serializer):
9
+ type = serializers.ChoiceField(choices=["group", "user"])
10
+ id = serializers.IntegerField(min_value=1)
11
+
12
+ def to_internal_value(self, data):
13
+ value = super().to_internal_value(data)
14
+ return PrincipalRef(value["type"], value["id"])
15
+
16
+
17
+ class CellChangeSerializer(serializers.Serializer):
18
+ principal = PrincipalSerializer()
19
+ object_type = serializers.RegexField(r"^[a-z0-9_]+\.[a-z0-9_]+$", max_length=200)
20
+ action = serializers.RegexField(ACTION)
21
+ granted = serializers.BooleanField()
22
+
23
+ def validate_object_type(self, value):
24
+ if value not in self.context["object_types"]:
25
+ raise serializers.ValidationError(f"{value} is not an object type permissions can be assigned to.")
26
+ return value
27
+
28
+ def to_internal_value(self, data):
29
+ return CellChange(**super().to_internal_value(data))
30
+
31
+
32
+ class PermEditSerializer(serializers.Serializer):
33
+ principal = PrincipalSerializer()
34
+ action = serializers.RegexField(ACTION)
35
+ name = serializers.CharField(max_length=100, required=False)
36
+ description = serializers.CharField(max_length=200, required=False, allow_blank=True)
37
+ enabled = serializers.BooleanField(required=False)
38
+ constraints = serializers.JSONField(required=False, allow_null=True)
39
+ add_actions = serializers.ListField(child=serializers.RegexField(ACTION), required=False)
40
+ add_groups = serializers.ListField(child=serializers.IntegerField(min_value=1), required=False)
41
+ add_users = serializers.ListField(child=serializers.IntegerField(min_value=1), required=False)
42
+
43
+ def validate_constraints(self, value):
44
+ if value is not None and not isinstance(value, dict | list):
45
+ raise serializers.ValidationError("Constraints must be an object, a list of objects, or null.")
46
+ if isinstance(value, list) and not all(isinstance(c, dict) for c in value):
47
+ raise serializers.ValidationError("A list of constraints must contain only objects.")
48
+ return value
49
+
50
+ def to_internal_value(self, data):
51
+ value = super().to_internal_value(data)
52
+ return PermEdit(
53
+ principal=value["principal"],
54
+ action=value["action"],
55
+ name=value.get("name", UNSET),
56
+ description=value.get("description", UNSET),
57
+ enabled=value.get("enabled", UNSET),
58
+ constraints=value.get("constraints", UNSET),
59
+ add_actions=tuple(value.get("add_actions", ())),
60
+ add_groups=tuple(value.get("add_groups", ())),
61
+ add_users=tuple(value.get("add_users", ())),
62
+ )
63
+
64
+
65
+ class ChangeSetSerializer(serializers.Serializer):
66
+ changes = CellChangeSerializer(many=True, required=False, default=list)
67
+ edits = PermEditSerializer(many=True, required=False, default=list)
68
+ token = serializers.CharField(required=False, max_length=64)
69
+
70
+
71
+ class ImportSerializer(serializers.Serializer):
72
+ yaml = serializers.CharField(trim_whitespace=False)
@@ -0,0 +1,11 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("matrix/", views.MatrixView.as_view(), name="matrix"),
7
+ path("principal/", views.PrincipalView.as_view(), name="principal"),
8
+ path("plan/", views.PlanView.as_view(), name="plan"),
9
+ path("apply/", views.ApplyView.as_view(), name="apply"),
10
+ path("import/", views.ImportView.as_view(), name="import"),
11
+ ]
@@ -0,0 +1,155 @@
1
+ from django.core.exceptions import PermissionDenied, ValidationError
2
+ from django.db import transaction
3
+ from django.http import Http404
4
+ from rest_framework import status
5
+ from rest_framework.exceptions import NotFound
6
+ from rest_framework.exceptions import PermissionDenied as APIPermissionDenied
7
+ from rest_framework.exceptions import ValidationError as APIValidationError
8
+ from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
9
+ from rest_framework.response import Response
10
+ from rest_framework.views import APIView
11
+
12
+ from .. import loader, service
13
+ from ..applier import apply_plan
14
+ from ..conf import get_setting
15
+ from ..core import yaml_io
16
+ from ..core.model import PrincipalRef
17
+ from .serializers import ChangeSetSerializer, ImportSerializer
18
+
19
+
20
+ class WriteEnabled(BasePermission):
21
+ """Refuse API tokens without write enabled on unsafe methods, as NetBox's own write endpoints do.
22
+
23
+ NetBox's TokenWritePermission also refuses session authentication, which the matrix page uses (with CSRF).
24
+ """
25
+
26
+ message = "This API token is not write-enabled."
27
+
28
+ def has_permission(self, request, view):
29
+ token = request.auth
30
+ return request.method in SAFE_METHODS or token is None or bool(getattr(token, "write_enabled", False))
31
+
32
+
33
+ def _ints(values):
34
+ try:
35
+ return tuple(int(v) for v in values)
36
+ except ValueError as exc:
37
+ raise APIValidationError({"detail": "IDs must be integers."}) from exc
38
+
39
+
40
+ def _bool(value, default):
41
+ if value is None:
42
+ return default
43
+ return value.lower() in ("1", "true", "yes", "on")
44
+
45
+
46
+ def _change_set(request):
47
+ object_types = {r.object_type for r in loader.rows()}
48
+ serializer = ChangeSetSerializer(data=request.data, context={"object_types": object_types})
49
+ serializer.is_valid(raise_exception=True)
50
+ return serializer.validated_data
51
+
52
+
53
+ def _guard(func):
54
+ """Turn the service's Django exceptions into API responses."""
55
+
56
+ def wrapper(self, request, *args, **kwargs):
57
+ try:
58
+ return func(self, request, *args, **kwargs)
59
+ except PermissionDenied as exc:
60
+ raise APIPermissionDenied(str(exc)) from exc
61
+ except ValidationError as exc:
62
+ raise APIValidationError(exc.message_dict if hasattr(exc, "error_dict") else exc.messages) from exc
63
+
64
+ return wrapper
65
+
66
+
67
+ class MatrixView(APIView):
68
+ """The matrix for one action: rows, columns, cell states and the permissions behind each cell."""
69
+
70
+ permission_classes = [IsAuthenticated]
71
+
72
+ @_guard
73
+ def get(self, request):
74
+ params = request.query_params
75
+ query = service.MatrixQuery(
76
+ action=params.get("action", "view"),
77
+ include_users=_bool(params.get("include_users"), get_setting("show_users_by_default")),
78
+ group_ids=_ints(params.getlist("group_id")),
79
+ user_ids=_ints(params.getlist("user_id")),
80
+ app_labels=tuple(params.getlist("app_label")),
81
+ )
82
+ return Response(service.matrix_payload(request.user, query))
83
+
84
+
85
+ class PrincipalView(APIView):
86
+ """One group's or user's cells for every action."""
87
+
88
+ permission_classes = [IsAuthenticated]
89
+
90
+ @_guard
91
+ def get(self, request):
92
+ kind = request.query_params.get("type")
93
+ if kind not in ("group", "user"):
94
+ raise APIValidationError({"type": ["Must be group or user."]})
95
+ (pk,) = _ints([request.query_params.get("id", "")]) or (0,)
96
+ try:
97
+ return Response(service.principal_payload(request.user, PrincipalRef(kind, pk)))
98
+ except Http404 as exc:
99
+ raise NotFound(str(exc)) from exc
100
+
101
+
102
+ class PlanView(APIView):
103
+ """Dry run: the operations a change set would perform, and the token to apply them with."""
104
+
105
+ permission_classes = [IsAuthenticated, WriteEnabled]
106
+
107
+ @_guard
108
+ def post(self, request):
109
+ service.check_edit(request.user)
110
+ data = _change_set(request)
111
+ snap, result, token = service.plan_for(request.user, data["changes"], data["edits"])
112
+ return Response(service.plan_json(snap, result, token))
113
+
114
+
115
+ class ApplyView(APIView):
116
+ """Apply a change set in one transaction, if nothing changed since it was planned."""
117
+
118
+ permission_classes = [IsAuthenticated, WriteEnabled]
119
+
120
+ @_guard
121
+ def post(self, request):
122
+ service.check_edit(request.user)
123
+ data = _change_set(request)
124
+ if not data.get("token"):
125
+ raise APIValidationError({"token": ["Plan the change set first and send the token it returned."]})
126
+ with transaction.atomic():
127
+ # Lock the permission table for the plan-and-write, so two applies cannot interleave.
128
+ loader.lock_permissions()
129
+ snap, result, token = service.plan_for(request.user, data["changes"], data["edits"])
130
+ if token != data["token"]:
131
+ return Response(
132
+ {"detail": "The permissions changed since this plan was made. Reload and review again."},
133
+ status=status.HTTP_409_CONFLICT,
134
+ )
135
+ written = apply_plan(result, request.user)
136
+ body = service.plan_json(snap, result, token)
137
+ body["written"] = written
138
+ return Response(body)
139
+
140
+
141
+ class ImportView(APIView):
142
+ """Read an exported YAML file and return the cell changes that would make it true. Nothing is written."""
143
+
144
+ permission_classes = [IsAuthenticated, WriteEnabled]
145
+
146
+ @_guard
147
+ def post(self, request):
148
+ service.check_edit(request.user)
149
+ serializer = ImportSerializer(data=request.data)
150
+ serializer.is_valid(raise_exception=True)
151
+ try:
152
+ doc = yaml_io.load(serializer.validated_data["yaml"])
153
+ except ValueError as exc:
154
+ raise APIValidationError({"yaml": [str(exc)]}) from exc
155
+ return Response(service.import_for(request.user, doc))
@@ -0,0 +1,78 @@
1
+ """Write a plan to NetBox in one transaction, checking the user's own permissions on every object it touches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from core.models import ObjectType
8
+ from django.core.exceptions import PermissionDenied
9
+ from django.db import transaction
10
+ from users.models import ObjectPermission
11
+
12
+ from .core.model import Operation, Perm, Plan
13
+
14
+ logger = logging.getLogger("netbox_permissions_matrix_plugin")
15
+
16
+
17
+ def _object_types(plan: Plan) -> dict[str, ObjectType]:
18
+ labels = {ot for op in plan.operations if op.after for ot in op.after.object_types}
19
+ found = {}
20
+ for ot in ObjectType.objects.filter(app_label__in={label.split(".")[0] for label in labels}):
21
+ found[f"{ot.app_label}.{ot.model}"] = ot
22
+ missing = labels - set(found)
23
+ if missing:
24
+ raise ValueError(f"Unknown object types: {', '.join(sorted(missing))}")
25
+ return found
26
+
27
+
28
+ def _write(obj: ObjectPermission, perm: Perm, object_types: dict[str, ObjectType]) -> None:
29
+ obj.name = perm.name
30
+ obj.description = perm.description
31
+ obj.enabled = perm.enabled
32
+ obj.actions = list(perm.actions)
33
+ obj.constraints = perm.constraints
34
+ obj.full_clean()
35
+ obj.save()
36
+ obj.object_types.set([object_types[label] for label in perm.object_types])
37
+ obj.groups.set(perm.groups)
38
+ obj.users.set(perm.users)
39
+
40
+
41
+ def _denied(op: Operation, action: str) -> PermissionDenied:
42
+ name = (op.before or op.after).name
43
+ return PermissionDenied(f"You are not allowed to {action} the permission {name!r}; nothing was written.")
44
+
45
+
46
+ def apply_plan(plan: Plan, user) -> list[dict]:
47
+ """Apply every operation or none. Returns what was written: [{"op", "id", "name"}]."""
48
+ object_types = _object_types(plan)
49
+ written = []
50
+ with transaction.atomic():
51
+ for op in plan.operations:
52
+ if op.op == "delete":
53
+ obj = ObjectPermission.objects.restrict(user, "delete").filter(pk=op.perm_id).first()
54
+ if obj is None:
55
+ raise _denied(op, "delete")
56
+ obj.delete()
57
+ written.append({"op": "delete", "id": op.perm_id, "name": op.before.name})
58
+ continue
59
+ if op.op == "update":
60
+ obj = ObjectPermission.objects.restrict(user, "change").filter(pk=op.perm_id).first()
61
+ if obj is None:
62
+ raise _denied(op, "change")
63
+ action = "change"
64
+ else:
65
+ obj = ObjectPermission()
66
+ action = "add"
67
+ _write(obj, op.after, object_types)
68
+ # Like NetBox's own edit views: the saved object must still be within what the user may add or change,
69
+ # so a constrained permission cannot be used to write an object outside its constraints.
70
+ if not ObjectPermission.objects.restrict(user, action).filter(pk=obj.pk).exists():
71
+ raise _denied(op, action)
72
+ written.append({"op": op.op, "id": obj.pk, "name": obj.name})
73
+ logger.info(
74
+ "%s applied a permissions matrix plan: %s",
75
+ user.username,
76
+ "; ".join(f"{w['op']} {w['name']!r} (#{w['id']})" for w in written) or "nothing to do",
77
+ )
78
+ return written
@@ -0,0 +1,21 @@
1
+ PLUGIN_NAME = "netbox_permissions_matrix_plugin"
2
+
3
+ DEFAULTS = {
4
+ # Name of the simple permissions the plugin creates for new grants. {principal} is the group name or username,
5
+ # {action} the action. A number is appended when the name is taken.
6
+ "new_permission_name": "{principal} | {action}",
7
+ # "admin" puts the menu item below Permissions in Admin > Authentication; "plugins" uses the Plugins menu.
8
+ "menu_placement": "admin",
9
+ # Whether user columns are shown when the matrix opens.
10
+ "show_users_by_default": False,
11
+ # App labels whose object types get no row in the matrix (their grants are still kept intact).
12
+ "hidden_apps": [],
13
+ }
14
+
15
+ MENU_PLACEMENTS = ("admin", "plugins")
16
+
17
+
18
+ def get_setting(key):
19
+ from netbox.plugins.utils import get_plugin_config
20
+
21
+ return get_plugin_config(PLUGIN_NAME, key, DEFAULTS[key])
@@ -0,0 +1,4 @@
1
+ """Pure-Python core: a snapshot of NetBox permissions, cell states and the change planner.
2
+
3
+ Nothing here imports Django or NetBox, so it is tested exhaustively without a NetBox install.
4
+ """
@@ -0,0 +1,48 @@
1
+ """What a matrix cell shows, worked out from the permissions that cover it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from collections.abc import Iterable
7
+
8
+ from .model import GROUP, Perm, PrincipalRef, Snapshot
9
+
10
+
11
+ class CellState(enum.StrEnum):
12
+ NONE = "none"
13
+ GRANTED = "granted"
14
+ CONSTRAINED = "constrained"
15
+ DISABLED = "disabled"
16
+
17
+
18
+ def covering(snap: Snapshot, ref: PrincipalRef, object_type: str, action: str) -> list[Perm]:
19
+ """Permissions assigned directly to the principal that list the object type and the action, by ID."""
20
+ return [snap.perms[pid] for pid in snap.covering_ids(ref, object_type, action)]
21
+
22
+
23
+ def state_of(perms: Iterable[Perm]) -> CellState:
24
+ perms = list(perms)
25
+ if any(p.enabled and not p.constrained for p in perms):
26
+ return CellState.GRANTED
27
+ if any(p.enabled for p in perms):
28
+ return CellState.CONSTRAINED
29
+ if perms:
30
+ return CellState.DISABLED
31
+ return CellState.NONE
32
+
33
+
34
+ def cell_state(snap: Snapshot, ref: PrincipalRef, object_type: str, action: str) -> CellState:
35
+ """The cell's state from the principal's own permissions. For a user, group grants do not count."""
36
+ return state_of(covering(snap, ref, object_type, action))
37
+
38
+
39
+ def inherited_state(snap: Snapshot, user_id: int, object_type: str, action: str) -> tuple[CellState, list[str]]:
40
+ """What a user gets on this cell through group membership, and from which groups (by name)."""
41
+ perms, names = [], []
42
+ for group_id in sorted(snap.memberships.get(user_id, ())):
43
+ ref = PrincipalRef(GROUP, group_id)
44
+ found = covering(snap, ref, object_type, action)
45
+ if found:
46
+ perms += found
47
+ names.append(snap.name_of(ref))
48
+ return state_of(perms), sorted(names)
@@ -0,0 +1,164 @@
1
+ """Plain data describing NetBox permissions and the changes asked of them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ GROUP = "group"
9
+ USER = "user"
10
+ PrincipalKind = Literal["group", "user"]
11
+
12
+
13
+ class _Unset:
14
+ def __repr__(self):
15
+ return "UNSET"
16
+
17
+ def __bool__(self):
18
+ return False
19
+
20
+
21
+ # Marks a PermEdit field that was not given, as opposed to one given as None (constraints: remove them).
22
+ UNSET: Any = _Unset()
23
+
24
+
25
+ @dataclass(frozen=True, order=True)
26
+ class PrincipalRef:
27
+ """A group or a user, by NetBox ID."""
28
+
29
+ kind: PrincipalKind
30
+ id: int
31
+
32
+ @property
33
+ def key(self) -> str:
34
+ return f"{self.kind}:{self.id}"
35
+
36
+ @classmethod
37
+ def parse(cls, key: str) -> PrincipalRef:
38
+ kind, _, pk = key.partition(":")
39
+ if kind not in (GROUP, USER) or not pk.isdigit():
40
+ raise ValueError(f"not a principal key: {key!r}")
41
+ return cls(kind, int(pk)) # type: ignore[arg-type]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Principal:
46
+ ref: PrincipalRef
47
+ name: str
48
+
49
+
50
+ def is_constrained(constraints) -> bool:
51
+ """NetBox treats null, {} and [] as "no constraints": the permission matches every object."""
52
+ if not constraints:
53
+ return False
54
+ if isinstance(constraints, list):
55
+ # A list is OR-ed; one empty set in it matches everything.
56
+ return all(bool(c) for c in constraints)
57
+ return True
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Perm:
62
+ """One NetBox ObjectPermission. object_types are "app_label.model" strings."""
63
+
64
+ id: int | None
65
+ name: str
66
+ description: str = ""
67
+ enabled: bool = True
68
+ actions: tuple[str, ...] = ()
69
+ object_types: frozenset[str] = frozenset()
70
+ groups: frozenset[int] = frozenset()
71
+ users: frozenset[int] = frozenset()
72
+ constraints: Any = None
73
+
74
+ def principals(self) -> frozenset[PrincipalRef]:
75
+ return frozenset({PrincipalRef(GROUP, g) for g in self.groups} | {PrincipalRef(USER, u) for u in self.users})
76
+
77
+ def assigned_to(self, ref: PrincipalRef) -> bool:
78
+ return ref.id in (self.groups if ref.kind == GROUP else self.users)
79
+
80
+ @property
81
+ def constrained(self) -> bool:
82
+ return is_constrained(self.constraints)
83
+
84
+ def covers(self, ref: PrincipalRef, object_type: str, action: str) -> bool:
85
+ return self.assigned_to(ref) and object_type in self.object_types and action in self.actions
86
+
87
+ def is_simple_for(self, ref: PrincipalRef, action: str) -> bool:
88
+ """One principal, one action, no constraints: the only kind of permission the plugin grows."""
89
+ return self.principals() == {ref} and self.actions == (action,) and not self.constrained
90
+
91
+
92
+ @dataclass
93
+ class Snapshot:
94
+ perms: dict[int, Perm]
95
+ principals: dict[PrincipalRef, Principal]
96
+ # user ID -> IDs of the groups the user belongs to
97
+ memberships: dict[int, frozenset[int]] = field(default_factory=dict)
98
+ _index: dict | None = field(default=None, init=False, repr=False, compare=False)
99
+
100
+ def covering_ids(self, ref: PrincipalRef, object_type: str, action: str) -> list[int]:
101
+ """IDs of the permissions covering a cell, from an index built on first use (snapshots are not mutated)."""
102
+ if self._index is None:
103
+ index: dict[tuple, list[int]] = {}
104
+ for pid in sorted(self.perms):
105
+ p = self.perms[pid]
106
+ for principal in p.principals():
107
+ for ot in p.object_types:
108
+ for a in p.actions:
109
+ index.setdefault((principal, ot, a), []).append(pid)
110
+ self._index = index
111
+ return self._index.get((ref, object_type, action), [])
112
+
113
+ def name_of(self, ref: PrincipalRef) -> str:
114
+ principal = self.principals.get(ref)
115
+ return principal.name if principal else ref.key
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class CellChange:
120
+ principal: PrincipalRef
121
+ object_type: str
122
+ action: str
123
+ granted: bool
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class PermEdit:
128
+ """A gear-panel edit of a principal's simple permission for one action."""
129
+
130
+ principal: PrincipalRef
131
+ action: str
132
+ name: Any = UNSET
133
+ description: Any = UNSET
134
+ enabled: Any = UNSET
135
+ constraints: Any = UNSET
136
+ add_actions: tuple[str, ...] = ()
137
+ add_groups: tuple[int, ...] = ()
138
+ add_users: tuple[int, ...] = ()
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Operation:
143
+ """One write. key is the permission ID as a string, or "new-N" for a permission the plan creates."""
144
+
145
+ op: Literal["create", "update", "delete"]
146
+ key: str
147
+ before: Perm | None
148
+ after: Perm | None
149
+ affects: frozenset[PrincipalRef]
150
+ reasons: tuple[str, ...] = ()
151
+
152
+ @property
153
+ def perm_id(self) -> int | None:
154
+ return self.before.id if self.before else None
155
+
156
+
157
+ @dataclass
158
+ class Plan:
159
+ operations: list[Operation]
160
+ warnings: list[str]
161
+
162
+ @property
163
+ def empty(self) -> bool:
164
+ return not self.operations