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,365 @@
1
+ """Audit operation inference and timeline note formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from ...constants.audit_operations import (
10
+ OP_COPY_ITEM,
11
+ OP_CREATE_ITEM,
12
+ OP_CREATE_LOCK,
13
+ OP_CREATE_NAMESPACE,
14
+ OP_CREATE_PERMISSION,
15
+ OP_DELETE_ITEM,
16
+ OP_DELETE_LOCK,
17
+ OP_DELETE_NAMESPACE,
18
+ OP_DELETE_PERMISSION,
19
+ OP_DELETE_TAG,
20
+ OP_DIFF_ITEM,
21
+ OP_DOWNLOAD_ARTIFACT,
22
+ OP_LIST_LOCKS,
23
+ OP_LIST_PERMISSIONS,
24
+ OP_LIST_VERSIONS,
25
+ OP_MOVE_ITEM,
26
+ OP_MOVE_PERMISSION,
27
+ OP_NAVIGATE,
28
+ OP_PROMOTE_STABLE_TAG,
29
+ OP_PROPAGATE_CONFIG,
30
+ OP_READ_LOCK,
31
+ OP_READ_NAMESPACE,
32
+ OP_READ_PERMISSION,
33
+ OP_REFERENCED_IN_RESOLVE,
34
+ OP_RESOLVE,
35
+ OP_ROTATE_TOKEN,
36
+ OP_SEARCH,
37
+ OP_SET_TAG,
38
+ OP_UPDATE_DESCRIPTION,
39
+ OP_UPDATE_ITEM,
40
+ OP_UPDATE_LOCK,
41
+ OP_UPDATE_NAMESPACE,
42
+ OP_UPDATE_PERMISSION,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from . import AuditEventDraft
47
+
48
+ _ENDPOINT_RULES: list[tuple[re.Pattern[str], dict[str, str] | str]] = [
49
+ (re.compile(r"/~config/~create/"), OP_CREATE_ITEM),
50
+ (re.compile(r"/~template/~create/"), OP_CREATE_ITEM),
51
+ (re.compile(r"/~secret/~create/"), OP_CREATE_ITEM),
52
+ (re.compile(r"/~resolver/~create/"), OP_CREATE_ITEM),
53
+ (re.compile(r"/~config/~update/"), OP_UPDATE_ITEM),
54
+ (re.compile(r"/~template/~update/"), OP_UPDATE_ITEM),
55
+ (re.compile(r"/~secret/~update/"), OP_UPDATE_ITEM),
56
+ (re.compile(r"/~resolver/~update/"), OP_UPDATE_ITEM),
57
+ (re.compile(r"/~delete/"), OP_DELETE_ITEM),
58
+ (re.compile(r"/~get/"), "read_typed"),
59
+ (re.compile(r"/~diff/"), OP_DIFF_ITEM),
60
+ (re.compile(r"/~versions/"), OP_LIST_VERSIONS),
61
+ (re.compile(r"/~navigate/"), OP_NAVIGATE),
62
+ (re.compile(r"/~search/"), OP_SEARCH),
63
+ (re.compile(r"/~describe/"), OP_UPDATE_DESCRIPTION),
64
+ (re.compile(r"/~move/"), OP_MOVE_ITEM),
65
+ (re.compile(r"/~copy/"), OP_COPY_ITEM),
66
+ (re.compile(r"/~propagate/"), OP_PROPAGATE_CONFIG),
67
+ (re.compile(r"/~download/"), OP_DOWNLOAD_ARTIFACT),
68
+ (re.compile(r"/~tag/"), {"POST": OP_SET_TAG, "PUT": OP_SET_TAG}),
69
+ (
70
+ re.compile(r"/~lock/"),
71
+ {
72
+ "GET": OP_READ_LOCK,
73
+ "POST": OP_CREATE_LOCK,
74
+ "PUT": OP_UPDATE_LOCK,
75
+ "DELETE": OP_DELETE_LOCK,
76
+ },
77
+ ),
78
+ (re.compile(r"/~rotate"), OP_ROTATE_TOKEN),
79
+ ]
80
+
81
+
82
+ def read_operation_for_type(object_type: str | None) -> str:
83
+ """Return a typed Read label when object_type is known."""
84
+ if object_type in ("config", "template", "secret", "resolver", "folder"):
85
+ return f"Read {object_type}"
86
+ if object_type == "namespace":
87
+ return OP_READ_NAMESPACE
88
+ if object_type == "lock":
89
+ return OP_READ_LOCK
90
+ if object_type == "global_permission":
91
+ return OP_READ_PERMISSION
92
+ if object_type == "artifact":
93
+ return OP_DOWNLOAD_ARTIFACT
94
+ return "Read item"
95
+
96
+
97
+ def infer_operation(draft: AuditEventDraft) -> str:
98
+ """Infer a user-friendly operation label from draft fields."""
99
+ from ...models import AuditEvent
100
+
101
+ if draft.operation:
102
+ return draft.operation
103
+
104
+ if draft.event_kind == AuditEvent.EVENT_KIND_RESOLVE_REQUEST:
105
+ return OP_RESOLVE
106
+ if draft.event_kind == AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT:
107
+ return OP_REFERENCED_IN_RESOLVE
108
+
109
+ endpoint = draft.api_endpoint or ""
110
+ method = (draft.http_method or "").upper()
111
+ object_type = draft.object_type
112
+
113
+ if object_type == "namespace":
114
+ if method == "GET":
115
+ return OP_READ_NAMESPACE
116
+ if method == "POST":
117
+ return OP_CREATE_NAMESPACE
118
+ if method in ("PUT", "PATCH"):
119
+ return OP_UPDATE_NAMESPACE
120
+ if method == "DELETE":
121
+ return OP_DELETE_NAMESPACE
122
+
123
+ if object_type == "global_permission":
124
+ if method == "GET" and (draft.object_id in (None, "", "*")):
125
+ return OP_LIST_PERMISSIONS
126
+ if method == "GET":
127
+ return OP_READ_PERMISSION
128
+ if method == "POST":
129
+ return OP_CREATE_PERMISSION
130
+ if method in ("PUT", "PATCH"):
131
+ return OP_UPDATE_PERMISSION
132
+ if method == "DELETE":
133
+ return OP_DELETE_PERMISSION
134
+
135
+ if object_type == "lock" and method == "GET" and draft.object_id == "*":
136
+ return OP_LIST_LOCKS
137
+
138
+ for pattern, rule in _ENDPOINT_RULES:
139
+ if not pattern.search(endpoint):
140
+ continue
141
+ if rule == "read_typed":
142
+ return read_operation_for_type(object_type)
143
+ if isinstance(rule, dict):
144
+ return rule.get(method, rule.get("GET", OP_UPDATE_ITEM))
145
+ return rule
146
+
147
+ if object_type == "propagation":
148
+ return OP_PROPAGATE_CONFIG
149
+ if object_type == "artifact":
150
+ return OP_DOWNLOAD_ARTIFACT
151
+
152
+ if method == "GET":
153
+ return read_operation_for_type(object_type)
154
+ if method == "POST":
155
+ return OP_CREATE_ITEM
156
+ if method in ("PUT", "PATCH"):
157
+ return OP_UPDATE_ITEM
158
+ if method == "DELETE":
159
+ return OP_DELETE_ITEM
160
+
161
+ return draft.event_kind or "Unknown"
162
+
163
+
164
+ def _parse_propagation_payload(subresource: str | None) -> dict[str, Any]:
165
+ """Parse propagation audit JSON stored in ``subresource``."""
166
+ raw = (subresource or "").strip()
167
+ if not raw:
168
+ return {}
169
+ try:
170
+ data = json.loads(raw)
171
+ except (ValueError, TypeError):
172
+ return {"trigger": raw}
173
+ return data if isinstance(data, dict) else {"trigger": raw}
174
+
175
+
176
+ def _format_propagation_targets(targets: list[dict]) -> str:
177
+ return ", ".join(
178
+ f"`{t['path']}`@v{t['version']}" for t in targets if t.get("path") is not None and t.get("version") is not None
179
+ )
180
+
181
+
182
+ def _parse_subresources(
183
+ subresource_type: str | None,
184
+ subresource: str | None,
185
+ ) -> dict[str, str]:
186
+ if not subresource:
187
+ return {}
188
+ types = (subresource_type or "").split(",")
189
+ values = subresource.split(",")
190
+ parsed: dict[str, str] = {}
191
+ for index, value in enumerate(values):
192
+ kind = types[index].strip() if index < len(types) else ""
193
+ if kind:
194
+ parsed[kind] = value
195
+ return parsed
196
+
197
+
198
+ def _timeline_message_body(
199
+ operation: str,
200
+ *,
201
+ actor: str,
202
+ object_type: str | None,
203
+ object_id: str | None,
204
+ object_version: int | None,
205
+ subresource_type: str | None,
206
+ subresource: str | None,
207
+ ) -> str:
208
+ kind = object_type or "item"
209
+ subresources = _parse_subresources(subresource_type, subresource)
210
+ path = f"`{object_id}`" if object_id else kind
211
+
212
+ if operation == OP_CREATE_ITEM:
213
+ if object_version is not None:
214
+ return f"{actor} created {kind} {path} (version {object_version})"
215
+ return f"{actor} created {kind} {path}"
216
+
217
+ if operation == OP_UPDATE_ITEM:
218
+ if object_version is not None:
219
+ return f"{actor} created new version of {kind} ({object_version})"
220
+ return f"{actor} updated {kind}"
221
+
222
+ if operation == OP_DELETE_ITEM:
223
+ if object_version is not None:
224
+ return f"{actor} deleted version {object_version} of {kind}"
225
+ return f"{actor} deleted {kind} {path}"
226
+
227
+ if operation == OP_SET_TAG:
228
+ tag = subresources.get("tag") or subresource
229
+ if tag and object_version is not None:
230
+ return f"{actor} set tag `{tag}` to version {object_version}"
231
+ if tag:
232
+ return f"{actor} set tag `{tag}`"
233
+ return f"{actor} set a tag on {kind}"
234
+
235
+ if operation == OP_DELETE_TAG:
236
+ tag = subresources.get("tag") or subresource
237
+ if tag and object_version is not None:
238
+ return f"{actor} removed tag `{tag}` from version {object_version}"
239
+ if tag:
240
+ return f"{actor} removed tag `{tag}`"
241
+ return f"{actor} removed a tag from {kind}"
242
+
243
+ if operation == OP_UPDATE_DESCRIPTION:
244
+ return f"{actor} updated description of {kind}"
245
+
246
+ if operation == OP_MOVE_ITEM:
247
+ destination = subresources.get("path") or subresource
248
+ if destination:
249
+ return f"{actor} moved {kind} to `{destination}`"
250
+ return f"{actor} moved {kind}"
251
+
252
+ if operation == OP_COPY_ITEM:
253
+ destination = subresources.get("path")
254
+ tag = subresources.get("tag")
255
+ if destination and tag:
256
+ return f"{actor} copied {kind} to `{destination}` (tag `{tag}`)"
257
+ if destination:
258
+ return f"{actor} copied {kind} to `{destination}`"
259
+ return f"{actor} copied {kind}"
260
+
261
+ if operation == OP_PROPAGATE_CONFIG:
262
+ data = _parse_propagation_payload(subresource)
263
+ trigger = data.get("trigger", "")
264
+ trigger_tag = data.get("trigger_tag", "")
265
+ updated = data.get("targets", [])
266
+ unchanged = data.get("unchanged", [])
267
+ targets_str = _format_propagation_targets(updated) if updated else ""
268
+
269
+ if trigger == "manual":
270
+ base = f"{actor} manually propagated version {object_version}"
271
+ if targets_str:
272
+ return f"{base} to {targets_str}"
273
+ if unchanged:
274
+ paths = ", ".join(f"`{p}`" for p in unchanged)
275
+ return f"{base}; all targets already matched ({paths})"
276
+ return base
277
+ if trigger == "tag" and trigger_tag:
278
+ base = f"{actor} propagated by setting tag `{trigger_tag}` to version {object_version}"
279
+ if targets_str:
280
+ return f"{base}, creating {targets_str}"
281
+ if unchanged:
282
+ paths = ", ".join(f"`{p}`" for p in unchanged)
283
+ return f"{base}; all targets already matched ({paths})"
284
+ return base
285
+ if object_version is not None:
286
+ return f"{actor} propagated version {object_version}"
287
+ return f"{actor} propagated {kind}"
288
+
289
+ if operation == OP_PROMOTE_STABLE_TAG:
290
+ tag = subresources.get("tag") or subresource or "stable"
291
+ return f"{actor} promoted tag `{tag}` on {kind}"
292
+
293
+ if operation == OP_ROTATE_TOKEN:
294
+ token = subresources.get("token") or subresource
295
+ if token:
296
+ return f"{actor} rotated resolver token {token}"
297
+ return f"{actor} rotated resolver token"
298
+
299
+ if operation == OP_CREATE_LOCK:
300
+ return f"{actor} created lock on {path}"
301
+ if operation == OP_UPDATE_LOCK:
302
+ return f"{actor} updated lock on {path}"
303
+ if operation == OP_DELETE_LOCK:
304
+ return f"{actor} deleted lock on {path}"
305
+
306
+ if operation == OP_CREATE_NAMESPACE:
307
+ return f"{actor} created namespace `{object_id}`"
308
+ if operation == OP_UPDATE_NAMESPACE:
309
+ return f"{actor} updated namespace `{object_id}`"
310
+ if operation == OP_DELETE_NAMESPACE:
311
+ return f"{actor} deleted namespace `{object_id}`"
312
+
313
+ if operation == OP_CREATE_PERMISSION:
314
+ return f"{actor} created global permission `{object_id}`"
315
+ if operation == OP_UPDATE_PERMISSION:
316
+ return f"{actor} updated global permission `{object_id}`"
317
+ if operation == OP_DELETE_PERMISSION:
318
+ return f"{actor} deleted global permission `{object_id}`"
319
+ if operation == OP_MOVE_PERMISSION:
320
+ return f"{actor} moved global permission `{object_id}`"
321
+
322
+ if operation == OP_REFERENCED_IN_RESOLVE:
323
+ return f"{actor} referenced this {kind} in a resolve"
324
+ if operation == OP_RESOLVE:
325
+ return f"{actor} resolved this {kind}"
326
+ if operation.startswith("Read "):
327
+ return f"{actor} viewed this {kind}"
328
+
329
+ return f"{actor} {operation.lower()}"
330
+
331
+
332
+ def format_timeline_note(event) -> str:
333
+ """Build a single user-friendly timeline sentence for an audit event."""
334
+ operation = event.operation
335
+ if not operation:
336
+ from . import AuditEventDraft
337
+
338
+ draft = AuditEventDraft(
339
+ event_kind=event.event_kind,
340
+ http_method=event.http_method,
341
+ api_endpoint=event.api_endpoint,
342
+ object_type=event.object_type,
343
+ object_id=event.object_id,
344
+ )
345
+ operation = infer_operation(draft)
346
+
347
+ actor = f"Resolver {event.auth_id}" if event.auth_type == "resolver" else f"User {event.auth_email}"
348
+ note = _timeline_message_body(
349
+ operation,
350
+ actor=actor,
351
+ object_type=event.object_type,
352
+ object_id=event.object_id,
353
+ object_version=event.object_version,
354
+ subresource_type=event.subresource_type,
355
+ subresource=event.subresource,
356
+ )
357
+
358
+ if event.from_cache and operation in (OP_RESOLVE, OP_REFERENCED_IN_RESOLVE):
359
+ note += " (from cache)"
360
+
361
+ if event.permission_ok is False:
362
+ note += " — permission denied"
363
+ elif event.error:
364
+ note += f" — {event.error}"
365
+ return note
core/managers/auth.py ADDED
@@ -0,0 +1,276 @@
1
+ """Request-scoped identity wrapper.
2
+
3
+ Wraps the parsed identity dict from request.auth and provides typed access
4
+ to user claims / resolver properties, plus per-request decision memoisation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ from django.conf import settings
13
+ from django.http import HttpRequest
14
+
15
+ from ..exceptions import Unauthenticated
16
+ from .namespace import NamespaceManager
17
+ from .permissions import PermissionsManager
18
+
19
+
20
+ class AuthManager:
21
+ """Wrap a parsed request.auth dict for type-safe identity access.
22
+
23
+ Two identity kinds:
24
+ User - {"_type": "user", <OIDC_USER_*_CLAIM>: ..., ...}
25
+ Resolver - {"_type": "resolver", "namespace": <ns_id>, "name": ...,
26
+ "access_scope": ..., "token_number": 1|2}
27
+
28
+ User identity fields are read via OIDC_USER_ID_CLAIM, OIDC_USER_EMAIL_CLAIM,
29
+ and OIDC_USER_DISPLAY_NAME_CLAIM (defaults: sub, email, name).
30
+ """
31
+
32
+ DEFAULT_ACTOR_IDENTITY = "Ocmo"
33
+
34
+ def __init__(self, raw: dict[str, Any]) -> None:
35
+ self._raw = raw
36
+ self._type: str = raw.get("_type", "")
37
+ # Per-request decision memo: {key_tuple: bool}
38
+ self._decisions: dict[tuple, bool] = {}
39
+ self._permissions_mgr: PermissionsManager | None = None
40
+
41
+ # ------------------------------------------------------------------
42
+ # OIDC user-identity claim configuration (from settings)
43
+ # ------------------------------------------------------------------
44
+
45
+ @classmethod
46
+ def user_id_claim(cls) -> str:
47
+ return getattr(settings, "OIDC_USER_ID_CLAIM", "sub")
48
+
49
+ @classmethod
50
+ def user_email_claim(cls) -> str:
51
+ return getattr(settings, "OIDC_USER_EMAIL_CLAIM", "email")
52
+
53
+ @classmethod
54
+ def user_display_name_claim(cls) -> str:
55
+ return getattr(settings, "OIDC_USER_DISPLAY_NAME_CLAIM", "name")
56
+
57
+ # ------------------------------------------------------------------
58
+ # Construction
59
+ # ------------------------------------------------------------------
60
+
61
+ @classmethod
62
+ def from_request(cls, request: HttpRequest) -> AuthManager:
63
+ """Build from Ninja's HttpRequest object."""
64
+ if request is None:
65
+ raise Unauthenticated("Authentication required")
66
+ auth_payload = request.auth # type: ignore[attr-defined]
67
+ if not isinstance(auth_payload, dict):
68
+ raise Unauthenticated("Authentication required")
69
+ return cls(auth_payload)
70
+
71
+ # ------------------------------------------------------------------
72
+ # Identity type
73
+ # ------------------------------------------------------------------
74
+
75
+ @property
76
+ def is_user(self) -> bool:
77
+ return self._type == "user"
78
+
79
+ @property
80
+ def is_resolver(self) -> bool:
81
+ return self._type == "resolver"
82
+
83
+ @property
84
+ def is_authorized(self) -> bool:
85
+ """True when a valid auth is present (OIDC enabled and authenticated)."""
86
+ return self._type in ("user", "resolver")
87
+
88
+ def to_whoami(self) -> dict[str, Any]:
89
+ """Serialize identity for GET /auth/whoami/ (validated as WhoAmISchema by Ninja)."""
90
+ payload: dict[str, Any] = {
91
+ "auth_type": "user" if self.is_user else "resolver",
92
+ "identifier": self.identifier,
93
+ "display_name": self.display_name,
94
+ "access_scope": self.access_scope,
95
+ }
96
+ if self.is_user:
97
+ payload["user_details"] = {
98
+ "email": self.email,
99
+ "is_global_admin": self.is_global_admin,
100
+ "claims": self.claims,
101
+ }
102
+ else:
103
+ if self.namespace_id is None:
104
+ raise Unauthenticated("Invalid resolver identity")
105
+ ns = NamespaceManager.get_by_pk(self.namespace_id)
106
+ payload["resolver_details"] = {
107
+ "namespace": ns.name,
108
+ "name": self.resolver_name,
109
+ "token_number": self.token_number,
110
+ }
111
+ return payload
112
+
113
+ # ------------------------------------------------------------------
114
+ # User identity
115
+ # ------------------------------------------------------------------
116
+
117
+ @property
118
+ def claims(self) -> dict[str, Any]:
119
+ """All JWT claims, excluding internal _-prefixed keys."""
120
+ if not self.is_user:
121
+ return {}
122
+ return {k: v for k, v in self._raw.items() if not k.startswith("_")}
123
+
124
+ def get_claim(self, name: str) -> Any:
125
+ return self._raw.get(name)
126
+
127
+ @property
128
+ def identifier(self) -> Any:
129
+ return self.get_claim(self.user_id_claim()) if self.is_user else self.resolver_path
130
+
131
+ @property
132
+ def email(self) -> Any:
133
+ """User email from the configured OIDC claim."""
134
+ return self.get_claim(self.user_email_claim()) if self.is_user else None
135
+
136
+ @property
137
+ def display_name(self) -> Any:
138
+ return self.get_claim(self.user_display_name_claim()) if self.is_user else f"Resolver ({self.resolver_path})"
139
+
140
+ @property
141
+ def actor_identity(self) -> str:
142
+ """Stable audit-trail identity for author, updater, and locked_by fields."""
143
+ return self.display_name
144
+
145
+ @property
146
+ def token_number(self) -> int | None:
147
+ if not self.is_resolver:
148
+ return None
149
+ return self._raw.get("token_number")
150
+
151
+ @classmethod
152
+ def resolve_actor_identity(cls, auth: AuthManager | None) -> str:
153
+ """Return actor identity from auth, or the default when auth is absent."""
154
+ if auth is None:
155
+ return cls.DEFAULT_ACTOR_IDENTITY
156
+ return auth.actor_identity
157
+
158
+ @property
159
+ def is_global_admin(self) -> bool:
160
+ """True when the JWT satisfies OIDC_GLOBAL_ADMIN_CLAIM/VALUE."""
161
+ if not self.is_user:
162
+ return False
163
+ claim_name: str = getattr(settings, "OIDC_GLOBAL_ADMIN_CLAIM", "groups")
164
+ claim_value: str = getattr(settings, "OIDC_GLOBAL_ADMIN_VALUE", "")
165
+ if not claim_value:
166
+ return False
167
+ user_val = self._raw.get(claim_name)
168
+ if isinstance(user_val, list):
169
+ return claim_value in user_val
170
+ return user_val == claim_value
171
+
172
+ # ------------------------------------------------------------------
173
+ # Resolver identity
174
+ # ------------------------------------------------------------------
175
+
176
+ @property
177
+ def namespace_id(self) -> int | None:
178
+ return self._raw.get("namespace") if self.is_resolver else None
179
+
180
+ @property
181
+ def access_scope(self) -> str:
182
+ return (self._raw.get("access_scope") or "") if self.is_resolver else ""
183
+
184
+ @property
185
+ def resolver_name(self) -> str | None:
186
+ return self._raw.get("name") if self.is_resolver else None
187
+
188
+ @property
189
+ def resolver_path(self) -> str | None:
190
+ """Full tree path of this resolver (<scope>/<name>)."""
191
+ if not self.is_resolver:
192
+ return None
193
+ scope = self._raw.get("access_scope", "") or ""
194
+ name = self._raw.get("name", "") or ""
195
+ return f"{scope}/{name}" if scope else name
196
+
197
+ # ------------------------------------------------------------------
198
+ # Per-request decision memoisation (used by PermissionsManager)
199
+ # ------------------------------------------------------------------
200
+
201
+ def _get_memo(self, key: tuple) -> bool | None:
202
+ return self._decisions.get(key)
203
+
204
+ def _set_memo(self, key: tuple, result: bool) -> None:
205
+ self._decisions[key] = result
206
+
207
+ # ------------------------------------------------------------------
208
+ # PermissionsManager lazy accessor
209
+ # ------------------------------------------------------------------
210
+
211
+ def permissions(self, namespace=None) -> PermissionsManager:
212
+ """Return (lazily created) PermissionsManager for this identity.
213
+
214
+ Re-creates the manager when a different namespace instance is supplied.
215
+ """
216
+ if self._permissions_mgr is None or (
217
+ namespace is not None and self._permissions_mgr.namespace is not namespace
218
+ ):
219
+ self._permissions_mgr = PermissionsManager(self, namespace)
220
+ return self._permissions_mgr
221
+
222
+ def probe_permissions(
223
+ self,
224
+ operations: list[str],
225
+ *,
226
+ namespace_name: str | None = None,
227
+ namespace=None,
228
+ resource: str | None = None,
229
+ request_ctx: dict[str, Any] | None = None,
230
+ ) -> dict[str, bool]:
231
+ """Batch permission probe for frontend UI gating."""
232
+ ctx = request_ctx or {"time": datetime.now(UTC)}
233
+ return {
234
+ operation: self._probe_one_permission(
235
+ operation,
236
+ namespace_name=namespace_name,
237
+ namespace=namespace,
238
+ resource=resource,
239
+ request_ctx=ctx,
240
+ )
241
+ for operation in operations
242
+ }
243
+
244
+ def _probe_one_permission(
245
+ self,
246
+ operation: str,
247
+ *,
248
+ namespace_name: str | None,
249
+ namespace,
250
+ resource: str | None,
251
+ request_ctx: dict[str, Any],
252
+ ) -> bool:
253
+ type_, _, verb = operation.partition(":")
254
+
255
+ if operation == "global:admin":
256
+ return self.is_global_admin
257
+
258
+ if operation == "namespace:create":
259
+ return self.permissions().can_create_namespace()
260
+
261
+ if type_ == "namespace":
262
+ if not namespace_name:
263
+ return False
264
+ return self.permissions().check_namespace_object(namespace_name, verb)
265
+
266
+ if namespace is None:
267
+ return False
268
+
269
+ # Lock list probes use resource="" (see LockManager.list_active).
270
+ if type_ == "lock" and resource in (None, ""):
271
+ return self.permissions(namespace).check_tree(operation, "", request_ctx)
272
+
273
+ if not resource:
274
+ return False
275
+
276
+ return self.permissions(namespace).check_tree(operation, resource, request_ctx)