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.
- netbox_permissions_matrix_plugin/__init__.py +25 -0
- netbox_permissions_matrix_plugin/api/__init__.py +0 -0
- netbox_permissions_matrix_plugin/api/serializers.py +72 -0
- netbox_permissions_matrix_plugin/api/urls.py +11 -0
- netbox_permissions_matrix_plugin/api/views.py +155 -0
- netbox_permissions_matrix_plugin/applier.py +78 -0
- netbox_permissions_matrix_plugin/conf.py +21 -0
- netbox_permissions_matrix_plugin/core/__init__.py +4 -0
- netbox_permissions_matrix_plugin/core/cells.py +48 -0
- netbox_permissions_matrix_plugin/core/model.py +164 -0
- netbox_permissions_matrix_plugin/core/planner.py +259 -0
- netbox_permissions_matrix_plugin/core/token.py +39 -0
- netbox_permissions_matrix_plugin/core/yaml_io.py +200 -0
- netbox_permissions_matrix_plugin/loader.py +118 -0
- netbox_permissions_matrix_plugin/matrix.py +130 -0
- netbox_permissions_matrix_plugin/navigation.py +46 -0
- netbox_permissions_matrix_plugin/service.py +303 -0
- netbox_permissions_matrix_plugin/static/netbox_permissions_matrix_plugin/matrix.css +331 -0
- netbox_permissions_matrix_plugin/static/netbox_permissions_matrix_plugin/matrix.js +746 -0
- netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/inc/shell.html +115 -0
- netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/inc/state.html +1 -0
- netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/matrix.html +32 -0
- netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/principal_tab.html +20 -0
- netbox_permissions_matrix_plugin/templates/netbox_permissions_matrix_plugin/who_can.html +118 -0
- netbox_permissions_matrix_plugin/urls.py +9 -0
- netbox_permissions_matrix_plugin/version.py +1 -0
- netbox_permissions_matrix_plugin/views.py +102 -0
- netbox_permissions_matrix_plugin-0.1.0.dist-info/METADATA +91 -0
- netbox_permissions_matrix_plugin-0.1.0.dist-info/RECORD +32 -0
- netbox_permissions_matrix_plugin-0.1.0.dist-info/WHEEL +4 -0
- netbox_permissions_matrix_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|