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.
- LICENSE +201 -0
- NOTICE +16 -0
- core/__init__.py +0 -0
- core/api/__init__.py +201 -0
- core/api/_common.py +37 -0
- core/api/audit.py +244 -0
- core/api/auth.py +50 -0
- core/api/config.py +96 -0
- core/api/errors.py +65 -0
- core/api/global_permissions.py +107 -0
- core/api/lock.py +99 -0
- core/api/namespace.py +86 -0
- core/api/propagation.py +29 -0
- core/api/resolve.py +229 -0
- core/api/resolver.py +96 -0
- core/api/secret.py +53 -0
- core/api/system.py +42 -0
- core/api/template.py +53 -0
- core/api/tree.py +274 -0
- core/apps.py +18 -0
- core/audit_operations.py +0 -0
- core/constants/__init__.py +5 -0
- core/constants/audit_operations.py +35 -0
- core/constants/permission_actions.py +59 -0
- core/constants/permissions.py +41 -0
- core/constants/resolve.py +3 -0
- core/constants/transformers.py +22 -0
- core/constants/webhook_events.py +31 -0
- core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
- core/data/builtin_schemas/_permissions.schema.yaml +211 -0
- core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
- core/decorators/__init__.py +14 -0
- core/decorators/audit.py +105 -0
- core/decorators/binding.py +49 -0
- core/decorators/permissions.py +129 -0
- core/decorators/webhook.py +87 -0
- core/exceptions.py +165 -0
- core/management/__init__.py +0 -0
- core/management/commands/__init__.py +0 -0
- core/management/commands/dump_openapi.py +40 -0
- core/managers/artifacts.py +346 -0
- core/managers/audit/__init__.py +1172 -0
- core/managers/audit/timeline.py +365 -0
- core/managers/auth.py +276 -0
- core/managers/cast.py +506 -0
- core/managers/config_validation.py +216 -0
- core/managers/crypto.py +78 -0
- core/managers/global_permissions.py +183 -0
- core/managers/health.py +56 -0
- core/managers/lock.py +229 -0
- core/managers/namespace.py +132 -0
- core/managers/permissions.py +168 -0
- core/managers/propagation.py +244 -0
- core/managers/resolution.py +938 -0
- core/managers/resolve_cache.py +266 -0
- core/managers/resolve_parameters.py +503 -0
- core/managers/resolver.py +56 -0
- core/managers/resolver_tokens.py +114 -0
- core/managers/resolving.py +673 -0
- core/managers/secret.py +147 -0
- core/managers/system.py +44 -0
- core/managers/tree/__init__.py +85 -0
- core/managers/tree/_common.py +85 -0
- core/managers/tree/base.py +203 -0
- core/managers/tree/config_ops.py +167 -0
- core/managers/tree/constants.py +11 -0
- core/managers/tree/create.py +75 -0
- core/managers/tree/diff.py +270 -0
- core/managers/tree/mutate.py +433 -0
- core/managers/tree/paths.py +49 -0
- core/managers/tree/propagation.py +200 -0
- core/managers/tree/read.py +263 -0
- core/managers/tree/resolver_ops.py +21 -0
- core/managers/tree/tags.py +167 -0
- core/managers/tree/update.py +137 -0
- core/managers/tree/versions.py +87 -0
- core/managers/tree_capabilities.py +189 -0
- core/managers/webhook/__init__.py +3 -0
- core/managers/webhook/cache.py +68 -0
- core/managers/webhook/main.py +357 -0
- core/middleware.py +32 -0
- core/migrations/0001_initial.py +157 -0
- core/migrations/0002_folder.py +33 -0
- core/migrations/0003_alter_namespace_name.py +17 -0
- core/migrations/0004_alter_namespace_name_and_more.py +25 -0
- core/migrations/0005_configversion_templateversion.py +85 -0
- core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
- core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
- core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
- core/migrations/0009_tree_lock.py +58 -0
- core/migrations/0010_resolver_token_encryption.py +75 -0
- core/migrations/0011_audit_event.py +134 -0
- core/migrations/0012_audit_event_namespace_name.py +24 -0
- core/migrations/0013_audit_event_object_id.py +28 -0
- core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
- core/migrations/0015_audit_event_operation_subresource.py +27 -0
- core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
- core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
- core/migrations/__init__.py +0 -0
- core/models/__init__.py +9 -0
- core/models/audit.py +97 -0
- core/models/config.py +132 -0
- core/models/folder.py +12 -0
- core/models/global_permissions.py +27 -0
- core/models/lock.py +35 -0
- core/models/namespace.py +45 -0
- core/models/resolver.py +25 -0
- core/models/secret.py +58 -0
- core/models/tree.py +29 -0
- core/notice.py +18 -0
- core/operation_ids.py +153 -0
- core/schemas/__init__.py +2 -0
- core/schemas/audit.py +75 -0
- core/schemas/cast_json_schema.py +57 -0
- core/schemas/cast_options.py +508 -0
- core/schemas/generic.py +233 -0
- core/schemas/metadata_json_schema.py +21 -0
- core/schemas/propagation.py +88 -0
- core/schemas/requests.py +773 -0
- core/schemas/resolver_json_schema.py +65 -0
- core/schemas/responses.py +690 -0
- core/schemas/webhooks.py +40 -0
- core/shortcuts.py +415 -0
- core/tests/__init__.py +0 -0
- core/tests/auth_helpers.py +23 -0
- core/tests/namespace_helpers.py +29 -0
- core/tests/test_api_cli.py +43 -0
- core/tests/test_api_errors.py +151 -0
- core/tests/test_artifacts.py +123 -0
- core/tests/test_audit.py +973 -0
- core/tests/test_audit_event_id.py +90 -0
- core/tests/test_audit_operations.py +222 -0
- core/tests/test_builtin_config_schemas.py +174 -0
- core/tests/test_can_i.py +510 -0
- core/tests/test_cast_format_options.py +57 -0
- core/tests/test_cast_options_schema.py +54 -0
- core/tests/test_cast_yaml.py +26 -0
- core/tests/test_config_schema_api.py +173 -0
- core/tests/test_config_validation.py +378 -0
- core/tests/test_document_parser.py +122 -0
- core/tests/test_dump_openapi.py +46 -0
- core/tests/test_extend_key.py +267 -0
- core/tests/test_folder_get_item.py +23 -0
- core/tests/test_folder_resolve_missing_tags.py +73 -0
- core/tests/test_get_item_version.py +31 -0
- core/tests/test_global_permission_create.py +171 -0
- core/tests/test_list_pagination.py +262 -0
- core/tests/test_locks.py +209 -0
- core/tests/test_multiline_transformer.py +132 -0
- core/tests/test_namespace_create.py +11 -0
- core/tests/test_ocmo_metadata.py +127 -0
- core/tests/test_oidc_jwt_audiences.py +64 -0
- core/tests/test_omit_transformer.py +78 -0
- core/tests/test_operation_ids.py +47 -0
- core/tests/test_permissions.py +1148 -0
- core/tests/test_propagation.py +441 -0
- core/tests/test_public_base_url.py +20 -0
- core/tests/test_resolve_draft.py +176 -0
- core/tests/test_resolve_parameters_version.py +181 -0
- core/tests/test_resolve_permissions.py +492 -0
- core/tests/test_resolve_resolver_response.py +107 -0
- core/tests/test_resolver_auth.py +33 -0
- core/tests/test_resolver_folder_filters.py +95 -0
- core/tests/test_resolver_get_item.py +23 -0
- core/tests/test_resolver_navigate_search_scope.py +98 -0
- core/tests/test_resolver_schema_api.py +91 -0
- core/tests/test_security_hardening.py +324 -0
- core/tests/test_selectors.py +65 -0
- core/tests/test_special_namespace_paths.py +409 -0
- core/tests/test_stable_promotion.py +124 -0
- core/tests/test_system.py +140 -0
- core/tests/test_tree_manager_for_item.py +192 -0
- core/tests/test_two_layer_cache.py +334 -0
- core/tests/test_url_trailing_slashes.py +53 -0
- core/tests/test_validation_error_responses.py +43 -0
- core/tests/test_webhooks.py +985 -0
- core/utils/__init__.py +23 -0
- core/utils/deep_merge.py +91 -0
- core/utils/namespace_special_configs.py +105 -0
- core/utils/permissions_compiler.py +627 -0
- core/utils/permissions_schema_document.py +52 -0
- core/validation_errors.py +105 -0
- core/version.py +27 -0
- core/views.py +1 -0
- ocmo_api-0.8.19.dist-info/METADATA +252 -0
- ocmo_api-0.8.19.dist-info/RECORD +203 -0
- ocmo_api-0.8.19.dist-info/WHEEL +4 -0
- ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
- ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
- ocmoapi/__init__.py +1 -0
- ocmoapi/__main__.py +6 -0
- ocmoapi/asgi.py +16 -0
- ocmoapi/auth.py +163 -0
- ocmoapi/cli.py +85 -0
- ocmoapi/oauth2_provider.py +23 -0
- ocmoapi/parser.py +120 -0
- ocmoapi/settings.py +275 -0
- ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
- ocmoapi/templates/ninja/swagger.html +55 -0
- ocmoapi/testing_auth.py +64 -0
- ocmoapi/urls.py +40 -0
- 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
|