GeneralManager 0.20.1__py3-none-any.whl → 0.21.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.
Potentially problematic release.
This version of GeneralManager might be problematic. Click here for more details.
- general_manager/_types/general_manager.py +4 -0
- general_manager/_types/permission.py +16 -0
- general_manager/apps.py +2 -0
- general_manager/permission/audit.py +380 -0
- general_manager/permission/base_permission.py +115 -19
- general_manager/permission/manager_based_permission.py +37 -4
- general_manager/permission/mutation_permission.py +68 -13
- general_manager/permission/permission_checks.py +208 -38
- general_manager/permission/permission_data_manager.py +0 -1
- general_manager/permission/utils.py +3 -3
- general_manager/public_api_registry.py +37 -0
- {generalmanager-0.20.1.dist-info → generalmanager-0.21.0.dist-info}/METADATA +1 -1
- {generalmanager-0.20.1.dist-info → generalmanager-0.21.0.dist-info}/RECORD +16 -16
- general_manager/permission/file_based_permission.py +0 -0
- {generalmanager-0.20.1.dist-info → generalmanager-0.21.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.20.1.dist-info → generalmanager-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {generalmanager-0.20.1.dist-info → generalmanager-0.21.0.dist-info}/top_level.txt +0 -0
|
@@ -14,6 +14,8 @@ __all__ = [
|
|
|
14
14
|
"get_logger",
|
|
15
15
|
"graph_ql_mutation",
|
|
16
16
|
"graph_ql_property",
|
|
17
|
+
"permission_functions",
|
|
18
|
+
"register_permission",
|
|
17
19
|
]
|
|
18
20
|
|
|
19
21
|
from general_manager.interface.calculation_interface import CalculationInterface
|
|
@@ -27,3 +29,5 @@ from general_manager.rule.rule import Rule
|
|
|
27
29
|
from general_manager.logging import get_logger
|
|
28
30
|
from general_manager.api.mutation import graph_ql_mutation
|
|
29
31
|
from general_manager.api.property import graph_ql_property
|
|
32
|
+
from general_manager.permission.permission_checks import permission_functions
|
|
33
|
+
from general_manager.permission.permission_checks import register_permission
|
|
@@ -3,11 +3,27 @@ from __future__ import annotations
|
|
|
3
3
|
"""Type-only imports for public API re-exports."""
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
+
"AuditLogger",
|
|
6
7
|
"BasePermission",
|
|
8
|
+
"DatabaseAuditLogger",
|
|
9
|
+
"FileAuditLogger",
|
|
7
10
|
"ManagerBasedPermission",
|
|
8
11
|
"MutationPermission",
|
|
12
|
+
"PermissionAuditEvent",
|
|
13
|
+
"configure_audit_logger",
|
|
14
|
+
"configure_audit_logger_from_settings",
|
|
15
|
+
"permission_functions",
|
|
16
|
+
"register_permission",
|
|
9
17
|
]
|
|
10
18
|
|
|
19
|
+
from general_manager.permission.audit import AuditLogger
|
|
11
20
|
from general_manager.permission.base_permission import BasePermission
|
|
21
|
+
from general_manager.permission.audit import DatabaseAuditLogger
|
|
22
|
+
from general_manager.permission.audit import FileAuditLogger
|
|
12
23
|
from general_manager.permission.manager_based_permission import ManagerBasedPermission
|
|
13
24
|
from general_manager.permission.mutation_permission import MutationPermission
|
|
25
|
+
from general_manager.permission.audit import PermissionAuditEvent
|
|
26
|
+
from general_manager.permission.audit import configure_audit_logger
|
|
27
|
+
from general_manager.permission.audit import configure_audit_logger_from_settings
|
|
28
|
+
from general_manager.permission.permission_checks import permission_functions
|
|
29
|
+
from general_manager.permission.permission_checks import register_permission
|
general_manager/apps.py
CHANGED
|
@@ -20,6 +20,7 @@ from general_manager.logging import get_logger
|
|
|
20
20
|
from general_manager.manager.general_manager import GeneralManager
|
|
21
21
|
from general_manager.manager.input import Input
|
|
22
22
|
from general_manager.manager.meta import GeneralManagerMeta
|
|
23
|
+
from general_manager.permission.audit import configure_audit_logger_from_settings
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class MissingRootUrlconfError(RuntimeError):
|
|
@@ -66,6 +67,7 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
66
67
|
GeneralManagerMeta.pending_attribute_initialization,
|
|
67
68
|
GeneralManagerMeta.all_classes,
|
|
68
69
|
)
|
|
70
|
+
configure_audit_logger_from_settings(settings)
|
|
69
71
|
if getattr(settings, "AUTOCREATE_GRAPHQL", False):
|
|
70
72
|
self.handle_graph_ql(GeneralManagerMeta.pending_graphql_interfaces)
|
|
71
73
|
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Lightweight audit logging hooks for permission evaluations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from queue import Empty, SimpleQueue
|
|
10
|
+
from threading import Event, Thread, Lock
|
|
11
|
+
from typing import Any, Iterable, Mapping, Protocol, runtime_checkable, Literal
|
|
12
|
+
|
|
13
|
+
from django.apps import apps
|
|
14
|
+
from django.db import connections, models
|
|
15
|
+
from django.utils import timezone
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
AuditAction = Literal["create", "read", "update", "delete", "mutation"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class PermissionAuditEvent:
|
|
23
|
+
"""
|
|
24
|
+
Payload describing a permission evaluation outcome.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
action (AuditAction): CRUD or mutation action that was evaluated.
|
|
28
|
+
attributes (tuple[str, ...]): Collection of attribute names covered by this evaluation.
|
|
29
|
+
granted (bool): True when the action was permitted.
|
|
30
|
+
user (Any): User object involved in the evaluation; consumers may extract ids.
|
|
31
|
+
manager (str | None): Name of the manager class (when applicable).
|
|
32
|
+
permissions (tuple[str, ...]): Permission expressions that were considered.
|
|
33
|
+
bypassed (bool): True when the decision relied on a superuser bypass.
|
|
34
|
+
metadata (Mapping[str, Any] | None): Optional additional context.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
action: AuditAction
|
|
38
|
+
attributes: tuple[str, ...]
|
|
39
|
+
granted: bool
|
|
40
|
+
user: Any
|
|
41
|
+
manager: str | None
|
|
42
|
+
permissions: tuple[str, ...] = ()
|
|
43
|
+
bypassed: bool = False
|
|
44
|
+
metadata: Mapping[str, Any] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@runtime_checkable
|
|
48
|
+
class AuditLogger(Protocol):
|
|
49
|
+
"""Protocol describing the expected behaviour of an audit logger implementation."""
|
|
50
|
+
|
|
51
|
+
def record(self, event: PermissionAuditEvent) -> None:
|
|
52
|
+
"""Persist or forward a permission audit event."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _NoOpAuditLogger:
|
|
56
|
+
"""Fallback logger used when no audit logger is configured."""
|
|
57
|
+
|
|
58
|
+
__slots__ = ()
|
|
59
|
+
|
|
60
|
+
def record(self, _event: PermissionAuditEvent) -> None:
|
|
61
|
+
"""Ignore the audit event."""
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_NOOP_LOGGER = _NoOpAuditLogger()
|
|
66
|
+
_audit_logger: AuditLogger = _NOOP_LOGGER
|
|
67
|
+
_SETTINGS_KEY = "GENERAL_MANAGER"
|
|
68
|
+
_AUDIT_LOGGER_KEY = "AUDIT_LOGGER"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def configure_audit_logger(logger: AuditLogger | None) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Configure the audit logger used by permission checks.
|
|
74
|
+
|
|
75
|
+
Parameters:
|
|
76
|
+
logger (AuditLogger | None): Concrete logger implementation. Passing ``None``
|
|
77
|
+
resets the logger to a no-op implementation.
|
|
78
|
+
"""
|
|
79
|
+
global _audit_logger
|
|
80
|
+
_audit_logger = logger or _NOOP_LOGGER
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_audit_logger() -> AuditLogger:
|
|
84
|
+
"""Return the currently configured audit logger."""
|
|
85
|
+
return _audit_logger
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def audit_logging_enabled() -> bool:
|
|
89
|
+
"""Return True when audit logging is active."""
|
|
90
|
+
return _audit_logger is not _NOOP_LOGGER
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def emit_permission_audit_event(event: PermissionAuditEvent) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Forward an audit event to the configured logger when logging is enabled.
|
|
96
|
+
|
|
97
|
+
Parameters:
|
|
98
|
+
event (PermissionAuditEvent): Event payload to record.
|
|
99
|
+
"""
|
|
100
|
+
if _audit_logger is _NOOP_LOGGER:
|
|
101
|
+
return
|
|
102
|
+
_audit_logger.record(event)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _serialize_event(event: PermissionAuditEvent) -> dict[str, Any]:
|
|
106
|
+
"""Convert an audit event into a JSON-serialisable mapping."""
|
|
107
|
+
user_pk = getattr(event.user, "pk", None)
|
|
108
|
+
user_id = None if user_pk is None else str(user_pk)
|
|
109
|
+
return {
|
|
110
|
+
"timestamp": timezone.now().isoformat(),
|
|
111
|
+
"action": event.action,
|
|
112
|
+
"attributes": list(event.attributes),
|
|
113
|
+
"granted": event.granted,
|
|
114
|
+
"bypassed": event.bypassed,
|
|
115
|
+
"manager": event.manager,
|
|
116
|
+
"user_id": user_id,
|
|
117
|
+
"user": None if user_id is not None else repr(event.user),
|
|
118
|
+
"permissions": list(event.permissions),
|
|
119
|
+
"metadata": event.metadata,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_logger_reference(value: Any) -> AuditLogger | None:
|
|
124
|
+
"""Resolve audit logger setting values into concrete logger instances."""
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
if isinstance(value, str):
|
|
128
|
+
from django.utils.module_loading import import_string
|
|
129
|
+
|
|
130
|
+
resolved = import_string(value)
|
|
131
|
+
elif isinstance(value, Mapping):
|
|
132
|
+
from django.utils.module_loading import import_string
|
|
133
|
+
|
|
134
|
+
class_path = value.get("class")
|
|
135
|
+
options = value.get("options", {})
|
|
136
|
+
if class_path is None:
|
|
137
|
+
return None
|
|
138
|
+
resolved = (
|
|
139
|
+
import_string(class_path) if isinstance(class_path, str) else class_path
|
|
140
|
+
)
|
|
141
|
+
if isinstance(resolved, type):
|
|
142
|
+
resolved = resolved(**options)
|
|
143
|
+
elif callable(resolved):
|
|
144
|
+
resolved = resolved(**options)
|
|
145
|
+
return resolved if hasattr(resolved, "record") else None
|
|
146
|
+
else:
|
|
147
|
+
resolved = value
|
|
148
|
+
|
|
149
|
+
if isinstance(resolved, type):
|
|
150
|
+
resolved = resolved()
|
|
151
|
+
elif callable(resolved) and not hasattr(resolved, "record"):
|
|
152
|
+
resolved = resolved()
|
|
153
|
+
|
|
154
|
+
if resolved is None or not hasattr(resolved, "record"):
|
|
155
|
+
return None
|
|
156
|
+
return resolved # type: ignore[return-value]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def configure_audit_logger_from_settings(django_settings: Any) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Configure the audit logger based on Django settings.
|
|
162
|
+
|
|
163
|
+
Expects either ``settings.GENERAL_MANAGER['AUDIT_LOGGER']`` or a top-level
|
|
164
|
+
``settings.AUDIT_LOGGER`` value pointing to an audit logger implementation
|
|
165
|
+
(instance, callable, or dotted import path).
|
|
166
|
+
"""
|
|
167
|
+
config: Mapping[str, Any] | None = getattr(django_settings, _SETTINGS_KEY, None)
|
|
168
|
+
logger_setting: Any = None
|
|
169
|
+
if isinstance(config, Mapping):
|
|
170
|
+
logger_setting = config.get(_AUDIT_LOGGER_KEY)
|
|
171
|
+
if logger_setting is None:
|
|
172
|
+
logger_setting = getattr(django_settings, _AUDIT_LOGGER_KEY, None)
|
|
173
|
+
|
|
174
|
+
logger_instance = _resolve_logger_reference(logger_setting)
|
|
175
|
+
configure_audit_logger(logger_instance)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_MODEL_CACHE: dict[str, type[models.Model]] = {}
|
|
179
|
+
_MODEL_CACHE_LOCK = Lock()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_field_definitions() -> dict[str, models.Field[Any, Any]]:
|
|
183
|
+
return {
|
|
184
|
+
"created_at": models.DateTimeField(auto_now_add=True),
|
|
185
|
+
"action": models.CharField(max_length=32),
|
|
186
|
+
"attributes": models.JSONField(default=list),
|
|
187
|
+
"granted": models.BooleanField(),
|
|
188
|
+
"bypassed": models.BooleanField(),
|
|
189
|
+
"manager": models.CharField(max_length=255, null=True, blank=True),
|
|
190
|
+
"user_id": models.CharField(max_length=255, null=True, blank=True),
|
|
191
|
+
"user_repr": models.TextField(null=True, blank=True),
|
|
192
|
+
"permissions": models.JSONField(default=list),
|
|
193
|
+
"metadata": models.JSONField(null=True, blank=True),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _get_audit_model(table_name: str) -> type[models.Model]:
|
|
198
|
+
"""Return (and register) a concrete audit model for the given table."""
|
|
199
|
+
with _MODEL_CACHE_LOCK:
|
|
200
|
+
cached = _MODEL_CACHE.get(table_name)
|
|
201
|
+
if cached is not None:
|
|
202
|
+
return cached
|
|
203
|
+
|
|
204
|
+
app_config = apps.get_app_config("general_manager")
|
|
205
|
+
for model in app_config.get_models():
|
|
206
|
+
if model._meta.db_table == table_name:
|
|
207
|
+
_MODEL_CACHE[table_name] = model
|
|
208
|
+
return model
|
|
209
|
+
|
|
210
|
+
attrs: dict[str, Any] = _build_field_definitions()
|
|
211
|
+
attrs["__module__"] = __name__
|
|
212
|
+
attrs["Meta"] = type(
|
|
213
|
+
"Meta",
|
|
214
|
+
(),
|
|
215
|
+
{"db_table": table_name, "app_label": "general_manager"},
|
|
216
|
+
)
|
|
217
|
+
model_name = f"PermissionAuditEntry_{abs(hash(table_name))}"
|
|
218
|
+
model = type(model_name, (models.Model,), attrs)
|
|
219
|
+
registry_key = model.__name__.lower()
|
|
220
|
+
if registry_key not in app_config.models:
|
|
221
|
+
apps.register_model("general_manager", model)
|
|
222
|
+
_MODEL_CACHE[table_name] = model
|
|
223
|
+
return model
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _BufferedAuditLogger:
|
|
227
|
+
"""Base class implementing a background worker that processes audit events in batches."""
|
|
228
|
+
|
|
229
|
+
_SENTINEL = object()
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
batch_size: int = 100,
|
|
235
|
+
flush_interval: float = 0.5,
|
|
236
|
+
use_worker: bool = True,
|
|
237
|
+
) -> None:
|
|
238
|
+
self._batch_size = max(batch_size, 1)
|
|
239
|
+
self._flush_interval = flush_interval
|
|
240
|
+
self._use_worker = use_worker
|
|
241
|
+
self._closed = Event()
|
|
242
|
+
if self._use_worker:
|
|
243
|
+
self._queue: SimpleQueue[PermissionAuditEvent | object] = SimpleQueue()
|
|
244
|
+
self._worker = Thread(target=self._worker_loop, daemon=True)
|
|
245
|
+
self._worker.start()
|
|
246
|
+
atexit.register(self.close)
|
|
247
|
+
else:
|
|
248
|
+
self._queue = None # type: ignore[assignment]
|
|
249
|
+
self._worker = None # type: ignore[assignment]
|
|
250
|
+
|
|
251
|
+
def record(self, event: PermissionAuditEvent) -> None:
|
|
252
|
+
if self._closed.is_set():
|
|
253
|
+
return
|
|
254
|
+
if not self._use_worker:
|
|
255
|
+
self._handle_batch((event,))
|
|
256
|
+
return
|
|
257
|
+
self._queue.put(event)
|
|
258
|
+
|
|
259
|
+
def close(self) -> None:
|
|
260
|
+
if self._closed.is_set() or not self._use_worker:
|
|
261
|
+
return
|
|
262
|
+
self._closed.set()
|
|
263
|
+
self._queue.put(self._SENTINEL)
|
|
264
|
+
self._worker.join(timeout=2.0)
|
|
265
|
+
|
|
266
|
+
def flush(self) -> None:
|
|
267
|
+
"""Block until all queued events are processed."""
|
|
268
|
+
if self._use_worker:
|
|
269
|
+
self.close()
|
|
270
|
+
|
|
271
|
+
def _worker_loop(self) -> None:
|
|
272
|
+
pending: list[PermissionAuditEvent] = []
|
|
273
|
+
while True:
|
|
274
|
+
try:
|
|
275
|
+
item = self._queue.get(timeout=self._flush_interval)
|
|
276
|
+
except Empty:
|
|
277
|
+
item = None
|
|
278
|
+
if item is self._SENTINEL:
|
|
279
|
+
break
|
|
280
|
+
if isinstance(item, PermissionAuditEvent):
|
|
281
|
+
pending.append(item)
|
|
282
|
+
if len(pending) >= self._batch_size or (item is None and pending):
|
|
283
|
+
self._handle_batch(pending)
|
|
284
|
+
pending = []
|
|
285
|
+
if pending:
|
|
286
|
+
self._handle_batch(pending)
|
|
287
|
+
|
|
288
|
+
def _handle_batch(self, events: Iterable[PermissionAuditEvent]) -> None:
|
|
289
|
+
raise NotImplementedError
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class FileAuditLogger(_BufferedAuditLogger):
|
|
293
|
+
"""Persist audit events as newline-delimited JSON records in a file."""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
path: str | Path,
|
|
298
|
+
*,
|
|
299
|
+
batch_size: int = 100,
|
|
300
|
+
flush_interval: float = 0.5,
|
|
301
|
+
) -> None:
|
|
302
|
+
self._path = Path(path)
|
|
303
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
super().__init__(batch_size=batch_size, flush_interval=flush_interval)
|
|
305
|
+
|
|
306
|
+
def _handle_batch(self, events: Iterable[PermissionAuditEvent]) -> None:
|
|
307
|
+
records = [json.dumps(_serialize_event(event)) for event in events]
|
|
308
|
+
if not records:
|
|
309
|
+
return
|
|
310
|
+
data = "\n".join(records) + "\n"
|
|
311
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
312
|
+
handle.write(data)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class DatabaseAuditLogger(_BufferedAuditLogger):
|
|
316
|
+
"""Store audit events inside a dedicated database table using Django connections."""
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
*,
|
|
321
|
+
using: str = "default",
|
|
322
|
+
table_name: str = "general_manager_permissionauditlog",
|
|
323
|
+
batch_size: int = 100,
|
|
324
|
+
flush_interval: float = 0.5,
|
|
325
|
+
) -> None:
|
|
326
|
+
self._using = using
|
|
327
|
+
self.table_name = table_name
|
|
328
|
+
self.model = _get_audit_model(table_name)
|
|
329
|
+
connection = connections[self._using]
|
|
330
|
+
use_worker = connection.vendor != "sqlite"
|
|
331
|
+
super().__init__(
|
|
332
|
+
batch_size=batch_size,
|
|
333
|
+
flush_interval=flush_interval,
|
|
334
|
+
use_worker=use_worker,
|
|
335
|
+
)
|
|
336
|
+
self._ensure_table()
|
|
337
|
+
|
|
338
|
+
def _ensure_table(self) -> None:
|
|
339
|
+
connection = connections[self._using]
|
|
340
|
+
table_names = connection.introspection.table_names()
|
|
341
|
+
if self.model._meta.db_table in table_names:
|
|
342
|
+
return
|
|
343
|
+
with connection.schema_editor(atomic=False) as editor:
|
|
344
|
+
editor.create_model(self.model)
|
|
345
|
+
|
|
346
|
+
def _handle_batch(self, events: Iterable[PermissionAuditEvent]) -> None:
|
|
347
|
+
entries = []
|
|
348
|
+
for event in events:
|
|
349
|
+
serialized = _serialize_event(event)
|
|
350
|
+
entries.append(
|
|
351
|
+
self.model(
|
|
352
|
+
action=event.action,
|
|
353
|
+
attributes=serialized["attributes"],
|
|
354
|
+
granted=event.granted,
|
|
355
|
+
bypassed=event.bypassed,
|
|
356
|
+
manager=event.manager,
|
|
357
|
+
user_id=serialized["user_id"],
|
|
358
|
+
user_repr=serialized["user"],
|
|
359
|
+
permissions=serialized["permissions"],
|
|
360
|
+
metadata=serialized["metadata"],
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
if not entries:
|
|
364
|
+
return
|
|
365
|
+
self.model.objects.using(self._using).bulk_create(
|
|
366
|
+
entries, batch_size=self._batch_size
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
__all__ = [
|
|
371
|
+
"AuditLogger",
|
|
372
|
+
"DatabaseAuditLogger",
|
|
373
|
+
"FileAuditLogger",
|
|
374
|
+
"PermissionAuditEvent",
|
|
375
|
+
"audit_logging_enabled",
|
|
376
|
+
"configure_audit_logger",
|
|
377
|
+
"configure_audit_logger_from_settings",
|
|
378
|
+
"emit_permission_audit_event",
|
|
379
|
+
"get_audit_logger",
|
|
380
|
+
]
|
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
7
7
|
|
|
8
|
-
from django.contrib.auth.models import AbstractBaseUser,
|
|
8
|
+
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
|
9
9
|
|
|
10
10
|
from general_manager.logging import get_logger
|
|
11
|
+
from general_manager.permission.audit import (
|
|
12
|
+
PermissionAuditEvent,
|
|
13
|
+
audit_logging_enabled,
|
|
14
|
+
emit_permission_audit_event,
|
|
15
|
+
)
|
|
11
16
|
from general_manager.permission.permission_checks import permission_functions
|
|
12
17
|
from general_manager.permission.permission_data_manager import PermissionDataManager
|
|
13
18
|
from general_manager.permission.utils import (
|
|
@@ -64,6 +69,18 @@ class BasePermission(ABC):
|
|
|
64
69
|
"""Return the user being evaluated for permission checks."""
|
|
65
70
|
return self._request_user
|
|
66
71
|
|
|
72
|
+
def describe_permissions(
|
|
73
|
+
self,
|
|
74
|
+
action: Literal["create", "read", "update", "delete"],
|
|
75
|
+
attribute: str,
|
|
76
|
+
) -> tuple[str, ...]:
|
|
77
|
+
"""Return permission expressions associated with an action/attribute pair."""
|
|
78
|
+
return ()
|
|
79
|
+
|
|
80
|
+
def _is_superuser(self) -> bool:
|
|
81
|
+
"""Return True when the current request user bypasses permission checks."""
|
|
82
|
+
return bool(getattr(self.request_user, "is_superuser", False))
|
|
83
|
+
|
|
67
84
|
@classmethod
|
|
68
85
|
def check_create_permission(
|
|
69
86
|
cls,
|
|
@@ -85,17 +102,45 @@ class BasePermission(ABC):
|
|
|
85
102
|
PermissionCheckError: If one or more attributes in `data` are denied for the resolved `request_user`.
|
|
86
103
|
"""
|
|
87
104
|
request_user = cls.get_user_with_id(request_user)
|
|
88
|
-
errors = []
|
|
89
105
|
permission_data = PermissionDataManager(permission_data=data, manager=manager)
|
|
90
106
|
Permission = cls(permission_data, request_user)
|
|
107
|
+
manager_name = manager.__name__ if manager is not None else None
|
|
108
|
+
if Permission._is_superuser():
|
|
109
|
+
if audit_logging_enabled():
|
|
110
|
+
for key in data.keys():
|
|
111
|
+
emit_permission_audit_event(
|
|
112
|
+
PermissionAuditEvent(
|
|
113
|
+
action="create",
|
|
114
|
+
attributes=(key,),
|
|
115
|
+
granted=True,
|
|
116
|
+
user=request_user,
|
|
117
|
+
manager=manager_name,
|
|
118
|
+
permissions=Permission.describe_permissions("create", key),
|
|
119
|
+
bypassed=True,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
errors: list[str] = []
|
|
91
125
|
user_identifier = getattr(request_user, "id", None)
|
|
92
126
|
for key in data.keys():
|
|
93
127
|
is_allowed = Permission.check_permission("create", key)
|
|
128
|
+
if audit_logging_enabled():
|
|
129
|
+
emit_permission_audit_event(
|
|
130
|
+
PermissionAuditEvent(
|
|
131
|
+
action="create",
|
|
132
|
+
attributes=(key,),
|
|
133
|
+
granted=is_allowed,
|
|
134
|
+
user=request_user,
|
|
135
|
+
manager=manager_name,
|
|
136
|
+
permissions=Permission.describe_permissions("create", key),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
94
139
|
if not is_allowed:
|
|
95
140
|
logger.info(
|
|
96
141
|
"permission denied",
|
|
97
142
|
context={
|
|
98
|
-
"manager":
|
|
143
|
+
"manager": manager_name,
|
|
99
144
|
"action": "create",
|
|
100
145
|
"attribute": key,
|
|
101
146
|
"user_id": user_identifier,
|
|
@@ -124,20 +169,47 @@ class BasePermission(ABC):
|
|
|
124
169
|
PermissionCheckError: Raised with a list of error messages when one or more fields are not permitted to be updated.
|
|
125
170
|
"""
|
|
126
171
|
request_user = cls.get_user_with_id(request_user)
|
|
127
|
-
|
|
128
|
-
errors = []
|
|
129
172
|
permission_data = PermissionDataManager.for_update(
|
|
130
173
|
base_data=old_manager_instance, update_data=data
|
|
131
174
|
)
|
|
132
175
|
Permission = cls(permission_data, request_user)
|
|
176
|
+
manager_name = old_manager_instance.__class__.__name__
|
|
177
|
+
if Permission._is_superuser():
|
|
178
|
+
if audit_logging_enabled():
|
|
179
|
+
for key in data.keys():
|
|
180
|
+
emit_permission_audit_event(
|
|
181
|
+
PermissionAuditEvent(
|
|
182
|
+
action="update",
|
|
183
|
+
attributes=(key,),
|
|
184
|
+
granted=True,
|
|
185
|
+
user=request_user,
|
|
186
|
+
manager=manager_name,
|
|
187
|
+
permissions=Permission.describe_permissions("update", key),
|
|
188
|
+
bypassed=True,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
errors: list[str] = []
|
|
133
194
|
user_identifier = getattr(request_user, "id", None)
|
|
134
195
|
for key in data.keys():
|
|
135
196
|
is_allowed = Permission.check_permission("update", key)
|
|
197
|
+
if audit_logging_enabled():
|
|
198
|
+
emit_permission_audit_event(
|
|
199
|
+
PermissionAuditEvent(
|
|
200
|
+
action="update",
|
|
201
|
+
attributes=(key,),
|
|
202
|
+
granted=is_allowed,
|
|
203
|
+
user=request_user,
|
|
204
|
+
manager=manager_name,
|
|
205
|
+
permissions=Permission.describe_permissions("update", key),
|
|
206
|
+
)
|
|
207
|
+
)
|
|
136
208
|
if not is_allowed:
|
|
137
209
|
logger.info(
|
|
138
210
|
"permission denied",
|
|
139
211
|
context={
|
|
140
|
-
"manager":
|
|
212
|
+
"manager": manager_name,
|
|
141
213
|
"action": "update",
|
|
142
214
|
"attribute": key,
|
|
143
215
|
"user_id": user_identifier,
|
|
@@ -166,18 +238,45 @@ class BasePermission(ABC):
|
|
|
166
238
|
PermissionCheckError: If one or more attributes are not permitted for deletion by request_user. The exception carries the user and the list of denial messages.
|
|
167
239
|
"""
|
|
168
240
|
request_user = cls.get_user_with_id(request_user)
|
|
169
|
-
|
|
170
|
-
errors = []
|
|
171
241
|
permission_data = PermissionDataManager(manager_instance)
|
|
172
242
|
Permission = cls(permission_data, request_user)
|
|
243
|
+
manager_name = manager_instance.__class__.__name__
|
|
244
|
+
if Permission._is_superuser():
|
|
245
|
+
if audit_logging_enabled():
|
|
246
|
+
for key in manager_instance.__dict__.keys():
|
|
247
|
+
emit_permission_audit_event(
|
|
248
|
+
PermissionAuditEvent(
|
|
249
|
+
action="delete",
|
|
250
|
+
attributes=(key,),
|
|
251
|
+
granted=True,
|
|
252
|
+
user=request_user,
|
|
253
|
+
manager=manager_name,
|
|
254
|
+
permissions=Permission.describe_permissions("delete", key),
|
|
255
|
+
bypassed=True,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
errors: list[str] = []
|
|
173
261
|
user_identifier = getattr(request_user, "id", None)
|
|
174
262
|
for key in manager_instance.__dict__.keys():
|
|
175
263
|
is_allowed = Permission.check_permission("delete", key)
|
|
264
|
+
if audit_logging_enabled():
|
|
265
|
+
emit_permission_audit_event(
|
|
266
|
+
PermissionAuditEvent(
|
|
267
|
+
action="delete",
|
|
268
|
+
attributes=(key,),
|
|
269
|
+
granted=is_allowed,
|
|
270
|
+
user=request_user,
|
|
271
|
+
manager=manager_name,
|
|
272
|
+
permissions=Permission.describe_permissions("delete", key),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
176
275
|
if not is_allowed:
|
|
177
276
|
logger.info(
|
|
178
277
|
"permission denied",
|
|
179
278
|
context={
|
|
180
|
-
"manager":
|
|
279
|
+
"manager": manager_name,
|
|
181
280
|
"action": "delete",
|
|
182
281
|
"attribute": key,
|
|
183
282
|
"user_id": user_identifier,
|
|
@@ -251,15 +350,14 @@ class BasePermission(ABC):
|
|
|
251
350
|
Raises:
|
|
252
351
|
PermissionNotFoundError: If no permission function matches the leading name in `permission`.
|
|
253
352
|
"""
|
|
353
|
+
if self._is_superuser():
|
|
354
|
+
return {"filter": {}, "exclude": {}}
|
|
254
355
|
permission_function, *config = permission.split(":")
|
|
255
356
|
if permission_function not in permission_functions:
|
|
256
357
|
raise PermissionNotFoundError(permission)
|
|
257
358
|
permission_filter = permission_functions[permission_function][
|
|
258
359
|
"permission_filter"
|
|
259
|
-
](
|
|
260
|
-
cast(AbstractUser | AnonymousUser, self.request_user),
|
|
261
|
-
config,
|
|
262
|
-
)
|
|
360
|
+
](self.request_user, config)
|
|
263
361
|
if permission_filter is None:
|
|
264
362
|
return {"filter": {}, "exclude": {}}
|
|
265
363
|
return permission_filter
|
|
@@ -277,8 +375,6 @@ class BasePermission(ABC):
|
|
|
277
375
|
Returns:
|
|
278
376
|
bool: True when every sub-permission evaluates to True for the current user.
|
|
279
377
|
"""
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
cast(AbstractUser | AnonymousUser, self.request_user),
|
|
284
|
-
)
|
|
378
|
+
if self._is_superuser():
|
|
379
|
+
return True
|
|
380
|
+
return validate_permission_string(permission, self.instance, self.request_user)
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
from typing import TYPE_CHECKING, Literal, Optional, Dict
|
|
5
|
-
from general_manager.permission.base_permission import BasePermission
|
|
5
|
+
from general_manager.permission.base_permission import BasePermission, UserLike
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from general_manager.permission.permission_data_manager import (
|
|
9
9
|
PermissionDataManager,
|
|
10
10
|
)
|
|
11
11
|
from general_manager.manager.general_manager import GeneralManager
|
|
12
|
-
from django.contrib.auth.models import AbstractUser
|
|
13
12
|
|
|
14
13
|
type permission_type = Literal[
|
|
15
14
|
"create",
|
|
@@ -76,14 +75,14 @@ class ManagerBasedPermission(BasePermission):
|
|
|
76
75
|
def __init__(
|
|
77
76
|
self,
|
|
78
77
|
instance: PermissionDataManager | GeneralManager,
|
|
79
|
-
request_user:
|
|
78
|
+
request_user: UserLike,
|
|
80
79
|
) -> None:
|
|
81
80
|
"""
|
|
82
81
|
Initialise the permission object and gather default and attribute-level rules.
|
|
83
82
|
|
|
84
83
|
Parameters:
|
|
85
84
|
instance (PermissionDataManager | GeneralManager): Target data used for permission evaluation.
|
|
86
|
-
request_user (
|
|
85
|
+
request_user (UserLike): User whose permissions are being checked.
|
|
87
86
|
"""
|
|
88
87
|
super().__init__(instance, request_user)
|
|
89
88
|
self.__set_permissions()
|
|
@@ -182,6 +181,9 @@ class ManagerBasedPermission(BasePermission):
|
|
|
182
181
|
Raises:
|
|
183
182
|
UnknownPermissionActionError: If `action` is not one of "create", "read", "update", or "delete".
|
|
184
183
|
"""
|
|
184
|
+
if self._is_superuser():
|
|
185
|
+
self.__overall_results[action] = True
|
|
186
|
+
return True
|
|
185
187
|
if (
|
|
186
188
|
self.__based_on_permission
|
|
187
189
|
and not self.__based_on_permission.check_permission(action, attribute)
|
|
@@ -241,6 +243,8 @@ class ManagerBasedPermission(BasePermission):
|
|
|
241
243
|
Returns:
|
|
242
244
|
list[dict[Literal["filter", "exclude"], dict[str, str]]]: A list of dictionaries each containing "filter" and "exclude" mappings where keys are queryset lookups and values are lookup values.
|
|
243
245
|
"""
|
|
246
|
+
if self._is_superuser():
|
|
247
|
+
return [{"filter": {}, "exclude": {}}]
|
|
244
248
|
__based_on__ = self.__based_on__
|
|
245
249
|
filters: list[dict[Literal["filter", "exclude"], dict[str, str]]] = []
|
|
246
250
|
|
|
@@ -266,3 +270,32 @@ class ManagerBasedPermission(BasePermission):
|
|
|
266
270
|
filters.append(self._get_permission_filter(permission))
|
|
267
271
|
|
|
268
272
|
return filters
|
|
273
|
+
|
|
274
|
+
def describe_permissions(
|
|
275
|
+
self,
|
|
276
|
+
action: permission_type,
|
|
277
|
+
attribute: str,
|
|
278
|
+
) -> tuple[str, ...]:
|
|
279
|
+
"""Return permission expressions considered for the given action/attribute."""
|
|
280
|
+
if action == "create":
|
|
281
|
+
base_permissions: tuple[str, ...] = tuple(self.__create__)
|
|
282
|
+
elif action == "read":
|
|
283
|
+
base_permissions = tuple(self.__read__)
|
|
284
|
+
elif action == "update":
|
|
285
|
+
base_permissions = tuple(self.__update__)
|
|
286
|
+
elif action == "delete":
|
|
287
|
+
base_permissions = tuple(self.__delete__)
|
|
288
|
+
else:
|
|
289
|
+
raise UnknownPermissionActionError(action)
|
|
290
|
+
|
|
291
|
+
attribute_source = self.__attribute_permissions.get(attribute)
|
|
292
|
+
if isinstance(attribute_source, dict):
|
|
293
|
+
attribute_permissions = tuple(attribute_source.get(action, []))
|
|
294
|
+
else:
|
|
295
|
+
attribute_permissions = tuple()
|
|
296
|
+
combined = base_permissions + attribute_permissions
|
|
297
|
+
if self.__based_on_permission is not None:
|
|
298
|
+
combined += self.__based_on_permission.describe_permissions(
|
|
299
|
+
action, attribute
|
|
300
|
+
)
|
|
301
|
+
return combined
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
"""Permission helper for GraphQL mutations."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
|
8
|
+
|
|
9
|
+
from general_manager.permission.audit import (
|
|
10
|
+
PermissionAuditEvent,
|
|
11
|
+
audit_logging_enabled,
|
|
12
|
+
emit_permission_audit_event,
|
|
13
|
+
)
|
|
6
14
|
from general_manager.permission.base_permission import (
|
|
7
15
|
BasePermission,
|
|
8
16
|
PermissionCheckError,
|
|
9
17
|
)
|
|
10
|
-
|
|
11
18
|
from general_manager.permission.permission_data_manager import PermissionDataManager
|
|
12
19
|
from general_manager.permission.utils import validate_permission_string
|
|
20
|
+
from general_manager.logging import get_logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = get_logger("permission.mutation")
|
|
13
24
|
|
|
14
25
|
|
|
15
26
|
class MutationPermission:
|
|
@@ -18,18 +29,19 @@ class MutationPermission:
|
|
|
18
29
|
__mutate__: list[str]
|
|
19
30
|
|
|
20
31
|
def __init__(
|
|
21
|
-
self, data: dict[str, Any], request_user:
|
|
32
|
+
self, data: dict[str, Any], request_user: AbstractBaseUser | AnonymousUser
|
|
22
33
|
) -> None:
|
|
23
34
|
"""
|
|
24
35
|
Create a mutation permission context for the given data and user.
|
|
25
36
|
|
|
26
37
|
Parameters:
|
|
27
38
|
data (dict[str, Any]): Input payload for the mutation.
|
|
28
|
-
request_user (
|
|
39
|
+
request_user (AbstractBaseUser | AnonymousUser): User attempting the mutation.
|
|
29
40
|
"""
|
|
30
41
|
self._data: PermissionDataManager = PermissionDataManager(data)
|
|
31
42
|
self._request_user = request_user
|
|
32
43
|
self.__attribute_permissions = self.__get_attribute_permissions()
|
|
44
|
+
self._mutate_permissions: list[str] = getattr(self.__class__, "__mutate__", [])
|
|
33
45
|
|
|
34
46
|
self.__overall_result: bool | None = None
|
|
35
47
|
|
|
@@ -39,7 +51,7 @@ class MutationPermission:
|
|
|
39
51
|
return self._data
|
|
40
52
|
|
|
41
53
|
@property
|
|
42
|
-
def request_user(self) ->
|
|
54
|
+
def request_user(self) -> AbstractBaseUser | AnonymousUser:
|
|
43
55
|
"""Return the user whose permissions are being evaluated."""
|
|
44
56
|
return self._request_user
|
|
45
57
|
|
|
@@ -53,31 +65,74 @@ class MutationPermission:
|
|
|
53
65
|
attribute_permissions[attribute] = getattr(self.__class__, attribute)
|
|
54
66
|
return attribute_permissions
|
|
55
67
|
|
|
68
|
+
def describe_permissions(self, attribute: str) -> tuple[str, ...]:
|
|
69
|
+
"""Return mutate-level and attribute-specific permissions for the field."""
|
|
70
|
+
base_permissions = tuple(self._mutate_permissions)
|
|
71
|
+
attribute_permissions = tuple(self.__attribute_permissions.get(attribute, []))
|
|
72
|
+
return base_permissions + attribute_permissions
|
|
73
|
+
|
|
56
74
|
@classmethod
|
|
57
75
|
def check(
|
|
58
76
|
cls,
|
|
59
77
|
data: dict[str, Any],
|
|
60
|
-
request_user:
|
|
78
|
+
request_user: AbstractBaseUser | AnonymousUser | Any,
|
|
61
79
|
) -> None:
|
|
62
80
|
"""
|
|
63
81
|
Validate that the given user is authorized to perform the mutation described by `data`.
|
|
64
82
|
|
|
65
83
|
Parameters:
|
|
66
84
|
data (dict[str, Any]): Mutation payload mapping field names to values.
|
|
67
|
-
request_user (
|
|
85
|
+
request_user (AbstractBaseUser | AnonymousUser | Any): A user object or a user identifier; if an identifier is provided it will be resolved to a user.
|
|
68
86
|
|
|
69
87
|
Raises:
|
|
70
88
|
PermissionCheckError: Raised with the `request_user` and a list of field-level error messages when one or more fields fail their permission checks.
|
|
71
89
|
"""
|
|
72
|
-
errors = []
|
|
73
|
-
if not isinstance(request_user, (
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
if not isinstance(request_user, (AbstractBaseUser, AnonymousUser)):
|
|
74
92
|
request_user = BasePermission.get_user_with_id(request_user)
|
|
75
93
|
Permission = cls(data, request_user)
|
|
94
|
+
class_name = cls.__name__
|
|
95
|
+
is_audit_enabled = audit_logging_enabled()
|
|
96
|
+
if getattr(request_user, "is_superuser", False):
|
|
97
|
+
if is_audit_enabled:
|
|
98
|
+
for key in data:
|
|
99
|
+
emit_permission_audit_event(
|
|
100
|
+
PermissionAuditEvent(
|
|
101
|
+
action="mutation",
|
|
102
|
+
attributes=(key,),
|
|
103
|
+
granted=True,
|
|
104
|
+
user=request_user,
|
|
105
|
+
manager=class_name,
|
|
106
|
+
permissions=Permission.describe_permissions(key),
|
|
107
|
+
bypassed=True,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return
|
|
76
111
|
for key in data:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
is_allowed = Permission.check_permission(key)
|
|
113
|
+
if is_audit_enabled:
|
|
114
|
+
emit_permission_audit_event(
|
|
115
|
+
PermissionAuditEvent(
|
|
116
|
+
action="mutation",
|
|
117
|
+
attributes=(key,),
|
|
118
|
+
granted=is_allowed,
|
|
119
|
+
user=request_user,
|
|
120
|
+
manager=class_name,
|
|
121
|
+
permissions=Permission.describe_permissions(key),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
if not is_allowed:
|
|
125
|
+
user_identifier = getattr(request_user, "id", None)
|
|
126
|
+
logger.info(
|
|
127
|
+
"permission denied",
|
|
128
|
+
context={
|
|
129
|
+
"mutation": class_name,
|
|
130
|
+
"action": "mutation",
|
|
131
|
+
"attribute": key,
|
|
132
|
+
"user_id": user_identifier,
|
|
133
|
+
},
|
|
80
134
|
)
|
|
135
|
+
errors.append(f"Mutation permission denied for attribute '{key}'")
|
|
81
136
|
if errors:
|
|
82
137
|
raise PermissionCheckError(request_user, errors)
|
|
83
138
|
|
|
@@ -109,7 +164,7 @@ class MutationPermission:
|
|
|
109
164
|
self.__attribute_permissions[attribute]
|
|
110
165
|
)
|
|
111
166
|
|
|
112
|
-
permission = self.__check_specific_permission(self.
|
|
167
|
+
permission = self.__check_specific_permission(self._mutate_permissions)
|
|
113
168
|
self.__overall_result = permission
|
|
114
169
|
return permission and attribute_permission
|
|
115
170
|
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
"""Registry of reusable permission checks and their queryset filters."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
from typing import Any, Callable, TYPE_CHECKING, TypedDict, Literal
|
|
4
6
|
|
|
5
7
|
if TYPE_CHECKING:
|
|
6
|
-
from django.contrib.auth.models import
|
|
8
|
+
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
|
7
9
|
from general_manager.permission.permission_data_manager import (
|
|
8
10
|
PermissionDataManager,
|
|
9
11
|
)
|
|
10
12
|
from general_manager.manager.general_manager import GeneralManager
|
|
11
13
|
from general_manager.manager.meta import GeneralManagerMeta
|
|
12
14
|
|
|
13
|
-
|
|
14
15
|
type permission_filter = Callable[
|
|
15
|
-
[
|
|
16
|
-
dict[Literal["filter", "exclude"], dict[str,
|
|
16
|
+
[AbstractBaseUser | AnonymousUser, list[str]],
|
|
17
|
+
dict[Literal["filter", "exclude"], dict[str, Any]] | None,
|
|
17
18
|
]
|
|
18
19
|
|
|
19
20
|
type permission_method = Callable[
|
|
20
21
|
[
|
|
21
22
|
PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
22
|
-
|
|
23
|
+
AbstractBaseUser | AnonymousUser,
|
|
23
24
|
list[str],
|
|
24
25
|
],
|
|
25
26
|
bool,
|
|
@@ -33,36 +34,205 @@ class PermissionDict(TypedDict):
|
|
|
33
34
|
permission_filter: permission_filter
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
permission_functions: dict[str, PermissionDict] = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
37
|
+
permission_functions: dict[str, PermissionDict] = {}
|
|
38
|
+
|
|
39
|
+
__all__ = ["permission_functions", "register_permission"]
|
|
40
|
+
|
|
41
|
+
_PERMISSION_ALREADY_REGISTERED_MESSAGE = "Permission function is already registered."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_permission_filter(
|
|
45
|
+
_user: AbstractBaseUser | AnonymousUser, _config: list[str]
|
|
46
|
+
) -> dict[Literal["filter", "exclude"], dict[str, Any]] | None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register_permission(
|
|
51
|
+
name: str, *, permission_filter: permission_filter | None = None
|
|
52
|
+
) -> Callable[[permission_method], permission_method]:
|
|
53
|
+
"""
|
|
54
|
+
Register a permission function in the global registry.
|
|
55
|
+
|
|
56
|
+
Parameters:
|
|
57
|
+
name (str): Identifier used in permission expressions.
|
|
58
|
+
permission_filter (permission_filter | None): Optional callable that
|
|
59
|
+
provides queryset filters corresponding to the permission.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Callable[[permission_method], permission_method]: Decorator that
|
|
63
|
+
registers the decorated function and returns it unchanged.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If another permission with the same name has already been
|
|
67
|
+
registered.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def decorator(func: permission_method) -> permission_method:
|
|
71
|
+
if name in permission_functions:
|
|
72
|
+
raise ValueError(_PERMISSION_ALREADY_REGISTERED_MESSAGE)
|
|
73
|
+
filter_callable = permission_filter or _default_permission_filter
|
|
74
|
+
permission_functions[name] = {
|
|
75
|
+
"permission_method": func,
|
|
76
|
+
"permission_filter": filter_callable,
|
|
77
|
+
}
|
|
78
|
+
return func
|
|
79
|
+
|
|
80
|
+
return decorator
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@register_permission("public")
|
|
84
|
+
def _permission_public(
|
|
85
|
+
_instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
86
|
+
_user: AbstractBaseUser | AnonymousUser,
|
|
87
|
+
_config: list[str],
|
|
88
|
+
) -> bool:
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _matches_permission_filter(
|
|
93
|
+
_user: AbstractBaseUser | AnonymousUser, config: list[str]
|
|
94
|
+
) -> dict[Literal["filter", "exclude"], dict[str, Any]] | None:
|
|
95
|
+
if len(config) < 2:
|
|
96
|
+
return None
|
|
97
|
+
return {"filter": {config[0]: config[1]}}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@register_permission("matches", permission_filter=_matches_permission_filter)
|
|
101
|
+
def _permission_matches(
|
|
102
|
+
instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
103
|
+
_user: AbstractBaseUser | AnonymousUser,
|
|
104
|
+
config: list[str],
|
|
105
|
+
) -> bool:
|
|
106
|
+
return bool(
|
|
107
|
+
len(config) >= 2 and str(getattr(instance, config[0], None)) == config[1]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@register_permission("isAdmin")
|
|
112
|
+
def _permission_is_admin(
|
|
113
|
+
_instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
114
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
115
|
+
_config: list[str],
|
|
116
|
+
) -> bool:
|
|
117
|
+
return bool(getattr(user, "is_staff", False))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_self_permission_filter(
|
|
121
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
122
|
+
_config: list[str],
|
|
123
|
+
) -> dict[Literal["filter", "exclude"], dict[str, Any]] | None:
|
|
124
|
+
return {"filter": {"creator_id": getattr(user, "id", None)}}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@register_permission("isSelf", permission_filter=_is_self_permission_filter)
|
|
128
|
+
def _permission_is_self(
|
|
129
|
+
instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
130
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
131
|
+
_config: list[str],
|
|
132
|
+
) -> bool:
|
|
133
|
+
return bool(instance.creator == user) # type: ignore[union-attr]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@register_permission("isAuthenticated")
|
|
137
|
+
def _permission_is_authenticated(
|
|
138
|
+
_instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
139
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
140
|
+
_config: list[str],
|
|
141
|
+
) -> bool:
|
|
142
|
+
return bool(getattr(user, "is_authenticated", False))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@register_permission("isActive")
|
|
146
|
+
def _permission_is_active(
|
|
147
|
+
_instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
148
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
149
|
+
_config: list[str],
|
|
150
|
+
) -> bool:
|
|
151
|
+
return bool(getattr(user, "is_active", False))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@register_permission("hasPermission")
|
|
155
|
+
def _permission_has_permission(
|
|
156
|
+
_instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
157
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
158
|
+
config: list[str],
|
|
159
|
+
) -> bool:
|
|
160
|
+
if not config:
|
|
161
|
+
return False
|
|
162
|
+
has_perm = getattr(user, "has_perm", None)
|
|
163
|
+
if not callable(has_perm):
|
|
164
|
+
return False
|
|
165
|
+
return bool(has_perm(config[0]))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@register_permission("inGroup")
|
|
169
|
+
def _permission_in_group(
|
|
170
|
+
_instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
171
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
172
|
+
config: list[str],
|
|
173
|
+
) -> bool:
|
|
174
|
+
if not config:
|
|
175
|
+
return False
|
|
176
|
+
group_manager = getattr(user, "groups", None)
|
|
177
|
+
if group_manager is None or not hasattr(group_manager, "filter"):
|
|
178
|
+
return False
|
|
179
|
+
filtered = group_manager.filter(name=config[0]) # type: ignore[attr-defined]
|
|
180
|
+
return bool(hasattr(filtered, "exists") and filtered.exists()) # type: ignore[call-arg]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _related_user_field_permission_filter(
|
|
184
|
+
user: AbstractBaseUser | AnonymousUser, config: list[str]
|
|
185
|
+
) -> dict[Literal["filter", "exclude"], dict[str, Any]] | None:
|
|
186
|
+
if not config:
|
|
187
|
+
return None
|
|
188
|
+
user_id = getattr(user, "id", None)
|
|
189
|
+
if user_id is None:
|
|
190
|
+
return None
|
|
191
|
+
return {"filter": {f"{config[0]}_id": user_id}}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@register_permission(
|
|
195
|
+
"relatedUserField",
|
|
196
|
+
permission_filter=_related_user_field_permission_filter,
|
|
197
|
+
)
|
|
198
|
+
def _permission_related_user_field(
|
|
199
|
+
instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
200
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
201
|
+
config: list[str],
|
|
202
|
+
) -> bool:
|
|
203
|
+
if not config:
|
|
204
|
+
return False
|
|
205
|
+
related_object = getattr(instance, config[0], None)
|
|
206
|
+
return bool(related_object == user) # type: ignore[arg-type]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _many_to_many_contains_user_permission_filter(
|
|
210
|
+
user: AbstractBaseUser | AnonymousUser, config: list[str]
|
|
211
|
+
) -> dict[Literal["filter", "exclude"], dict[str, Any]] | None:
|
|
212
|
+
if not config:
|
|
213
|
+
return None
|
|
214
|
+
user_id = getattr(user, "id", None)
|
|
215
|
+
if user_id is None:
|
|
216
|
+
return None
|
|
217
|
+
return {"filter": {f"{config[0]}__id": user_id}}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@register_permission(
|
|
221
|
+
"manyToManyContainsUser",
|
|
222
|
+
permission_filter=_many_to_many_contains_user_permission_filter,
|
|
223
|
+
)
|
|
224
|
+
def _permission_many_to_many_contains_user(
|
|
225
|
+
instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
226
|
+
user: AbstractBaseUser | AnonymousUser,
|
|
227
|
+
config: list[str],
|
|
228
|
+
) -> bool:
|
|
229
|
+
if not config:
|
|
230
|
+
return False
|
|
231
|
+
related_manager = getattr(instance, config[0], None)
|
|
232
|
+
if related_manager is None or not hasattr(related_manager, "filter"):
|
|
233
|
+
return False
|
|
234
|
+
user_pk = getattr(user, "pk", None)
|
|
235
|
+
if user_pk is None:
|
|
236
|
+
return False
|
|
237
|
+
filtered = related_manager.filter(pk=user_pk) # type: ignore[attr-defined]
|
|
238
|
+
return bool(hasattr(filtered, "exists") and filtered.exists()) # type: ignore[call-arg]
|
|
@@ -4,7 +4,7 @@ from general_manager.permission.permission_checks import (
|
|
|
4
4
|
permission_functions,
|
|
5
5
|
)
|
|
6
6
|
from general_manager.permission.permission_data_manager import PermissionDataManager
|
|
7
|
-
from django.contrib.auth.models import
|
|
7
|
+
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
|
8
8
|
|
|
9
9
|
from general_manager.manager.general_manager import GeneralManager
|
|
10
10
|
from general_manager.manager.meta import GeneralManagerMeta
|
|
@@ -26,7 +26,7 @@ class PermissionNotFoundError(ValueError):
|
|
|
26
26
|
def validate_permission_string(
|
|
27
27
|
permission: str,
|
|
28
28
|
data: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
29
|
-
request_user:
|
|
29
|
+
request_user: AbstractBaseUser | AnonymousUser,
|
|
30
30
|
) -> bool:
|
|
31
31
|
"""
|
|
32
32
|
Evaluate a compound permission expression joined by '&' operators.
|
|
@@ -34,7 +34,7 @@ def validate_permission_string(
|
|
|
34
34
|
Parameters:
|
|
35
35
|
permission (str): Permission expression where sub-permissions are joined with '&'. Individual sub-permissions may include ':'-separated configuration parts (for example, "isAuthenticated&admin:level").
|
|
36
36
|
data (PermissionDataManager | GeneralManager | GeneralManagerMeta): Object passed to each permission function.
|
|
37
|
-
request_user (
|
|
37
|
+
request_user (AbstractBaseUser | AnonymousUser): User for whom permissions are evaluated.
|
|
38
38
|
|
|
39
39
|
Returns:
|
|
40
40
|
`true` if every sub-permission evaluates to True, `false` otherwise.
|
|
@@ -35,6 +35,14 @@ GENERAL_MANAGER_EXPORTS: LazyExportMap = {
|
|
|
35
35
|
"general_manager.permission.manager_based_permission",
|
|
36
36
|
"ManagerBasedPermission",
|
|
37
37
|
),
|
|
38
|
+
"register_permission": (
|
|
39
|
+
"general_manager.permission.permission_checks",
|
|
40
|
+
"register_permission",
|
|
41
|
+
),
|
|
42
|
+
"permission_functions": (
|
|
43
|
+
"general_manager.permission.permission_checks",
|
|
44
|
+
"permission_functions",
|
|
45
|
+
),
|
|
38
46
|
"Rule": ("general_manager.rule.rule", "Rule"),
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -124,6 +132,35 @@ PERMISSION_EXPORTS: LazyExportMap = {
|
|
|
124
132
|
"general_manager.permission.mutation_permission",
|
|
125
133
|
"MutationPermission",
|
|
126
134
|
),
|
|
135
|
+
"register_permission": (
|
|
136
|
+
"general_manager.permission.permission_checks",
|
|
137
|
+
"register_permission",
|
|
138
|
+
),
|
|
139
|
+
"permission_functions": (
|
|
140
|
+
"general_manager.permission.permission_checks",
|
|
141
|
+
"permission_functions",
|
|
142
|
+
),
|
|
143
|
+
"configure_audit_logger": (
|
|
144
|
+
"general_manager.permission.audit",
|
|
145
|
+
"configure_audit_logger",
|
|
146
|
+
),
|
|
147
|
+
"configure_audit_logger_from_settings": (
|
|
148
|
+
"general_manager.permission.audit",
|
|
149
|
+
"configure_audit_logger_from_settings",
|
|
150
|
+
),
|
|
151
|
+
"FileAuditLogger": (
|
|
152
|
+
"general_manager.permission.audit",
|
|
153
|
+
"FileAuditLogger",
|
|
154
|
+
),
|
|
155
|
+
"DatabaseAuditLogger": (
|
|
156
|
+
"general_manager.permission.audit",
|
|
157
|
+
"DatabaseAuditLogger",
|
|
158
|
+
),
|
|
159
|
+
"PermissionAuditEvent": (
|
|
160
|
+
"general_manager.permission.audit",
|
|
161
|
+
"PermissionAuditEvent",
|
|
162
|
+
),
|
|
163
|
+
"AuditLogger": ("general_manager.permission.audit", "AuditLogger"),
|
|
127
164
|
}
|
|
128
165
|
|
|
129
166
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: GeneralManager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.0
|
|
4
4
|
Summary: Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching.
|
|
5
5
|
Author-email: Tim Kleindick <tkleindick@yahoo.de>
|
|
6
6
|
License: MIT License
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
general_manager/__init__.py,sha256=Xy4_fFjChIYtlBPDv7K3JEHOulq9_IFXkCc4CElS32Q,1035
|
|
2
|
-
general_manager/apps.py,sha256=
|
|
2
|
+
general_manager/apps.py,sha256=1wzMof8IKn_Zw4QyD3WwD7zVedAvsZKhCwsa0ajx6U8,27480
|
|
3
3
|
general_manager/logging.py,sha256=bL2dEdNnxo74qRmhOCG06lbPtVCOozfF3GS133T9HH8,4250
|
|
4
|
-
general_manager/public_api_registry.py,sha256=
|
|
4
|
+
general_manager/public_api_registry.py,sha256=zAYG6UPfbv5NDSM59kvLxUZBJce5NF8lzEB4op8LnPM,8974
|
|
5
5
|
general_manager/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
general_manager/_types/__init__.py,sha256=RmS0Ok-1-CwWOZL_socLWksEk9MfsshL9eQx879I4mU,60
|
|
7
7
|
general_manager/_types/api.py,sha256=i5fi7O3C3frI74oqPT-tAoztDplgNDOShyvPNxhw7CY,496
|
|
8
8
|
general_manager/_types/bucket.py,sha256=DZ3GC-1iR4RiNBKydsMhp_R4GTaYMfsonwe7MCADVP4,435
|
|
9
9
|
general_manager/_types/cache.py,sha256=jMbIEXDq1pUvVxTN8hvD8rSH7QRjE3ewrMx2gEhYP3w,660
|
|
10
10
|
general_manager/_types/factory.py,sha256=cPG_td05KCWv2j6_h_vYCKETPol3k_nXkEMWCcUdNHM,1726
|
|
11
|
-
general_manager/_types/general_manager.py,sha256=
|
|
11
|
+
general_manager/_types/general_manager.py,sha256=m-VA1968DYEFiunWyA72fUI1EicMLKarYJ5bkSyKJ7I,1241
|
|
12
12
|
general_manager/_types/interface.py,sha256=JlPn3G3ompn1PRfFQfsrIJv2Pf0I3n1RL2ki5_fDX54,605
|
|
13
13
|
general_manager/_types/manager.py,sha256=rXM6NrQaPdfE9CQoRV_jhbI3SK23rvdpvyBpybVgWi8,506
|
|
14
14
|
general_manager/_types/measurement.py,sha256=Unkpf3H7TGaUKfkjmFDR7QWBU4lPp5I2A4GlK8pps-Y,443
|
|
15
|
-
general_manager/_types/permission.py,sha256=
|
|
15
|
+
general_manager/_types/permission.py,sha256=olbtZNPkYoI1mbtzs6FDuVlKyGiP6VfGwufe8G56CQQ,1196
|
|
16
16
|
general_manager/_types/rule.py,sha256=aNbx3jlVinAYQGJlmFwW8rOwTjf19F6khq5xbGTdD3U,238
|
|
17
17
|
general_manager/_types/utils.py,sha256=8VyD6EKkcRIzdVs6FANP5Ri71eT4BT4c3dTUeyjDr40,1043
|
|
18
18
|
general_manager/api/__init__.py,sha256=ChV1UPCUjW1Lci0BajmXVtE-3C-WLQTvjO058BMu1Bs,1020
|
|
@@ -51,13 +51,13 @@ general_manager/measurement/__init__.py,sha256=38-6kTERbw7EeOQAw3eZ9DDEu6hwwqkGJ
|
|
|
51
51
|
general_manager/measurement/measurement.py,sha256=ArEnntLu3ffHID71uFbXwiB5e5tpsBT2_TbBPJfCWJI,26070
|
|
52
52
|
general_manager/measurement/measurement_field.py,sha256=zQbyLZT7E9Lx4f3u_wQ-rEoLPqH0FWhfWyOd1BaW--M,13950
|
|
53
53
|
general_manager/permission/__init__.py,sha256=s4Ka11uYBrymM5O-n5kIxOjwUprZ394jTuzGf5prO_8,981
|
|
54
|
-
general_manager/permission/
|
|
55
|
-
general_manager/permission/
|
|
56
|
-
general_manager/permission/manager_based_permission.py,sha256=
|
|
57
|
-
general_manager/permission/mutation_permission.py,sha256=
|
|
58
|
-
general_manager/permission/permission_checks.py,sha256=
|
|
59
|
-
general_manager/permission/permission_data_manager.py,sha256=
|
|
60
|
-
general_manager/permission/utils.py,sha256=
|
|
54
|
+
general_manager/permission/audit.py,sha256=mOT4Dj5LnAMbHWXKf6JCM01L-jd7GsMSmAIBTR1hTS0,12802
|
|
55
|
+
general_manager/permission/base_permission.py,sha256=69mgDS7iuxEA1V8lDanDBAH20BY3yc4vp8E0sjrqzmE,16045
|
|
56
|
+
general_manager/permission/manager_based_permission.py,sha256=SfqEvC3RbXjWxxm6JOgEJI9li_6uiB4-avihhgfjynw,11902
|
|
57
|
+
general_manager/permission/mutation_permission.py,sha256=QG-cH1T8anIS4DYRdUHwHOilpop9UzkwMUTiak-sG8s,6871
|
|
58
|
+
general_manager/permission/permission_checks.py,sha256=S0agZzG-tdZWLKWAF_1jdrvsLVbhVk1k1MrBdqNYc8o,7710
|
|
59
|
+
general_manager/permission/permission_data_manager.py,sha256=aqmj5rpslOgCT0GdXnsBtjpFoRerFOa3xG84Jus17b8,3853
|
|
60
|
+
general_manager/permission/utils.py,sha256=Q3E9aq3jUOhBXOdLwWSQsXgNMuV6Bp7R84xM_JLB6Y0,2917
|
|
61
61
|
general_manager/rule/__init__.py,sha256=dFQLJNr0qpkpuzuBbMY-74jz_Y033YyB9X_OfeJYhSs,1026
|
|
62
62
|
general_manager/rule/handler.py,sha256=qSBXmXvl7fRgjxUo4wyUOzdZm14a8Umd2Jec4zPXJQc,16309
|
|
63
63
|
general_manager/rule/rule.py,sha256=41VXFTSWkBEOj8045vZTezm38VEGimjrk22J4YRl144,22691
|
|
@@ -71,8 +71,8 @@ general_manager/utils/none_to_zero.py,sha256=Z2KVXBKCz02V2ZBs-7tIs_32n7YDxiXyood
|
|
|
71
71
|
general_manager/utils/path_mapping.py,sha256=f3sd3OFh9aJgyF-ayqr22mHnCo-uoIXqio5DRQmziGM,9931
|
|
72
72
|
general_manager/utils/public_api.py,sha256=bas4SKchJUg2dI2SLRQwkF_CoXJKKwkzQx_JOjMJ2VY,3272
|
|
73
73
|
general_manager/utils/testing.py,sha256=_V4Y5SXvu-ocoq-AntPFPd140igclUJUT2xVXxhRrSs,14954
|
|
74
|
-
generalmanager-0.
|
|
75
|
-
generalmanager-0.
|
|
76
|
-
generalmanager-0.
|
|
77
|
-
generalmanager-0.
|
|
78
|
-
generalmanager-0.
|
|
74
|
+
generalmanager-0.21.0.dist-info/licenses/LICENSE,sha256=OcRwUgM4iiESN1oNELgyuwuah39XX-EPZiPDDHndNVI,1070
|
|
75
|
+
generalmanager-0.21.0.dist-info/METADATA,sha256=ubnYLTNd5AzlLYju1ykPewMtGIMF-e7lVKl4Hqo2Os8,8266
|
|
76
|
+
generalmanager-0.21.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
77
|
+
generalmanager-0.21.0.dist-info/top_level.txt,sha256=sTDtExP9ga-YP3h3h42yivUY-A2Q23C2nw6LNKOho4I,16
|
|
78
|
+
generalmanager-0.21.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|