oxutils 0.1.8__py3-none-any.whl → 0.1.11__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.
- oxutils/__init__.py +2 -1
- oxutils/constants.py +4 -0
- oxutils/jwt/auth.py +150 -1
- oxutils/jwt/models.py +13 -0
- oxutils/logger/receivers.py +10 -6
- oxutils/models/base.py +102 -0
- oxutils/models/fields.py +79 -0
- oxutils/oxiliere/apps.py +6 -1
- oxutils/oxiliere/authorization.py +45 -0
- oxutils/oxiliere/caches.py +7 -7
- oxutils/oxiliere/checks.py +31 -0
- oxutils/oxiliere/constants.py +3 -0
- oxutils/oxiliere/context.py +16 -0
- oxutils/oxiliere/exceptions.py +16 -0
- oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
- oxutils/oxiliere/management/commands/init_oxiliere_system.py +30 -11
- oxutils/oxiliere/middleware.py +29 -13
- oxutils/oxiliere/models.py +130 -19
- oxutils/oxiliere/permissions.py +6 -5
- oxutils/oxiliere/schemas.py +13 -4
- oxutils/oxiliere/signals.py +5 -0
- oxutils/oxiliere/utils.py +14 -0
- oxutils/pagination/__init__.py +0 -0
- oxutils/pagination/cursor.py +367 -0
- oxutils/permissions/__init__.py +0 -0
- oxutils/permissions/actions.py +57 -0
- oxutils/permissions/admin.py +3 -0
- oxutils/permissions/apps.py +10 -0
- oxutils/permissions/caches.py +19 -0
- oxutils/permissions/checks.py +188 -0
- oxutils/permissions/constants.py +0 -0
- oxutils/permissions/controllers.py +344 -0
- oxutils/permissions/exceptions.py +60 -0
- oxutils/permissions/management/__init__.py +0 -0
- oxutils/permissions/management/commands/__init__.py +0 -0
- oxutils/permissions/management/commands/load_permission_preset.py +112 -0
- oxutils/permissions/migrations/0001_initial.py +112 -0
- oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
- oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
- oxutils/permissions/migrations/__init__.py +0 -0
- oxutils/permissions/models.py +171 -0
- oxutils/permissions/perms.py +95 -0
- oxutils/permissions/queryset.py +92 -0
- oxutils/permissions/schemas.py +276 -0
- oxutils/permissions/services.py +663 -0
- oxutils/permissions/tests.py +3 -0
- oxutils/permissions/utils.py +628 -0
- oxutils/settings.py +1 -0
- oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
- oxutils/users/models.py +2 -0
- {oxutils-0.1.8.dist-info → oxutils-0.1.11.dist-info}/METADATA +1 -1
- {oxutils-0.1.8.dist-info → oxutils-0.1.11.dist-info}/RECORD +53 -19
- {oxutils-0.1.8.dist-info → oxutils-0.1.11.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cursor pagination for django ninja & ninja extra
|
|
3
|
+
|
|
4
|
+
forked from django-ninja-cursor-pagination
|
|
5
|
+
https://github.com/kitware-resonant/django-ninja-cursor-pagination
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
adapted for django ninja extra by @prosper-groups-soft for Oxiliere
|
|
9
|
+
https://github.com/prosper-groups-soft
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from base64 import b64decode, b64encode
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib import parse
|
|
17
|
+
from django.db.models import QuerySet
|
|
18
|
+
from django.http import HttpRequest
|
|
19
|
+
from django.utils.translation import gettext as _
|
|
20
|
+
from ninja import Field, Schema
|
|
21
|
+
from ninja.pagination import PaginationBase
|
|
22
|
+
from pydantic import field_validator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Cursor:
|
|
27
|
+
offset: int = 0
|
|
28
|
+
reverse: bool = False
|
|
29
|
+
position: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clamp(val: int, min_: int, max_: int) -> int:
|
|
33
|
+
return max(min_, min(val, max_))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _reverse_order(order: tuple) -> tuple:
|
|
37
|
+
# Reverse the ordering specification for a Django ORM query.
|
|
38
|
+
# Given an order_by tuple such as `('-created_at', 'uuid')` reverse the
|
|
39
|
+
# ordering and return a new tuple, eg. `('created_at', '-uuid')`.
|
|
40
|
+
def invert(x: str) -> str:
|
|
41
|
+
return x[1:] if x.startswith("-") else f"-{x}"
|
|
42
|
+
|
|
43
|
+
return tuple(invert(item) for item in order)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _replace_query_param(url: str, key: str, val: str) -> str:
|
|
47
|
+
scheme, netloc, path, query, fragment = parse.urlsplit(url)
|
|
48
|
+
query_dict = parse.parse_qs(query, keep_blank_values=True)
|
|
49
|
+
query_dict[key] = [val]
|
|
50
|
+
query = parse.urlencode(sorted(query_dict.items()), doseq=True)
|
|
51
|
+
return parse.urlunsplit((scheme, netloc, path, query, fragment))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_http_request(request) -> HttpRequest:
|
|
55
|
+
"""
|
|
56
|
+
Normalize the incoming request object to a Django HttpRequest.
|
|
57
|
+
|
|
58
|
+
Supports both direct HttpRequest instances and ninja-extra ControllerBase
|
|
59
|
+
wrappers (where the HttpRequest is available as request.context.request).
|
|
60
|
+
"""
|
|
61
|
+
if isinstance(request, HttpRequest):
|
|
62
|
+
return request
|
|
63
|
+
|
|
64
|
+
if hasattr(request, "context") and hasattr(request.context, "request"):
|
|
65
|
+
return request.context.request
|
|
66
|
+
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"Unsupported request type for pagination: {type(request)!r}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CursorPagination(PaginationBase):
|
|
74
|
+
class Input(Schema):
|
|
75
|
+
limit: int | None = Field(
|
|
76
|
+
None,
|
|
77
|
+
description=_("Number of results to return per page."),
|
|
78
|
+
)
|
|
79
|
+
cursor: str | None = Field(
|
|
80
|
+
None,
|
|
81
|
+
description=_("The pagination cursor value."),
|
|
82
|
+
validate_default=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@field_validator("cursor")
|
|
86
|
+
@classmethod
|
|
87
|
+
def decode_cursor(cls, encoded_cursor: str | None) -> Cursor:
|
|
88
|
+
if encoded_cursor is None:
|
|
89
|
+
return Cursor()
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
encoded_cursor = parse.unquote(encoded_cursor)
|
|
93
|
+
querystring = b64decode(encoded_cursor).decode()
|
|
94
|
+
tokens = parse.parse_qs(querystring, keep_blank_values=True)
|
|
95
|
+
|
|
96
|
+
offset = int(tokens.get("o", ["0"])[0])
|
|
97
|
+
offset = _clamp(offset, 0, CursorPagination._offset_cutoff)
|
|
98
|
+
|
|
99
|
+
reverse = tokens.get("r", ["0"])[0]
|
|
100
|
+
reverse = bool(int(reverse))
|
|
101
|
+
|
|
102
|
+
position = tokens.get("p", [None])[0]
|
|
103
|
+
except (TypeError, ValueError) as e:
|
|
104
|
+
raise ValueError(_("Invalid cursor.")) from e
|
|
105
|
+
|
|
106
|
+
return Cursor(offset=offset, reverse=reverse, position=position)
|
|
107
|
+
|
|
108
|
+
class Output(Schema):
|
|
109
|
+
results: list[Any] = Field(description=_("The page of objects."))
|
|
110
|
+
count: int = Field(
|
|
111
|
+
description=_("The total number of results across all pages."),
|
|
112
|
+
)
|
|
113
|
+
next: str | None = Field(
|
|
114
|
+
description=_("URL of next page of results if there is one."),
|
|
115
|
+
)
|
|
116
|
+
previous: str | None = Field(
|
|
117
|
+
description=_("URL of previous page of results if there is one."),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
items_attribute = "results"
|
|
121
|
+
default_ordering = ("-id",)
|
|
122
|
+
max_page_size = 100
|
|
123
|
+
_offset_cutoff = 100 # limit to protect against possibly malicious queries
|
|
124
|
+
|
|
125
|
+
def paginate_queryset(
|
|
126
|
+
self,
|
|
127
|
+
queryset: QuerySet,
|
|
128
|
+
pagination: Input,
|
|
129
|
+
request: Any,
|
|
130
|
+
**params,
|
|
131
|
+
) -> dict:
|
|
132
|
+
limit = _clamp(pagination.limit or self.max_page_size, 0, self.max_page_size)
|
|
133
|
+
request = _get_http_request(request)
|
|
134
|
+
|
|
135
|
+
if not queryset.query.order_by:
|
|
136
|
+
queryset = queryset.order_by(*self.default_ordering)
|
|
137
|
+
|
|
138
|
+
order = queryset.query.order_by
|
|
139
|
+
total_count = queryset.count()
|
|
140
|
+
|
|
141
|
+
base_url = request.build_absolute_uri()
|
|
142
|
+
cursor = pagination.cursor
|
|
143
|
+
|
|
144
|
+
if cursor.reverse:
|
|
145
|
+
queryset = queryset.order_by(*_reverse_order(order))
|
|
146
|
+
|
|
147
|
+
if cursor.position is not None:
|
|
148
|
+
values = cursor.position.split("|")
|
|
149
|
+
fields = [f.lstrip("-") for f in order]
|
|
150
|
+
|
|
151
|
+
filters = {}
|
|
152
|
+
for i, field in enumerate(fields):
|
|
153
|
+
if i < len(values) - 1:
|
|
154
|
+
filters[field] = values[i]
|
|
155
|
+
else:
|
|
156
|
+
op = "lt" if order[i].startswith("-") ^ cursor.reverse else "gt"
|
|
157
|
+
filters[f"{field}__{op}"] = values[i]
|
|
158
|
+
|
|
159
|
+
queryset = queryset.filter(**filters)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# If we have an offset cursor then offset the entire page by that amount.
|
|
163
|
+
# We also always fetch an extra item in order to determine if there is a
|
|
164
|
+
# page following on from this one.
|
|
165
|
+
results = list(queryset[cursor.offset : cursor.offset + limit + 1])
|
|
166
|
+
page = list(results[:limit])
|
|
167
|
+
|
|
168
|
+
# Determine the position of the final item following the page.
|
|
169
|
+
if len(results) > len(page):
|
|
170
|
+
has_following_position = True
|
|
171
|
+
following_position = self._get_position_from_instance(results[-1], order)
|
|
172
|
+
else:
|
|
173
|
+
has_following_position = False
|
|
174
|
+
following_position = None
|
|
175
|
+
|
|
176
|
+
if cursor.reverse:
|
|
177
|
+
# If we have a reverse queryset, then the query ordering was in reverse
|
|
178
|
+
# so we need to reverse the items again before returning them to the user.
|
|
179
|
+
page.reverse()
|
|
180
|
+
|
|
181
|
+
has_next = (cursor.position is not None) or (cursor.offset > 0)
|
|
182
|
+
has_previous = has_following_position
|
|
183
|
+
next_position = cursor.position if has_next else None
|
|
184
|
+
previous_position = following_position if has_previous else None
|
|
185
|
+
else:
|
|
186
|
+
has_next = has_following_position
|
|
187
|
+
has_previous = (cursor.position is not None) or (cursor.offset > 0)
|
|
188
|
+
next_position = following_position if has_next else None
|
|
189
|
+
previous_position = cursor.position if has_previous else None
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"results": page,
|
|
193
|
+
"count": total_count,
|
|
194
|
+
"next": self.next_link(
|
|
195
|
+
base_url=base_url,
|
|
196
|
+
page=page,
|
|
197
|
+
cursor=cursor,
|
|
198
|
+
order=order,
|
|
199
|
+
has_previous=has_previous,
|
|
200
|
+
limit=limit,
|
|
201
|
+
next_position=next_position,
|
|
202
|
+
previous_position=previous_position,
|
|
203
|
+
)
|
|
204
|
+
if has_next
|
|
205
|
+
else None,
|
|
206
|
+
"previous": self.previous_link(
|
|
207
|
+
base_url=base_url,
|
|
208
|
+
page=page,
|
|
209
|
+
cursor=cursor,
|
|
210
|
+
order=order,
|
|
211
|
+
has_next=has_next,
|
|
212
|
+
limit=limit,
|
|
213
|
+
next_position=next_position,
|
|
214
|
+
previous_position=previous_position,
|
|
215
|
+
)
|
|
216
|
+
if has_previous
|
|
217
|
+
else None,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
def _encode_cursor(self, cursor: Cursor, base_url: str) -> str:
|
|
221
|
+
tokens = {}
|
|
222
|
+
if cursor.offset != 0:
|
|
223
|
+
tokens["o"] = str(cursor.offset)
|
|
224
|
+
if cursor.reverse:
|
|
225
|
+
tokens["r"] = "1"
|
|
226
|
+
if cursor.position is not None:
|
|
227
|
+
tokens["p"] = cursor.position
|
|
228
|
+
|
|
229
|
+
querystring = parse.urlencode(tokens, doseq=True)
|
|
230
|
+
encoded = b64encode(querystring.encode()).decode()
|
|
231
|
+
return _replace_query_param(base_url, "cursor", encoded)
|
|
232
|
+
|
|
233
|
+
def next_link( # noqa: PLR0913
|
|
234
|
+
self,
|
|
235
|
+
*,
|
|
236
|
+
base_url: str,
|
|
237
|
+
page: list,
|
|
238
|
+
cursor: Cursor,
|
|
239
|
+
order: tuple,
|
|
240
|
+
has_previous: bool,
|
|
241
|
+
limit: int,
|
|
242
|
+
next_position: str,
|
|
243
|
+
previous_position: str,
|
|
244
|
+
) -> str:
|
|
245
|
+
if page and cursor.reverse and cursor.offset:
|
|
246
|
+
# If we're reversing direction and we have an offset cursor
|
|
247
|
+
# then we cannot use the first position we find as a marker.
|
|
248
|
+
compare = self._get_position_from_instance(page[-1], order)
|
|
249
|
+
else:
|
|
250
|
+
compare = next_position
|
|
251
|
+
offset = 0
|
|
252
|
+
|
|
253
|
+
has_item_with_unique_position = False
|
|
254
|
+
for item in reversed(page):
|
|
255
|
+
position = self._get_position_from_instance(item, order)
|
|
256
|
+
if position != compare:
|
|
257
|
+
# The item in this position and the item following it
|
|
258
|
+
# have different positions. We can use this position as
|
|
259
|
+
# our marker.
|
|
260
|
+
has_item_with_unique_position = True
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
# The item in this position has the same position as the item
|
|
264
|
+
# following it, we can't use it as a marker position, so increment
|
|
265
|
+
# the offset and keep seeking to the previous item.
|
|
266
|
+
compare = position
|
|
267
|
+
offset += 1 # noqa: SIM113
|
|
268
|
+
|
|
269
|
+
if page and not has_item_with_unique_position:
|
|
270
|
+
# There were no unique positions in the page.
|
|
271
|
+
if not has_previous:
|
|
272
|
+
# We are on the first page.
|
|
273
|
+
# Our cursor will have an offset equal to the page size,
|
|
274
|
+
# but no position to filter against yet.
|
|
275
|
+
offset = limit
|
|
276
|
+
position = None
|
|
277
|
+
elif cursor.reverse:
|
|
278
|
+
# The change in direction will introduce a paging artifact,
|
|
279
|
+
# where we end up skipping forward a few extra items.
|
|
280
|
+
offset = 0
|
|
281
|
+
position = previous_position
|
|
282
|
+
else:
|
|
283
|
+
# Use the position from the existing cursor and increment
|
|
284
|
+
# it's offset by the page size.
|
|
285
|
+
offset = cursor.offset + limit
|
|
286
|
+
position = previous_position
|
|
287
|
+
|
|
288
|
+
if not page:
|
|
289
|
+
position = next_position
|
|
290
|
+
|
|
291
|
+
next_cursor = Cursor(offset=offset, reverse=False, position=position)
|
|
292
|
+
return self._encode_cursor(next_cursor, base_url)
|
|
293
|
+
|
|
294
|
+
def previous_link( # noqa: PLR0913
|
|
295
|
+
self,
|
|
296
|
+
*,
|
|
297
|
+
base_url: str,
|
|
298
|
+
page: list,
|
|
299
|
+
cursor: Cursor,
|
|
300
|
+
order: tuple,
|
|
301
|
+
has_next: bool,
|
|
302
|
+
limit: int,
|
|
303
|
+
next_position: str,
|
|
304
|
+
previous_position: str,
|
|
305
|
+
):
|
|
306
|
+
if page and not cursor.reverse and cursor.offset:
|
|
307
|
+
# If we're reversing direction and we have an offset cursor
|
|
308
|
+
# then we cannot use the first position we find as a marker.
|
|
309
|
+
compare = self._get_position_from_instance(page[0], order)
|
|
310
|
+
else:
|
|
311
|
+
compare = previous_position
|
|
312
|
+
offset = 0
|
|
313
|
+
|
|
314
|
+
has_item_with_unique_position = False
|
|
315
|
+
for item in page:
|
|
316
|
+
position = self._get_position_from_instance(item, order)
|
|
317
|
+
if position != compare:
|
|
318
|
+
# The item in this position and the item following it
|
|
319
|
+
# have different positions. We can use this position as
|
|
320
|
+
# our marker.
|
|
321
|
+
has_item_with_unique_position = True
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
# The item in this position has the same position as the item
|
|
325
|
+
# following it, we can't use it as a marker position, so increment
|
|
326
|
+
# the offset and keep seeking to the previous item.
|
|
327
|
+
compare = position
|
|
328
|
+
offset += 1 # noqa: SIM113
|
|
329
|
+
|
|
330
|
+
if page and not has_item_with_unique_position:
|
|
331
|
+
# There were no unique positions in the page.
|
|
332
|
+
if not has_next:
|
|
333
|
+
# We are on the final page.
|
|
334
|
+
# Our cursor will have an offset equal to the page size,
|
|
335
|
+
# but no position to filter against yet.
|
|
336
|
+
offset = limit
|
|
337
|
+
position = None
|
|
338
|
+
elif cursor.reverse:
|
|
339
|
+
# Use the position from the existing cursor and increment
|
|
340
|
+
# it's offset by the page size.
|
|
341
|
+
offset = cursor.offset + limit
|
|
342
|
+
position = next_position
|
|
343
|
+
else:
|
|
344
|
+
# The change in direction will introduce a paging artifact,
|
|
345
|
+
# where we end up skipping back a few extra items.
|
|
346
|
+
offset = 0
|
|
347
|
+
position = next_position
|
|
348
|
+
|
|
349
|
+
if not page:
|
|
350
|
+
position = previous_position
|
|
351
|
+
|
|
352
|
+
cursor = Cursor(offset=offset, reverse=True, position=position)
|
|
353
|
+
return self._encode_cursor(cursor, base_url)
|
|
354
|
+
|
|
355
|
+
def _get_position_from_instance(self, instance, ordering) -> str:
|
|
356
|
+
values: list[str] = []
|
|
357
|
+
|
|
358
|
+
for field in ordering:
|
|
359
|
+
name = field.lstrip("-")
|
|
360
|
+
value = (
|
|
361
|
+
instance[name]
|
|
362
|
+
if isinstance(instance, dict)
|
|
363
|
+
else getattr(instance, name)
|
|
364
|
+
)
|
|
365
|
+
values.append(str(value))
|
|
366
|
+
|
|
367
|
+
return "|".join(values)
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# actions.py
|
|
2
|
+
|
|
3
|
+
READ = "r"
|
|
4
|
+
WRITE = "w"
|
|
5
|
+
DELETE = "d"
|
|
6
|
+
UPDATE = "u"
|
|
7
|
+
APPROVE = "a"
|
|
8
|
+
|
|
9
|
+
ACTIONS = [READ, WRITE, DELETE, UPDATE, APPROVE]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ACTION_HIERARCHY = {
|
|
13
|
+
"r": set(), # read
|
|
14
|
+
"w": {"r"}, # write ⇒ read
|
|
15
|
+
"u": {"r"}, # update ⇒ read
|
|
16
|
+
"d": {"r", "w"}, # delete ⇒ write ⇒ read
|
|
17
|
+
"a": {"r"}, # approve ⇒ read
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def collapse_actions(actions: list[str]) -> set[str]:
|
|
22
|
+
"""
|
|
23
|
+
['d','w','r'] -> {'d'}
|
|
24
|
+
['w','r'] -> {'w'}
|
|
25
|
+
['r'] -> {'r'}
|
|
26
|
+
"""
|
|
27
|
+
actions = set(actions)
|
|
28
|
+
roots = set(actions)
|
|
29
|
+
|
|
30
|
+
# Remove all implied actions from roots
|
|
31
|
+
for action in list(roots):
|
|
32
|
+
if action in ACTION_HIERARCHY:
|
|
33
|
+
implied = ACTION_HIERARCHY[action]
|
|
34
|
+
roots -= implied
|
|
35
|
+
|
|
36
|
+
return roots
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def expand_actions(actions: list[str]) -> list[str]:
|
|
40
|
+
"""
|
|
41
|
+
['w'] -> ['w', 'r']
|
|
42
|
+
['d'] -> ['d', 'w', 'r']
|
|
43
|
+
['a', 'w'] -> ['a', 'w', 'r']
|
|
44
|
+
"""
|
|
45
|
+
expanded = set(actions)
|
|
46
|
+
|
|
47
|
+
stack = list(actions)
|
|
48
|
+
while stack:
|
|
49
|
+
action = stack.pop()
|
|
50
|
+
implied = ACTION_HIERARCHY.get(action, set())
|
|
51
|
+
|
|
52
|
+
for a in implied:
|
|
53
|
+
if a not in expanded:
|
|
54
|
+
expanded.add(a)
|
|
55
|
+
stack.append(a)
|
|
56
|
+
|
|
57
|
+
return sorted(expanded)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
CACHE_CHECK_PERMISSION = getattr(settings, 'CACHE_CHECK_PERMISSION', False)
|
|
6
|
+
|
|
7
|
+
if CACHE_CHECK_PERMISSION:
|
|
8
|
+
from cacheops import cached_as
|
|
9
|
+
from .models import Grant
|
|
10
|
+
from .utils import check
|
|
11
|
+
|
|
12
|
+
@cached_as(Grant, timeout=60*5)
|
|
13
|
+
def cache_check(user, scope, actions, group = None, **context):
|
|
14
|
+
return check(user, scope, actions, group, **context)
|
|
15
|
+
else:
|
|
16
|
+
from .utils import check
|
|
17
|
+
|
|
18
|
+
def cache_check(user, scope, actions, group = None, **context):
|
|
19
|
+
return check(user, scope, actions, group, **context)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django system checks for permissions configuration.
|
|
3
|
+
|
|
4
|
+
Example configuration in settings.py:
|
|
5
|
+
|
|
6
|
+
ACCESS_MANAGER_SCOPE = "access"
|
|
7
|
+
ACCESS_MANAGER_GROUP = "manager" # or None
|
|
8
|
+
ACCESS_MANAGER_CONTEXT = {}
|
|
9
|
+
|
|
10
|
+
CACHE_CHECK_PERMISSION = False
|
|
11
|
+
|
|
12
|
+
ACCESS_SCOPES = [
|
|
13
|
+
"users",
|
|
14
|
+
"articles",
|
|
15
|
+
"comments"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
PERMISSION_PRESET = {
|
|
19
|
+
"roles": [...],
|
|
20
|
+
"group": [...],
|
|
21
|
+
"role_grants": [...]
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from django.conf import settings
|
|
26
|
+
from django.core.checks import Error, Warning, register, Tags
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@register(Tags.security)
|
|
30
|
+
def check_permission_settings(app_configs, **kwargs):
|
|
31
|
+
"""
|
|
32
|
+
Validate permission-related settings.
|
|
33
|
+
|
|
34
|
+
Checks:
|
|
35
|
+
- ACCESS_MANAGER_SCOPE is defined
|
|
36
|
+
- ACCESS_MANAGER_GROUP is defined (can be None)
|
|
37
|
+
- ACCESS_MANAGER_CONTEXT is defined
|
|
38
|
+
- ACCESS_SCOPES is defined
|
|
39
|
+
- PERMISSION_PRESET is defined
|
|
40
|
+
- ACCESS_MANAGER_SCOPE exists in ACCESS_SCOPES
|
|
41
|
+
- ACCESS_MANAGER_GROUP exists in PERMISSION_PRESET groups (if not None)
|
|
42
|
+
"""
|
|
43
|
+
errors = []
|
|
44
|
+
|
|
45
|
+
# Check ACCESS_MANAGER_SCOPE
|
|
46
|
+
if not hasattr(settings, 'ACCESS_MANAGER_SCOPE'):
|
|
47
|
+
errors.append(
|
|
48
|
+
Error(
|
|
49
|
+
'ACCESS_MANAGER_SCOPE is not defined',
|
|
50
|
+
hint='Add ACCESS_MANAGER_SCOPE = "access" to your settings',
|
|
51
|
+
id='permissions.E001',
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Check ACCESS_MANAGER_GROUP
|
|
56
|
+
if not hasattr(settings, 'ACCESS_MANAGER_GROUP'):
|
|
57
|
+
errors.append(
|
|
58
|
+
Error(
|
|
59
|
+
'ACCESS_MANAGER_GROUP is not defined',
|
|
60
|
+
hint='Add ACCESS_MANAGER_GROUP = "manager" or None to your settings',
|
|
61
|
+
id='permissions.E002',
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Check ACCESS_MANAGER_CONTEXT
|
|
66
|
+
if not hasattr(settings, 'ACCESS_MANAGER_CONTEXT'):
|
|
67
|
+
errors.append(
|
|
68
|
+
Error(
|
|
69
|
+
'ACCESS_MANAGER_CONTEXT is not defined',
|
|
70
|
+
hint='Add ACCESS_MANAGER_CONTEXT = {} to your settings',
|
|
71
|
+
id='permissions.E003',
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Check ACCESS_SCOPES
|
|
76
|
+
if not hasattr(settings, 'ACCESS_SCOPES'):
|
|
77
|
+
errors.append(
|
|
78
|
+
Error(
|
|
79
|
+
'ACCESS_SCOPES is not defined',
|
|
80
|
+
hint='Add ACCESS_SCOPES = ["users", "articles", ...] to your settings',
|
|
81
|
+
id='permissions.E004',
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
# Validate ACCESS_SCOPES is a list
|
|
86
|
+
if not isinstance(settings.ACCESS_SCOPES, list):
|
|
87
|
+
errors.append(
|
|
88
|
+
Error(
|
|
89
|
+
'ACCESS_SCOPES must be a list',
|
|
90
|
+
hint='Set ACCESS_SCOPES = ["users", "articles", ...]',
|
|
91
|
+
id='permissions.E005',
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Check PERMISSION_PRESET
|
|
96
|
+
if not hasattr(settings, 'PERMISSION_PRESET'):
|
|
97
|
+
errors.append(
|
|
98
|
+
Warning(
|
|
99
|
+
'PERMISSION_PRESET is not defined',
|
|
100
|
+
hint='Add PERMISSION_PRESET dict to your settings or use load_permission_preset',
|
|
101
|
+
id='permissions.W001',
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
# Validate PERMISSION_PRESET structure
|
|
106
|
+
preset = settings.PERMISSION_PRESET
|
|
107
|
+
if not isinstance(preset, dict):
|
|
108
|
+
errors.append(
|
|
109
|
+
Error(
|
|
110
|
+
'PERMISSION_PRESET must be a dictionary',
|
|
111
|
+
id='permissions.E006',
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
# Check required keys
|
|
116
|
+
required_keys = ['roles', 'group', 'role_grants']
|
|
117
|
+
for key in required_keys:
|
|
118
|
+
if key not in preset:
|
|
119
|
+
errors.append(
|
|
120
|
+
Error(
|
|
121
|
+
f'PERMISSION_PRESET is missing required key: {key}',
|
|
122
|
+
hint=f'Add "{key}" key to PERMISSION_PRESET',
|
|
123
|
+
id=f'permissions.E007',
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Cross-validation: ACCESS_MANAGER_SCOPE in ACCESS_SCOPES
|
|
128
|
+
if (hasattr(settings, 'ACCESS_MANAGER_SCOPE') and
|
|
129
|
+
hasattr(settings, 'ACCESS_SCOPES') and
|
|
130
|
+
isinstance(settings.ACCESS_SCOPES, list)):
|
|
131
|
+
|
|
132
|
+
if settings.ACCESS_MANAGER_SCOPE not in settings.ACCESS_SCOPES:
|
|
133
|
+
errors.append(
|
|
134
|
+
Error(
|
|
135
|
+
f'ACCESS_MANAGER_SCOPE "{settings.ACCESS_MANAGER_SCOPE}" is not in ACCESS_SCOPES',
|
|
136
|
+
hint=f'Add "{settings.ACCESS_MANAGER_SCOPE}" to ACCESS_SCOPES list',
|
|
137
|
+
id='permissions.E008',
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Cross-validation: ACCESS_MANAGER_GROUP in PERMISSION_PRESET groups
|
|
142
|
+
if (hasattr(settings, 'ACCESS_MANAGER_GROUP') and
|
|
143
|
+
settings.ACCESS_MANAGER_GROUP is not None and
|
|
144
|
+
hasattr(settings, 'PERMISSION_PRESET') and
|
|
145
|
+
isinstance(settings.PERMISSION_PRESET, dict) and
|
|
146
|
+
'group' in settings.PERMISSION_PRESET):
|
|
147
|
+
|
|
148
|
+
group_slugs = [g.get('slug') for g in settings.PERMISSION_PRESET.get('group', [])]
|
|
149
|
+
|
|
150
|
+
if settings.ACCESS_MANAGER_GROUP not in group_slugs:
|
|
151
|
+
errors.append(
|
|
152
|
+
Error(
|
|
153
|
+
f'ACCESS_MANAGER_GROUP "{settings.ACCESS_MANAGER_GROUP}" is not in PERMISSION_PRESET groups',
|
|
154
|
+
hint=f'Add a group with slug "{settings.ACCESS_MANAGER_GROUP}" to PERMISSION_PRESET["group"]',
|
|
155
|
+
id='permissions.E009',
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Validate ACCESS_MANAGER_CONTEXT is a dict
|
|
160
|
+
if hasattr(settings, 'ACCESS_MANAGER_CONTEXT'):
|
|
161
|
+
if not isinstance(settings.ACCESS_MANAGER_CONTEXT, dict):
|
|
162
|
+
errors.append(
|
|
163
|
+
Error(
|
|
164
|
+
'ACCESS_MANAGER_CONTEXT must be a dictionary',
|
|
165
|
+
hint='Set ACCESS_MANAGER_CONTEXT = {}',
|
|
166
|
+
id='permissions.E010',
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Check CACHE_CHECK_PERMISSION and cacheops dependency
|
|
171
|
+
if hasattr(settings, 'CACHE_CHECK_PERMISSION') and settings.CACHE_CHECK_PERMISSION:
|
|
172
|
+
if not hasattr(settings, 'INSTALLED_APPS'):
|
|
173
|
+
errors.append(
|
|
174
|
+
Error(
|
|
175
|
+
'INSTALLED_APPS is not defined',
|
|
176
|
+
id='permissions.E011',
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
elif 'cacheops' not in settings.INSTALLED_APPS:
|
|
180
|
+
errors.append(
|
|
181
|
+
Error(
|
|
182
|
+
'CACHE_CHECK_PERMISSION is True but cacheops is not in INSTALLED_APPS',
|
|
183
|
+
hint='Add "cacheops" to INSTALLED_APPS or set CACHE_CHECK_PERMISSION = False',
|
|
184
|
+
id='permissions.E012',
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return errors
|
|
File without changes
|