GeneralManager 0.20.1__tar.gz → 0.21.0__tar.gz

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.

Files changed (89) hide show
  1. {generalmanager-0.20.1/src/GeneralManager.egg-info → generalmanager-0.21.0}/PKG-INFO +1 -1
  2. {generalmanager-0.20.1 → generalmanager-0.21.0}/pyproject.toml +1 -1
  3. {generalmanager-0.20.1 → generalmanager-0.21.0/src/GeneralManager.egg-info}/PKG-INFO +1 -1
  4. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/GeneralManager.egg-info/SOURCES.txt +1 -1
  5. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/general_manager.py +4 -0
  6. generalmanager-0.21.0/src/general_manager/_types/permission.py +29 -0
  7. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/apps.py +2 -0
  8. generalmanager-0.21.0/src/general_manager/permission/audit.py +380 -0
  9. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/permission/base_permission.py +115 -19
  10. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/permission/manager_based_permission.py +37 -4
  11. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/permission/mutation_permission.py +68 -13
  12. generalmanager-0.21.0/src/general_manager/permission/permission_checks.py +238 -0
  13. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/permission/permission_data_manager.py +0 -1
  14. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/permission/utils.py +3 -3
  15. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/public_api_registry.py +37 -0
  16. generalmanager-0.20.1/src/general_manager/_types/permission.py +0 -13
  17. generalmanager-0.20.1/src/general_manager/permission/file_based_permission.py +0 -0
  18. generalmanager-0.20.1/src/general_manager/permission/permission_checks.py +0 -68
  19. {generalmanager-0.20.1 → generalmanager-0.21.0}/LICENSE +0 -0
  20. {generalmanager-0.20.1 → generalmanager-0.21.0}/README.md +0 -0
  21. {generalmanager-0.20.1 → generalmanager-0.21.0}/setup.cfg +0 -0
  22. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/GeneralManager.egg-info/dependency_links.txt +0 -0
  23. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/GeneralManager.egg-info/requires.txt +0 -0
  24. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/GeneralManager.egg-info/top_level.txt +0 -0
  25. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/__init__.py +0 -0
  26. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/__init__.py +0 -0
  27. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/api.py +0 -0
  28. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/bucket.py +0 -0
  29. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/cache.py +0 -0
  30. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/factory.py +0 -0
  31. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/interface.py +0 -0
  32. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/manager.py +0 -0
  33. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/measurement.py +0 -0
  34. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/rule.py +0 -0
  35. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/_types/utils.py +0 -0
  36. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/api/__init__.py +0 -0
  37. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/api/graphql.py +0 -0
  38. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/api/graphql_subscription_consumer.py +0 -0
  39. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/api/mutation.py +0 -0
  40. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/api/property.py +0 -0
  41. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/bucket/__init__.py +0 -0
  42. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/bucket/base_bucket.py +0 -0
  43. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/bucket/calculation_bucket.py +0 -0
  44. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/bucket/database_bucket.py +0 -0
  45. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/bucket/group_bucket.py +0 -0
  46. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/cache/__init__.py +0 -0
  47. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/cache/cache_decorator.py +0 -0
  48. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/cache/cache_tracker.py +0 -0
  49. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/cache/dependency_index.py +0 -0
  50. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/cache/model_dependency_collector.py +0 -0
  51. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/cache/signals.py +0 -0
  52. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/factory/__init__.py +0 -0
  53. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/factory/auto_factory.py +0 -0
  54. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/factory/factories.py +0 -0
  55. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/factory/factory_methods.py +0 -0
  56. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/__init__.py +0 -0
  57. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/base_interface.py +0 -0
  58. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/calculation_interface.py +0 -0
  59. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/database_based_interface.py +0 -0
  60. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/database_interface.py +0 -0
  61. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/models.py +0 -0
  62. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/interface/read_only_interface.py +0 -0
  63. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/logging.py +0 -0
  64. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/manager/__init__.py +0 -0
  65. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/manager/general_manager.py +0 -0
  66. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/manager/group_manager.py +0 -0
  67. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/manager/input.py +0 -0
  68. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/manager/meta.py +0 -0
  69. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/measurement/__init__.py +0 -0
  70. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/measurement/measurement.py +0 -0
  71. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/measurement/measurement_field.py +0 -0
  72. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/permission/__init__.py +0 -0
  73. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/py.typed +0 -0
  74. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/rule/__init__.py +0 -0
  75. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/rule/handler.py +0 -0
  76. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/rule/rule.py +0 -0
  77. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/__init__.py +0 -0
  78. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/args_to_kwargs.py +0 -0
  79. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/filter_parser.py +0 -0
  80. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/format_string.py +0 -0
  81. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/json_encoder.py +0 -0
  82. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/make_cache_key.py +0 -0
  83. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/none_to_zero.py +0 -0
  84. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/path_mapping.py +0 -0
  85. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/public_api.py +0 -0
  86. {generalmanager-0.20.1 → generalmanager-0.21.0}/src/general_manager/utils/testing.py +0 -0
  87. {generalmanager-0.20.1 → generalmanager-0.21.0}/tests/test_settings.py +0 -0
  88. {generalmanager-0.20.1 → generalmanager-0.21.0}/tests/test_urls.py +0 -0
  89. {generalmanager-0.20.1 → generalmanager-0.21.0}/tests/testing_asgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeneralManager
3
- Version: 0.20.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "GeneralManager"
7
- version = "0.20.1"
7
+ version = "0.21.0"
8
8
  description = "Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Tim Kleindick", email = "tkleindick@yahoo.de" }]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeneralManager
3
- Version: 0.20.1
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
@@ -59,8 +59,8 @@ src/general_manager/measurement/__init__.py
59
59
  src/general_manager/measurement/measurement.py
60
60
  src/general_manager/measurement/measurement_field.py
61
61
  src/general_manager/permission/__init__.py
62
+ src/general_manager/permission/audit.py
62
63
  src/general_manager/permission/base_permission.py
63
- src/general_manager/permission/file_based_permission.py
64
64
  src/general_manager/permission/manager_based_permission.py
65
65
  src/general_manager/permission/mutation_permission.py
66
66
  src/general_manager/permission/permission_checks.py
@@ -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
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ """Type-only imports for public API re-exports."""
4
+
5
+ __all__ = [
6
+ "AuditLogger",
7
+ "BasePermission",
8
+ "DatabaseAuditLogger",
9
+ "FileAuditLogger",
10
+ "ManagerBasedPermission",
11
+ "MutationPermission",
12
+ "PermissionAuditEvent",
13
+ "configure_audit_logger",
14
+ "configure_audit_logger_from_settings",
15
+ "permission_functions",
16
+ "register_permission",
17
+ ]
18
+
19
+ from general_manager.permission.audit import AuditLogger
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
23
+ from general_manager.permission.manager_based_permission import ManagerBasedPermission
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
@@ -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
+ ]