GeneralManager 0.20.0__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/interface/read_only_interface.py +31 -14
- 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.0.dist-info → generalmanager-0.21.0.dist-info}/METADATA +1 -1
- {generalmanager-0.20.0.dist-info → generalmanager-0.21.0.dist-info}/RECORD +17 -17
- general_manager/permission/file_based_permission.py +0 -0
- {generalmanager-0.20.0.dist-info → generalmanager-0.21.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.20.0.dist-info → generalmanager-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {generalmanager-0.20.0.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
|
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Type, cast
|
|
7
7
|
|
|
8
8
|
from django.core.checks import Warning
|
|
9
|
-
from django.db import connection, models, transaction
|
|
9
|
+
from django.db import connection, models, transaction, IntegrityError
|
|
10
10
|
|
|
11
11
|
from general_manager.interface.database_based_interface import (
|
|
12
12
|
DBBasedInterface,
|
|
@@ -159,6 +159,7 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
159
159
|
data_list = cast(list[dict[str, Any]], parsed_data)
|
|
160
160
|
|
|
161
161
|
unique_fields = cls.get_unique_fields(model)
|
|
162
|
+
unique_field_order = tuple(sorted(unique_fields))
|
|
162
163
|
if not unique_fields:
|
|
163
164
|
raise MissingUniqueFieldError(parent_class.__name__)
|
|
164
165
|
|
|
@@ -168,29 +169,43 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
168
169
|
"deactivated": [],
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
editable_fields = {
|
|
173
|
+
f.name
|
|
174
|
+
for f in model._meta.local_fields
|
|
175
|
+
if getattr(f, "editable", True) and not getattr(f, "primary_key", False)
|
|
176
|
+
} - {"is_active"}
|
|
177
|
+
|
|
171
178
|
with transaction.atomic():
|
|
172
|
-
json_unique_values: set[Any] = set()
|
|
179
|
+
json_unique_values: set[tuple[Any, ...]] = set()
|
|
173
180
|
|
|
174
181
|
# data synchronization
|
|
175
182
|
for idx, data in enumerate(data_list):
|
|
176
183
|
try:
|
|
177
|
-
lookup = {field: data[field] for field in
|
|
184
|
+
lookup = {field: data[field] for field in unique_field_order}
|
|
178
185
|
except KeyError as e:
|
|
179
186
|
missing = e.args[0]
|
|
180
187
|
raise InvalidReadOnlyDataFormatError() from KeyError(
|
|
181
188
|
f"Item {idx} missing unique field '{missing}'."
|
|
182
189
|
)
|
|
183
|
-
unique_identifier = tuple(lookup[field] for field in
|
|
190
|
+
unique_identifier = tuple(lookup[field] for field in unique_field_order)
|
|
184
191
|
json_unique_values.add(unique_identifier)
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
instance = model.objects.filter(**lookup).first()
|
|
193
|
+
is_created = False
|
|
194
|
+
if instance is None:
|
|
195
|
+
# sanitize input and create with race-safety
|
|
196
|
+
allowed_fields = {f.name for f in model._meta.local_fields}
|
|
197
|
+
create_kwargs = {
|
|
198
|
+
k: v for k, v in data.items() if k in allowed_fields
|
|
199
|
+
}
|
|
200
|
+
try:
|
|
201
|
+
instance = model.objects.create(**create_kwargs)
|
|
202
|
+
is_created = True
|
|
203
|
+
except IntegrityError:
|
|
204
|
+
# created concurrently — fetch it
|
|
205
|
+
instance = model.objects.filter(**lookup).first()
|
|
206
|
+
if instance is None:
|
|
207
|
+
raise
|
|
187
208
|
updated = False
|
|
188
|
-
editable_fields = {
|
|
189
|
-
f.name
|
|
190
|
-
for f in model._meta.local_fields
|
|
191
|
-
if getattr(f, "editable", True)
|
|
192
|
-
and not getattr(f, "primary_key", False)
|
|
193
|
-
} - {"is_active"}
|
|
194
209
|
for field_name in editable_fields.intersection(data.keys()):
|
|
195
210
|
value = data[field_name]
|
|
196
211
|
if getattr(instance, field_name, None) != value:
|
|
@@ -204,8 +219,10 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
204
219
|
# deactivate instances not in JSON data
|
|
205
220
|
existing_instances = model.objects.filter(is_active=True)
|
|
206
221
|
for instance in existing_instances:
|
|
207
|
-
lookup = {
|
|
208
|
-
|
|
222
|
+
lookup = {
|
|
223
|
+
field: getattr(instance, field) for field in unique_field_order
|
|
224
|
+
}
|
|
225
|
+
unique_identifier = tuple(lookup[field] for field in unique_field_order)
|
|
209
226
|
if unique_identifier not in json_unique_values:
|
|
210
227
|
instance.is_active = False
|
|
211
228
|
instance.save()
|
|
@@ -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
|
+
]
|