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