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,973 @@
1
+ """Tests for append-only audit logging."""
2
+
3
+ from datetime import timedelta
4
+ from unittest.mock import patch
5
+
6
+ from django.test import Client, RequestFactory, TestCase, override_settings
7
+ from django.utils import timezone
8
+
9
+ from core.api.errors import error_payload
10
+ from core.decorators import PermCheck, audit, enrich_audit, require_permissions
11
+ from core.exceptions import NotFound, PermissionDenied, TreeItemConflict
12
+ from core.managers.audit import AuditManager
13
+ from core.managers.auth import AuthManager
14
+ from core.managers.namespace import NamespaceManager
15
+ from core.managers.resolving import CacheParticipant
16
+ from core.managers.tree import TreeManager
17
+ from core.models import AuditEvent, GlobalPermissionRule, Namespace
18
+ from core.tests.namespace_helpers import create_test_namespace
19
+ from core.utils.permissions_compiler import PermissionsCompiler
20
+
21
+
22
+ def _user_auth(**claims):
23
+ raw = {"_type": "user", "sub": "user-1", "email": "bob@example.com", "name": "Bob", **claims}
24
+ return AuthManager(raw)
25
+
26
+
27
+ def _admin_auth():
28
+ return AuthManager(
29
+ {"_type": "user", "sub": "admin-1", "email": "admin@example.com", "groups": "ocmo-global-admins"}
30
+ )
31
+
32
+
33
+ def _bind_request(
34
+ auth: AuthManager,
35
+ *,
36
+ path: str = "/api/v1/ns/test/~config/",
37
+ method: str = "GET",
38
+ namespace=None,
39
+ ) -> AuthManager:
40
+ AuditManager.unbind()
41
+ factory = RequestFactory()
42
+ request = getattr(factory, method.lower())(path)
43
+ request.auth = auth._raw
44
+ AuditManager.bind(request, auth, namespace=namespace)
45
+ return auth
46
+
47
+
48
+ class TestAuditManager(TestCase):
49
+ def setUp(self):
50
+ AuditManager.unbind()
51
+ self.ns = create_test_namespace("audit-test")
52
+
53
+ def tearDown(self):
54
+ AuditManager.unbind()
55
+
56
+ @override_settings(OCMO_AUDIT_MODE="all")
57
+ def test_begin_save_creates_event(self):
58
+ _bind_request(_user_auth(), namespace=self.ns)
59
+ AuditManager.current().begin_operation("config", "app/conf").set_outcome(permission_ok=True).save()
60
+
61
+ event = AuditEvent.objects.get(object_id="app/conf", event_kind=AuditEvent.EVENT_KIND_OPERATION)
62
+ self.assertEqual(event.namespace_name, "audit-test")
63
+ self.assertEqual(event.event_kind, AuditEvent.EVENT_KIND_OPERATION)
64
+ self.assertEqual(event.auth_email, "bob@example.com")
65
+ self.assertEqual(event.http_method, "GET")
66
+ self.assertTrue(event.permission_ok)
67
+
68
+ def test_save_participants(self):
69
+ _bind_request(_user_auth(), namespace=self.ns)
70
+ audit = AuditManager.current()
71
+ assert audit is not None
72
+ audit.begin_resolve_request("root/cfg").set_outcome(permission_ok=True).save()
73
+ parent = audit._last_saved
74
+ self.assertEqual(parent.operation, "Resolve")
75
+
76
+ participants = [
77
+ CacheParticipant(kind="config", path="root/cfg", ref="latest", version=1),
78
+ CacheParticipant(kind="secret", path="secrets/db", ref="latest", version=2),
79
+ CacheParticipant(kind="config", path="base/cfg", ref="3", version=3),
80
+ ]
81
+ saved = audit.save_participants(participants, roots={"root/cfg"})
82
+ self.assertEqual(len(saved), 2)
83
+
84
+ self.assertFalse(AuditEvent.objects.filter(object_id="root/cfg", event_kind="resolve_participant").exists())
85
+ nested = AuditEvent.objects.get(object_id="secrets/db", event_kind="resolve_participant")
86
+ self.assertEqual(nested.resolve_type, "nested")
87
+ self.assertEqual(nested.operation, "Referenced in resolve")
88
+ self.assertEqual(nested.subresource_type, "tag")
89
+ self.assertEqual(nested.subresource, "latest")
90
+ self.assertEqual(nested.object_version, 2)
91
+ self.assertEqual(nested.parent_event_id, parent.id)
92
+
93
+ numeric_ref = AuditEvent.objects.get(object_id="base/cfg", event_kind="resolve_participant")
94
+ self.assertEqual(numeric_ref.object_version, 3)
95
+ self.assertIsNone(numeric_ref.subresource_type)
96
+ self.assertIsNone(numeric_ref.subresource)
97
+
98
+ def test_config_resolve_stats(self):
99
+ _bind_request(_user_auth(), namespace=self.ns)
100
+ audit = AuditManager.current()
101
+ assert audit is not None
102
+ now = timezone.now()
103
+ audit.begin_resolve_request("stats/cfg").set_outcome(permission_ok=True).save()
104
+ audit.save_participants(
105
+ [
106
+ CacheParticipant(kind="config", path="stats/cfg", ref="latest", version=1),
107
+ CacheParticipant(kind="config", path="base/cfg", ref="latest", version=1),
108
+ ],
109
+ roots={"stats/cfg"},
110
+ )
111
+ # Same path also referenced as nested from another resolve
112
+ audit.begin_resolve_request("other/cfg").set_outcome(permission_ok=True).save()
113
+ audit.save_participants(
114
+ [
115
+ CacheParticipant(kind="config", path="other/cfg", ref="latest", version=1),
116
+ CacheParticipant(kind="config", path="stats/cfg", ref="latest", version=1),
117
+ ],
118
+ roots={"other/cfg"},
119
+ )
120
+
121
+ stats = AuditManager(namespace=self.ns).config_resolve_stats(
122
+ "stats/cfg",
123
+ since=now - timedelta(hours=1),
124
+ until=now + timedelta(hours=1),
125
+ )
126
+ self.assertEqual(stats.direct, 1)
127
+ self.assertEqual(stats.nested, 1)
128
+
129
+ def test_resolve_stats_series_buckets(self):
130
+ TreeManager(self.ns, "series/cfg", auth=None).create_item("key: value\n", "config")
131
+ _bind_request(_user_auth(), namespace=self.ns)
132
+ audit = AuditManager.current()
133
+ assert audit is not None
134
+ now = timezone.now()
135
+ since = now - timedelta(days=2)
136
+ until = now + timedelta(hours=1)
137
+ audit.begin_resolve_request("series/cfg").set_outcome(permission_ok=True).save()
138
+ audit.begin_resolve_request("series/cfg").set_outcome(
139
+ permission_ok=True,
140
+ error="validation failed",
141
+ ).save()
142
+ audit.save_participants(
143
+ [
144
+ CacheParticipant(kind="config", path="series/cfg", ref="latest", version=1),
145
+ CacheParticipant(kind="config", path="dep/cfg", ref="latest", version=1),
146
+ ],
147
+ roots={"series/cfg"},
148
+ )
149
+ audit.begin_resolve_request("other/cfg").set_outcome(permission_ok=True).save()
150
+ audit.save_participants(
151
+ [
152
+ CacheParticipant(kind="config", path="series/cfg", ref="latest", version=1),
153
+ ],
154
+ roots={"other/cfg"},
155
+ )
156
+
157
+ buckets = AuditManager(namespace=self.ns).resolve_stats_series(
158
+ object_id="series/cfg",
159
+ object_type="config",
160
+ since=since,
161
+ until=until,
162
+ bucket_seconds=24 * 60 * 60,
163
+ )
164
+ self.assertGreaterEqual(len(buckets), 2)
165
+ total_direct = sum(bucket.direct for bucket in buckets)
166
+ total_nested = sum(bucket.nested for bucket in buckets)
167
+ total_errors = sum(bucket.errors for bucket in buckets)
168
+ self.assertEqual(total_direct, 2)
169
+ self.assertEqual(total_nested, 1)
170
+ self.assertEqual(total_errors, 1)
171
+
172
+ def test_resolve_stats_series_resolver_auth_only(self):
173
+ TreeManager(self.ns, "app/svc", auth=None).create_item("{}", "resolver")
174
+ TreeManager(self.ns, "app/cfg", auth=None).create_item("key: value\n", "config")
175
+ TreeManager(self.ns, "other/cfg", auth=None).create_item("key: value\n", "config")
176
+
177
+ def resolver_auth(path: str) -> AuthManager:
178
+ scope = "/".join(path.split("/")[:-1])
179
+ name = path.split("/")[-1]
180
+ return AuthManager(
181
+ {
182
+ "_type": "resolver",
183
+ "namespace": self.ns.id,
184
+ "name": name,
185
+ "access_scope": scope,
186
+ "token_number": 1,
187
+ }
188
+ )
189
+
190
+ now = timezone.now()
191
+ since = now - timedelta(days=1)
192
+ until = now + timedelta(hours=1)
193
+
194
+ _bind_request(resolver_auth("app/svc"), namespace=self.ns)
195
+ audit = AuditManager.current()
196
+ assert audit is not None
197
+ audit.begin_resolve_request("app/cfg").set_outcome(permission_ok=True).save()
198
+ audit.begin_resolve_request("other/cfg").set_outcome(
199
+ permission_ok=True,
200
+ error="failed",
201
+ ).save()
202
+ audit.save_participants(
203
+ [
204
+ CacheParticipant(kind="config", path="app/cfg", ref="latest", version=1),
205
+ CacheParticipant(kind="config", path="dep/cfg", ref="latest", version=1),
206
+ ],
207
+ roots={"app/cfg"},
208
+ )
209
+
210
+ _bind_request(_user_auth(), namespace=self.ns)
211
+ audit = AuditManager.current()
212
+ assert audit is not None
213
+ audit.begin_resolve_request("app/cfg").set_outcome(permission_ok=True).save()
214
+
215
+ buckets = AuditManager(namespace=self.ns).resolve_stats_series(
216
+ object_id="app/svc",
217
+ object_type="resolver",
218
+ since=since,
219
+ until=until,
220
+ bucket_seconds=24 * 60 * 60,
221
+ )
222
+ total_direct = sum(bucket.direct for bucket in buckets)
223
+ total_nested = sum(bucket.nested for bucket in buckets)
224
+ total_errors = sum(bucket.errors for bucket in buckets)
225
+ self.assertEqual(total_direct, 2)
226
+ self.assertEqual(total_nested, 0)
227
+ self.assertEqual(total_errors, 1)
228
+
229
+ def test_resolve_stats_series_folder_aggregate(self):
230
+ TreeManager(self.ns, "app/cfg", auth=None).create_item("key: value\n", "config")
231
+ TreeManager(self.ns, "app/nested/cfg", auth=None).create_item("key: value\n", "config")
232
+ TreeManager(self.ns, "other/cfg", auth=None).create_item("key: value\n", "config")
233
+
234
+ _bind_request(_user_auth(), namespace=self.ns)
235
+ audit = AuditManager.current()
236
+ assert audit is not None
237
+ now = timezone.now()
238
+ since = now - timedelta(days=1)
239
+ until = now + timedelta(hours=1)
240
+
241
+ audit.begin_resolve_request("app/cfg").set_outcome(permission_ok=True).save()
242
+ audit.save_participants(
243
+ [
244
+ CacheParticipant(kind="config", path="app/cfg", ref="latest", version=1),
245
+ CacheParticipant(kind="config", path="dep/cfg", ref="latest", version=1),
246
+ ],
247
+ roots={"app/cfg"},
248
+ )
249
+ audit.begin_resolve_request("app/nested/cfg").set_outcome(permission_ok=True).save()
250
+ audit.begin_resolve_request("other/cfg").set_outcome(permission_ok=True).save()
251
+ audit.save_participants(
252
+ [
253
+ CacheParticipant(kind="config", path="other/cfg", ref="latest", version=1),
254
+ CacheParticipant(kind="config", path="app/cfg", ref="latest", version=1),
255
+ ],
256
+ roots={"other/cfg"},
257
+ )
258
+
259
+ buckets = AuditManager(namespace=self.ns).resolve_stats_series(
260
+ object_id="app",
261
+ object_type="folder",
262
+ since=since,
263
+ until=until,
264
+ bucket_seconds=24 * 60 * 60,
265
+ )
266
+ total_direct = sum(bucket.direct for bucket in buckets)
267
+ total_nested = sum(bucket.nested for bucket in buckets)
268
+ self.assertEqual(total_direct, 2)
269
+ self.assertEqual(total_nested, 1)
270
+
271
+ @override_settings(OCMO_AUDIT_MODE="all")
272
+ def test_operation_inferred_when_not_set(self):
273
+ _bind_request(
274
+ _user_auth(),
275
+ path="/api/v1/ns/audit-test/~get/app/conf",
276
+ method="GET",
277
+ namespace=self.ns,
278
+ )
279
+ AuditManager.current().begin_operation("config", "app/conf").set_outcome(permission_ok=True).save()
280
+ event = AuditEvent.objects.get(object_id="app/conf", event_kind="operation")
281
+ self.assertEqual(event.operation, "Read config")
282
+
283
+ @override_settings(OCMO_AUDIT_MODE="all")
284
+ def test_decorator_sets_operation_and_subresource(self):
285
+ class FakeManager:
286
+ def __init__(self, namespace, auth):
287
+ self.namespace = namespace
288
+ self.auth = auth
289
+ self.path = "app/cfg"
290
+ self.item = type("Item", (), {"node_type": "config", "tags": {"latest": 2}})()
291
+
292
+ @audit(
293
+ "config",
294
+ operation="Set tag",
295
+ object_version=lambda self, result, bound: bound["payload"].version,
296
+ subresource_type="tag",
297
+ subresource=lambda self, result, bound: bound["payload"].tag,
298
+ )
299
+ def set_tag(self, payload):
300
+ return self.item
301
+
302
+ auth = _user_auth()
303
+ _bind_request(
304
+ auth,
305
+ path="/api/v1/ns/audit-test/~tag/app/cfg",
306
+ method="POST",
307
+ namespace=self.ns,
308
+ )
309
+ mgr = FakeManager(self.ns, auth)
310
+ payload = type("Payload", (), {"tag": "mytag", "version": 11})()
311
+ mgr.set_tag(payload)
312
+
313
+ event = AuditEvent.objects.get(object_id="app/cfg", operation="Set tag")
314
+ self.assertEqual(event.object_version, 11)
315
+ self.assertEqual(event.subresource_type, "tag")
316
+ self.assertEqual(event.subresource, "mytag")
317
+
318
+ @override_settings(OCMO_AUDIT_MODE="all")
319
+ def test_namespace_delete_retains_namespace_name(self):
320
+ _bind_request(_user_auth(), namespace=self.ns)
321
+ ns_name = self.ns.name
322
+ self.ns.delete()
323
+ AuditManager.current().begin_operation("namespace", ns_name).set_outcome(permission_ok=True).save()
324
+ event = AuditEvent.objects.get(object_id=ns_name, object_type="namespace")
325
+ self.assertEqual(event.namespace_name, ns_name)
326
+ self.assertIsNone(event.namespace_id)
327
+
328
+ @override_settings(OCMO_AUDIT_MODE="all")
329
+ def test_save_clears_namespace_fk_when_namespace_deleted(self):
330
+ _bind_request(_user_auth(), namespace=self.ns)
331
+ audit = AuditManager.current()
332
+ assert audit is not None
333
+ ns_name = self.ns.name
334
+ stale_namespace = self.ns
335
+ stale_namespace.delete()
336
+
337
+ audit.begin_operation("config", "app/cfg")
338
+ audit._draft.namespace = stale_namespace
339
+ audit._draft.namespace_name = ns_name
340
+ audit.set_outcome(permission_ok=True).save()
341
+
342
+ event = AuditEvent.objects.get(object_id="app/cfg", event_kind=AuditEvent.EVENT_KIND_OPERATION)
343
+ self.assertEqual(event.namespace_name, ns_name)
344
+ self.assertIsNone(event.namespace_id)
345
+
346
+
347
+ class TestAuditDecorator(TestCase):
348
+ def setUp(self):
349
+ AuditManager.unbind()
350
+ self.ns = create_test_namespace("audit-decorator")
351
+
352
+ def tearDown(self):
353
+ AuditManager.unbind()
354
+
355
+ @override_settings(OCMO_AUDIT_MODE="all")
356
+ def test_decorator_logs_success(self):
357
+ auth = _bind_request(_user_auth(), namespace=self.ns)
358
+ ns = self.ns
359
+
360
+ class Mgr:
361
+ namespace = ns
362
+ path = "app/cfg"
363
+
364
+ @audit("config")
365
+ @require_permissions(PermCheck("config:read"))
366
+ def read(self):
367
+ return "ok"
368
+
369
+ mgr = Mgr()
370
+ mgr.auth = auth
371
+ mgr.read()
372
+ event = AuditEvent.objects.get(object_id="app/cfg", event_kind=AuditEvent.EVENT_KIND_OPERATION)
373
+ self.assertTrue(event.permission_ok)
374
+ self.assertIsNone(event.error)
375
+
376
+ @override_settings(OCMO_AUDIT_MODE="all")
377
+ def test_decorator_logs_permission_denied(self):
378
+ ns = Namespace.objects.create(name="audit-deny", description="test")
379
+ from core.utils.namespace_special_configs import _load_builtin_schema_content
380
+
381
+ TreeManager(ns, "_permissions.schema", auth=None).create_item(
382
+ _load_builtin_schema_content("_permissions.schema"),
383
+ "config",
384
+ )
385
+ TreeManager(ns, "_permissions", auth=None).create_item("policies: []\n", "config")
386
+ auth = _bind_request(
387
+ AuthManager({"_type": "user", "sub": "x", "email": "denied@example.com", "name": "Denied"}),
388
+ namespace=ns,
389
+ )
390
+
391
+ class Mgr:
392
+ namespace = ns
393
+ path = "app/cfg"
394
+
395
+ @audit("config")
396
+ @require_permissions(PermCheck("config:read"))
397
+ def read(self):
398
+ return "ok"
399
+
400
+ mgr = Mgr()
401
+ mgr.auth = auth
402
+ with self.assertRaises(PermissionDenied):
403
+ mgr.read()
404
+
405
+ event = AuditEvent.objects.get(object_id="app/cfg", event_kind=AuditEvent.EVENT_KIND_OPERATION)
406
+ self.assertFalse(event.permission_ok)
407
+
408
+ @override_settings(OCMO_AUDIT_MODE="all")
409
+ def test_decorator_attaches_audit_event_id_on_business_error(self):
410
+ auth = _bind_request(_user_auth(), namespace=self.ns)
411
+ ns = self.ns
412
+
413
+ class Mgr:
414
+ namespace = ns
415
+ path = "app/cfg"
416
+
417
+ @audit("config")
418
+ def write(self):
419
+ raise TreeItemConflict("Another item by the same path already exists")
420
+
421
+ mgr = Mgr()
422
+ mgr.auth = auth
423
+ with self.assertRaises(TreeItemConflict) as ctx:
424
+ mgr.write()
425
+
426
+ event = AuditEvent.objects.get(object_id="app/cfg", event_kind=AuditEvent.EVENT_KIND_OPERATION)
427
+ self.assertEqual(event.error, "Another item by the same path already exists")
428
+ self.assertEqual(ctx.exception.audit_event_id, event.id)
429
+ payload = error_payload(ctx.exception, str(ctx.exception))
430
+ self.assertEqual(payload["audit_event_id"], str(event.id))
431
+
432
+ def test_internal_call_skipped(self):
433
+ before = AuditEvent.objects.count()
434
+
435
+ class Mgr:
436
+ namespace = self.ns
437
+ auth = None
438
+ path = "app/cfg"
439
+
440
+ @audit("config")
441
+ @require_permissions(PermCheck("config:read"))
442
+ def read(self):
443
+ return "ok"
444
+
445
+ Mgr().read()
446
+ self.assertEqual(AuditEvent.objects.count(), before)
447
+
448
+ @override_settings(OCMO_AUDIT_MODE="all")
449
+ def test_namespace_get_or_raise_audited_when_bound_first(self):
450
+ GlobalPermissionRule.objects.create(
451
+ position=1.0,
452
+ rule={
453
+ "namespace": "audit-decorator",
454
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
455
+ },
456
+ )
457
+ auth = _user_auth()
458
+ request = RequestFactory().get(f"/api/v1/ns/{self.ns.name}")
459
+ mgr = NamespaceManager(self.ns.name, auth=auth)
460
+ AuditManager.bind(request, auth, namespace=mgr.ns)
461
+ mgr.get_or_raise()
462
+ event = AuditEvent.objects.get(
463
+ object_type="namespace",
464
+ object_id=self.ns.name,
465
+ event_kind=AuditEvent.EVENT_KIND_OPERATION,
466
+ )
467
+ self.assertTrue(event.permission_ok)
468
+ self.assertEqual(event.namespace_name, self.ns.name)
469
+
470
+ @override_settings(OCMO_AUDIT_MODE="all")
471
+ def test_enrich_audit_sets_fields_on_decorated_method(self):
472
+ auth = _bind_request(_user_auth(), namespace=self.ns)
473
+
474
+ class Mgr:
475
+ namespace = self.ns
476
+ path = "app/cfg"
477
+
478
+ @audit("config", operation="Update item")
479
+ def write(self):
480
+ enrich_audit(
481
+ object_version=7,
482
+ subresource_type="tag",
483
+ subresource="x",
484
+ )
485
+ return "ok"
486
+
487
+ mgr = Mgr()
488
+ mgr.auth = auth
489
+ mgr.write()
490
+ event = AuditEvent.objects.get(object_id="app/cfg", operation="Update item")
491
+ self.assertEqual(event.object_version, 7)
492
+ self.assertEqual(event.subresource_type, "tag")
493
+ self.assertEqual(event.subresource, "x")
494
+
495
+ @override_settings(OCMO_AUDIT_MODE="all")
496
+ def test_enrich_audit_noop_without_active_audit(self):
497
+ enrich_audit(object_version=1)
498
+
499
+ @override_settings(OCMO_AUDIT_MODE="all")
500
+ def test_enrich_audit_overrides_decorator_defaults(self):
501
+ auth = _bind_request(_user_auth(), namespace=self.ns)
502
+
503
+ class Mgr:
504
+ namespace = self.ns
505
+ path = "app/cfg"
506
+
507
+ @audit("config", operation="Update item", object_version=1)
508
+ def write(self):
509
+ enrich_audit(object_version=9)
510
+ return "ok"
511
+
512
+ mgr = Mgr()
513
+ mgr.auth = auth
514
+ mgr.write()
515
+ event = AuditEvent.objects.get(object_id="app/cfg", operation="Update item")
516
+ self.assertEqual(event.object_version, 9)
517
+
518
+ @override_settings(OCMO_AUDIT_MODE="all")
519
+ def test_nested_decorated_calls_persist_outer_and_inner_audit_events(self):
520
+ auth = _bind_request(
521
+ _user_auth(),
522
+ path="/api/v1/ns/audit-decorator/~propagate/app/cfg",
523
+ method="POST",
524
+ namespace=self.ns,
525
+ )
526
+
527
+ class Mgr:
528
+ namespace = self.ns
529
+ path = "app/cfg"
530
+
531
+ @audit("config", operation="Propagate config", subresource_type="trigger")
532
+ def propagate(self):
533
+ enrich_audit(
534
+ object_version=3,
535
+ subresource='{"trigger":"manual","targets":[{"path":"other/cfg","version":2}]}',
536
+ )
537
+ self.update_target()
538
+ return {"source_version": 3}
539
+
540
+ @audit("config", operation="Update item")
541
+ def update_target(self):
542
+ enrich_audit(object_version=2)
543
+ return "ok"
544
+
545
+ mgr = Mgr()
546
+ mgr.auth = auth
547
+ mgr.propagate()
548
+
549
+ propagate_event = AuditEvent.objects.get(
550
+ object_id="app/cfg",
551
+ operation="Propagate config",
552
+ )
553
+ self.assertEqual(propagate_event.object_version, 3)
554
+ self.assertIn("manual", propagate_event.subresource or "")
555
+
556
+ update_event = AuditEvent.objects.get(
557
+ object_id="app/cfg",
558
+ operation="Update item",
559
+ )
560
+ self.assertEqual(update_event.object_version, 2)
561
+ self.assertEqual(AuditEvent.objects.filter(object_id="app/cfg").count(), 2)
562
+
563
+
564
+ class TestNamespaceAuditPermission(TestCase):
565
+ def setUp(self):
566
+ AuditManager.unbind()
567
+ PermissionsCompiler._global_cache.clear()
568
+ self.ns = create_test_namespace("audit-perm")
569
+ self.client = Client()
570
+
571
+ def tearDown(self):
572
+ AuditManager.unbind()
573
+ PermissionsCompiler._global_cache.clear()
574
+
575
+ def test_compile_global_rules_audit_section(self):
576
+ GlobalPermissionRule.objects.create(
577
+ position=1.0,
578
+ rule={
579
+ "namespace": "audit-*",
580
+ "audit": {"actors": [{"kind": "User", "claims": {"email": "auditor@example.com"}}]},
581
+ },
582
+ )
583
+ auth = AuthManager({"_type": "user", "email": "auditor@example.com"})
584
+ pm = auth.permissions()
585
+ self.assertTrue(pm.check_namespace_object("audit-perm", "audit"))
586
+
587
+ def test_audit_list_requires_namespace_audit(self):
588
+ GlobalPermissionRule.objects.create(
589
+ position=1.0,
590
+ rule={
591
+ "namespace": "audit-*",
592
+ "read": {"actors": [{"kind": "User", "claims": {"email": "reader@example.com"}}]},
593
+ },
594
+ )
595
+ auth = AuthManager({"_type": "user", "email": "reader@example.com"})
596
+ with self.assertRaises(PermissionDenied):
597
+ AuditManager(auth=auth, namespace=self.ns).list()
598
+
599
+ def test_audit_list_allowed_with_audit_permission(self):
600
+ GlobalPermissionRule.objects.create(
601
+ position=1.0,
602
+ rule={
603
+ "namespace": "audit-*",
604
+ "audit": {"actors": [{"kind": "User", "claims": {"email": "auditor@example.com"}}]},
605
+ },
606
+ )
607
+ auth = AuthManager({"_type": "user", "email": "auditor@example.com"})
608
+ self.assertEqual(AuditManager(auth=auth, namespace=self.ns).list().count(), 0)
609
+
610
+ @override_settings(OCMO_AUDIT_MODE="all")
611
+ def test_global_admin_can_list_all_audit(self):
612
+ auth = _admin_auth()
613
+ AuditManager(auth=auth).begin_operation("namespace", "other-ns").set_outcome(permission_ok=True).save()
614
+ self.assertEqual(AuditManager(auth=auth).list().count(), 1)
615
+
616
+
617
+ @override_settings(OCMO_AUDIT_MODE="all")
618
+ class TestAuditAPI(TestCase):
619
+ def setUp(self):
620
+ AuditManager.unbind()
621
+ PermissionsCompiler._global_cache.clear()
622
+ self.ns = create_test_namespace("audit-api")
623
+ self.client = Client()
624
+ GlobalPermissionRule.objects.create(
625
+ position=1.0,
626
+ rule={
627
+ "namespace": "audit-*",
628
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
629
+ "audit": {"actors": [{"kind": "User", "claims": {"email": "auditor@example.com"}}]},
630
+ },
631
+ )
632
+ _bind_request(
633
+ AuthManager({"_type": "user", "email": "auditor@example.com", "sub": "auditor"}),
634
+ namespace=self.ns,
635
+ )
636
+ AuditManager.current().begin_operation("config", "x").set_outcome(permission_ok=True).save()
637
+
638
+ def tearDown(self):
639
+ AuditManager.unbind()
640
+ PermissionsCompiler._global_cache.clear()
641
+
642
+ def test_namespace_audit_endpoint_requires_auth(self):
643
+ from core.tests.auth_helpers import deny_authentication
644
+
645
+ with deny_authentication():
646
+ resp = self.client.get("/api/v1/ns/audit-api/~audit/")
647
+ self.assertEqual(resp.status_code, 403)
648
+
649
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
650
+ def test_tree_create_writes_audit(self):
651
+ auth = _bind_request(
652
+ _user_auth(),
653
+ path="/api/v1/ns/audit-api/~config/~create/new/cfg",
654
+ method="POST",
655
+ namespace=self.ns,
656
+ )
657
+ TreeManager(self.ns, "new/cfg", auth=auth).create_item("key: value\n", "config")
658
+ self.assertTrue(AuditEvent.objects.filter(object_id="new/cfg", object_type="config").exists())
659
+
660
+
661
+ class TestAuditMode(TestCase):
662
+ def setUp(self):
663
+ AuditManager.unbind()
664
+ self.ns = create_test_namespace("audit-mode")
665
+
666
+ def tearDown(self):
667
+ AuditManager.unbind()
668
+
669
+ @override_settings(OCMO_AUDIT_MODE="resolve")
670
+ def test_resolve_mode_skips_get_operation(self):
671
+ TreeManager(self.ns, "read/cfg", auth=None).create_item("k: v\n", "config")
672
+ auth = _bind_request(_user_auth(), namespace=self.ns)
673
+ TreeManager(self.ns, "read/cfg", auth=auth).get_extended()
674
+ self.assertFalse(AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_OPERATION).exists())
675
+
676
+ @override_settings(OCMO_AUDIT_MODE="resolve")
677
+ def test_resolve_mode_skips_post_operation(self):
678
+ auth = _bind_request(
679
+ _user_auth(),
680
+ path="/api/v1/ns/audit-mode/~config/~create/new/cfg",
681
+ method="POST",
682
+ namespace=self.ns,
683
+ )
684
+ TreeManager(self.ns, "new/cfg", auth=auth).create_item("key: value\n", "config")
685
+ self.assertFalse(AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_OPERATION).exists())
686
+
687
+ @override_settings(OCMO_AUDIT_MODE="resolve")
688
+ def test_resolve_mode_persists_resolve_events(self):
689
+ _bind_request(_user_auth(), namespace=self.ns)
690
+ audit = AuditManager.current()
691
+ assert audit is not None
692
+ audit.begin_resolve_request("root/cfg").set_outcome(permission_ok=True).save()
693
+ self.assertEqual(
694
+ AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST).count(),
695
+ 1,
696
+ )
697
+
698
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
699
+ def test_modifications_mode_skips_get_operation(self):
700
+ TreeManager(self.ns, "read/cfg", auth=None).create_item("k: v\n", "config")
701
+ auth = _bind_request(_user_auth(), namespace=self.ns)
702
+ TreeManager(self.ns, "read/cfg", auth=auth).get_extended()
703
+ self.assertFalse(AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_OPERATION).exists())
704
+
705
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
706
+ def test_modifications_mode_persists_post_operation(self):
707
+ auth = _bind_request(
708
+ _user_auth(),
709
+ path="/api/v1/ns/audit-mode/~config/~create/new/cfg",
710
+ method="POST",
711
+ namespace=self.ns,
712
+ )
713
+ TreeManager(self.ns, "new/cfg", auth=auth).create_item("key: value\n", "config")
714
+ self.assertTrue(
715
+ AuditEvent.objects.filter(object_id="new/cfg", event_kind=AuditEvent.EVENT_KIND_OPERATION).exists()
716
+ )
717
+
718
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
719
+ def test_modifications_mode_persists_resolve_events(self):
720
+ _bind_request(_user_auth(), namespace=self.ns)
721
+ AuditManager.current().begin_resolve_request("cfg").set_outcome(permission_ok=True).save()
722
+ self.assertEqual(
723
+ AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST).count(),
724
+ 1,
725
+ )
726
+
727
+ @override_settings(OCMO_AUDIT_MODE="all")
728
+ def test_all_mode_persists_get_operation(self):
729
+ auth = _bind_request(_user_auth(), namespace=self.ns)
730
+ ns = self.ns
731
+
732
+ class Mgr:
733
+ namespace = ns
734
+ path = "app/cfg"
735
+
736
+ @audit("config")
737
+ @require_permissions(PermCheck("config:read"))
738
+ def read(self):
739
+ return "ok"
740
+
741
+ mgr = Mgr()
742
+ mgr.auth = auth
743
+ mgr.read()
744
+ self.assertTrue(AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_OPERATION).exists())
745
+
746
+ @override_settings(OCMO_AUDIT_MODE="bogus")
747
+ def test_invalid_mode_falls_back_to_resolve(self):
748
+ self.assertEqual(AuditManager.get_audit_mode(), "resolve")
749
+ TreeManager(self.ns, "read/cfg", auth=None).create_item("k: v\n", "config")
750
+ auth = _bind_request(_user_auth(), namespace=self.ns)
751
+ TreeManager(self.ns, "read/cfg", auth=auth).get_extended()
752
+ self.assertFalse(AuditEvent.objects.filter(event_kind=AuditEvent.EVENT_KIND_OPERATION).exists())
753
+
754
+ @override_settings(OCMO_AUDIT_MODE="resolve")
755
+ def test_permission_denied_on_get_not_audited_in_resolve_mode(self):
756
+ ns = Namespace.objects.create(name="audit-mode-deny", description="test")
757
+ from core.utils.namespace_special_configs import _load_builtin_schema_content
758
+
759
+ TreeManager(ns, "_permissions.schema", auth=None).create_item(
760
+ _load_builtin_schema_content("_permissions.schema"),
761
+ "config",
762
+ )
763
+ TreeManager(ns, "_permissions", auth=None).create_item("policies: []\n", "config")
764
+ auth = _bind_request(
765
+ AuthManager({"_type": "user", "sub": "x", "email": "denied@example.com", "name": "Denied"}),
766
+ namespace=ns,
767
+ )
768
+
769
+ class Mgr:
770
+ namespace = ns
771
+ path = "app/cfg"
772
+
773
+ @audit("config")
774
+ @require_permissions(PermCheck("config:read"))
775
+ def read(self):
776
+ return "ok"
777
+
778
+ mgr = Mgr()
779
+ mgr.auth = auth
780
+ with self.assertRaises(PermissionDenied):
781
+ mgr.read()
782
+ self.assertFalse(AuditEvent.objects.exists())
783
+
784
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
785
+ def test_delete_preview_skipped(self):
786
+ TreeManager(self.ns, "del/cfg", auth=None).create_item("k: v\n", "config")
787
+ auth = _bind_request(
788
+ _user_auth(),
789
+ path="/api/v1/ns/audit-mode/~delete/del/cfg",
790
+ method="DELETE",
791
+ namespace=self.ns,
792
+ )
793
+ TreeManager(self.ns, "del/cfg", auth=auth).delete_item(preview=True)
794
+ self.assertFalse(AuditEvent.objects.exists())
795
+
796
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
797
+ def test_delete_without_preview_audited(self):
798
+ TreeManager(self.ns, "del/cfg", auth=None).create_item("k: v\n", "config")
799
+ auth = _bind_request(
800
+ _user_auth(),
801
+ path="/api/v1/ns/audit-mode/~delete/del/cfg",
802
+ method="DELETE",
803
+ namespace=self.ns,
804
+ )
805
+ TreeManager(self.ns, "del/cfg", auth=auth).delete_item(preview=False)
806
+ self.assertTrue(
807
+ AuditEvent.objects.filter(
808
+ object_id="del/cfg",
809
+ event_kind=AuditEvent.EVENT_KIND_OPERATION,
810
+ ).exists()
811
+ )
812
+
813
+ @override_settings(OCMO_AUDIT_MODE="all")
814
+ def test_delete_preview_skipped_even_in_all_mode(self):
815
+ TreeManager(self.ns, "del/cfg", auth=None).create_item("k: v\n", "config")
816
+ auth = _bind_request(_user_auth(), namespace=self.ns)
817
+ TreeManager(self.ns, "del/cfg", auth=auth).delete_item(preview=True)
818
+ self.assertFalse(AuditEvent.objects.exists())
819
+
820
+
821
+ class TestItemAuditTimeline(TestCase):
822
+ def setUp(self):
823
+ AuditManager.unbind()
824
+ PermissionsCompiler._policy_cache.clear()
825
+ PermissionsCompiler._global_cache.clear()
826
+ self.ns = create_test_namespace("item-audit")
827
+ TreeManager(self.ns, "app/cfg", auth=None).create_item("key: value\n", "config")
828
+
829
+ def tearDown(self):
830
+ AuditManager.unbind()
831
+ PermissionsCompiler._policy_cache.clear()
832
+ PermissionsCompiler._global_cache.clear()
833
+
834
+ def _policy(self, *, email: str, actions: list[str]):
835
+ return PermissionsCompiler.compile_policy_set(
836
+ {
837
+ "policies": [
838
+ {
839
+ "effect": "Allow",
840
+ "actors": [{"kind": "User", "claims": {"email": email}}],
841
+ "actions": actions,
842
+ "resources": ["**"],
843
+ }
844
+ ],
845
+ }
846
+ )
847
+
848
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
849
+ def test_item_timeline_requires_item_audit(self):
850
+ ps = self._policy(email="reader@example.com", actions=["config:read"])
851
+ auth = AuthManager({"_type": "user", "email": "reader@example.com"})
852
+ with patch(
853
+ "core.utils.permissions_compiler.PermissionsCompiler.load_policy_set",
854
+ return_value=ps,
855
+ ):
856
+ with self.assertRaises(NotFound):
857
+ AuditManager(auth=auth, namespace=self.ns).item_timeline(
858
+ "app/cfg",
859
+ "config",
860
+ )
861
+
862
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
863
+ def test_item_timeline_allowed_with_item_audit(self):
864
+ ps = self._policy(email="auditor@example.com", actions=["config:audit"])
865
+ auth = _bind_request(
866
+ AuthManager({"_type": "user", "email": "auditor@example.com"}),
867
+ method="POST",
868
+ path="/api/v1/ns/item-audit/~config/~update/app/cfg",
869
+ namespace=self.ns,
870
+ )
871
+ AuditManager.current().begin_operation("config", "app/cfg").set_operation("Update item").set_outcome(
872
+ permission_ok=True
873
+ ).save()
874
+
875
+ with patch(
876
+ "core.utils.permissions_compiler.PermissionsCompiler.load_policy_set",
877
+ return_value=ps,
878
+ ):
879
+ rows = list(AuditManager(auth=auth, namespace=self.ns).item_timeline("app/cfg", "config"))
880
+ self.assertEqual(len(rows), 1)
881
+ self.assertEqual(rows[0].object_id, "app/cfg")
882
+
883
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
884
+ def test_item_timeline_excludes_failed_events(self):
885
+ ps = self._policy(email="auditor@example.com", actions=["config:audit"])
886
+ auth = _bind_request(
887
+ AuthManager({"_type": "user", "email": "auditor@example.com"}),
888
+ method="POST",
889
+ path="/api/v1/ns/item-audit/~config/~update/app/cfg",
890
+ namespace=self.ns,
891
+ )
892
+ AuditManager.current().begin_operation("config", "app/cfg").set_operation("Update item").set_outcome(
893
+ permission_ok=True
894
+ ).save()
895
+ AuditManager.current().begin_operation("config", "app/cfg").set_operation("Set tag").set_outcome(
896
+ permission_ok=True, error="tag already exists"
897
+ ).save()
898
+ AuditManager.current().begin_operation("config", "app/cfg").set_operation("Update item").set_outcome(
899
+ permission_ok=False, error="Permission denied"
900
+ ).save()
901
+
902
+ with patch(
903
+ "core.utils.permissions_compiler.PermissionsCompiler.load_policy_set",
904
+ return_value=ps,
905
+ ):
906
+ rows = list(AuditManager(auth=auth, namespace=self.ns).item_timeline("app/cfg", "config"))
907
+ self.assertEqual(len(rows), 1)
908
+ self.assertEqual(rows[0].operation, "Update item")
909
+
910
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
911
+ def test_item_timeline_search_matches_operation(self):
912
+ ps = self._policy(email="auditor@example.com", actions=["config:audit"])
913
+ auth = _bind_request(
914
+ AuthManager({"_type": "user", "email": "auditor@example.com"}),
915
+ method="POST",
916
+ path="/api/v1/ns/item-audit/~config/~update/app/cfg",
917
+ namespace=self.ns,
918
+ )
919
+ AuditManager.current().begin_operation("config", "app/cfg").set_operation("Update item").set_outcome(
920
+ permission_ok=True
921
+ ).save()
922
+ AuditManager.current().begin_resolve_request("app/cfg").set_outcome(permission_ok=True).save()
923
+
924
+ with patch(
925
+ "core.utils.permissions_compiler.PermissionsCompiler.load_policy_set",
926
+ return_value=ps,
927
+ ):
928
+ mgr = AuditManager(auth=auth, namespace=self.ns)
929
+ self.assertEqual(mgr.item_timeline("app/cfg", "config").count(), 1)
930
+ self.assertEqual(mgr.item_timeline("app/cfg", "config", search="Update item").count(), 1)
931
+ self.assertEqual(mgr.item_timeline("app/cfg", "config", search="Resolve").count(), 0)
932
+ self.assertEqual(mgr.item_timeline("app/cfg", "config", search="missing").count(), 0)
933
+
934
+ @override_settings(OCMO_AUDIT_MODE="modifications-and-resolve")
935
+ def test_item_timeline_includes_events_from_prior_paths_after_move(self):
936
+ ps = self._policy(
937
+ email="auditor@example.com",
938
+ actions=["config:audit", "config:read", "config:delete", "config:write"],
939
+ )
940
+ _bind_request(
941
+ AuthManager({"_type": "user", "email": "auditor@example.com"}),
942
+ method="POST",
943
+ path="/api/v1/ns/item-audit/~config/~update/app/cfg",
944
+ namespace=self.ns,
945
+ )
946
+ AuditManager.current().begin_operation("config", "app/cfg").set_operation("Update item").set_outcome(
947
+ permission_ok=True
948
+ ).save()
949
+
950
+ move_auth = _bind_request(
951
+ AuthManager({"_type": "user", "email": "auditor@example.com"}),
952
+ method="POST",
953
+ path="/api/v1/ns/item-audit/~move/app/cfg",
954
+ namespace=self.ns,
955
+ )
956
+ TreeManager(self.ns, "app/cfg", auth=move_auth).move_item("moved/cfg")
957
+
958
+ with patch(
959
+ "core.utils.permissions_compiler.PermissionsCompiler.load_policy_set",
960
+ return_value=ps,
961
+ ):
962
+ rows = list(
963
+ AuditManager(auth=move_auth, namespace=self.ns).item_timeline(
964
+ "moved/cfg",
965
+ "config",
966
+ )
967
+ )
968
+
969
+ operations = {row.operation for row in rows}
970
+ object_ids = {row.object_id for row in rows}
971
+ self.assertIn("Update item", operations)
972
+ self.assertIn("Move item", operations)
973
+ self.assertIn("app/cfg", object_ids)