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
core/api/audit.py ADDED
@@ -0,0 +1,244 @@
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ from ninja import Query, Router
5
+ from ninja.pagination import paginate
6
+
7
+ from ..managers.audit import AuditListFilters, AuditManager
8
+ from ..managers.auth import AuthManager
9
+ from ..managers.namespace import NamespaceManager
10
+ from ..operation_ids import (
11
+ GET_GLOBAL_AUDIT_EVENT,
12
+ GET_NAMESPACE_AUDIT_EVENT,
13
+ LIST_GLOBAL_AUDIT,
14
+ LIST_NAMESPACE_AUDIT,
15
+ NAMESPACE_AUDIT_RESOLVE_SERIES,
16
+ NAMESPACE_AUDIT_TIMELINE,
17
+ )
18
+ from ..schemas import ErrorSchema
19
+ from ..schemas.audit import AuditEventSchema, AuditTimelineEntrySchema, ResolveSeriesSchema
20
+
21
+ router = Router()
22
+
23
+
24
+ @router.get(
25
+ "/audit/",
26
+ response=list[AuditEventSchema],
27
+ tags=["Audit"],
28
+ operation_id=LIST_GLOBAL_AUDIT,
29
+ )
30
+ @paginate
31
+ def list_global_audit(
32
+ request,
33
+ auth_id: str | None = None,
34
+ auth_email: str | None = None,
35
+ auth_type: str | None = None,
36
+ object_type: str | None = None,
37
+ object_id: str | None = None,
38
+ http_method: str | None = None,
39
+ api_endpoint: str | None = None,
40
+ permission_ok: bool | None = None,
41
+ resolve_type: str | None = None,
42
+ from_cache: bool | None = None,
43
+ event_kind: str | None = None,
44
+ category: str | None = None,
45
+ parent_event_id: UUID | None = None,
46
+ client_ip: str | None = None,
47
+ user_agent: str | None = None,
48
+ token_number: int | None = None,
49
+ object_version: int | None = None,
50
+ operation: str | None = None,
51
+ subresource_type: str | None = None,
52
+ subresource: str | None = None,
53
+ event_id: UUID | None = None,
54
+ error: str | None = None,
55
+ namespace: str | None = None,
56
+ search: str | None = None,
57
+ occurred_from: datetime | None = Query(None, alias="from"),
58
+ occurred_to: datetime | None = Query(None, alias="to"),
59
+ ):
60
+ """Cross-namespace audit log (global admin only)."""
61
+ auth = AuthManager.from_request(request)
62
+ AuditManager.bind(request, auth)
63
+ filters = AuditListFilters(
64
+ auth_id=auth_id,
65
+ auth_email=auth_email,
66
+ auth_type=auth_type,
67
+ object_type=object_type,
68
+ object_id=object_id,
69
+ http_method=http_method,
70
+ api_endpoint=api_endpoint,
71
+ permission_ok=permission_ok,
72
+ resolve_type=resolve_type,
73
+ from_cache=from_cache,
74
+ event_kind=event_kind,
75
+ category=category,
76
+ parent_event_id=parent_event_id,
77
+ client_ip=client_ip,
78
+ user_agent=user_agent,
79
+ token_number=token_number,
80
+ object_version=object_version,
81
+ operation=operation,
82
+ subresource_type=subresource_type,
83
+ subresource=subresource,
84
+ event_id=event_id,
85
+ error=error,
86
+ namespace_name=namespace,
87
+ occurred_from=occurred_from,
88
+ occurred_to=occurred_to,
89
+ )
90
+ return AuditManager(auth=auth).list(filters, search=search)
91
+
92
+
93
+ @router.get(
94
+ "/audit/{event_id}",
95
+ response={200: AuditEventSchema, 404: ErrorSchema},
96
+ tags=["Audit"],
97
+ operation_id=GET_GLOBAL_AUDIT_EVENT,
98
+ )
99
+ def get_global_audit_event(request, event_id: str):
100
+ """Get a single audit event (global admin only)."""
101
+ auth = AuthManager.from_request(request)
102
+ AuditManager.bind(request, auth)
103
+ return AuditManager(auth=auth).get(event_id)
104
+
105
+
106
+ @router.get(
107
+ "/ns/{namespace}/~audit/",
108
+ response={200: list[AuditEventSchema], 404: ErrorSchema},
109
+ tags=["Audit"],
110
+ operation_id=LIST_NAMESPACE_AUDIT,
111
+ )
112
+ @paginate
113
+ def list_namespace_audit(
114
+ request,
115
+ namespace: str,
116
+ auth_id: str | None = None,
117
+ auth_email: str | None = None,
118
+ auth_type: str | None = None,
119
+ object_type: str | None = None,
120
+ object_id: str | None = None,
121
+ http_method: str | None = None,
122
+ api_endpoint: str | None = None,
123
+ permission_ok: bool | None = None,
124
+ resolve_type: str | None = None,
125
+ from_cache: bool | None = None,
126
+ event_kind: str | None = None,
127
+ category: str | None = None,
128
+ parent_event_id: UUID | None = None,
129
+ client_ip: str | None = None,
130
+ user_agent: str | None = None,
131
+ token_number: int | None = None,
132
+ object_version: int | None = None,
133
+ operation: str | None = None,
134
+ subresource_type: str | None = None,
135
+ subresource: str | None = None,
136
+ event_id: UUID | None = None,
137
+ error: str | None = None,
138
+ search: str | None = None,
139
+ occurred_from: datetime | None = Query(None, alias="from"),
140
+ occurred_to: datetime | None = Query(None, alias="to"),
141
+ ):
142
+ """Namespace-scoped audit log (requires namespace:audit or global admin)."""
143
+ auth = AuthManager.from_request(request)
144
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
145
+ AuditManager.bind(request, auth, namespace=ns)
146
+ filters = AuditListFilters(
147
+ auth_id=auth_id,
148
+ auth_email=auth_email,
149
+ auth_type=auth_type,
150
+ object_type=object_type,
151
+ object_id=object_id,
152
+ http_method=http_method,
153
+ api_endpoint=api_endpoint,
154
+ permission_ok=permission_ok,
155
+ resolve_type=resolve_type,
156
+ from_cache=from_cache,
157
+ event_kind=event_kind,
158
+ category=category,
159
+ parent_event_id=parent_event_id,
160
+ client_ip=client_ip,
161
+ user_agent=user_agent,
162
+ token_number=token_number,
163
+ object_version=object_version,
164
+ operation=operation,
165
+ subresource_type=subresource_type,
166
+ subresource=subresource,
167
+ event_id=event_id,
168
+ error=error,
169
+ namespace_name=namespace,
170
+ occurred_from=occurred_from,
171
+ occurred_to=occurred_to,
172
+ )
173
+ return AuditManager(auth=auth, namespace=ns).list(filters, search=search)
174
+
175
+
176
+ @router.get(
177
+ "/ns/{namespace}/~audit/timeline/",
178
+ response={200: list[AuditTimelineEntrySchema], 404: ErrorSchema},
179
+ tags=["Audit"],
180
+ operation_id=NAMESPACE_AUDIT_TIMELINE,
181
+ )
182
+ @paginate
183
+ def namespace_audit_timeline(
184
+ request,
185
+ namespace: str,
186
+ object_id: str,
187
+ object_type: str,
188
+ search: str | None = None,
189
+ ):
190
+ """Item-scoped audit timeline (requires ``<object_type>:audit`` on ``object_id``)."""
191
+ auth = AuthManager.from_request(request)
192
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
193
+ AuditManager.bind(request, auth, namespace=ns)
194
+ return AuditManager(auth=auth, namespace=ns).item_timeline(
195
+ object_id,
196
+ object_type,
197
+ search=search,
198
+ )
199
+
200
+
201
+ @router.get(
202
+ "/ns/{namespace}/~audit/resolve-series/",
203
+ response={200: ResolveSeriesSchema, 404: ErrorSchema},
204
+ tags=["Audit"],
205
+ operation_id=NAMESPACE_AUDIT_RESOLVE_SERIES,
206
+ )
207
+ def namespace_audit_resolve_series(
208
+ request,
209
+ namespace: str,
210
+ object_id: str,
211
+ object_type: str,
212
+ occurred_from: datetime = Query(..., alias="from"),
213
+ occurred_to: datetime = Query(..., alias="to"),
214
+ bucket_seconds: int = Query(..., ge=1800),
215
+ ):
216
+ """Time-bucketed direct vs nested resolve counts for a tree object."""
217
+ auth = AuthManager.from_request(request)
218
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
219
+ AuditManager.bind(request, auth, namespace=ns)
220
+ buckets = AuditManager(auth=auth, namespace=ns).resolve_stats_series(
221
+ object_id=object_id,
222
+ object_type=object_type,
223
+ since=occurred_from,
224
+ until=occurred_to,
225
+ bucket_seconds=bucket_seconds,
226
+ )
227
+ return {
228
+ "bucket_seconds": bucket_seconds,
229
+ "buckets": buckets,
230
+ }
231
+
232
+
233
+ @router.get(
234
+ "/ns/{namespace}/~audit/{event_id}",
235
+ response={200: AuditEventSchema, 404: ErrorSchema},
236
+ tags=["Audit"],
237
+ operation_id=GET_NAMESPACE_AUDIT_EVENT,
238
+ )
239
+ def get_namespace_audit_event(request, namespace: str, event_id: str):
240
+ """Get a single audit event within a namespace."""
241
+ auth = AuthManager.from_request(request)
242
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
243
+ AuditManager.bind(request, auth, namespace=ns)
244
+ return AuditManager(auth=auth, namespace=ns).get(event_id)
core/api/auth.py ADDED
@@ -0,0 +1,50 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from ninja import Router
4
+
5
+ from ocmoapi.auth import resolver_auth
6
+ from ocmoapi.oauth2_provider import oauth2_auth
7
+
8
+ from ..managers.audit import client_ip_from_request
9
+ from ..managers.auth import AuthManager
10
+ from ..managers.namespace import NamespaceManager
11
+ from ..operation_ids import CAN_I, WHOAMI
12
+ from ..schemas import CanIRequestSchema, CanIResponseSchema, WhoAmISchema
13
+
14
+ router = Router()
15
+
16
+ _auth = [resolver_auth, oauth2_auth]
17
+
18
+
19
+ @router.get("/auth/whoami/", response=WhoAmISchema, tags=["Auth"], auth=_auth, operation_id=WHOAMI)
20
+ def who_are_me(request):
21
+ """Return information about current authenticated user or resolver."""
22
+ return AuthManager.from_request(request).to_whoami()
23
+
24
+
25
+ @router.post(
26
+ "/auth/can-i/",
27
+ response=CanIResponseSchema,
28
+ tags=["Auth"],
29
+ auth=_auth,
30
+ operation_id=CAN_I,
31
+ )
32
+ def can_i(request, payload: CanIRequestSchema):
33
+ """Probe whether the current identity may perform requested operations."""
34
+ auth = AuthManager.from_request(request)
35
+
36
+ namespace = None
37
+ if payload.namespace:
38
+ namespace = NamespaceManager(payload.namespace, auth=auth).get_or_raise()
39
+
40
+ allowed = auth.probe_permissions(
41
+ payload.operations,
42
+ namespace_name=payload.namespace,
43
+ namespace=namespace,
44
+ resource=payload.resource,
45
+ request_ctx={
46
+ "time": datetime.now(UTC),
47
+ "ip": client_ip_from_request(request),
48
+ },
49
+ )
50
+ return {"allowed": allowed}
core/api/config.py ADDED
@@ -0,0 +1,96 @@
1
+ from typing import Any
2
+
3
+ from ninja import Body, Query, Router
4
+
5
+ from ocmoapi.oauth2_provider import oauth2_auth
6
+
7
+ from ..managers.audit import AuditManager
8
+ from ..managers.auth import AuthManager
9
+ from ..managers.namespace import NamespaceManager
10
+ from ..managers.tree import TreeManager
11
+ from ..operation_ids import (
12
+ CREATE_CONFIG,
13
+ GET_CONFIG_DATA_SCHEMA,
14
+ GET_CONFIG_METADATA_SCHEMA,
15
+ UPDATE_CONFIG,
16
+ )
17
+ from ..schemas import AnyExtendedNodeSchema, ErrorSchema
18
+ from ..schemas.metadata_json_schema import build_config_metadata_json_schema
19
+ from ..schemas.requests import ConfigDocument
20
+ from ._common import root_model_openapi_extension
21
+
22
+ router = Router()
23
+
24
+
25
+ @router.get(
26
+ "/~config-metadata-schema",
27
+ response=dict[str, Any],
28
+ tags=["Config"],
29
+ auth=oauth2_auth,
30
+ summary="Config metadata block JSON Schema",
31
+ operation_id=GET_CONFIG_METADATA_SCHEMA,
32
+ )
33
+ def get_config_metadata_schema(request):
34
+ """Return JSON Schema for the top-level config metadata block (e.g. ``_ocmo``)."""
35
+ return build_config_metadata_json_schema()
36
+
37
+
38
+ @router.get(
39
+ "/ns/{namespace}/~config-schema/{path:path}",
40
+ response={200: dict[str, Any], 404: ErrorSchema},
41
+ tags=["Config"],
42
+ auth=oauth2_auth,
43
+ summary="Config data JSON Schema",
44
+ operation_id=GET_CONFIG_DATA_SCHEMA,
45
+ )
46
+ def get_config_data_schema(
47
+ request,
48
+ namespace: str,
49
+ path: str,
50
+ version: str = Query("latest"),
51
+ ):
52
+ """Return JSON Schema for a config's data body, if one is defined."""
53
+ auth = AuthManager.from_request(request)
54
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
55
+ return TreeManager(ns, path, auth=auth).get_config_data_json_schema(version=version)
56
+
57
+
58
+ @router.post(
59
+ "/ns/{namespace}/~config/~create/{path:path}",
60
+ response={201: AnyExtendedNodeSchema, 409: ErrorSchema, 413: ErrorSchema, 422: ErrorSchema},
61
+ tags=["Config"],
62
+ openapi_extra=root_model_openapi_extension(ConfigDocument),
63
+ operation_id=CREATE_CONFIG,
64
+ )
65
+ def create_config(
66
+ request,
67
+ namespace: str,
68
+ path: str,
69
+ document: ConfigDocument = Body(...),
70
+ ):
71
+ """Create a new Config at the specified path."""
72
+ auth = AuthManager.from_request(request)
73
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
74
+ AuditManager.bind(request, auth, namespace=ns)
75
+ item = TreeManager(ns, path, auth=auth).create_item(document.root, "config")
76
+ return 201, item
77
+
78
+
79
+ @router.put(
80
+ "/ns/{namespace}/~config/~update/{path:path}",
81
+ response={200: AnyExtendedNodeSchema, 404: ErrorSchema, 413: ErrorSchema, 422: ErrorSchema},
82
+ tags=["Config"],
83
+ openapi_extra=root_model_openapi_extension(ConfigDocument),
84
+ operation_id=UPDATE_CONFIG,
85
+ )
86
+ def update_config(
87
+ request,
88
+ namespace: str,
89
+ path: str,
90
+ document: ConfigDocument = Body(...),
91
+ ):
92
+ """Update an existing Config. Creates a new version only when content changes."""
93
+ auth = AuthManager.from_request(request)
94
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
95
+ AuditManager.bind(request, auth, namespace=ns)
96
+ return TreeManager(ns, path, auth=auth).update_item(document.root)
core/api/errors.py ADDED
@@ -0,0 +1,65 @@
1
+ """Shared helpers for API error responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django.core.exceptions import ValidationError as DjangoValidationError
8
+
9
+ from ..validation_errors import (
10
+ format_ninja_validation_errors,
11
+ format_pydantic_validation_error,
12
+ is_response_validation_error,
13
+ )
14
+
15
+ __all__ = [
16
+ "create_error_response",
17
+ "error_payload",
18
+ "format_django_validation_error",
19
+ "format_ninja_validation_errors",
20
+ "format_pydantic_validation_error",
21
+ "is_response_validation_error",
22
+ ]
23
+
24
+
25
+ def format_django_validation_error(exc: DjangoValidationError) -> list[str]:
26
+ """Return human-readable validation messages with field names when available."""
27
+ message_dict = getattr(exc, "message_dict", None)
28
+ if message_dict:
29
+ lines: list[str] = []
30
+ for field, messages in message_dict.items():
31
+ for message in messages:
32
+ if field in (None, "__all__"):
33
+ lines.append(str(message))
34
+ else:
35
+ lines.append(f"{field}: {message}")
36
+ return lines
37
+ return [str(message) for message in exc.messages]
38
+
39
+
40
+ def audit_event_id_from_exception(exc: BaseException) -> str | None:
41
+ """Return audit event UUID attached to *exc*, if any."""
42
+ audit_id = getattr(exc, "audit_event_id", None)
43
+ if audit_id is None:
44
+ return None
45
+ return str(audit_id)
46
+
47
+
48
+ def error_payload(exc: BaseException, error: Any, **extra: Any) -> dict[str, Any]:
49
+ """Build a JSON error body, including audit_event_id when present on *exc*."""
50
+ payload: dict[str, Any] = {"error": error, **extra}
51
+ if isinstance(error, list):
52
+ payload["errors"] = error
53
+ audit_id = audit_event_id_from_exception(exc)
54
+ if audit_id:
55
+ payload["audit_event_id"] = audit_id
56
+ return payload
57
+
58
+
59
+ def create_error_response(api, request, exc: BaseException, error: Any, status: int, **extra: Any):
60
+ """Create a Ninja response for an application error."""
61
+ return api.create_response(
62
+ request,
63
+ error_payload(exc, error, **extra),
64
+ status=status,
65
+ )
@@ -0,0 +1,107 @@
1
+ from ninja import Query, Router
2
+
3
+ from ..managers.audit import AuditManager
4
+ from ..managers.auth import AuthManager
5
+ from ..managers.global_permissions import GlobalPermissionsManager
6
+ from ..operation_ids import (
7
+ CREATE_GLOBAL_PERMISSION,
8
+ DELETE_GLOBAL_PERMISSION,
9
+ GET_GLOBAL_PERMISSION,
10
+ LIST_GLOBAL_PERMISSIONS,
11
+ MOVE_GLOBAL_PERMISSION,
12
+ UPDATE_GLOBAL_PERMISSION,
13
+ )
14
+ from ..schemas import (
15
+ ErrorSchema,
16
+ GlobalPermissionRuleMovePayload,
17
+ GlobalPermissionRulePayload,
18
+ GlobalPermissionRuleSchema,
19
+ GlobalPermissionRulesListSchema,
20
+ InfoSchema,
21
+ )
22
+
23
+ router = Router()
24
+
25
+
26
+ @router.get(
27
+ "/global-permissions/",
28
+ response=GlobalPermissionRulesListSchema,
29
+ tags=["Global Permissions"],
30
+ operation_id=LIST_GLOBAL_PERMISSIONS,
31
+ )
32
+ def list_global_permissions(
33
+ request,
34
+ limit: int = Query(100, ge=1),
35
+ offset: int = Query(0, ge=0),
36
+ ):
37
+ """Return all Global Permission rules in evaluation order."""
38
+ auth = AuthManager.from_request(request)
39
+ AuditManager.bind(request, auth)
40
+ return GlobalPermissionsManager(auth=auth).list(limit=limit, offset=offset)
41
+
42
+
43
+ @router.post(
44
+ "/global-permissions/",
45
+ response={201: GlobalPermissionRuleSchema, 422: ErrorSchema},
46
+ tags=["Global Permissions"],
47
+ operation_id=CREATE_GLOBAL_PERMISSION,
48
+ )
49
+ def create_global_permission(request, payload: GlobalPermissionRulePayload):
50
+ """Append a new Global Permission rule at the end of the ordered list."""
51
+ auth = AuthManager.from_request(request)
52
+ AuditManager.bind(request, auth)
53
+ rule = GlobalPermissionsManager(auth=auth).create(payload)
54
+ return 201, rule
55
+
56
+
57
+ @router.get(
58
+ "/global-permissions/{rule_id}",
59
+ response={200: GlobalPermissionRuleSchema, 404: ErrorSchema},
60
+ tags=["Global Permissions"],
61
+ operation_id=GET_GLOBAL_PERMISSION,
62
+ )
63
+ def get_global_permission(request, rule_id: str):
64
+ """Get a single Global Permission rule."""
65
+ auth = AuthManager.from_request(request)
66
+ AuditManager.bind(request, auth)
67
+ return GlobalPermissionsManager(auth=auth).get(rule_id)
68
+
69
+
70
+ @router.put(
71
+ "/global-permissions/{rule_id}",
72
+ response={200: GlobalPermissionRuleSchema, 404: ErrorSchema},
73
+ tags=["Global Permissions"],
74
+ operation_id=UPDATE_GLOBAL_PERMISSION,
75
+ )
76
+ def update_global_permission(request, rule_id: str, payload: GlobalPermissionRulePayload):
77
+ """Replace a Global Permission rule."""
78
+ auth = AuthManager.from_request(request)
79
+ AuditManager.bind(request, auth)
80
+ return GlobalPermissionsManager(auth=auth).update(rule_id, payload)
81
+
82
+
83
+ @router.delete(
84
+ "/global-permissions/{rule_id}",
85
+ response={204: InfoSchema, 404: ErrorSchema},
86
+ tags=["Global Permissions"],
87
+ operation_id=DELETE_GLOBAL_PERMISSION,
88
+ )
89
+ def delete_global_permission(request, rule_id: str):
90
+ """Delete a Global Permission rule."""
91
+ auth = AuthManager.from_request(request)
92
+ AuditManager.bind(request, auth)
93
+ GlobalPermissionsManager(auth=auth).delete(rule_id)
94
+ return 204, {"details": "Rule deleted"}
95
+
96
+
97
+ @router.post(
98
+ "/global-permissions/{rule_id}/~move/",
99
+ response={200: GlobalPermissionRuleSchema, 404: ErrorSchema},
100
+ tags=["Global Permissions"],
101
+ operation_id=MOVE_GLOBAL_PERMISSION,
102
+ )
103
+ def move_global_permission(request, rule_id: str, payload: GlobalPermissionRuleMovePayload):
104
+ """Reorder a Global Permission rule by setting a new position."""
105
+ auth = AuthManager.from_request(request)
106
+ AuditManager.bind(request, auth)
107
+ return GlobalPermissionsManager(auth=auth).move(rule_id, payload.position)
core/api/lock.py ADDED
@@ -0,0 +1,99 @@
1
+ from ninja import Query, Router
2
+
3
+ from ..managers.audit import AuditManager
4
+ from ..managers.auth import AuthManager
5
+ from ..managers.lock import LockManager
6
+ from ..managers.namespace import NamespaceManager
7
+ from ..operation_ids import (
8
+ CREATE_LOCK,
9
+ DELETE_LOCK,
10
+ GET_LOCK,
11
+ LIST_LOCKS,
12
+ REPLACE_LOCK,
13
+ )
14
+ from ..schemas import ErrorSchema, LockSchema, LocksListSchema
15
+ from ..schemas.requests import LockPayload
16
+
17
+ router = Router()
18
+
19
+
20
+ @router.get(
21
+ "/ns/{namespace}/~lock/",
22
+ response={200: LocksListSchema, 404: ErrorSchema},
23
+ tags=["Lock"],
24
+ operation_id=LIST_LOCKS,
25
+ )
26
+ def list_locks(
27
+ request,
28
+ namespace: str,
29
+ limit: int = Query(100, ge=1),
30
+ offset: int = Query(0, ge=0),
31
+ ):
32
+ """List all active locks in the namespace."""
33
+ auth = AuthManager.from_request(request)
34
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
35
+ AuditManager.bind(request, auth, namespace=ns)
36
+ return LockManager(ns, "*", auth=auth).list_active(limit=limit, offset=offset)
37
+
38
+
39
+ @router.get(
40
+ "/ns/{namespace}/~lock/{path:path}",
41
+ response={200: LockSchema, 404: ErrorSchema},
42
+ tags=["Lock"],
43
+ operation_id=GET_LOCK,
44
+ )
45
+ def get_lock(request, namespace: str, path: str):
46
+ """Get lock details for a path."""
47
+ auth = AuthManager.from_request(request)
48
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
49
+ AuditManager.bind(request, auth, namespace=ns)
50
+ return LockManager(ns, path, auth=auth).get()
51
+
52
+
53
+ @router.post(
54
+ "/ns/{namespace}/~lock/{path:path}",
55
+ response={201: LockSchema, 404: ErrorSchema, 409: ErrorSchema, 422: ErrorSchema},
56
+ tags=["Lock"],
57
+ operation_id=CREATE_LOCK,
58
+ )
59
+ def create_lock(request, namespace: str, path: str, payload: LockPayload):
60
+ """Create a lock on an existing tree path. Returns 409 if already locked."""
61
+ auth = AuthManager.from_request(request)
62
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
63
+ AuditManager.bind(request, auth, namespace=ns)
64
+ return 201, LockManager(ns, path, auth=auth).create(
65
+ reason=payload.reason,
66
+ expires_at=payload.expires_at,
67
+ )
68
+
69
+
70
+ @router.put(
71
+ "/ns/{namespace}/~lock/{path:path}",
72
+ response={200: LockSchema, 404: ErrorSchema, 422: ErrorSchema},
73
+ tags=["Lock"],
74
+ operation_id=REPLACE_LOCK,
75
+ )
76
+ def replace_lock(request, namespace: str, path: str, payload: LockPayload):
77
+ """Replace an existing lock (extend expiry or update reason)."""
78
+ auth = AuthManager.from_request(request)
79
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
80
+ AuditManager.bind(request, auth, namespace=ns)
81
+ return LockManager(ns, path, auth=auth).replace(
82
+ reason=payload.reason,
83
+ expires_at=payload.expires_at,
84
+ )
85
+
86
+
87
+ @router.delete(
88
+ "/ns/{namespace}/~lock/{path:path}",
89
+ response={204: None, 404: ErrorSchema},
90
+ tags=["Lock"],
91
+ operation_id=DELETE_LOCK,
92
+ )
93
+ def delete_lock(request, namespace: str, path: str):
94
+ """Remove a lock from a path."""
95
+ auth = AuthManager.from_request(request)
96
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
97
+ AuditManager.bind(request, auth, namespace=ns)
98
+ LockManager(ns, path, auth=auth).delete()
99
+ return 204, None