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/namespace.py ADDED
@@ -0,0 +1,86 @@
1
+ from ninja import Query, Router
2
+ from ninja.pagination import paginate
3
+
4
+ from ..managers.audit import AuditManager
5
+ from ..managers.auth import AuthManager
6
+ from ..managers.namespace import NamespaceManager
7
+ from ..operation_ids import (
8
+ CREATE_NAMESPACE,
9
+ DELETE_NAMESPACE,
10
+ LIST_NAMESPACES,
11
+ SHOW_NAMESPACE,
12
+ UPDATE_NAMESPACE,
13
+ )
14
+ from ..schemas import (
15
+ ErrorSchema,
16
+ NamespaceCreateSchema,
17
+ NamespaceDeletedSchema,
18
+ NamespacePatchSchema,
19
+ NamespaceSchema,
20
+ )
21
+
22
+ router = Router()
23
+
24
+
25
+ @router.get("/ns/", response=list[NamespaceSchema], tags=["Namespace"], operation_id=LIST_NAMESPACES)
26
+ @paginate
27
+ def list_namespaces(request, name_filter: str = Query(None)):
28
+ """List existing namespaces with optional name filter."""
29
+ auth = AuthManager.from_request(request)
30
+ AuditManager.bind(request, auth)
31
+ return NamespaceManager(None, auth=auth).list(name_filter)
32
+
33
+
34
+ @router.get(
35
+ "/ns/{namespace}",
36
+ response={200: NamespaceSchema, 404: ErrorSchema},
37
+ tags=["Namespace"],
38
+ operation_id=SHOW_NAMESPACE,
39
+ )
40
+ def show_namespace(request, namespace: str):
41
+ """Get specific namespace details."""
42
+ auth = AuthManager.from_request(request)
43
+ mgr = NamespaceManager(namespace, auth=auth)
44
+ AuditManager.bind(request, auth, namespace=mgr.ns)
45
+ return mgr.get_or_raise()
46
+
47
+
48
+ @router.post(
49
+ "/ns/",
50
+ response={201: NamespaceSchema, 409: ErrorSchema},
51
+ tags=["Namespace"],
52
+ operation_id=CREATE_NAMESPACE,
53
+ )
54
+ def create_namespace(request, payload: NamespaceCreateSchema):
55
+ """Create a new namespace."""
56
+ auth = AuthManager.from_request(request)
57
+ AuditManager.bind(request, auth)
58
+ return 201, NamespaceManager(payload.name, auth=auth).create(payload)
59
+
60
+
61
+ @router.patch(
62
+ "/ns/{namespace}",
63
+ response={200: NamespaceSchema, 404: ErrorSchema, 422: ErrorSchema},
64
+ tags=["Namespace"],
65
+ operation_id=UPDATE_NAMESPACE,
66
+ )
67
+ def update_namespace(request, namespace: str, payload: NamespacePatchSchema):
68
+ """Update namespace metadata or active tag pointers."""
69
+ auth = AuthManager.from_request(request)
70
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
71
+ AuditManager.bind(request, auth, namespace=ns)
72
+ return 200, NamespaceManager(namespace, auth=auth).update(payload)
73
+
74
+
75
+ @router.delete(
76
+ "/ns/{namespace}",
77
+ response={204: NamespaceDeletedSchema, 404: ErrorSchema},
78
+ tags=["Namespace"],
79
+ operation_id=DELETE_NAMESPACE,
80
+ )
81
+ def delete_namespace(request, namespace: str):
82
+ """Delete namespace and all its contents."""
83
+ auth = AuthManager.from_request(request)
84
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
85
+ AuditManager.bind(request, auth, namespace=ns)
86
+ return 204, NamespaceManager(namespace, auth=auth).delete()
@@ -0,0 +1,29 @@
1
+ from ninja import Router
2
+
3
+ from ..managers.audit import AuditManager
4
+ from ..managers.auth import AuthManager
5
+ from ..managers.namespace import NamespaceManager
6
+ from ..managers.propagation import PropagationManager
7
+ from ..operation_ids import PROPAGATE_CONFIG
8
+ from ..schemas import ErrorSchema, PropagationResultSchema
9
+
10
+ router = Router()
11
+
12
+
13
+ @router.post(
14
+ "/ns/{namespace}/~propagate/{path:path}",
15
+ response={200: PropagationResultSchema, 404: ErrorSchema},
16
+ tags=["Propagation"],
17
+ operation_id=PROPAGATE_CONFIG,
18
+ )
19
+ def propagate_config(
20
+ request,
21
+ namespace: str,
22
+ path: str,
23
+ version: str = "latest",
24
+ ):
25
+ """Manually trigger propagation from the config at ``path``."""
26
+ auth = AuthManager.from_request(request)
27
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
28
+ AuditManager.bind(request, auth, namespace=ns)
29
+ return PropagationManager(ns, path, auth=auth).propagate_manual(version)
core/api/resolve.py ADDED
@@ -0,0 +1,229 @@
1
+ from django.http import HttpResponse
2
+ from ninja import Body, Query, Router
3
+
4
+ from ocmoapi.auth import resolver_auth
5
+ from ocmoapi.oauth2_provider import oauth2_auth
6
+
7
+ from ..managers.artifacts import ArtifactsManager
8
+ from ..managers.audit import AuditManager
9
+ from ..managers.auth import AuthManager
10
+ from ..managers.namespace import NamespaceManager
11
+ from ..managers.resolution import (
12
+ ResolutionManager,
13
+ decode_resolve_path,
14
+ parse_query_params,
15
+ scoped_resolve_path,
16
+ validate_cast_format,
17
+ )
18
+ from ..managers.resolve_parameters import ResolveParametersManager
19
+ from ..managers.tree import TreeManager
20
+ from ..operation_ids import (
21
+ DOWNLOAD_RESOLVED_ARTIFACT,
22
+ LIST_CAST_FORMATS,
23
+ RESOLVE_CONFIG,
24
+ RESOLVE_DRAFT_CONFIG,
25
+ RESOLVE_PARAMETERS,
26
+ )
27
+ from ..schemas import (
28
+ CastFormatsListSchema,
29
+ ResolveParametersResponseSchema,
30
+ ResolveResponseSchema,
31
+ )
32
+ from ..schemas.cast_options import cast_format_option_schemas, format_cast_options_json_schema
33
+ from ..schemas.requests import ConfigDocument
34
+ from ..shortcuts import public_base_url
35
+ from ._common import root_model_openapi_extension
36
+
37
+ router = Router()
38
+
39
+ _resolve_auth = [resolver_auth, oauth2_auth]
40
+
41
+
42
+ @router.get(
43
+ "/~cast-formats/",
44
+ response=CastFormatsListSchema,
45
+ tags=["Resolve"],
46
+ auth=oauth2_auth,
47
+ operation_id=LIST_CAST_FORMATS,
48
+ )
49
+ def list_cast_formats(request):
50
+ """Return all REST-supported cast formats and their option schemas."""
51
+ return {
52
+ "formats": [
53
+ {
54
+ "format": name,
55
+ "options_schema": format_cast_options_json_schema(name),
56
+ }
57
+ for name in sorted(cast_format_option_schemas())
58
+ ],
59
+ }
60
+
61
+
62
+ @router.get(
63
+ "/ns/{namespace}/~resolve/{path:path}/~download/{token}",
64
+ tags=["Resolve"],
65
+ auth=None,
66
+ operation_id=DOWNLOAD_RESOLVED_ARTIFACT,
67
+ )
68
+ def download_resolved_artifact(request, namespace: str, path: str, token: str):
69
+ """Download a previously resolved artifact using a signed token.
70
+
71
+ Request authentication is not used; the short-lived token in the URL is the
72
+ sole credential (signed-URL semantics). Any Authorization header is ignored.
73
+ """
74
+ ns = NamespaceManager(namespace, auth=None).get_or_raise()
75
+ mode, content = ArtifactsManager(ns).download_artifact(path, token)
76
+ if mode == "xaccel":
77
+ # X-Accel-Redirect mode: delegate to Nginx
78
+ response = HttpResponse(status=200)
79
+ response["X-Accel-Redirect"] = content
80
+ response["Content-Type"] = "application/octet-stream"
81
+ return response
82
+ elif mode == "direct":
83
+ return HttpResponse(content, content_type="application/octet-stream")
84
+ else:
85
+ raise ValueError(f"Unknown download mode: {mode}")
86
+
87
+
88
+ @router.get(
89
+ "/ns/{namespace}/~resolve/{path:path}",
90
+ response=ResolveResponseSchema,
91
+ tags=["Resolve"],
92
+ auth=_resolve_auth,
93
+ operation_id=RESOLVE_CONFIG,
94
+ )
95
+ def resolve_config(
96
+ request,
97
+ namespace: str,
98
+ path: str,
99
+ version: str = Query("latest"),
100
+ cast: str | None = Query(None),
101
+ trace_only: bool = Query(False),
102
+ mark_stable: bool = Query(False, alias="mark-stable"),
103
+ ignore_configs_with_missing_tags: bool = Query(False, alias="ignore-configs-with-missing-tags"),
104
+ no_creds: bool = Query(False, alias="no-creds"),
105
+ ):
106
+ """Resolve a Config (or every Config under a folder path) through the
107
+ parameters → extend → render → cast pipeline.
108
+
109
+ When ``mark-stable=true``, advance the reserved ``stable`` tag on resolved
110
+ Config root(s) to the version that was just resolved (Configs only).
111
+
112
+ When ``ignore-configs-with-missing-tags=true`` and ``path`` is a folder,
113
+ skip Configs that do not have the requested ``version`` tag or number
114
+ instead of failing the whole folder resolve.
115
+
116
+ When ``no-creds=true``, secret parameters are replaced with the dummy
117
+ value ``<secret-value-placeholder>`` without fetching or decrypting secrets
118
+ (``secret:resolve`` is not required).
119
+
120
+ When authenticated as a Resolver, ``path`` is relative to the resolver's
121
+ scope. Use ``'.'`` to resolve the scope root folder.
122
+ """
123
+ auth = AuthManager.from_request(request)
124
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
125
+ AuditManager.bind(request, auth, namespace=ns)
126
+ path = decode_resolve_path(path)
127
+ validate_cast_format(cast)
128
+ mgr = ResolutionManager(
129
+ ns,
130
+ path,
131
+ auth=auth,
132
+ query_params=request.GET,
133
+ base_url=public_base_url(request),
134
+ version=version,
135
+ cast=cast,
136
+ trace_only=trace_only,
137
+ promote_stable=mark_stable,
138
+ ignore_configs_with_missing_tags=ignore_configs_with_missing_tags,
139
+ no_creds=no_creds,
140
+ )
141
+ result = mgr.resolve()
142
+ request._resolve_cache_status = mgr.cache_status
143
+ return result
144
+
145
+
146
+ @router.get(
147
+ "/ns/{namespace}/~resolve-parameters/{path:path}",
148
+ response=ResolveParametersResponseSchema,
149
+ tags=["Resolve"],
150
+ auth=_resolve_auth,
151
+ operation_id=RESOLVE_PARAMETERS,
152
+ )
153
+ def resolve_parameters(
154
+ request,
155
+ namespace: str,
156
+ path: str,
157
+ version: str = Query("latest"),
158
+ no_creds: bool = Query(False, alias="no-creds"),
159
+ ):
160
+ """Return effective parameter values for a single Config (debug).
161
+
162
+ When ``no-creds=true``, secret parameters show the dummy value
163
+ ``<secret-value-placeholder>`` without fetching secrets (``secret:resolve``
164
+ is not required).
165
+
166
+ When authenticated as a Resolver, ``path`` is relative to the resolver's
167
+ scope. Use ``'.'`` to refer to the scope root (must be a Config, not a
168
+ folder — a folder path is rejected by the underlying resolver).
169
+ """
170
+ auth = AuthManager.from_request(request)
171
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
172
+ AuditManager.bind(request, auth, namespace=ns)
173
+ path = decode_resolve_path(path)
174
+ effective_path, _ = scoped_resolve_path(ns, path, auth)
175
+ dynamic_params, _ = parse_query_params(request.GET)
176
+ config_obj = TreeManager(ns, effective_path, auth=auth).get_or_raise(["config"])
177
+ rpm = ResolveParametersManager(
178
+ ns,
179
+ config_obj,
180
+ base_folder="/".join(config_obj.path.split("/")[:-1]),
181
+ version_tag=version,
182
+ version_number=TreeManager.resolve_version(config_obj, version).version,
183
+ dynamic_params=dynamic_params,
184
+ auth=auth,
185
+ no_creds=no_creds,
186
+ )
187
+ return rpm.resolve_debug()
188
+
189
+
190
+ @router.post(
191
+ "/ns/{namespace}/~resolve-draft/{path:path}",
192
+ response=ResolveResponseSchema,
193
+ tags=["Resolve"],
194
+ auth=[oauth2_auth],
195
+ openapi_extra=root_model_openapi_extension(ConfigDocument),
196
+ operation_id=RESOLVE_DRAFT_CONFIG,
197
+ )
198
+ def resolve_draft_config(
199
+ request,
200
+ namespace: str,
201
+ path: str,
202
+ document: ConfigDocument = Body(...),
203
+ cast: str | None = Query(None),
204
+ trace_only: bool = Query(False),
205
+ no_creds: bool = Query(False, alias="no-creds"),
206
+ ):
207
+ """Resolve unsaved draft YAML at the given path without persisting it.
208
+
209
+ The full parameters → extend → render → cast pipeline runs against the
210
+ submitted content. ``config:resolve`` is required on the path and all
211
+ transitive participants. Results are returned as download URLs (same
212
+ shape as ``GET /~resolve/{path}``). Response items use ``version=0``.
213
+ """
214
+ auth = AuthManager.from_request(request)
215
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
216
+ AuditManager.bind(request, auth, namespace=ns)
217
+ validate_cast_format(cast)
218
+ mgr = ResolutionManager(
219
+ ns,
220
+ path,
221
+ auth=auth,
222
+ query_params=request.GET,
223
+ base_url=public_base_url(request),
224
+ cast=cast,
225
+ trace_only=trace_only,
226
+ no_creds=no_creds,
227
+ draft_content=document.root,
228
+ )
229
+ return mgr.resolve()
core/api/resolver.py ADDED
@@ -0,0 +1,96 @@
1
+ from typing import Any
2
+
3
+ from ninja import Body, 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_RESOLVER,
13
+ GET_RESOLVER_CONFIGURATION_SCHEMA,
14
+ ROTATE_RESOLVER_TOKEN,
15
+ UPDATE_RESOLVER,
16
+ )
17
+ from ..schemas import ErrorSchema, ResolverSchema, ResolverTokenRotationResponseSchema
18
+ from ..schemas.requests import ResolverDocument, ResolverRotateTokenPayload
19
+ from ..schemas.resolver_json_schema import build_resolver_configuration_json_schema
20
+ from ._common import root_model_openapi_extension
21
+
22
+ router = Router()
23
+
24
+
25
+ @router.get(
26
+ "/~resolver-configuration-schema",
27
+ response=dict[str, Any],
28
+ tags=["Resolver"],
29
+ auth=oauth2_auth,
30
+ summary="Resolver configuration JSON Schema",
31
+ operation_id=GET_RESOLVER_CONFIGURATION_SCHEMA,
32
+ )
33
+ def get_resolver_configuration_schema(request):
34
+ """Return JSON Schema for resolver configuration YAML (editor autocomplete)."""
35
+ return build_resolver_configuration_json_schema()
36
+
37
+
38
+ @router.post(
39
+ "/ns/{namespace}/~resolver/~create/{path:path}",
40
+ response={201: ResolverSchema, 409: ErrorSchema, 422: ErrorSchema},
41
+ tags=["Resolver"],
42
+ openapi_extra=root_model_openapi_extension(ResolverDocument),
43
+ operation_id=CREATE_RESOLVER,
44
+ )
45
+ def create_resolver(
46
+ request,
47
+ namespace: str,
48
+ path: str,
49
+ document: ResolverDocument = Body(ResolverDocument("")),
50
+ ):
51
+ """Create a new Resolver. Returns token1 in full on creation."""
52
+ auth = AuthManager.from_request(request)
53
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
54
+ AuditManager.bind(request, auth, namespace=ns)
55
+ item = TreeManager(ns, path, auth=auth).create_item(document.root, "resolver")
56
+ item._reveal_token1 = True
57
+ return 201, item
58
+
59
+
60
+ @router.put(
61
+ "/ns/{namespace}/~resolver/~update/{path:path}",
62
+ response={200: ResolverSchema, 404: ErrorSchema, 422: ErrorSchema},
63
+ tags=["Resolver"],
64
+ openapi_extra=root_model_openapi_extension(ResolverDocument),
65
+ operation_id=UPDATE_RESOLVER,
66
+ )
67
+ def update_resolver(
68
+ request,
69
+ namespace: str,
70
+ path: str,
71
+ document: ResolverDocument = Body(ResolverDocument("")),
72
+ ):
73
+ """Update resolver configuration."""
74
+ auth = AuthManager.from_request(request)
75
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
76
+ AuditManager.bind(request, auth, namespace=ns)
77
+ return TreeManager(ns, path, auth=auth).update_item(document.root)
78
+
79
+
80
+ @router.post(
81
+ "/ns/{namespace}/~resolver/~rotate-token/{path:path}",
82
+ response={200: ResolverTokenRotationResponseSchema, 404: ErrorSchema, 422: ErrorSchema},
83
+ tags=["Resolver"],
84
+ operation_id=ROTATE_RESOLVER_TOKEN,
85
+ )
86
+ def rotate_resolver_token(
87
+ request,
88
+ namespace: str,
89
+ path: str,
90
+ payload: ResolverRotateTokenPayload,
91
+ ):
92
+ """Rotate token1 or token2 for a Resolver (token_number must be 1 or 2)."""
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).rotate_resolver_token(payload.token_number)
core/api/secret.py ADDED
@@ -0,0 +1,53 @@
1
+ from ninja import Body, Router
2
+
3
+ from ..managers.audit import AuditManager
4
+ from ..managers.auth import AuthManager
5
+ from ..managers.namespace import NamespaceManager
6
+ from ..managers.secret import SecretManager
7
+ from ..operation_ids import CREATE_SECRET, UPDATE_SECRET
8
+ from ..schemas import AnyExtendedNodeSchema, ErrorSchema
9
+ from ..schemas.requests import SecretDocument
10
+ from ._common import root_model_openapi_extension
11
+
12
+ router = Router()
13
+
14
+
15
+ @router.post(
16
+ "/ns/{namespace}/~secret/~create/{path:path}",
17
+ response={201: AnyExtendedNodeSchema, 409: ErrorSchema, 413: ErrorSchema, 422: ErrorSchema},
18
+ tags=["Secret"],
19
+ openapi_extra=root_model_openapi_extension(SecretDocument),
20
+ operation_id=CREATE_SECRET,
21
+ )
22
+ def create_secret(
23
+ request,
24
+ namespace: str,
25
+ path: str,
26
+ document: SecretDocument = Body(...),
27
+ ):
28
+ """Create a new Secret. The document is validated as YAML then encrypted at rest."""
29
+ auth = AuthManager.from_request(request)
30
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
31
+ AuditManager.bind(request, auth, namespace=ns)
32
+ item = SecretManager(ns, path, auth=auth).create(document.root)
33
+ return 201, item
34
+
35
+
36
+ @router.put(
37
+ "/ns/{namespace}/~secret/~update/{path:path}",
38
+ response={200: AnyExtendedNodeSchema, 404: ErrorSchema, 413: ErrorSchema, 422: ErrorSchema},
39
+ tags=["Secret"],
40
+ openapi_extra=root_model_openapi_extension(SecretDocument),
41
+ operation_id=UPDATE_SECRET,
42
+ )
43
+ def update_secret(
44
+ request,
45
+ namespace: str,
46
+ path: str,
47
+ document: SecretDocument = Body(...),
48
+ ):
49
+ """Update an existing Secret. A new encrypted version is created only when content differs."""
50
+ auth = AuthManager.from_request(request)
51
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
52
+ AuditManager.bind(request, auth, namespace=ns)
53
+ return SecretManager(ns, path, auth=auth).update(document.root)
core/api/system.py ADDED
@@ -0,0 +1,42 @@
1
+ import json
2
+
3
+ from django.http import HttpResponse
4
+ from ninja import Router
5
+
6
+ from ..managers.health import HealthManager
7
+ from ..managers.system import SystemManager
8
+ from ..operation_ids import HEALTH, VERSION
9
+ from ..schemas import HealthSchema, ProductVersionWithNoticeSchema
10
+
11
+ router = Router(tags=["System"])
12
+
13
+
14
+ @router.get(
15
+ "/health",
16
+ response={200: HealthSchema, 503: HealthSchema},
17
+ auth=None,
18
+ summary="Application health",
19
+ operation_id=HEALTH,
20
+ )
21
+ def health(request):
22
+ """Validate core dependencies required for normal API operation."""
23
+ payload = HealthManager().check()
24
+ status_code = 200 if payload["status"] == "ok" else 503
25
+ return status_code, payload
26
+
27
+
28
+ @router.get(
29
+ "/version",
30
+ response=ProductVersionWithNoticeSchema,
31
+ auth=None,
32
+ summary="Application version",
33
+ operation_id=VERSION,
34
+ )
35
+ def version(request, notice: bool = False):
36
+ """Return the deployed OCMO product version and public auth configuration."""
37
+ payload = SystemManager().version_payload(include_notice=notice)
38
+ body = ProductVersionWithNoticeSchema.model_validate(payload).model_dump(
39
+ mode="json",
40
+ exclude_none=True,
41
+ )
42
+ return HttpResponse(json.dumps(body), content_type="application/json")
core/api/template.py ADDED
@@ -0,0 +1,53 @@
1
+ from ninja import Body, Router
2
+
3
+ from ..managers.audit import AuditManager
4
+ from ..managers.auth import AuthManager
5
+ from ..managers.namespace import NamespaceManager
6
+ from ..managers.tree import TreeManager
7
+ from ..operation_ids import CREATE_TEMPLATE, UPDATE_TEMPLATE
8
+ from ..schemas import AnyExtendedNodeSchema, ErrorSchema
9
+ from ..schemas.requests import TemplateDocument
10
+ from ._common import root_model_openapi_extension
11
+
12
+ router = Router()
13
+
14
+
15
+ @router.post(
16
+ "/ns/{namespace}/~template/~create/{path:path}",
17
+ response={201: AnyExtendedNodeSchema, 409: ErrorSchema, 413: ErrorSchema, 422: ErrorSchema},
18
+ tags=["Template"],
19
+ openapi_extra=root_model_openapi_extension(TemplateDocument),
20
+ operation_id=CREATE_TEMPLATE,
21
+ )
22
+ def create_template(
23
+ request,
24
+ namespace: str,
25
+ path: str,
26
+ document: TemplateDocument = Body(...),
27
+ ):
28
+ """Create a new Template at the specified path."""
29
+ auth = AuthManager.from_request(request)
30
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
31
+ AuditManager.bind(request, auth, namespace=ns)
32
+ item = TreeManager(ns, path, auth=auth).create_item(document.root, "template")
33
+ return 201, item
34
+
35
+
36
+ @router.put(
37
+ "/ns/{namespace}/~template/~update/{path:path}",
38
+ response={200: AnyExtendedNodeSchema, 404: ErrorSchema, 413: ErrorSchema, 422: ErrorSchema},
39
+ tags=["Template"],
40
+ openapi_extra=root_model_openapi_extension(TemplateDocument),
41
+ operation_id=UPDATE_TEMPLATE,
42
+ )
43
+ def update_template(
44
+ request,
45
+ namespace: str,
46
+ path: str,
47
+ document: TemplateDocument = Body(...),
48
+ ):
49
+ """Update an existing Template. Creates a new version only when content changes."""
50
+ auth = AuthManager.from_request(request)
51
+ ns = NamespaceManager(namespace, auth=auth).get_or_raise()
52
+ AuditManager.bind(request, auth, namespace=ns)
53
+ return TreeManager(ns, path, auth=auth).update_item(document.root)