ocmo-api 0.8.19__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.
Files changed (203) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. core/__init__.py +0 -0
  4. core/api/__init__.py +201 -0
  5. core/api/_common.py +37 -0
  6. core/api/audit.py +244 -0
  7. core/api/auth.py +50 -0
  8. core/api/config.py +96 -0
  9. core/api/errors.py +65 -0
  10. core/api/global_permissions.py +107 -0
  11. core/api/lock.py +99 -0
  12. core/api/namespace.py +86 -0
  13. core/api/propagation.py +29 -0
  14. core/api/resolve.py +229 -0
  15. core/api/resolver.py +96 -0
  16. core/api/secret.py +53 -0
  17. core/api/system.py +42 -0
  18. core/api/template.py +53 -0
  19. core/api/tree.py +274 -0
  20. core/apps.py +18 -0
  21. core/audit_operations.py +0 -0
  22. core/constants/__init__.py +5 -0
  23. core/constants/audit_operations.py +35 -0
  24. core/constants/permission_actions.py +59 -0
  25. core/constants/permissions.py +41 -0
  26. core/constants/resolve.py +3 -0
  27. core/constants/transformers.py +22 -0
  28. core/constants/webhook_events.py +31 -0
  29. core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
  30. core/data/builtin_schemas/_permissions.schema.yaml +211 -0
  31. core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
  32. core/decorators/__init__.py +14 -0
  33. core/decorators/audit.py +105 -0
  34. core/decorators/binding.py +49 -0
  35. core/decorators/permissions.py +129 -0
  36. core/decorators/webhook.py +87 -0
  37. core/exceptions.py +165 -0
  38. core/management/__init__.py +0 -0
  39. core/management/commands/__init__.py +0 -0
  40. core/management/commands/dump_openapi.py +40 -0
  41. core/managers/artifacts.py +346 -0
  42. core/managers/audit/__init__.py +1172 -0
  43. core/managers/audit/timeline.py +365 -0
  44. core/managers/auth.py +276 -0
  45. core/managers/cast.py +506 -0
  46. core/managers/config_validation.py +216 -0
  47. core/managers/crypto.py +78 -0
  48. core/managers/global_permissions.py +183 -0
  49. core/managers/health.py +56 -0
  50. core/managers/lock.py +229 -0
  51. core/managers/namespace.py +132 -0
  52. core/managers/permissions.py +168 -0
  53. core/managers/propagation.py +244 -0
  54. core/managers/resolution.py +938 -0
  55. core/managers/resolve_cache.py +266 -0
  56. core/managers/resolve_parameters.py +503 -0
  57. core/managers/resolver.py +56 -0
  58. core/managers/resolver_tokens.py +114 -0
  59. core/managers/resolving.py +673 -0
  60. core/managers/secret.py +147 -0
  61. core/managers/system.py +44 -0
  62. core/managers/tree/__init__.py +85 -0
  63. core/managers/tree/_common.py +85 -0
  64. core/managers/tree/base.py +203 -0
  65. core/managers/tree/config_ops.py +167 -0
  66. core/managers/tree/constants.py +11 -0
  67. core/managers/tree/create.py +75 -0
  68. core/managers/tree/diff.py +270 -0
  69. core/managers/tree/mutate.py +433 -0
  70. core/managers/tree/paths.py +49 -0
  71. core/managers/tree/propagation.py +200 -0
  72. core/managers/tree/read.py +263 -0
  73. core/managers/tree/resolver_ops.py +21 -0
  74. core/managers/tree/tags.py +167 -0
  75. core/managers/tree/update.py +137 -0
  76. core/managers/tree/versions.py +87 -0
  77. core/managers/tree_capabilities.py +189 -0
  78. core/managers/webhook/__init__.py +3 -0
  79. core/managers/webhook/cache.py +68 -0
  80. core/managers/webhook/main.py +357 -0
  81. core/middleware.py +32 -0
  82. core/migrations/0001_initial.py +157 -0
  83. core/migrations/0002_folder.py +33 -0
  84. core/migrations/0003_alter_namespace_name.py +17 -0
  85. core/migrations/0004_alter_namespace_name_and_more.py +25 -0
  86. core/migrations/0005_configversion_templateversion.py +85 -0
  87. core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
  88. core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
  89. core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
  90. core/migrations/0009_tree_lock.py +58 -0
  91. core/migrations/0010_resolver_token_encryption.py +75 -0
  92. core/migrations/0011_audit_event.py +134 -0
  93. core/migrations/0012_audit_event_namespace_name.py +24 -0
  94. core/migrations/0013_audit_event_object_id.py +28 -0
  95. core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
  96. core/migrations/0015_audit_event_operation_subresource.py +27 -0
  97. core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
  98. core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
  99. core/migrations/__init__.py +0 -0
  100. core/models/__init__.py +9 -0
  101. core/models/audit.py +97 -0
  102. core/models/config.py +132 -0
  103. core/models/folder.py +12 -0
  104. core/models/global_permissions.py +27 -0
  105. core/models/lock.py +35 -0
  106. core/models/namespace.py +45 -0
  107. core/models/resolver.py +25 -0
  108. core/models/secret.py +58 -0
  109. core/models/tree.py +29 -0
  110. core/notice.py +18 -0
  111. core/operation_ids.py +153 -0
  112. core/schemas/__init__.py +2 -0
  113. core/schemas/audit.py +75 -0
  114. core/schemas/cast_json_schema.py +57 -0
  115. core/schemas/cast_options.py +508 -0
  116. core/schemas/generic.py +233 -0
  117. core/schemas/metadata_json_schema.py +21 -0
  118. core/schemas/propagation.py +88 -0
  119. core/schemas/requests.py +773 -0
  120. core/schemas/resolver_json_schema.py +65 -0
  121. core/schemas/responses.py +690 -0
  122. core/schemas/webhooks.py +40 -0
  123. core/shortcuts.py +415 -0
  124. core/tests/__init__.py +0 -0
  125. core/tests/auth_helpers.py +23 -0
  126. core/tests/namespace_helpers.py +29 -0
  127. core/tests/test_api_cli.py +43 -0
  128. core/tests/test_api_errors.py +151 -0
  129. core/tests/test_artifacts.py +123 -0
  130. core/tests/test_audit.py +973 -0
  131. core/tests/test_audit_event_id.py +90 -0
  132. core/tests/test_audit_operations.py +222 -0
  133. core/tests/test_builtin_config_schemas.py +174 -0
  134. core/tests/test_can_i.py +510 -0
  135. core/tests/test_cast_format_options.py +57 -0
  136. core/tests/test_cast_options_schema.py +54 -0
  137. core/tests/test_cast_yaml.py +26 -0
  138. core/tests/test_config_schema_api.py +173 -0
  139. core/tests/test_config_validation.py +378 -0
  140. core/tests/test_document_parser.py +122 -0
  141. core/tests/test_dump_openapi.py +46 -0
  142. core/tests/test_extend_key.py +267 -0
  143. core/tests/test_folder_get_item.py +23 -0
  144. core/tests/test_folder_resolve_missing_tags.py +73 -0
  145. core/tests/test_get_item_version.py +31 -0
  146. core/tests/test_global_permission_create.py +171 -0
  147. core/tests/test_list_pagination.py +262 -0
  148. core/tests/test_locks.py +209 -0
  149. core/tests/test_multiline_transformer.py +132 -0
  150. core/tests/test_namespace_create.py +11 -0
  151. core/tests/test_ocmo_metadata.py +127 -0
  152. core/tests/test_oidc_jwt_audiences.py +64 -0
  153. core/tests/test_omit_transformer.py +78 -0
  154. core/tests/test_operation_ids.py +47 -0
  155. core/tests/test_permissions.py +1148 -0
  156. core/tests/test_propagation.py +441 -0
  157. core/tests/test_public_base_url.py +20 -0
  158. core/tests/test_resolve_draft.py +176 -0
  159. core/tests/test_resolve_parameters_version.py +181 -0
  160. core/tests/test_resolve_permissions.py +492 -0
  161. core/tests/test_resolve_resolver_response.py +107 -0
  162. core/tests/test_resolver_auth.py +33 -0
  163. core/tests/test_resolver_folder_filters.py +95 -0
  164. core/tests/test_resolver_get_item.py +23 -0
  165. core/tests/test_resolver_navigate_search_scope.py +98 -0
  166. core/tests/test_resolver_schema_api.py +91 -0
  167. core/tests/test_security_hardening.py +324 -0
  168. core/tests/test_selectors.py +65 -0
  169. core/tests/test_special_namespace_paths.py +409 -0
  170. core/tests/test_stable_promotion.py +124 -0
  171. core/tests/test_system.py +140 -0
  172. core/tests/test_tree_manager_for_item.py +192 -0
  173. core/tests/test_two_layer_cache.py +334 -0
  174. core/tests/test_url_trailing_slashes.py +53 -0
  175. core/tests/test_validation_error_responses.py +43 -0
  176. core/tests/test_webhooks.py +985 -0
  177. core/utils/__init__.py +23 -0
  178. core/utils/deep_merge.py +91 -0
  179. core/utils/namespace_special_configs.py +105 -0
  180. core/utils/permissions_compiler.py +627 -0
  181. core/utils/permissions_schema_document.py +52 -0
  182. core/validation_errors.py +105 -0
  183. core/version.py +27 -0
  184. core/views.py +1 -0
  185. ocmo_api-0.8.19.dist-info/METADATA +252 -0
  186. ocmo_api-0.8.19.dist-info/RECORD +203 -0
  187. ocmo_api-0.8.19.dist-info/WHEEL +4 -0
  188. ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
  189. ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
  190. ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
  191. ocmoapi/__init__.py +1 -0
  192. ocmoapi/__main__.py +6 -0
  193. ocmoapi/asgi.py +16 -0
  194. ocmoapi/auth.py +163 -0
  195. ocmoapi/cli.py +85 -0
  196. ocmoapi/oauth2_provider.py +23 -0
  197. ocmoapi/parser.py +120 -0
  198. ocmoapi/settings.py +275 -0
  199. ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
  200. ocmoapi/templates/ninja/swagger.html +55 -0
  201. ocmoapi/testing_auth.py +64 -0
  202. ocmoapi/urls.py +40 -0
  203. ocmoapi/wsgi.py +16 -0
@@ -0,0 +1,1172 @@
1
+ """AuditManager for append-only operation logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import contextvars
7
+ import logging
8
+ import math
9
+ import re
10
+ import uuid
11
+ from collections.abc import Callable
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta
15
+ from typing import TYPE_CHECKING, Any, cast
16
+ from uuid import UUID
17
+
18
+ from django.conf import settings
19
+ from django.db.models import Q, QuerySet
20
+ from django.utils import timezone
21
+
22
+ from ...constants.audit_operations import (
23
+ OP_MOVE_ITEM,
24
+ OP_REFERENCED_IN_RESOLVE,
25
+ OP_RESOLVE,
26
+ )
27
+ from ...decorators.audit import AuditEventContext, _audit_operation_context, _take_audit_context
28
+ from ...decorators.binding import bind_args, invoke, resolve_resource
29
+ from ...decorators.permissions import PermCheck, arg, require_permissions
30
+ from ...exceptions import NotFound, PermissionDenied
31
+ from ...models import AuditEvent, Namespace
32
+ from ...shortcuts import tag_subresource_from_ref
33
+ from ..auth import AuthManager
34
+ from .timeline import (
35
+ format_timeline_note as format_timeline_note,
36
+ )
37
+ from .timeline import (
38
+ infer_operation,
39
+ )
40
+ from .timeline import (
41
+ read_operation_for_type as read_operation_for_type,
42
+ )
43
+
44
+ if TYPE_CHECKING:
45
+ from ..resolving import CacheParticipant
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ AUDIT_MODE_ALL = "all"
50
+ AUDIT_MODE_MODIFICATIONS_AND_RESOLVE = "modifications-and-resolve"
51
+ AUDIT_MODE_RESOLVE = "resolve"
52
+ VALID_AUDIT_MODES = frozenset(
53
+ {
54
+ AUDIT_MODE_ALL,
55
+ AUDIT_MODE_MODIFICATIONS_AND_RESOLVE,
56
+ AUDIT_MODE_RESOLVE,
57
+ }
58
+ )
59
+ MODIFICATION_HTTP_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
60
+ _RESOLVE_EVENT_KINDS = frozenset(
61
+ {
62
+ AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
63
+ AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
64
+ }
65
+ )
66
+
67
+ _request_audit: contextvars.ContextVar[AuditManager | None] = contextvars.ContextVar(
68
+ "request_audit",
69
+ default=None,
70
+ )
71
+
72
+
73
+ def client_ip_from_request(request) -> str | None:
74
+ """Extract client IP from X-Forwarded-For or REMOTE_ADDR."""
75
+ forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
76
+ if forwarded:
77
+ return forwarded.split(",")[0].strip() or None
78
+ return request.META.get("REMOTE_ADDR")
79
+
80
+
81
+ @dataclass
82
+ class AuditEventDraft:
83
+ """In-memory audit row under construction."""
84
+
85
+ event_kind: str = AuditEvent.EVENT_KIND_OPERATION
86
+ client_ip: str | None = None
87
+ user_agent: str | None = None
88
+ auth_id: str = ""
89
+ auth_email: str | None = None
90
+ auth_type: str = AuditEvent.AUTH_TYPE_USER
91
+ token_number: int | None = None
92
+ namespace: Any = None
93
+ namespace_name: str | None = None
94
+ http_method: str = ""
95
+ api_endpoint: str = ""
96
+ object_type: str | None = None
97
+ object_id: str | None = None
98
+ object_version: int | None = None
99
+ operation: str | None = None
100
+ subresource_type: str | None = None
101
+ subresource: str | None = None
102
+ permission_ok: bool | None = None
103
+ error: str | None = None
104
+ resolve_type: str | None = None
105
+ from_cache: bool | None = None
106
+ parent_event: AuditEvent | None = None
107
+
108
+
109
+ @dataclass
110
+ class AuditListFilters:
111
+ """Optional filters for audit list queries."""
112
+
113
+ auth_id: str | None = None
114
+ auth_email: str | None = None
115
+ auth_type: str | None = None
116
+ object_type: str | None = None
117
+ object_id: str | None = None
118
+ http_method: str | None = None
119
+ api_endpoint: str | None = None
120
+ permission_ok: bool | None = None
121
+ resolve_type: str | None = None
122
+ from_cache: bool | None = None
123
+ event_kind: str | None = None
124
+ category: str | None = None
125
+ parent_event_id: str | UUID | None = None
126
+ client_ip: str | None = None
127
+ user_agent: str | None = None
128
+ token_number: int | None = None
129
+ object_version: int | None = None
130
+ operation: str | None = None
131
+ subresource_type: str | None = None
132
+ subresource: str | None = None
133
+ event_id: str | UUID | None = None
134
+ error: str | None = None
135
+ namespace_name: str | None = None
136
+ occurred_from: datetime | None = None
137
+ occurred_to: datetime | None = None
138
+
139
+ def __post_init__(self) -> None:
140
+ if isinstance(self.parent_event_id, UUID):
141
+ self.parent_event_id = str(self.parent_event_id)
142
+ if isinstance(self.event_id, UUID):
143
+ self.event_id = str(self.event_id)
144
+
145
+
146
+ @dataclass
147
+ class ResolveStats:
148
+ direct: int = 0
149
+ nested: int = 0
150
+
151
+
152
+ @dataclass
153
+ class ResolveSeriesBucket:
154
+ start: datetime
155
+ direct: int = 0
156
+ nested: int = 0
157
+ errors: int = 0
158
+
159
+
160
+ _audit_list_access = PermCheck(
161
+ lambda self: "global:admin" if self.namespace is None else "namespace:audit",
162
+ resource=lambda self: self.namespace.name if self.namespace is not None else "",
163
+ )
164
+
165
+ _item_audit_access = PermCheck(
166
+ action=lambda self, object_id, object_type, **_: f"{object_type}:audit",
167
+ resource=arg("object_id"),
168
+ mask_as_not_found=True,
169
+ )
170
+
171
+
172
+ class AuditManager:
173
+ """Build, persist, and query append-only audit events.
174
+
175
+ One instance is bound per HTTP request via :meth:`bind` and retrieved
176
+ elsewhere with :meth:`current`.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ auth: AuthManager | None = None,
182
+ namespace=None,
183
+ *,
184
+ request=None,
185
+ ) -> None:
186
+ self.auth = auth
187
+ self.namespace = namespace
188
+ self.request = request
189
+ self._draft: AuditEventDraft | None = None
190
+ self._draft_stack: list[AuditEventDraft] = []
191
+ self._last_saved: AuditEvent | None = None
192
+
193
+ @classmethod
194
+ def bind(cls, request, auth: AuthManager, namespace=None) -> AuditManager:
195
+ """Create and store the request-scoped AuditManager singleton."""
196
+ mgr = cls(auth=auth, namespace=namespace, request=request)
197
+ _request_audit.set(mgr)
198
+ return mgr
199
+
200
+ @classmethod
201
+ def unbind(cls) -> None:
202
+ """Clear the request-scoped AuditManager (mainly for tests)."""
203
+ current = _request_audit.get()
204
+ if current is not None:
205
+ current._draft = None
206
+ current._draft_stack.clear()
207
+ _request_audit.set(None)
208
+
209
+ @classmethod
210
+ def current(cls) -> AuditManager | None:
211
+ """Return the request-scoped AuditManager, if bound."""
212
+ return _request_audit.get()
213
+
214
+ @classmethod
215
+ def from_manager(cls, manager) -> AuditManager | None:
216
+ """Return the bound singleton, updating namespace from *manager*."""
217
+ audit_mgr = cls.current()
218
+ if audit_mgr is None:
219
+ return None
220
+
221
+ namespace = getattr(manager, "namespace", None)
222
+ if namespace is None and hasattr(manager, "ns") and manager.ns is not None:
223
+ namespace = manager.ns
224
+ if namespace is not None:
225
+ audit_mgr.set_namespace(namespace)
226
+ return audit_mgr
227
+
228
+ @classmethod
229
+ def for_decorated_call(
230
+ cls,
231
+ manager,
232
+ *,
233
+ auth_manager_attribute: str = "auth",
234
+ skip_when_no_auth: bool = True,
235
+ ) -> AuditManager | None:
236
+ """Return request-scoped manager for a decorated call, or None to skip auditing."""
237
+ if getattr(manager, auth_manager_attribute, None) is None:
238
+ return None
239
+ if cls.current() is None and skip_when_no_auth:
240
+ return None
241
+ return cls.from_manager(manager)
242
+
243
+ def set_namespace(self, namespace) -> AuditManager:
244
+ """Attach or replace the namespace context for subsequent events."""
245
+ self.namespace = namespace
246
+ if self._draft is not None and namespace is not None:
247
+ self._draft.namespace = namespace
248
+ self._draft.namespace_name = namespace.name
249
+ return self
250
+
251
+ def _prefill_actor_and_request(self) -> None:
252
+ if self._draft is None:
253
+ return
254
+
255
+ auth = self.auth
256
+ if auth is not None:
257
+ self._draft.auth_id = str(auth.identifier or AuthManager.DEFAULT_ACTOR_IDENTITY)
258
+ email = auth.email
259
+ self._draft.auth_email = str(email) if email is not None else None
260
+ self._draft.auth_type = AuditEvent.AUTH_TYPE_RESOLVER if auth.is_resolver else AuditEvent.AUTH_TYPE_USER
261
+ self._draft.token_number = auth.token_number
262
+ else:
263
+ self._draft.auth_id = AuthManager.DEFAULT_ACTOR_IDENTITY
264
+ self._draft.auth_type = AuditEvent.AUTH_TYPE_USER
265
+
266
+ request = self.request
267
+ if request is not None and hasattr(request, "META"):
268
+ self._draft.http_method = getattr(request, "method", "") or ""
269
+ self._draft.api_endpoint = getattr(request, "path", "") or ""
270
+ self._draft.client_ip = client_ip_from_request(request)
271
+ self._draft.user_agent = request.META.get("HTTP_USER_AGENT")
272
+
273
+ if self._draft.namespace is None and self.namespace is not None:
274
+ self._draft.namespace = self.namespace
275
+ self._draft.namespace_name = self.namespace.name
276
+
277
+ def begin(self, event_kind: str = AuditEvent.EVENT_KIND_OPERATION) -> AuditManager:
278
+ if self._draft is not None:
279
+ self._draft_stack.append(self._draft)
280
+ self._draft = AuditEventDraft(event_kind=event_kind)
281
+ self._prefill_actor_and_request()
282
+ return self
283
+
284
+ def set_object(
285
+ self,
286
+ object_type: str,
287
+ object_id: str | None,
288
+ *,
289
+ version: int | None = None,
290
+ ) -> AuditManager:
291
+ if self._draft is None:
292
+ raise RuntimeError("Call begin() before set_object()")
293
+ self._draft.object_type = object_type
294
+ self._draft.object_id = object_id
295
+ self._draft.object_version = version
296
+ if object_type == "namespace" and object_id and self._draft.namespace_name is None:
297
+ self._draft.namespace_name = object_id
298
+ return self
299
+
300
+ def set_resolve(
301
+ self,
302
+ *,
303
+ resolve_type: str | None = None,
304
+ from_cache: bool | None = None,
305
+ ) -> AuditManager:
306
+ if self._draft is None:
307
+ raise RuntimeError("Call begin() before set_resolve()")
308
+ self._draft.resolve_type = resolve_type
309
+ self._draft.from_cache = from_cache
310
+ return self
311
+
312
+ def set_outcome(
313
+ self,
314
+ *,
315
+ permission_ok: bool | None,
316
+ error: str | None = None,
317
+ ) -> AuditManager:
318
+ if self._draft is None:
319
+ raise RuntimeError("Call begin() before set_outcome()")
320
+ self._draft.permission_ok = permission_ok
321
+ self._draft.error = error
322
+ return self
323
+
324
+ def set_parent(self, parent: AuditEvent) -> AuditManager:
325
+ if self._draft is None:
326
+ raise RuntimeError("Call begin() before set_parent()")
327
+ self._draft.parent_event = parent
328
+ return self
329
+
330
+ def set_operation(self, operation: str | None) -> AuditManager:
331
+ if self._draft is None:
332
+ raise RuntimeError("Call begin() before set_operation()")
333
+ self._draft.operation = operation
334
+ return self
335
+
336
+ def set_subresource(
337
+ self,
338
+ *,
339
+ subresource_type: str | None = None,
340
+ subresource: str | None = None,
341
+ ) -> AuditManager:
342
+ if self._draft is None:
343
+ raise RuntimeError("Call begin() before set_subresource()")
344
+ self._draft.subresource_type = subresource_type
345
+ self._draft.subresource = subresource
346
+ return self
347
+
348
+ def begin_operation(self, object_type: str, object_id: str | None) -> AuditManager:
349
+ return self.begin(AuditEvent.EVENT_KIND_OPERATION).set_object(object_type, object_id)
350
+
351
+ @contextmanager
352
+ def operation(
353
+ self,
354
+ object_type: str,
355
+ object_id: str | None,
356
+ *,
357
+ resolve_type: str | None = None,
358
+ operation: str | None = None,
359
+ ):
360
+ """Context manager for one audited manager operation."""
361
+ self.begin_operation(object_type, object_id)
362
+ if resolve_type:
363
+ self.set_resolve(resolve_type=resolve_type)
364
+ if operation:
365
+ self.set_operation(operation)
366
+ try:
367
+ yield
368
+ except PermissionDenied as exc:
369
+ event = self._finalize_operation(permission_ok=False, error=str(exc))
370
+ self._attach_audit_event_id(exc, event)
371
+ raise
372
+ except Exception as exc:
373
+ event = self._finalize_operation(permission_ok=True, error=str(exc))
374
+ self._attach_audit_event_id(exc, event)
375
+ raise
376
+ else:
377
+ self._finalize_operation(permission_ok=True)
378
+
379
+ @staticmethod
380
+ def _attach_audit_event_id(exc: BaseException, event: AuditEvent | None) -> None:
381
+ if event is not None:
382
+ setattr(exc, "audit_event_id", event.id)
383
+
384
+ def _finalize_operation(
385
+ self,
386
+ *,
387
+ permission_ok: bool,
388
+ error: str | None = None,
389
+ ) -> AuditEvent | None:
390
+ try:
391
+ return self.set_outcome(permission_ok=permission_ok, error=error).save()
392
+ except Exception:
393
+ logger.exception("Failed to save audit event")
394
+ return None
395
+
396
+ @staticmethod
397
+ def _resolve_post_value(spec, manager, bound_args: dict, result) -> Any:
398
+ """Resolve a decorator field that may be a constant or post-call callable."""
399
+ if spec is None:
400
+ return None
401
+ if not callable(spec):
402
+ return spec
403
+ try:
404
+ return spec(manager, result, bound_args)
405
+ except TypeError:
406
+ return invoke(spec, manager, bound_args)
407
+
408
+ @staticmethod
409
+ def _merge_post_field_spec(
410
+ body_ctx: AuditEventContext | None,
411
+ attr: str,
412
+ decorator_spec: str | Callable | int | None,
413
+ ) -> str | Callable | int | None:
414
+ if body_ctx is not None:
415
+ value = getattr(body_ctx, attr, None)
416
+ if value is not None:
417
+ return value
418
+ return decorator_spec
419
+
420
+ def run_decorated(
421
+ self,
422
+ func: Callable,
423
+ manager,
424
+ args: tuple,
425
+ kwargs: dict,
426
+ *,
427
+ object_type: str | Callable,
428
+ object_id_attr: str | Callable | None,
429
+ skip_when: Callable | None,
430
+ func_params: list[str],
431
+ resolve_type: str | None = None,
432
+ operation: str | Callable | None = None,
433
+ subresource_type: str | Callable | None = None,
434
+ subresource: str | Callable | None = None,
435
+ object_version: int | str | Callable | None = None,
436
+ ) -> Any:
437
+ """Execute a decorated manager method with operation audit when configured."""
438
+ bound_args = bind_args(func_params, args, kwargs)
439
+ if skip_when is not None and invoke(skip_when, manager, bound_args):
440
+ return func(manager, *args, **kwargs)
441
+
442
+ if not self.should_audit_operation(resolve_related=resolve_type is not None):
443
+ return func(manager, *args, **kwargs)
444
+
445
+ resolved_object_type = invoke(object_type, manager, bound_args) if callable(object_type) else object_type
446
+ resolved_object_id = resolve_resource(manager, object_id_attr, bound_args)
447
+
448
+ self.begin_operation(
449
+ str(resolved_object_type),
450
+ str(resolved_object_id) if resolved_object_id else None,
451
+ )
452
+ if resolve_type:
453
+ self.set_resolve(resolve_type=resolve_type)
454
+
455
+ parent_ctx = _audit_operation_context.get()
456
+ _audit_operation_context.set(AuditEventContext())
457
+ try:
458
+ result = func(manager, *args, **kwargs)
459
+ except PermissionDenied as exc:
460
+ body_ctx = _take_audit_context()
461
+ if parent_ctx is not None:
462
+ _audit_operation_context.set(parent_ctx)
463
+ self._apply_post_fields(
464
+ manager,
465
+ bound_args,
466
+ None,
467
+ operation=cast(
468
+ "str | Callable | None",
469
+ self._merge_post_field_spec(body_ctx, "operation", operation),
470
+ ),
471
+ subresource_type=cast(
472
+ "str | Callable | None",
473
+ self._merge_post_field_spec(body_ctx, "subresource_type", subresource_type),
474
+ ),
475
+ subresource=cast(
476
+ "str | Callable | None",
477
+ self._merge_post_field_spec(body_ctx, "subresource", subresource),
478
+ ),
479
+ object_version=self._merge_post_field_spec(body_ctx, "object_version", object_version),
480
+ )
481
+ event = self._finalize_operation(permission_ok=False, error=str(exc))
482
+ self._attach_audit_event_id(exc, event)
483
+ raise
484
+ except Exception as exc:
485
+ body_ctx = _take_audit_context()
486
+ if parent_ctx is not None:
487
+ _audit_operation_context.set(parent_ctx)
488
+ self._apply_post_fields(
489
+ manager,
490
+ bound_args,
491
+ None,
492
+ operation=cast(
493
+ "str | Callable | None",
494
+ self._merge_post_field_spec(body_ctx, "operation", operation),
495
+ ),
496
+ subresource_type=cast(
497
+ "str | Callable | None",
498
+ self._merge_post_field_spec(body_ctx, "subresource_type", subresource_type),
499
+ ),
500
+ subresource=cast(
501
+ "str | Callable | None",
502
+ self._merge_post_field_spec(body_ctx, "subresource", subresource),
503
+ ),
504
+ object_version=self._merge_post_field_spec(body_ctx, "object_version", object_version),
505
+ )
506
+ event = self._finalize_operation(permission_ok=True, error=str(exc))
507
+ self._attach_audit_event_id(exc, event)
508
+ raise
509
+ else:
510
+ body_ctx = _take_audit_context()
511
+ if parent_ctx is not None:
512
+ _audit_operation_context.set(parent_ctx)
513
+ self._apply_post_fields(
514
+ manager,
515
+ bound_args,
516
+ result,
517
+ operation=cast(
518
+ "str | Callable | None",
519
+ self._merge_post_field_spec(body_ctx, "operation", operation),
520
+ ),
521
+ subresource_type=cast(
522
+ "str | Callable | None",
523
+ self._merge_post_field_spec(body_ctx, "subresource_type", subresource_type),
524
+ ),
525
+ subresource=cast(
526
+ "str | Callable | None",
527
+ self._merge_post_field_spec(body_ctx, "subresource", subresource),
528
+ ),
529
+ object_version=self._merge_post_field_spec(body_ctx, "object_version", object_version),
530
+ )
531
+ self._finalize_operation(permission_ok=True)
532
+ return result
533
+
534
+ def _apply_post_fields(
535
+ self,
536
+ manager,
537
+ bound_args: dict,
538
+ result,
539
+ *,
540
+ operation: str | Callable | None,
541
+ subresource_type: str | Callable | None,
542
+ subresource: str | Callable | None,
543
+ object_version: int | str | Callable | None,
544
+ ) -> None:
545
+ if self._draft is None:
546
+ return
547
+
548
+ resolved_operation = self._resolve_post_value(operation, manager, bound_args, result)
549
+ if resolved_operation is not None:
550
+ self.set_operation(str(resolved_operation))
551
+
552
+ resolved_version = self._resolve_post_value(object_version, manager, bound_args, result)
553
+ if resolved_version is not None and resolved_version != "":
554
+ try:
555
+ self._draft.object_version = int(resolved_version)
556
+ except (TypeError, ValueError):
557
+ pass
558
+
559
+ resolved_sr_type = self._resolve_post_value(subresource_type, manager, bound_args, result)
560
+ resolved_sr = self._resolve_post_value(subresource, manager, bound_args, result)
561
+ if resolved_sr_type or resolved_sr:
562
+ self.set_subresource(
563
+ subresource_type=str(resolved_sr_type) if resolved_sr_type else None,
564
+ subresource=str(resolved_sr) if resolved_sr is not None else None,
565
+ )
566
+
567
+ def begin_resolve_request(self, config_path: str) -> AuditManager:
568
+ return (
569
+ self.begin(AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
570
+ .set_object("config", config_path)
571
+ .set_resolve(resolve_type=AuditEvent.RESOLVE_TYPE_DIRECT)
572
+ .set_operation(OP_RESOLVE)
573
+ )
574
+
575
+ def save(self) -> AuditEvent | None:
576
+ if self._draft is None:
577
+ raise RuntimeError("No audit draft to save")
578
+
579
+ draft = self._draft
580
+ self._draft = None
581
+
582
+ if not self.should_persist(draft):
583
+ return None
584
+
585
+ if not draft.operation:
586
+ draft.operation = infer_operation(draft)
587
+
588
+ namespace_name = draft.namespace_name
589
+ namespace_obj = draft.namespace
590
+ if namespace_name is None and namespace_obj is not None:
591
+ namespace_name = namespace_obj.name
592
+ if namespace_obj is not None and not Namespace.objects.filter(pk=namespace_obj.pk).exists():
593
+ namespace_obj = None
594
+
595
+ try:
596
+ event = AuditEvent.objects.create(
597
+ client_ip=draft.client_ip,
598
+ user_agent=draft.user_agent,
599
+ auth_id=draft.auth_id,
600
+ auth_email=draft.auth_email,
601
+ auth_type=draft.auth_type,
602
+ token_number=draft.token_number,
603
+ namespace=namespace_obj,
604
+ namespace_name=namespace_name,
605
+ http_method=draft.http_method or "",
606
+ api_endpoint=draft.api_endpoint or "",
607
+ object_type=draft.object_type,
608
+ object_id=draft.object_id,
609
+ object_version=draft.object_version,
610
+ operation=draft.operation,
611
+ subresource_type=draft.subresource_type,
612
+ subresource=draft.subresource,
613
+ permission_ok=draft.permission_ok,
614
+ error=draft.error,
615
+ resolve_type=draft.resolve_type,
616
+ from_cache=draft.from_cache,
617
+ parent_event=draft.parent_event,
618
+ event_kind=draft.event_kind,
619
+ )
620
+ self._last_saved = event
621
+ if self._draft_stack:
622
+ self._draft = self._draft_stack.pop()
623
+ else:
624
+ self._draft = None
625
+ return event
626
+ except Exception:
627
+ logger.exception("Failed to persist audit event")
628
+ raise
629
+
630
+ def save_participants(
631
+ self,
632
+ participants: list[CacheParticipant],
633
+ roots: set[str],
634
+ *,
635
+ parent: AuditEvent | None = None,
636
+ ) -> list[AuditEvent]:
637
+ parent_event = parent or self._last_saved
638
+ saved: list[AuditEvent] = []
639
+
640
+ for participant in participants:
641
+ if participant.path in roots:
642
+ continue
643
+ try:
644
+ tag_sr = tag_subresource_from_ref(participant.ref)
645
+ builder = (
646
+ self.begin(AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT)
647
+ .set_object(participant.kind, participant.path, version=participant.version)
648
+ .set_resolve(
649
+ resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
650
+ from_cache=participant.from_cache,
651
+ )
652
+ .set_operation(OP_REFERENCED_IN_RESOLVE)
653
+ .set_outcome(permission_ok=None)
654
+ )
655
+ if parent_event is not None:
656
+ builder = builder.set_parent(parent_event)
657
+ if tag_sr is not None:
658
+ builder.set_subresource(
659
+ subresource_type=tag_sr[0],
660
+ subresource=tag_sr[1],
661
+ )
662
+ builder.save()
663
+ if self._last_saved is not None:
664
+ saved.append(self._last_saved)
665
+ except Exception:
666
+ logger.exception("Failed to persist audit participant for %s", participant.path)
667
+
668
+ return saved
669
+
670
+ def _apply_filters(self, qs: QuerySet, filters: AuditListFilters) -> QuerySet:
671
+ if filters.auth_id:
672
+ qs = qs.filter(auth_id__icontains=filters.auth_id)
673
+ if filters.auth_email:
674
+ qs = qs.filter(auth_email__icontains=filters.auth_email)
675
+ if filters.auth_type:
676
+ qs = qs.filter(auth_type=filters.auth_type)
677
+ if filters.object_type:
678
+ qs = qs.filter(object_type=filters.object_type)
679
+ if filters.object_id:
680
+ qs = qs.filter(object_id__icontains=filters.object_id)
681
+ if filters.http_method:
682
+ qs = qs.filter(http_method__iexact=filters.http_method)
683
+ if filters.api_endpoint:
684
+ qs = qs.filter(api_endpoint__icontains=filters.api_endpoint)
685
+ if filters.permission_ok is not None:
686
+ qs = qs.filter(permission_ok=filters.permission_ok)
687
+ if filters.resolve_type:
688
+ qs = qs.filter(resolve_type=filters.resolve_type)
689
+ if filters.from_cache is not None:
690
+ qs = qs.filter(from_cache=filters.from_cache)
691
+ if filters.event_kind:
692
+ qs = qs.filter(event_kind=filters.event_kind)
693
+ if filters.category == "resolve":
694
+ qs = qs.filter(event_kind__in=_RESOLVE_EVENT_KINDS)
695
+ elif filters.category in ("operation", "modifications"):
696
+ qs = qs.filter(
697
+ event_kind=AuditEvent.EVENT_KIND_OPERATION,
698
+ http_method__in=MODIFICATION_HTTP_METHODS,
699
+ )
700
+ elif filters.category == "modifications-and-resolve":
701
+ qs = qs.filter(
702
+ Q(event_kind__in=_RESOLVE_EVENT_KINDS)
703
+ | Q(
704
+ event_kind=AuditEvent.EVENT_KIND_OPERATION,
705
+ http_method__in=MODIFICATION_HTTP_METHODS,
706
+ )
707
+ )
708
+ if filters.parent_event_id:
709
+ qs = qs.filter(parent_event_id=filters.parent_event_id)
710
+ if filters.client_ip:
711
+ qs = qs.filter(client_ip__icontains=filters.client_ip)
712
+ if filters.user_agent:
713
+ qs = qs.filter(user_agent__icontains=filters.user_agent)
714
+ if filters.token_number is not None:
715
+ qs = qs.filter(token_number=filters.token_number)
716
+ if filters.object_version is not None:
717
+ qs = qs.filter(object_version=filters.object_version)
718
+ if filters.operation:
719
+ qs = qs.filter(operation__icontains=filters.operation)
720
+ if filters.subresource_type:
721
+ qs = qs.filter(subresource_type__icontains=filters.subresource_type)
722
+ if filters.subresource:
723
+ qs = qs.filter(subresource__icontains=filters.subresource)
724
+ if filters.event_id:
725
+ qs = qs.filter(pk=filters.event_id)
726
+ if filters.error:
727
+ qs = qs.filter(error__icontains=filters.error)
728
+ if filters.namespace_name:
729
+ qs = qs.filter(namespace_name__iexact=filters.namespace_name)
730
+ if filters.occurred_from:
731
+ qs = qs.filter(occurred_at__gte=filters.occurred_from)
732
+ if filters.occurred_to:
733
+ qs = qs.filter(occurred_at__lte=filters.occurred_to)
734
+ return qs
735
+
736
+ @staticmethod
737
+ def _apply_timeline_search(qs: QuerySet, search: str) -> QuerySet:
738
+ term = search.strip()
739
+ if not term:
740
+ return qs
741
+
742
+ q = (
743
+ Q(auth_id__icontains=term)
744
+ | Q(auth_email__icontains=term)
745
+ | Q(auth_type__icontains=term)
746
+ | Q(namespace_name__icontains=term)
747
+ | Q(http_method__icontains=term)
748
+ | Q(api_endpoint__icontains=term)
749
+ | Q(object_type__icontains=term)
750
+ | Q(object_id__icontains=term)
751
+ | Q(operation__icontains=term)
752
+ | Q(subresource_type__icontains=term)
753
+ | Q(subresource__icontains=term)
754
+ | Q(error__icontains=term)
755
+ | Q(resolve_type__icontains=term)
756
+ | Q(event_kind__icontains=term)
757
+ | Q(client_ip__icontains=term)
758
+ | Q(user_agent__icontains=term)
759
+ )
760
+
761
+ if term.isdigit():
762
+ number = int(term)
763
+ q |= Q(object_version=number) | Q(token_number=number)
764
+
765
+ lowered = term.lower()
766
+ if lowered in ("true", "false"):
767
+ flag = lowered == "true"
768
+ q |= Q(from_cache=flag) | Q(permission_ok=flag)
769
+
770
+ try:
771
+ parsed = uuid.UUID(term)
772
+ except ValueError:
773
+ pass
774
+ else:
775
+ q |= Q(pk=parsed) | Q(parent_event_id=parsed)
776
+
777
+ return qs.filter(q)
778
+
779
+ @staticmethod
780
+ def _folder_descendant_path_filter(folder_path: str) -> Q:
781
+ """Match audit object_id for configs under a folder (inclusive)."""
782
+ normalized = folder_path.strip("/")
783
+ if not normalized:
784
+ return Q(object_id__isnull=False) & ~Q(object_id="")
785
+ return Q(object_id=normalized) | Q(object_id__startswith=f"{normalized}/")
786
+
787
+ @staticmethod
788
+ def _collect_prior_paths_from_moves(
789
+ namespace_name: str,
790
+ path: str,
791
+ object_type: str,
792
+ ) -> set[str]:
793
+ """Return former paths for *path* by walking recorded move events."""
794
+ normalized = path.strip("/")
795
+ prior: set[str] = set()
796
+ frontier = [normalized]
797
+ while frontier:
798
+ destination = frontier.pop()
799
+ sources = (
800
+ AuditEvent.objects.filter(
801
+ namespace_name__iexact=namespace_name,
802
+ event_kind=AuditEvent.EVENT_KIND_OPERATION,
803
+ operation=OP_MOVE_ITEM,
804
+ object_type=object_type,
805
+ subresource_type="path",
806
+ subresource=destination,
807
+ )
808
+ .exclude(object_id="")
809
+ .values_list("object_id", flat=True)
810
+ .distinct()
811
+ )
812
+ for source in sources:
813
+ source_path = str(source).strip("/")
814
+ if not source_path or source_path in prior:
815
+ continue
816
+ prior.add(source_path)
817
+ frontier.append(source_path)
818
+ return prior
819
+
820
+ @staticmethod
821
+ def _item_timeline_object_id_filter(
822
+ object_id: str,
823
+ prior_paths: set[str],
824
+ ) -> Q:
825
+ paths = {object_id.strip("/")} | prior_paths
826
+ if len(paths) == 1:
827
+ return Q(object_id=next(iter(paths)))
828
+ return Q(object_id__in=paths)
829
+
830
+ def _validate_timeline_object(self, object_id: str, object_type: str) -> None:
831
+ from ..tree import TreeManager
832
+
833
+ if self.namespace is None:
834
+ raise ValueError("item timeline requires a namespace")
835
+
836
+ tree = TreeManager(self.namespace, object_id, auth=self.auth)
837
+ if tree.item is None:
838
+ raise NotFound(f"Item wasn't found by path '{object_id}'")
839
+ if tree.item.node_type != object_type:
840
+ raise NotFound(f"Item wasn't found by path '{object_id}'")
841
+
842
+ @require_permissions(_item_audit_access)
843
+ def item_timeline(
844
+ self,
845
+ object_id: str,
846
+ object_type: str,
847
+ *,
848
+ search: str | None = None,
849
+ ) -> QuerySet:
850
+ """Audit events for one tree item as a user-friendly timeline."""
851
+ self._validate_timeline_object(object_id, object_type)
852
+
853
+ prior_paths = self._collect_prior_paths_from_moves(
854
+ self.namespace.name,
855
+ object_id,
856
+ object_type,
857
+ )
858
+ filters = AuditListFilters(
859
+ object_type=object_type,
860
+ category="modifications",
861
+ )
862
+ qs = AuditEvent.objects.select_related("namespace", "parent_event").filter(
863
+ namespace_name__iexact=self.namespace.name,
864
+ )
865
+ qs = self._apply_filters(qs, filters)
866
+ qs = qs.filter(self._item_timeline_object_id_filter(object_id, prior_paths))
867
+ qs = qs.filter(
868
+ Q(error__isnull=True) | Q(error=""),
869
+ ).exclude(permission_ok=False)
870
+ if search:
871
+ qs = self._apply_timeline_search(qs, search)
872
+ return qs.order_by("-occurred_at", "-id")
873
+
874
+ @require_permissions(_audit_list_access)
875
+ def list(
876
+ self,
877
+ filters: AuditListFilters | None = None,
878
+ *,
879
+ search: str | None = None,
880
+ ) -> QuerySet:
881
+ filters = filters or AuditListFilters()
882
+
883
+ qs = AuditEvent.objects.select_related("namespace", "parent_event").all()
884
+
885
+ if self.namespace is not None:
886
+ qs = qs.filter(namespace_name__iexact=self.namespace.name)
887
+ elif filters.namespace_name:
888
+ qs = qs.filter(namespace_name__iexact=filters.namespace_name)
889
+
890
+ qs = self._apply_filters(qs, filters)
891
+
892
+ if search:
893
+ qs = qs.filter(
894
+ Q(auth_id__icontains=search)
895
+ | Q(auth_email__icontains=search)
896
+ | Q(namespace_name__icontains=search)
897
+ | Q(object_id__icontains=search)
898
+ | Q(api_endpoint__icontains=search)
899
+ | Q(operation__icontains=search)
900
+ | Q(error__icontains=search)
901
+ )
902
+
903
+ return qs.order_by("-occurred_at", "-id")
904
+
905
+ _MIN_EVENT_ID_PREFIX_LEN = 4
906
+
907
+ def resolve_event_id(self, event_id_ref: str) -> UUID:
908
+ """Resolve a full event UUID from a complete value or unique hex prefix."""
909
+ ref = event_id_ref.strip()
910
+ if not ref:
911
+ raise ValueError("Audit event id must not be empty.")
912
+ try:
913
+ return uuid.UUID(ref)
914
+ except ValueError:
915
+ hex_prefix = ref.replace("-", "").lower()
916
+ if not re.fullmatch(r"[0-9a-f]+", hex_prefix):
917
+ raise ValueError(f"Invalid audit event id {ref!r}.") from None
918
+ if len(hex_prefix) < self._MIN_EVENT_ID_PREFIX_LEN:
919
+ raise ValueError(
920
+ f"Audit event id prefix {ref!r} is too short; "
921
+ f"use at least {self._MIN_EVENT_ID_PREFIX_LEN} hex characters."
922
+ ) from None
923
+
924
+ from django.db.models import CharField, Value
925
+ from django.db.models.functions import Cast, Lower, Replace
926
+
927
+ qs = AuditEvent.objects.all()
928
+ if self.namespace is not None:
929
+ qs = qs.filter(namespace_name__iexact=self.namespace.name)
930
+
931
+ matches = list(
932
+ qs.annotate(id_hex=Lower(Replace(Cast("id", CharField(max_length=36)), Value("-"), Value(""))))
933
+ .filter(id_hex__startswith=hex_prefix)
934
+ .order_by("id")[:2]
935
+ )
936
+ if not matches:
937
+ raise NotFound(f"Audit event '{ref}' not found")
938
+ if len(matches) > 1:
939
+ raise ValueError(f"Ambiguous audit event id prefix {ref!r}; use a longer prefix.")
940
+ return matches[0].id
941
+
942
+ @require_permissions(_audit_list_access)
943
+ def get(self, event_id) -> AuditEvent:
944
+ resolved_id = self.resolve_event_id(str(event_id))
945
+ qs = AuditEvent.objects.select_related("namespace", "parent_event")
946
+ if self.namespace is not None:
947
+ qs = qs.filter(namespace_name__iexact=self.namespace.name)
948
+ try:
949
+ return qs.get(pk=resolved_id)
950
+ except AuditEvent.DoesNotExist as exc:
951
+ raise NotFound(f"Audit event '{event_id}' not found") from exc
952
+
953
+ def config_resolve_stats(
954
+ self,
955
+ config_path: str,
956
+ since: datetime,
957
+ until: datetime,
958
+ ) -> ResolveStats:
959
+ if self.namespace is None:
960
+ raise ValueError("config_resolve_stats requires a namespace")
961
+
962
+ direct = AuditEvent.objects.filter(
963
+ namespace_name=self.namespace.name,
964
+ event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
965
+ object_type="config",
966
+ object_id=config_path,
967
+ occurred_at__gte=since,
968
+ occurred_at__lte=until,
969
+ ).count()
970
+ nested = AuditEvent.objects.filter(
971
+ namespace_name=self.namespace.name,
972
+ event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
973
+ resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
974
+ object_type="config",
975
+ object_id=config_path,
976
+ occurred_at__gte=since,
977
+ occurred_at__lte=until,
978
+ ).count()
979
+ return ResolveStats(direct=direct, nested=nested)
980
+
981
+ def _resolve_events_for_stats(
982
+ self,
983
+ *,
984
+ object_id: str,
985
+ object_type: str | None,
986
+ since: datetime,
987
+ until: datetime,
988
+ ) -> QuerySet:
989
+ if self.namespace is None:
990
+ raise ValueError("resolve stats require a namespace")
991
+
992
+ base = AuditEvent.objects.filter(
993
+ namespace_name=self.namespace.name,
994
+ occurred_at__gte=since,
995
+ occurred_at__lt=until,
996
+ )
997
+
998
+ if object_type == "resolver":
999
+ return base.filter(
1000
+ event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
1001
+ auth_type=AuditEvent.AUTH_TYPE_RESOLVER,
1002
+ auth_id=object_id,
1003
+ )
1004
+
1005
+ if object_type == "folder":
1006
+ path_filter = self._folder_descendant_path_filter(object_id)
1007
+ scoped = base.filter(object_type="config").filter(path_filter)
1008
+ direct_q = Q(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
1009
+ nested_q = Q(
1010
+ event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
1011
+ resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
1012
+ )
1013
+ return scoped.filter(direct_q | nested_q)
1014
+
1015
+ base = base.filter(object_id=object_id)
1016
+ if object_type:
1017
+ base = base.filter(object_type=object_type)
1018
+
1019
+ direct_q = Q(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
1020
+ nested_q = Q(
1021
+ event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
1022
+ resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
1023
+ )
1024
+ return base.filter(direct_q | nested_q)
1025
+
1026
+ @require_permissions(_item_audit_access)
1027
+ def resolve_stats_series(
1028
+ self,
1029
+ *,
1030
+ object_id: str,
1031
+ object_type: str | None,
1032
+ since: datetime,
1033
+ until: datetime,
1034
+ bucket_seconds: int,
1035
+ ) -> builtins.list[ResolveSeriesBucket]:
1036
+ if bucket_seconds < 60:
1037
+ raise ValueError("bucket_seconds must be at least 60")
1038
+
1039
+ if object_type is None:
1040
+ raise ValueError("object_type is required")
1041
+
1042
+ self._validate_timeline_object(object_id, object_type)
1043
+
1044
+ if since.tzinfo is None or until.tzinfo is None:
1045
+ raise ValueError("since and until must be timezone-aware")
1046
+
1047
+ aligned_epoch = (int(since.timestamp()) // bucket_seconds) * bucket_seconds
1048
+ aligned_start = datetime.fromtimestamp(aligned_epoch, tz=since.tzinfo)
1049
+ span_seconds = max(bucket_seconds, int((until - aligned_start).total_seconds()))
1050
+ bucket_count = max(1, math.ceil(span_seconds / bucket_seconds))
1051
+
1052
+ counts: dict[int, dict[str, int]] = {
1053
+ index: {"direct": 0, "nested": 0, "errors": 0} for index in range(bucket_count)
1054
+ }
1055
+
1056
+ qs = self._resolve_events_for_stats(
1057
+ object_id=object_id,
1058
+ object_type=object_type,
1059
+ since=aligned_start,
1060
+ until=until,
1061
+ )
1062
+ for row in qs.values("occurred_at", "event_kind", "resolve_type", "error"):
1063
+ occurred_at = row["occurred_at"]
1064
+ if occurred_at.tzinfo is None:
1065
+ occurred_at = timezone.make_aware(occurred_at)
1066
+ index = int((occurred_at - aligned_start).total_seconds() // bucket_seconds)
1067
+ if index < 0 or index >= bucket_count:
1068
+ continue
1069
+ if row["event_kind"] == AuditEvent.EVENT_KIND_RESOLVE_REQUEST:
1070
+ counts[index]["direct"] += 1
1071
+ if row.get("error"):
1072
+ counts[index]["errors"] += 1
1073
+ elif (
1074
+ row["event_kind"] == AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT
1075
+ and row["resolve_type"] == AuditEvent.RESOLVE_TYPE_NESTED
1076
+ ):
1077
+ counts[index]["nested"] += 1
1078
+
1079
+ step = timedelta(seconds=bucket_seconds)
1080
+ return [
1081
+ ResolveSeriesBucket(
1082
+ start=aligned_start + step * index,
1083
+ direct=counts[index]["direct"],
1084
+ nested=counts[index]["nested"],
1085
+ errors=counts[index]["errors"],
1086
+ )
1087
+ for index in range(bucket_count)
1088
+ ]
1089
+
1090
+ @staticmethod
1091
+ def get_audit_mode() -> str:
1092
+ """Return the validated audit mode from settings."""
1093
+ mode = getattr(settings, "OCMO_AUDIT_MODE", AUDIT_MODE_RESOLVE)
1094
+ if mode in VALID_AUDIT_MODES:
1095
+ return mode
1096
+ logger.warning("Invalid OCMO_AUDIT_MODE %r; falling back to %r", mode, AUDIT_MODE_RESOLVE)
1097
+ return AUDIT_MODE_RESOLVE
1098
+
1099
+ def is_modification_request(self) -> bool:
1100
+ request = self.request
1101
+ if request is None:
1102
+ return False
1103
+ method = (getattr(request, "method", "") or "").upper()
1104
+ return method in MODIFICATION_HTTP_METHODS
1105
+
1106
+ def should_persist(self, draft: AuditEventDraft) -> bool:
1107
+ mode = self.get_audit_mode()
1108
+ if mode == AUDIT_MODE_ALL:
1109
+ return True
1110
+ if _is_resolve_event(draft):
1111
+ return True
1112
+ if mode == AUDIT_MODE_MODIFICATIONS_AND_RESOLVE:
1113
+ return self.is_modification_request()
1114
+ return False
1115
+
1116
+ def should_audit_operation(self, *, resolve_related: bool) -> bool:
1117
+ mode = self.get_audit_mode()
1118
+ if mode == AUDIT_MODE_ALL:
1119
+ return True
1120
+ if resolve_related:
1121
+ return True
1122
+ if mode == AUDIT_MODE_MODIFICATIONS_AND_RESOLVE:
1123
+ return self.is_modification_request()
1124
+ return False
1125
+
1126
+ @classmethod
1127
+ def resolve_request(cls, config_path: str) -> _ResolveAuditRecorder:
1128
+ """Context manager: persist resolve_request + participants on exit."""
1129
+ return _ResolveAuditRecorder(cls.current(), config_path)
1130
+
1131
+
1132
+ class _ResolveAuditRecorder:
1133
+ """Record one resolve request and optional participant rows when the block ends."""
1134
+
1135
+ def __init__(self, audit_mgr: AuditManager | None, config_path: str) -> None:
1136
+ self._audit_mgr = audit_mgr
1137
+ self._config_path = config_path
1138
+ self.participants: list[CacheParticipant] = []
1139
+ self.error: str | None = None
1140
+
1141
+ def __enter__(self) -> _ResolveAuditRecorder:
1142
+ return self
1143
+
1144
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
1145
+ if self._audit_mgr is None:
1146
+ return
1147
+ error = str(exc_val) if exc_val is not None else self.error
1148
+ try:
1149
+ event = (
1150
+ self._audit_mgr.begin_resolve_request(self._config_path)
1151
+ .set_outcome(
1152
+ permission_ok=True,
1153
+ error=error,
1154
+ )
1155
+ .save()
1156
+ )
1157
+ if exc_val is not None:
1158
+ AuditManager._attach_audit_event_id(exc_val, event)
1159
+ if self.participants:
1160
+ self._audit_mgr.save_participants(
1161
+ self.participants,
1162
+ roots={self._config_path},
1163
+ )
1164
+ except Exception:
1165
+ logger.exception("Failed to record resolve audit for %s", self._config_path)
1166
+ return
1167
+
1168
+
1169
+ def _is_resolve_event(draft: AuditEventDraft) -> bool:
1170
+ if draft.event_kind in _RESOLVE_EVENT_KINDS:
1171
+ return True
1172
+ return draft.resolve_type is not None