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,1172 @@
|
|
|
1
|
+
"""AuditManager for append-only operation logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import contextvars
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from django.conf import settings
|
|
19
|
+
from django.db.models import Q, QuerySet
|
|
20
|
+
from django.utils import timezone
|
|
21
|
+
|
|
22
|
+
from ...constants.audit_operations import (
|
|
23
|
+
OP_MOVE_ITEM,
|
|
24
|
+
OP_REFERENCED_IN_RESOLVE,
|
|
25
|
+
OP_RESOLVE,
|
|
26
|
+
)
|
|
27
|
+
from ...decorators.audit import AuditEventContext, _audit_operation_context, _take_audit_context
|
|
28
|
+
from ...decorators.binding import bind_args, invoke, resolve_resource
|
|
29
|
+
from ...decorators.permissions import PermCheck, arg, require_permissions
|
|
30
|
+
from ...exceptions import NotFound, PermissionDenied
|
|
31
|
+
from ...models import AuditEvent, Namespace
|
|
32
|
+
from ...shortcuts import tag_subresource_from_ref
|
|
33
|
+
from ..auth import AuthManager
|
|
34
|
+
from .timeline import (
|
|
35
|
+
format_timeline_note as format_timeline_note,
|
|
36
|
+
)
|
|
37
|
+
from .timeline import (
|
|
38
|
+
infer_operation,
|
|
39
|
+
)
|
|
40
|
+
from .timeline import (
|
|
41
|
+
read_operation_for_type as read_operation_for_type,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from ..resolving import CacheParticipant
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
AUDIT_MODE_ALL = "all"
|
|
50
|
+
AUDIT_MODE_MODIFICATIONS_AND_RESOLVE = "modifications-and-resolve"
|
|
51
|
+
AUDIT_MODE_RESOLVE = "resolve"
|
|
52
|
+
VALID_AUDIT_MODES = frozenset(
|
|
53
|
+
{
|
|
54
|
+
AUDIT_MODE_ALL,
|
|
55
|
+
AUDIT_MODE_MODIFICATIONS_AND_RESOLVE,
|
|
56
|
+
AUDIT_MODE_RESOLVE,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
MODIFICATION_HTTP_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
60
|
+
_RESOLVE_EVENT_KINDS = frozenset(
|
|
61
|
+
{
|
|
62
|
+
AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
|
|
63
|
+
AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_request_audit: contextvars.ContextVar[AuditManager | None] = contextvars.ContextVar(
|
|
68
|
+
"request_audit",
|
|
69
|
+
default=None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def client_ip_from_request(request) -> str | None:
|
|
74
|
+
"""Extract client IP from X-Forwarded-For or REMOTE_ADDR."""
|
|
75
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
76
|
+
if forwarded:
|
|
77
|
+
return forwarded.split(",")[0].strip() or None
|
|
78
|
+
return request.META.get("REMOTE_ADDR")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class AuditEventDraft:
|
|
83
|
+
"""In-memory audit row under construction."""
|
|
84
|
+
|
|
85
|
+
event_kind: str = AuditEvent.EVENT_KIND_OPERATION
|
|
86
|
+
client_ip: str | None = None
|
|
87
|
+
user_agent: str | None = None
|
|
88
|
+
auth_id: str = ""
|
|
89
|
+
auth_email: str | None = None
|
|
90
|
+
auth_type: str = AuditEvent.AUTH_TYPE_USER
|
|
91
|
+
token_number: int | None = None
|
|
92
|
+
namespace: Any = None
|
|
93
|
+
namespace_name: str | None = None
|
|
94
|
+
http_method: str = ""
|
|
95
|
+
api_endpoint: str = ""
|
|
96
|
+
object_type: str | None = None
|
|
97
|
+
object_id: str | None = None
|
|
98
|
+
object_version: int | None = None
|
|
99
|
+
operation: str | None = None
|
|
100
|
+
subresource_type: str | None = None
|
|
101
|
+
subresource: str | None = None
|
|
102
|
+
permission_ok: bool | None = None
|
|
103
|
+
error: str | None = None
|
|
104
|
+
resolve_type: str | None = None
|
|
105
|
+
from_cache: bool | None = None
|
|
106
|
+
parent_event: AuditEvent | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class AuditListFilters:
|
|
111
|
+
"""Optional filters for audit list queries."""
|
|
112
|
+
|
|
113
|
+
auth_id: str | None = None
|
|
114
|
+
auth_email: str | None = None
|
|
115
|
+
auth_type: str | None = None
|
|
116
|
+
object_type: str | None = None
|
|
117
|
+
object_id: str | None = None
|
|
118
|
+
http_method: str | None = None
|
|
119
|
+
api_endpoint: str | None = None
|
|
120
|
+
permission_ok: bool | None = None
|
|
121
|
+
resolve_type: str | None = None
|
|
122
|
+
from_cache: bool | None = None
|
|
123
|
+
event_kind: str | None = None
|
|
124
|
+
category: str | None = None
|
|
125
|
+
parent_event_id: str | UUID | None = None
|
|
126
|
+
client_ip: str | None = None
|
|
127
|
+
user_agent: str | None = None
|
|
128
|
+
token_number: int | None = None
|
|
129
|
+
object_version: int | None = None
|
|
130
|
+
operation: str | None = None
|
|
131
|
+
subresource_type: str | None = None
|
|
132
|
+
subresource: str | None = None
|
|
133
|
+
event_id: str | UUID | None = None
|
|
134
|
+
error: str | None = None
|
|
135
|
+
namespace_name: str | None = None
|
|
136
|
+
occurred_from: datetime | None = None
|
|
137
|
+
occurred_to: datetime | None = None
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if isinstance(self.parent_event_id, UUID):
|
|
141
|
+
self.parent_event_id = str(self.parent_event_id)
|
|
142
|
+
if isinstance(self.event_id, UUID):
|
|
143
|
+
self.event_id = str(self.event_id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class ResolveStats:
|
|
148
|
+
direct: int = 0
|
|
149
|
+
nested: int = 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ResolveSeriesBucket:
|
|
154
|
+
start: datetime
|
|
155
|
+
direct: int = 0
|
|
156
|
+
nested: int = 0
|
|
157
|
+
errors: int = 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_audit_list_access = PermCheck(
|
|
161
|
+
lambda self: "global:admin" if self.namespace is None else "namespace:audit",
|
|
162
|
+
resource=lambda self: self.namespace.name if self.namespace is not None else "",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
_item_audit_access = PermCheck(
|
|
166
|
+
action=lambda self, object_id, object_type, **_: f"{object_type}:audit",
|
|
167
|
+
resource=arg("object_id"),
|
|
168
|
+
mask_as_not_found=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AuditManager:
|
|
173
|
+
"""Build, persist, and query append-only audit events.
|
|
174
|
+
|
|
175
|
+
One instance is bound per HTTP request via :meth:`bind` and retrieved
|
|
176
|
+
elsewhere with :meth:`current`.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
auth: AuthManager | None = None,
|
|
182
|
+
namespace=None,
|
|
183
|
+
*,
|
|
184
|
+
request=None,
|
|
185
|
+
) -> None:
|
|
186
|
+
self.auth = auth
|
|
187
|
+
self.namespace = namespace
|
|
188
|
+
self.request = request
|
|
189
|
+
self._draft: AuditEventDraft | None = None
|
|
190
|
+
self._draft_stack: list[AuditEventDraft] = []
|
|
191
|
+
self._last_saved: AuditEvent | None = None
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def bind(cls, request, auth: AuthManager, namespace=None) -> AuditManager:
|
|
195
|
+
"""Create and store the request-scoped AuditManager singleton."""
|
|
196
|
+
mgr = cls(auth=auth, namespace=namespace, request=request)
|
|
197
|
+
_request_audit.set(mgr)
|
|
198
|
+
return mgr
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def unbind(cls) -> None:
|
|
202
|
+
"""Clear the request-scoped AuditManager (mainly for tests)."""
|
|
203
|
+
current = _request_audit.get()
|
|
204
|
+
if current is not None:
|
|
205
|
+
current._draft = None
|
|
206
|
+
current._draft_stack.clear()
|
|
207
|
+
_request_audit.set(None)
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def current(cls) -> AuditManager | None:
|
|
211
|
+
"""Return the request-scoped AuditManager, if bound."""
|
|
212
|
+
return _request_audit.get()
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_manager(cls, manager) -> AuditManager | None:
|
|
216
|
+
"""Return the bound singleton, updating namespace from *manager*."""
|
|
217
|
+
audit_mgr = cls.current()
|
|
218
|
+
if audit_mgr is None:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
namespace = getattr(manager, "namespace", None)
|
|
222
|
+
if namespace is None and hasattr(manager, "ns") and manager.ns is not None:
|
|
223
|
+
namespace = manager.ns
|
|
224
|
+
if namespace is not None:
|
|
225
|
+
audit_mgr.set_namespace(namespace)
|
|
226
|
+
return audit_mgr
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def for_decorated_call(
|
|
230
|
+
cls,
|
|
231
|
+
manager,
|
|
232
|
+
*,
|
|
233
|
+
auth_manager_attribute: str = "auth",
|
|
234
|
+
skip_when_no_auth: bool = True,
|
|
235
|
+
) -> AuditManager | None:
|
|
236
|
+
"""Return request-scoped manager for a decorated call, or None to skip auditing."""
|
|
237
|
+
if getattr(manager, auth_manager_attribute, None) is None:
|
|
238
|
+
return None
|
|
239
|
+
if cls.current() is None and skip_when_no_auth:
|
|
240
|
+
return None
|
|
241
|
+
return cls.from_manager(manager)
|
|
242
|
+
|
|
243
|
+
def set_namespace(self, namespace) -> AuditManager:
|
|
244
|
+
"""Attach or replace the namespace context for subsequent events."""
|
|
245
|
+
self.namespace = namespace
|
|
246
|
+
if self._draft is not None and namespace is not None:
|
|
247
|
+
self._draft.namespace = namespace
|
|
248
|
+
self._draft.namespace_name = namespace.name
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def _prefill_actor_and_request(self) -> None:
|
|
252
|
+
if self._draft is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
auth = self.auth
|
|
256
|
+
if auth is not None:
|
|
257
|
+
self._draft.auth_id = str(auth.identifier or AuthManager.DEFAULT_ACTOR_IDENTITY)
|
|
258
|
+
email = auth.email
|
|
259
|
+
self._draft.auth_email = str(email) if email is not None else None
|
|
260
|
+
self._draft.auth_type = AuditEvent.AUTH_TYPE_RESOLVER if auth.is_resolver else AuditEvent.AUTH_TYPE_USER
|
|
261
|
+
self._draft.token_number = auth.token_number
|
|
262
|
+
else:
|
|
263
|
+
self._draft.auth_id = AuthManager.DEFAULT_ACTOR_IDENTITY
|
|
264
|
+
self._draft.auth_type = AuditEvent.AUTH_TYPE_USER
|
|
265
|
+
|
|
266
|
+
request = self.request
|
|
267
|
+
if request is not None and hasattr(request, "META"):
|
|
268
|
+
self._draft.http_method = getattr(request, "method", "") or ""
|
|
269
|
+
self._draft.api_endpoint = getattr(request, "path", "") or ""
|
|
270
|
+
self._draft.client_ip = client_ip_from_request(request)
|
|
271
|
+
self._draft.user_agent = request.META.get("HTTP_USER_AGENT")
|
|
272
|
+
|
|
273
|
+
if self._draft.namespace is None and self.namespace is not None:
|
|
274
|
+
self._draft.namespace = self.namespace
|
|
275
|
+
self._draft.namespace_name = self.namespace.name
|
|
276
|
+
|
|
277
|
+
def begin(self, event_kind: str = AuditEvent.EVENT_KIND_OPERATION) -> AuditManager:
|
|
278
|
+
if self._draft is not None:
|
|
279
|
+
self._draft_stack.append(self._draft)
|
|
280
|
+
self._draft = AuditEventDraft(event_kind=event_kind)
|
|
281
|
+
self._prefill_actor_and_request()
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def set_object(
|
|
285
|
+
self,
|
|
286
|
+
object_type: str,
|
|
287
|
+
object_id: str | None,
|
|
288
|
+
*,
|
|
289
|
+
version: int | None = None,
|
|
290
|
+
) -> AuditManager:
|
|
291
|
+
if self._draft is None:
|
|
292
|
+
raise RuntimeError("Call begin() before set_object()")
|
|
293
|
+
self._draft.object_type = object_type
|
|
294
|
+
self._draft.object_id = object_id
|
|
295
|
+
self._draft.object_version = version
|
|
296
|
+
if object_type == "namespace" and object_id and self._draft.namespace_name is None:
|
|
297
|
+
self._draft.namespace_name = object_id
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
def set_resolve(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
resolve_type: str | None = None,
|
|
304
|
+
from_cache: bool | None = None,
|
|
305
|
+
) -> AuditManager:
|
|
306
|
+
if self._draft is None:
|
|
307
|
+
raise RuntimeError("Call begin() before set_resolve()")
|
|
308
|
+
self._draft.resolve_type = resolve_type
|
|
309
|
+
self._draft.from_cache = from_cache
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
def set_outcome(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
permission_ok: bool | None,
|
|
316
|
+
error: str | None = None,
|
|
317
|
+
) -> AuditManager:
|
|
318
|
+
if self._draft is None:
|
|
319
|
+
raise RuntimeError("Call begin() before set_outcome()")
|
|
320
|
+
self._draft.permission_ok = permission_ok
|
|
321
|
+
self._draft.error = error
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def set_parent(self, parent: AuditEvent) -> AuditManager:
|
|
325
|
+
if self._draft is None:
|
|
326
|
+
raise RuntimeError("Call begin() before set_parent()")
|
|
327
|
+
self._draft.parent_event = parent
|
|
328
|
+
return self
|
|
329
|
+
|
|
330
|
+
def set_operation(self, operation: str | None) -> AuditManager:
|
|
331
|
+
if self._draft is None:
|
|
332
|
+
raise RuntimeError("Call begin() before set_operation()")
|
|
333
|
+
self._draft.operation = operation
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def set_subresource(
|
|
337
|
+
self,
|
|
338
|
+
*,
|
|
339
|
+
subresource_type: str | None = None,
|
|
340
|
+
subresource: str | None = None,
|
|
341
|
+
) -> AuditManager:
|
|
342
|
+
if self._draft is None:
|
|
343
|
+
raise RuntimeError("Call begin() before set_subresource()")
|
|
344
|
+
self._draft.subresource_type = subresource_type
|
|
345
|
+
self._draft.subresource = subresource
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def begin_operation(self, object_type: str, object_id: str | None) -> AuditManager:
|
|
349
|
+
return self.begin(AuditEvent.EVENT_KIND_OPERATION).set_object(object_type, object_id)
|
|
350
|
+
|
|
351
|
+
@contextmanager
|
|
352
|
+
def operation(
|
|
353
|
+
self,
|
|
354
|
+
object_type: str,
|
|
355
|
+
object_id: str | None,
|
|
356
|
+
*,
|
|
357
|
+
resolve_type: str | None = None,
|
|
358
|
+
operation: str | None = None,
|
|
359
|
+
):
|
|
360
|
+
"""Context manager for one audited manager operation."""
|
|
361
|
+
self.begin_operation(object_type, object_id)
|
|
362
|
+
if resolve_type:
|
|
363
|
+
self.set_resolve(resolve_type=resolve_type)
|
|
364
|
+
if operation:
|
|
365
|
+
self.set_operation(operation)
|
|
366
|
+
try:
|
|
367
|
+
yield
|
|
368
|
+
except PermissionDenied as exc:
|
|
369
|
+
event = self._finalize_operation(permission_ok=False, error=str(exc))
|
|
370
|
+
self._attach_audit_event_id(exc, event)
|
|
371
|
+
raise
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
event = self._finalize_operation(permission_ok=True, error=str(exc))
|
|
374
|
+
self._attach_audit_event_id(exc, event)
|
|
375
|
+
raise
|
|
376
|
+
else:
|
|
377
|
+
self._finalize_operation(permission_ok=True)
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _attach_audit_event_id(exc: BaseException, event: AuditEvent | None) -> None:
|
|
381
|
+
if event is not None:
|
|
382
|
+
setattr(exc, "audit_event_id", event.id)
|
|
383
|
+
|
|
384
|
+
def _finalize_operation(
|
|
385
|
+
self,
|
|
386
|
+
*,
|
|
387
|
+
permission_ok: bool,
|
|
388
|
+
error: str | None = None,
|
|
389
|
+
) -> AuditEvent | None:
|
|
390
|
+
try:
|
|
391
|
+
return self.set_outcome(permission_ok=permission_ok, error=error).save()
|
|
392
|
+
except Exception:
|
|
393
|
+
logger.exception("Failed to save audit event")
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _resolve_post_value(spec, manager, bound_args: dict, result) -> Any:
|
|
398
|
+
"""Resolve a decorator field that may be a constant or post-call callable."""
|
|
399
|
+
if spec is None:
|
|
400
|
+
return None
|
|
401
|
+
if not callable(spec):
|
|
402
|
+
return spec
|
|
403
|
+
try:
|
|
404
|
+
return spec(manager, result, bound_args)
|
|
405
|
+
except TypeError:
|
|
406
|
+
return invoke(spec, manager, bound_args)
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _merge_post_field_spec(
|
|
410
|
+
body_ctx: AuditEventContext | None,
|
|
411
|
+
attr: str,
|
|
412
|
+
decorator_spec: str | Callable | int | None,
|
|
413
|
+
) -> str | Callable | int | None:
|
|
414
|
+
if body_ctx is not None:
|
|
415
|
+
value = getattr(body_ctx, attr, None)
|
|
416
|
+
if value is not None:
|
|
417
|
+
return value
|
|
418
|
+
return decorator_spec
|
|
419
|
+
|
|
420
|
+
def run_decorated(
|
|
421
|
+
self,
|
|
422
|
+
func: Callable,
|
|
423
|
+
manager,
|
|
424
|
+
args: tuple,
|
|
425
|
+
kwargs: dict,
|
|
426
|
+
*,
|
|
427
|
+
object_type: str | Callable,
|
|
428
|
+
object_id_attr: str | Callable | None,
|
|
429
|
+
skip_when: Callable | None,
|
|
430
|
+
func_params: list[str],
|
|
431
|
+
resolve_type: str | None = None,
|
|
432
|
+
operation: str | Callable | None = None,
|
|
433
|
+
subresource_type: str | Callable | None = None,
|
|
434
|
+
subresource: str | Callable | None = None,
|
|
435
|
+
object_version: int | str | Callable | None = None,
|
|
436
|
+
) -> Any:
|
|
437
|
+
"""Execute a decorated manager method with operation audit when configured."""
|
|
438
|
+
bound_args = bind_args(func_params, args, kwargs)
|
|
439
|
+
if skip_when is not None and invoke(skip_when, manager, bound_args):
|
|
440
|
+
return func(manager, *args, **kwargs)
|
|
441
|
+
|
|
442
|
+
if not self.should_audit_operation(resolve_related=resolve_type is not None):
|
|
443
|
+
return func(manager, *args, **kwargs)
|
|
444
|
+
|
|
445
|
+
resolved_object_type = invoke(object_type, manager, bound_args) if callable(object_type) else object_type
|
|
446
|
+
resolved_object_id = resolve_resource(manager, object_id_attr, bound_args)
|
|
447
|
+
|
|
448
|
+
self.begin_operation(
|
|
449
|
+
str(resolved_object_type),
|
|
450
|
+
str(resolved_object_id) if resolved_object_id else None,
|
|
451
|
+
)
|
|
452
|
+
if resolve_type:
|
|
453
|
+
self.set_resolve(resolve_type=resolve_type)
|
|
454
|
+
|
|
455
|
+
parent_ctx = _audit_operation_context.get()
|
|
456
|
+
_audit_operation_context.set(AuditEventContext())
|
|
457
|
+
try:
|
|
458
|
+
result = func(manager, *args, **kwargs)
|
|
459
|
+
except PermissionDenied as exc:
|
|
460
|
+
body_ctx = _take_audit_context()
|
|
461
|
+
if parent_ctx is not None:
|
|
462
|
+
_audit_operation_context.set(parent_ctx)
|
|
463
|
+
self._apply_post_fields(
|
|
464
|
+
manager,
|
|
465
|
+
bound_args,
|
|
466
|
+
None,
|
|
467
|
+
operation=cast(
|
|
468
|
+
"str | Callable | None",
|
|
469
|
+
self._merge_post_field_spec(body_ctx, "operation", operation),
|
|
470
|
+
),
|
|
471
|
+
subresource_type=cast(
|
|
472
|
+
"str | Callable | None",
|
|
473
|
+
self._merge_post_field_spec(body_ctx, "subresource_type", subresource_type),
|
|
474
|
+
),
|
|
475
|
+
subresource=cast(
|
|
476
|
+
"str | Callable | None",
|
|
477
|
+
self._merge_post_field_spec(body_ctx, "subresource", subresource),
|
|
478
|
+
),
|
|
479
|
+
object_version=self._merge_post_field_spec(body_ctx, "object_version", object_version),
|
|
480
|
+
)
|
|
481
|
+
event = self._finalize_operation(permission_ok=False, error=str(exc))
|
|
482
|
+
self._attach_audit_event_id(exc, event)
|
|
483
|
+
raise
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
body_ctx = _take_audit_context()
|
|
486
|
+
if parent_ctx is not None:
|
|
487
|
+
_audit_operation_context.set(parent_ctx)
|
|
488
|
+
self._apply_post_fields(
|
|
489
|
+
manager,
|
|
490
|
+
bound_args,
|
|
491
|
+
None,
|
|
492
|
+
operation=cast(
|
|
493
|
+
"str | Callable | None",
|
|
494
|
+
self._merge_post_field_spec(body_ctx, "operation", operation),
|
|
495
|
+
),
|
|
496
|
+
subresource_type=cast(
|
|
497
|
+
"str | Callable | None",
|
|
498
|
+
self._merge_post_field_spec(body_ctx, "subresource_type", subresource_type),
|
|
499
|
+
),
|
|
500
|
+
subresource=cast(
|
|
501
|
+
"str | Callable | None",
|
|
502
|
+
self._merge_post_field_spec(body_ctx, "subresource", subresource),
|
|
503
|
+
),
|
|
504
|
+
object_version=self._merge_post_field_spec(body_ctx, "object_version", object_version),
|
|
505
|
+
)
|
|
506
|
+
event = self._finalize_operation(permission_ok=True, error=str(exc))
|
|
507
|
+
self._attach_audit_event_id(exc, event)
|
|
508
|
+
raise
|
|
509
|
+
else:
|
|
510
|
+
body_ctx = _take_audit_context()
|
|
511
|
+
if parent_ctx is not None:
|
|
512
|
+
_audit_operation_context.set(parent_ctx)
|
|
513
|
+
self._apply_post_fields(
|
|
514
|
+
manager,
|
|
515
|
+
bound_args,
|
|
516
|
+
result,
|
|
517
|
+
operation=cast(
|
|
518
|
+
"str | Callable | None",
|
|
519
|
+
self._merge_post_field_spec(body_ctx, "operation", operation),
|
|
520
|
+
),
|
|
521
|
+
subresource_type=cast(
|
|
522
|
+
"str | Callable | None",
|
|
523
|
+
self._merge_post_field_spec(body_ctx, "subresource_type", subresource_type),
|
|
524
|
+
),
|
|
525
|
+
subresource=cast(
|
|
526
|
+
"str | Callable | None",
|
|
527
|
+
self._merge_post_field_spec(body_ctx, "subresource", subresource),
|
|
528
|
+
),
|
|
529
|
+
object_version=self._merge_post_field_spec(body_ctx, "object_version", object_version),
|
|
530
|
+
)
|
|
531
|
+
self._finalize_operation(permission_ok=True)
|
|
532
|
+
return result
|
|
533
|
+
|
|
534
|
+
def _apply_post_fields(
|
|
535
|
+
self,
|
|
536
|
+
manager,
|
|
537
|
+
bound_args: dict,
|
|
538
|
+
result,
|
|
539
|
+
*,
|
|
540
|
+
operation: str | Callable | None,
|
|
541
|
+
subresource_type: str | Callable | None,
|
|
542
|
+
subresource: str | Callable | None,
|
|
543
|
+
object_version: int | str | Callable | None,
|
|
544
|
+
) -> None:
|
|
545
|
+
if self._draft is None:
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
resolved_operation = self._resolve_post_value(operation, manager, bound_args, result)
|
|
549
|
+
if resolved_operation is not None:
|
|
550
|
+
self.set_operation(str(resolved_operation))
|
|
551
|
+
|
|
552
|
+
resolved_version = self._resolve_post_value(object_version, manager, bound_args, result)
|
|
553
|
+
if resolved_version is not None and resolved_version != "":
|
|
554
|
+
try:
|
|
555
|
+
self._draft.object_version = int(resolved_version)
|
|
556
|
+
except (TypeError, ValueError):
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
resolved_sr_type = self._resolve_post_value(subresource_type, manager, bound_args, result)
|
|
560
|
+
resolved_sr = self._resolve_post_value(subresource, manager, bound_args, result)
|
|
561
|
+
if resolved_sr_type or resolved_sr:
|
|
562
|
+
self.set_subresource(
|
|
563
|
+
subresource_type=str(resolved_sr_type) if resolved_sr_type else None,
|
|
564
|
+
subresource=str(resolved_sr) if resolved_sr is not None else None,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def begin_resolve_request(self, config_path: str) -> AuditManager:
|
|
568
|
+
return (
|
|
569
|
+
self.begin(AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
|
|
570
|
+
.set_object("config", config_path)
|
|
571
|
+
.set_resolve(resolve_type=AuditEvent.RESOLVE_TYPE_DIRECT)
|
|
572
|
+
.set_operation(OP_RESOLVE)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def save(self) -> AuditEvent | None:
|
|
576
|
+
if self._draft is None:
|
|
577
|
+
raise RuntimeError("No audit draft to save")
|
|
578
|
+
|
|
579
|
+
draft = self._draft
|
|
580
|
+
self._draft = None
|
|
581
|
+
|
|
582
|
+
if not self.should_persist(draft):
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
if not draft.operation:
|
|
586
|
+
draft.operation = infer_operation(draft)
|
|
587
|
+
|
|
588
|
+
namespace_name = draft.namespace_name
|
|
589
|
+
namespace_obj = draft.namespace
|
|
590
|
+
if namespace_name is None and namespace_obj is not None:
|
|
591
|
+
namespace_name = namespace_obj.name
|
|
592
|
+
if namespace_obj is not None and not Namespace.objects.filter(pk=namespace_obj.pk).exists():
|
|
593
|
+
namespace_obj = None
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
event = AuditEvent.objects.create(
|
|
597
|
+
client_ip=draft.client_ip,
|
|
598
|
+
user_agent=draft.user_agent,
|
|
599
|
+
auth_id=draft.auth_id,
|
|
600
|
+
auth_email=draft.auth_email,
|
|
601
|
+
auth_type=draft.auth_type,
|
|
602
|
+
token_number=draft.token_number,
|
|
603
|
+
namespace=namespace_obj,
|
|
604
|
+
namespace_name=namespace_name,
|
|
605
|
+
http_method=draft.http_method or "",
|
|
606
|
+
api_endpoint=draft.api_endpoint or "",
|
|
607
|
+
object_type=draft.object_type,
|
|
608
|
+
object_id=draft.object_id,
|
|
609
|
+
object_version=draft.object_version,
|
|
610
|
+
operation=draft.operation,
|
|
611
|
+
subresource_type=draft.subresource_type,
|
|
612
|
+
subresource=draft.subresource,
|
|
613
|
+
permission_ok=draft.permission_ok,
|
|
614
|
+
error=draft.error,
|
|
615
|
+
resolve_type=draft.resolve_type,
|
|
616
|
+
from_cache=draft.from_cache,
|
|
617
|
+
parent_event=draft.parent_event,
|
|
618
|
+
event_kind=draft.event_kind,
|
|
619
|
+
)
|
|
620
|
+
self._last_saved = event
|
|
621
|
+
if self._draft_stack:
|
|
622
|
+
self._draft = self._draft_stack.pop()
|
|
623
|
+
else:
|
|
624
|
+
self._draft = None
|
|
625
|
+
return event
|
|
626
|
+
except Exception:
|
|
627
|
+
logger.exception("Failed to persist audit event")
|
|
628
|
+
raise
|
|
629
|
+
|
|
630
|
+
def save_participants(
|
|
631
|
+
self,
|
|
632
|
+
participants: list[CacheParticipant],
|
|
633
|
+
roots: set[str],
|
|
634
|
+
*,
|
|
635
|
+
parent: AuditEvent | None = None,
|
|
636
|
+
) -> list[AuditEvent]:
|
|
637
|
+
parent_event = parent or self._last_saved
|
|
638
|
+
saved: list[AuditEvent] = []
|
|
639
|
+
|
|
640
|
+
for participant in participants:
|
|
641
|
+
if participant.path in roots:
|
|
642
|
+
continue
|
|
643
|
+
try:
|
|
644
|
+
tag_sr = tag_subresource_from_ref(participant.ref)
|
|
645
|
+
builder = (
|
|
646
|
+
self.begin(AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT)
|
|
647
|
+
.set_object(participant.kind, participant.path, version=participant.version)
|
|
648
|
+
.set_resolve(
|
|
649
|
+
resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
|
|
650
|
+
from_cache=participant.from_cache,
|
|
651
|
+
)
|
|
652
|
+
.set_operation(OP_REFERENCED_IN_RESOLVE)
|
|
653
|
+
.set_outcome(permission_ok=None)
|
|
654
|
+
)
|
|
655
|
+
if parent_event is not None:
|
|
656
|
+
builder = builder.set_parent(parent_event)
|
|
657
|
+
if tag_sr is not None:
|
|
658
|
+
builder.set_subresource(
|
|
659
|
+
subresource_type=tag_sr[0],
|
|
660
|
+
subresource=tag_sr[1],
|
|
661
|
+
)
|
|
662
|
+
builder.save()
|
|
663
|
+
if self._last_saved is not None:
|
|
664
|
+
saved.append(self._last_saved)
|
|
665
|
+
except Exception:
|
|
666
|
+
logger.exception("Failed to persist audit participant for %s", participant.path)
|
|
667
|
+
|
|
668
|
+
return saved
|
|
669
|
+
|
|
670
|
+
def _apply_filters(self, qs: QuerySet, filters: AuditListFilters) -> QuerySet:
|
|
671
|
+
if filters.auth_id:
|
|
672
|
+
qs = qs.filter(auth_id__icontains=filters.auth_id)
|
|
673
|
+
if filters.auth_email:
|
|
674
|
+
qs = qs.filter(auth_email__icontains=filters.auth_email)
|
|
675
|
+
if filters.auth_type:
|
|
676
|
+
qs = qs.filter(auth_type=filters.auth_type)
|
|
677
|
+
if filters.object_type:
|
|
678
|
+
qs = qs.filter(object_type=filters.object_type)
|
|
679
|
+
if filters.object_id:
|
|
680
|
+
qs = qs.filter(object_id__icontains=filters.object_id)
|
|
681
|
+
if filters.http_method:
|
|
682
|
+
qs = qs.filter(http_method__iexact=filters.http_method)
|
|
683
|
+
if filters.api_endpoint:
|
|
684
|
+
qs = qs.filter(api_endpoint__icontains=filters.api_endpoint)
|
|
685
|
+
if filters.permission_ok is not None:
|
|
686
|
+
qs = qs.filter(permission_ok=filters.permission_ok)
|
|
687
|
+
if filters.resolve_type:
|
|
688
|
+
qs = qs.filter(resolve_type=filters.resolve_type)
|
|
689
|
+
if filters.from_cache is not None:
|
|
690
|
+
qs = qs.filter(from_cache=filters.from_cache)
|
|
691
|
+
if filters.event_kind:
|
|
692
|
+
qs = qs.filter(event_kind=filters.event_kind)
|
|
693
|
+
if filters.category == "resolve":
|
|
694
|
+
qs = qs.filter(event_kind__in=_RESOLVE_EVENT_KINDS)
|
|
695
|
+
elif filters.category in ("operation", "modifications"):
|
|
696
|
+
qs = qs.filter(
|
|
697
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
698
|
+
http_method__in=MODIFICATION_HTTP_METHODS,
|
|
699
|
+
)
|
|
700
|
+
elif filters.category == "modifications-and-resolve":
|
|
701
|
+
qs = qs.filter(
|
|
702
|
+
Q(event_kind__in=_RESOLVE_EVENT_KINDS)
|
|
703
|
+
| Q(
|
|
704
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
705
|
+
http_method__in=MODIFICATION_HTTP_METHODS,
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
if filters.parent_event_id:
|
|
709
|
+
qs = qs.filter(parent_event_id=filters.parent_event_id)
|
|
710
|
+
if filters.client_ip:
|
|
711
|
+
qs = qs.filter(client_ip__icontains=filters.client_ip)
|
|
712
|
+
if filters.user_agent:
|
|
713
|
+
qs = qs.filter(user_agent__icontains=filters.user_agent)
|
|
714
|
+
if filters.token_number is not None:
|
|
715
|
+
qs = qs.filter(token_number=filters.token_number)
|
|
716
|
+
if filters.object_version is not None:
|
|
717
|
+
qs = qs.filter(object_version=filters.object_version)
|
|
718
|
+
if filters.operation:
|
|
719
|
+
qs = qs.filter(operation__icontains=filters.operation)
|
|
720
|
+
if filters.subresource_type:
|
|
721
|
+
qs = qs.filter(subresource_type__icontains=filters.subresource_type)
|
|
722
|
+
if filters.subresource:
|
|
723
|
+
qs = qs.filter(subresource__icontains=filters.subresource)
|
|
724
|
+
if filters.event_id:
|
|
725
|
+
qs = qs.filter(pk=filters.event_id)
|
|
726
|
+
if filters.error:
|
|
727
|
+
qs = qs.filter(error__icontains=filters.error)
|
|
728
|
+
if filters.namespace_name:
|
|
729
|
+
qs = qs.filter(namespace_name__iexact=filters.namespace_name)
|
|
730
|
+
if filters.occurred_from:
|
|
731
|
+
qs = qs.filter(occurred_at__gte=filters.occurred_from)
|
|
732
|
+
if filters.occurred_to:
|
|
733
|
+
qs = qs.filter(occurred_at__lte=filters.occurred_to)
|
|
734
|
+
return qs
|
|
735
|
+
|
|
736
|
+
@staticmethod
|
|
737
|
+
def _apply_timeline_search(qs: QuerySet, search: str) -> QuerySet:
|
|
738
|
+
term = search.strip()
|
|
739
|
+
if not term:
|
|
740
|
+
return qs
|
|
741
|
+
|
|
742
|
+
q = (
|
|
743
|
+
Q(auth_id__icontains=term)
|
|
744
|
+
| Q(auth_email__icontains=term)
|
|
745
|
+
| Q(auth_type__icontains=term)
|
|
746
|
+
| Q(namespace_name__icontains=term)
|
|
747
|
+
| Q(http_method__icontains=term)
|
|
748
|
+
| Q(api_endpoint__icontains=term)
|
|
749
|
+
| Q(object_type__icontains=term)
|
|
750
|
+
| Q(object_id__icontains=term)
|
|
751
|
+
| Q(operation__icontains=term)
|
|
752
|
+
| Q(subresource_type__icontains=term)
|
|
753
|
+
| Q(subresource__icontains=term)
|
|
754
|
+
| Q(error__icontains=term)
|
|
755
|
+
| Q(resolve_type__icontains=term)
|
|
756
|
+
| Q(event_kind__icontains=term)
|
|
757
|
+
| Q(client_ip__icontains=term)
|
|
758
|
+
| Q(user_agent__icontains=term)
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
if term.isdigit():
|
|
762
|
+
number = int(term)
|
|
763
|
+
q |= Q(object_version=number) | Q(token_number=number)
|
|
764
|
+
|
|
765
|
+
lowered = term.lower()
|
|
766
|
+
if lowered in ("true", "false"):
|
|
767
|
+
flag = lowered == "true"
|
|
768
|
+
q |= Q(from_cache=flag) | Q(permission_ok=flag)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
parsed = uuid.UUID(term)
|
|
772
|
+
except ValueError:
|
|
773
|
+
pass
|
|
774
|
+
else:
|
|
775
|
+
q |= Q(pk=parsed) | Q(parent_event_id=parsed)
|
|
776
|
+
|
|
777
|
+
return qs.filter(q)
|
|
778
|
+
|
|
779
|
+
@staticmethod
|
|
780
|
+
def _folder_descendant_path_filter(folder_path: str) -> Q:
|
|
781
|
+
"""Match audit object_id for configs under a folder (inclusive)."""
|
|
782
|
+
normalized = folder_path.strip("/")
|
|
783
|
+
if not normalized:
|
|
784
|
+
return Q(object_id__isnull=False) & ~Q(object_id="")
|
|
785
|
+
return Q(object_id=normalized) | Q(object_id__startswith=f"{normalized}/")
|
|
786
|
+
|
|
787
|
+
@staticmethod
|
|
788
|
+
def _collect_prior_paths_from_moves(
|
|
789
|
+
namespace_name: str,
|
|
790
|
+
path: str,
|
|
791
|
+
object_type: str,
|
|
792
|
+
) -> set[str]:
|
|
793
|
+
"""Return former paths for *path* by walking recorded move events."""
|
|
794
|
+
normalized = path.strip("/")
|
|
795
|
+
prior: set[str] = set()
|
|
796
|
+
frontier = [normalized]
|
|
797
|
+
while frontier:
|
|
798
|
+
destination = frontier.pop()
|
|
799
|
+
sources = (
|
|
800
|
+
AuditEvent.objects.filter(
|
|
801
|
+
namespace_name__iexact=namespace_name,
|
|
802
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
803
|
+
operation=OP_MOVE_ITEM,
|
|
804
|
+
object_type=object_type,
|
|
805
|
+
subresource_type="path",
|
|
806
|
+
subresource=destination,
|
|
807
|
+
)
|
|
808
|
+
.exclude(object_id="")
|
|
809
|
+
.values_list("object_id", flat=True)
|
|
810
|
+
.distinct()
|
|
811
|
+
)
|
|
812
|
+
for source in sources:
|
|
813
|
+
source_path = str(source).strip("/")
|
|
814
|
+
if not source_path or source_path in prior:
|
|
815
|
+
continue
|
|
816
|
+
prior.add(source_path)
|
|
817
|
+
frontier.append(source_path)
|
|
818
|
+
return prior
|
|
819
|
+
|
|
820
|
+
@staticmethod
|
|
821
|
+
def _item_timeline_object_id_filter(
|
|
822
|
+
object_id: str,
|
|
823
|
+
prior_paths: set[str],
|
|
824
|
+
) -> Q:
|
|
825
|
+
paths = {object_id.strip("/")} | prior_paths
|
|
826
|
+
if len(paths) == 1:
|
|
827
|
+
return Q(object_id=next(iter(paths)))
|
|
828
|
+
return Q(object_id__in=paths)
|
|
829
|
+
|
|
830
|
+
def _validate_timeline_object(self, object_id: str, object_type: str) -> None:
|
|
831
|
+
from ..tree import TreeManager
|
|
832
|
+
|
|
833
|
+
if self.namespace is None:
|
|
834
|
+
raise ValueError("item timeline requires a namespace")
|
|
835
|
+
|
|
836
|
+
tree = TreeManager(self.namespace, object_id, auth=self.auth)
|
|
837
|
+
if tree.item is None:
|
|
838
|
+
raise NotFound(f"Item wasn't found by path '{object_id}'")
|
|
839
|
+
if tree.item.node_type != object_type:
|
|
840
|
+
raise NotFound(f"Item wasn't found by path '{object_id}'")
|
|
841
|
+
|
|
842
|
+
@require_permissions(_item_audit_access)
|
|
843
|
+
def item_timeline(
|
|
844
|
+
self,
|
|
845
|
+
object_id: str,
|
|
846
|
+
object_type: str,
|
|
847
|
+
*,
|
|
848
|
+
search: str | None = None,
|
|
849
|
+
) -> QuerySet:
|
|
850
|
+
"""Audit events for one tree item as a user-friendly timeline."""
|
|
851
|
+
self._validate_timeline_object(object_id, object_type)
|
|
852
|
+
|
|
853
|
+
prior_paths = self._collect_prior_paths_from_moves(
|
|
854
|
+
self.namespace.name,
|
|
855
|
+
object_id,
|
|
856
|
+
object_type,
|
|
857
|
+
)
|
|
858
|
+
filters = AuditListFilters(
|
|
859
|
+
object_type=object_type,
|
|
860
|
+
category="modifications",
|
|
861
|
+
)
|
|
862
|
+
qs = AuditEvent.objects.select_related("namespace", "parent_event").filter(
|
|
863
|
+
namespace_name__iexact=self.namespace.name,
|
|
864
|
+
)
|
|
865
|
+
qs = self._apply_filters(qs, filters)
|
|
866
|
+
qs = qs.filter(self._item_timeline_object_id_filter(object_id, prior_paths))
|
|
867
|
+
qs = qs.filter(
|
|
868
|
+
Q(error__isnull=True) | Q(error=""),
|
|
869
|
+
).exclude(permission_ok=False)
|
|
870
|
+
if search:
|
|
871
|
+
qs = self._apply_timeline_search(qs, search)
|
|
872
|
+
return qs.order_by("-occurred_at", "-id")
|
|
873
|
+
|
|
874
|
+
@require_permissions(_audit_list_access)
|
|
875
|
+
def list(
|
|
876
|
+
self,
|
|
877
|
+
filters: AuditListFilters | None = None,
|
|
878
|
+
*,
|
|
879
|
+
search: str | None = None,
|
|
880
|
+
) -> QuerySet:
|
|
881
|
+
filters = filters or AuditListFilters()
|
|
882
|
+
|
|
883
|
+
qs = AuditEvent.objects.select_related("namespace", "parent_event").all()
|
|
884
|
+
|
|
885
|
+
if self.namespace is not None:
|
|
886
|
+
qs = qs.filter(namespace_name__iexact=self.namespace.name)
|
|
887
|
+
elif filters.namespace_name:
|
|
888
|
+
qs = qs.filter(namespace_name__iexact=filters.namespace_name)
|
|
889
|
+
|
|
890
|
+
qs = self._apply_filters(qs, filters)
|
|
891
|
+
|
|
892
|
+
if search:
|
|
893
|
+
qs = qs.filter(
|
|
894
|
+
Q(auth_id__icontains=search)
|
|
895
|
+
| Q(auth_email__icontains=search)
|
|
896
|
+
| Q(namespace_name__icontains=search)
|
|
897
|
+
| Q(object_id__icontains=search)
|
|
898
|
+
| Q(api_endpoint__icontains=search)
|
|
899
|
+
| Q(operation__icontains=search)
|
|
900
|
+
| Q(error__icontains=search)
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
return qs.order_by("-occurred_at", "-id")
|
|
904
|
+
|
|
905
|
+
_MIN_EVENT_ID_PREFIX_LEN = 4
|
|
906
|
+
|
|
907
|
+
def resolve_event_id(self, event_id_ref: str) -> UUID:
|
|
908
|
+
"""Resolve a full event UUID from a complete value or unique hex prefix."""
|
|
909
|
+
ref = event_id_ref.strip()
|
|
910
|
+
if not ref:
|
|
911
|
+
raise ValueError("Audit event id must not be empty.")
|
|
912
|
+
try:
|
|
913
|
+
return uuid.UUID(ref)
|
|
914
|
+
except ValueError:
|
|
915
|
+
hex_prefix = ref.replace("-", "").lower()
|
|
916
|
+
if not re.fullmatch(r"[0-9a-f]+", hex_prefix):
|
|
917
|
+
raise ValueError(f"Invalid audit event id {ref!r}.") from None
|
|
918
|
+
if len(hex_prefix) < self._MIN_EVENT_ID_PREFIX_LEN:
|
|
919
|
+
raise ValueError(
|
|
920
|
+
f"Audit event id prefix {ref!r} is too short; "
|
|
921
|
+
f"use at least {self._MIN_EVENT_ID_PREFIX_LEN} hex characters."
|
|
922
|
+
) from None
|
|
923
|
+
|
|
924
|
+
from django.db.models import CharField, Value
|
|
925
|
+
from django.db.models.functions import Cast, Lower, Replace
|
|
926
|
+
|
|
927
|
+
qs = AuditEvent.objects.all()
|
|
928
|
+
if self.namespace is not None:
|
|
929
|
+
qs = qs.filter(namespace_name__iexact=self.namespace.name)
|
|
930
|
+
|
|
931
|
+
matches = list(
|
|
932
|
+
qs.annotate(id_hex=Lower(Replace(Cast("id", CharField(max_length=36)), Value("-"), Value(""))))
|
|
933
|
+
.filter(id_hex__startswith=hex_prefix)
|
|
934
|
+
.order_by("id")[:2]
|
|
935
|
+
)
|
|
936
|
+
if not matches:
|
|
937
|
+
raise NotFound(f"Audit event '{ref}' not found")
|
|
938
|
+
if len(matches) > 1:
|
|
939
|
+
raise ValueError(f"Ambiguous audit event id prefix {ref!r}; use a longer prefix.")
|
|
940
|
+
return matches[0].id
|
|
941
|
+
|
|
942
|
+
@require_permissions(_audit_list_access)
|
|
943
|
+
def get(self, event_id) -> AuditEvent:
|
|
944
|
+
resolved_id = self.resolve_event_id(str(event_id))
|
|
945
|
+
qs = AuditEvent.objects.select_related("namespace", "parent_event")
|
|
946
|
+
if self.namespace is not None:
|
|
947
|
+
qs = qs.filter(namespace_name__iexact=self.namespace.name)
|
|
948
|
+
try:
|
|
949
|
+
return qs.get(pk=resolved_id)
|
|
950
|
+
except AuditEvent.DoesNotExist as exc:
|
|
951
|
+
raise NotFound(f"Audit event '{event_id}' not found") from exc
|
|
952
|
+
|
|
953
|
+
def config_resolve_stats(
|
|
954
|
+
self,
|
|
955
|
+
config_path: str,
|
|
956
|
+
since: datetime,
|
|
957
|
+
until: datetime,
|
|
958
|
+
) -> ResolveStats:
|
|
959
|
+
if self.namespace is None:
|
|
960
|
+
raise ValueError("config_resolve_stats requires a namespace")
|
|
961
|
+
|
|
962
|
+
direct = AuditEvent.objects.filter(
|
|
963
|
+
namespace_name=self.namespace.name,
|
|
964
|
+
event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
|
|
965
|
+
object_type="config",
|
|
966
|
+
object_id=config_path,
|
|
967
|
+
occurred_at__gte=since,
|
|
968
|
+
occurred_at__lte=until,
|
|
969
|
+
).count()
|
|
970
|
+
nested = AuditEvent.objects.filter(
|
|
971
|
+
namespace_name=self.namespace.name,
|
|
972
|
+
event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
|
|
973
|
+
resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
|
|
974
|
+
object_type="config",
|
|
975
|
+
object_id=config_path,
|
|
976
|
+
occurred_at__gte=since,
|
|
977
|
+
occurred_at__lte=until,
|
|
978
|
+
).count()
|
|
979
|
+
return ResolveStats(direct=direct, nested=nested)
|
|
980
|
+
|
|
981
|
+
def _resolve_events_for_stats(
|
|
982
|
+
self,
|
|
983
|
+
*,
|
|
984
|
+
object_id: str,
|
|
985
|
+
object_type: str | None,
|
|
986
|
+
since: datetime,
|
|
987
|
+
until: datetime,
|
|
988
|
+
) -> QuerySet:
|
|
989
|
+
if self.namespace is None:
|
|
990
|
+
raise ValueError("resolve stats require a namespace")
|
|
991
|
+
|
|
992
|
+
base = AuditEvent.objects.filter(
|
|
993
|
+
namespace_name=self.namespace.name,
|
|
994
|
+
occurred_at__gte=since,
|
|
995
|
+
occurred_at__lt=until,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
if object_type == "resolver":
|
|
999
|
+
return base.filter(
|
|
1000
|
+
event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
|
|
1001
|
+
auth_type=AuditEvent.AUTH_TYPE_RESOLVER,
|
|
1002
|
+
auth_id=object_id,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
if object_type == "folder":
|
|
1006
|
+
path_filter = self._folder_descendant_path_filter(object_id)
|
|
1007
|
+
scoped = base.filter(object_type="config").filter(path_filter)
|
|
1008
|
+
direct_q = Q(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
|
|
1009
|
+
nested_q = Q(
|
|
1010
|
+
event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
|
|
1011
|
+
resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
|
|
1012
|
+
)
|
|
1013
|
+
return scoped.filter(direct_q | nested_q)
|
|
1014
|
+
|
|
1015
|
+
base = base.filter(object_id=object_id)
|
|
1016
|
+
if object_type:
|
|
1017
|
+
base = base.filter(object_type=object_type)
|
|
1018
|
+
|
|
1019
|
+
direct_q = Q(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
|
|
1020
|
+
nested_q = Q(
|
|
1021
|
+
event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT,
|
|
1022
|
+
resolve_type=AuditEvent.RESOLVE_TYPE_NESTED,
|
|
1023
|
+
)
|
|
1024
|
+
return base.filter(direct_q | nested_q)
|
|
1025
|
+
|
|
1026
|
+
@require_permissions(_item_audit_access)
|
|
1027
|
+
def resolve_stats_series(
|
|
1028
|
+
self,
|
|
1029
|
+
*,
|
|
1030
|
+
object_id: str,
|
|
1031
|
+
object_type: str | None,
|
|
1032
|
+
since: datetime,
|
|
1033
|
+
until: datetime,
|
|
1034
|
+
bucket_seconds: int,
|
|
1035
|
+
) -> builtins.list[ResolveSeriesBucket]:
|
|
1036
|
+
if bucket_seconds < 60:
|
|
1037
|
+
raise ValueError("bucket_seconds must be at least 60")
|
|
1038
|
+
|
|
1039
|
+
if object_type is None:
|
|
1040
|
+
raise ValueError("object_type is required")
|
|
1041
|
+
|
|
1042
|
+
self._validate_timeline_object(object_id, object_type)
|
|
1043
|
+
|
|
1044
|
+
if since.tzinfo is None or until.tzinfo is None:
|
|
1045
|
+
raise ValueError("since and until must be timezone-aware")
|
|
1046
|
+
|
|
1047
|
+
aligned_epoch = (int(since.timestamp()) // bucket_seconds) * bucket_seconds
|
|
1048
|
+
aligned_start = datetime.fromtimestamp(aligned_epoch, tz=since.tzinfo)
|
|
1049
|
+
span_seconds = max(bucket_seconds, int((until - aligned_start).total_seconds()))
|
|
1050
|
+
bucket_count = max(1, math.ceil(span_seconds / bucket_seconds))
|
|
1051
|
+
|
|
1052
|
+
counts: dict[int, dict[str, int]] = {
|
|
1053
|
+
index: {"direct": 0, "nested": 0, "errors": 0} for index in range(bucket_count)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
qs = self._resolve_events_for_stats(
|
|
1057
|
+
object_id=object_id,
|
|
1058
|
+
object_type=object_type,
|
|
1059
|
+
since=aligned_start,
|
|
1060
|
+
until=until,
|
|
1061
|
+
)
|
|
1062
|
+
for row in qs.values("occurred_at", "event_kind", "resolve_type", "error"):
|
|
1063
|
+
occurred_at = row["occurred_at"]
|
|
1064
|
+
if occurred_at.tzinfo is None:
|
|
1065
|
+
occurred_at = timezone.make_aware(occurred_at)
|
|
1066
|
+
index = int((occurred_at - aligned_start).total_seconds() // bucket_seconds)
|
|
1067
|
+
if index < 0 or index >= bucket_count:
|
|
1068
|
+
continue
|
|
1069
|
+
if row["event_kind"] == AuditEvent.EVENT_KIND_RESOLVE_REQUEST:
|
|
1070
|
+
counts[index]["direct"] += 1
|
|
1071
|
+
if row.get("error"):
|
|
1072
|
+
counts[index]["errors"] += 1
|
|
1073
|
+
elif (
|
|
1074
|
+
row["event_kind"] == AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT
|
|
1075
|
+
and row["resolve_type"] == AuditEvent.RESOLVE_TYPE_NESTED
|
|
1076
|
+
):
|
|
1077
|
+
counts[index]["nested"] += 1
|
|
1078
|
+
|
|
1079
|
+
step = timedelta(seconds=bucket_seconds)
|
|
1080
|
+
return [
|
|
1081
|
+
ResolveSeriesBucket(
|
|
1082
|
+
start=aligned_start + step * index,
|
|
1083
|
+
direct=counts[index]["direct"],
|
|
1084
|
+
nested=counts[index]["nested"],
|
|
1085
|
+
errors=counts[index]["errors"],
|
|
1086
|
+
)
|
|
1087
|
+
for index in range(bucket_count)
|
|
1088
|
+
]
|
|
1089
|
+
|
|
1090
|
+
@staticmethod
|
|
1091
|
+
def get_audit_mode() -> str:
|
|
1092
|
+
"""Return the validated audit mode from settings."""
|
|
1093
|
+
mode = getattr(settings, "OCMO_AUDIT_MODE", AUDIT_MODE_RESOLVE)
|
|
1094
|
+
if mode in VALID_AUDIT_MODES:
|
|
1095
|
+
return mode
|
|
1096
|
+
logger.warning("Invalid OCMO_AUDIT_MODE %r; falling back to %r", mode, AUDIT_MODE_RESOLVE)
|
|
1097
|
+
return AUDIT_MODE_RESOLVE
|
|
1098
|
+
|
|
1099
|
+
def is_modification_request(self) -> bool:
|
|
1100
|
+
request = self.request
|
|
1101
|
+
if request is None:
|
|
1102
|
+
return False
|
|
1103
|
+
method = (getattr(request, "method", "") or "").upper()
|
|
1104
|
+
return method in MODIFICATION_HTTP_METHODS
|
|
1105
|
+
|
|
1106
|
+
def should_persist(self, draft: AuditEventDraft) -> bool:
|
|
1107
|
+
mode = self.get_audit_mode()
|
|
1108
|
+
if mode == AUDIT_MODE_ALL:
|
|
1109
|
+
return True
|
|
1110
|
+
if _is_resolve_event(draft):
|
|
1111
|
+
return True
|
|
1112
|
+
if mode == AUDIT_MODE_MODIFICATIONS_AND_RESOLVE:
|
|
1113
|
+
return self.is_modification_request()
|
|
1114
|
+
return False
|
|
1115
|
+
|
|
1116
|
+
def should_audit_operation(self, *, resolve_related: bool) -> bool:
|
|
1117
|
+
mode = self.get_audit_mode()
|
|
1118
|
+
if mode == AUDIT_MODE_ALL:
|
|
1119
|
+
return True
|
|
1120
|
+
if resolve_related:
|
|
1121
|
+
return True
|
|
1122
|
+
if mode == AUDIT_MODE_MODIFICATIONS_AND_RESOLVE:
|
|
1123
|
+
return self.is_modification_request()
|
|
1124
|
+
return False
|
|
1125
|
+
|
|
1126
|
+
@classmethod
|
|
1127
|
+
def resolve_request(cls, config_path: str) -> _ResolveAuditRecorder:
|
|
1128
|
+
"""Context manager: persist resolve_request + participants on exit."""
|
|
1129
|
+
return _ResolveAuditRecorder(cls.current(), config_path)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class _ResolveAuditRecorder:
|
|
1133
|
+
"""Record one resolve request and optional participant rows when the block ends."""
|
|
1134
|
+
|
|
1135
|
+
def __init__(self, audit_mgr: AuditManager | None, config_path: str) -> None:
|
|
1136
|
+
self._audit_mgr = audit_mgr
|
|
1137
|
+
self._config_path = config_path
|
|
1138
|
+
self.participants: list[CacheParticipant] = []
|
|
1139
|
+
self.error: str | None = None
|
|
1140
|
+
|
|
1141
|
+
def __enter__(self) -> _ResolveAuditRecorder:
|
|
1142
|
+
return self
|
|
1143
|
+
|
|
1144
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
1145
|
+
if self._audit_mgr is None:
|
|
1146
|
+
return
|
|
1147
|
+
error = str(exc_val) if exc_val is not None else self.error
|
|
1148
|
+
try:
|
|
1149
|
+
event = (
|
|
1150
|
+
self._audit_mgr.begin_resolve_request(self._config_path)
|
|
1151
|
+
.set_outcome(
|
|
1152
|
+
permission_ok=True,
|
|
1153
|
+
error=error,
|
|
1154
|
+
)
|
|
1155
|
+
.save()
|
|
1156
|
+
)
|
|
1157
|
+
if exc_val is not None:
|
|
1158
|
+
AuditManager._attach_audit_event_id(exc_val, event)
|
|
1159
|
+
if self.participants:
|
|
1160
|
+
self._audit_mgr.save_participants(
|
|
1161
|
+
self.participants,
|
|
1162
|
+
roots={self._config_path},
|
|
1163
|
+
)
|
|
1164
|
+
except Exception:
|
|
1165
|
+
logger.exception("Failed to record resolve audit for %s", self._config_path)
|
|
1166
|
+
return
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _is_resolve_event(draft: AuditEventDraft) -> bool:
|
|
1170
|
+
if draft.event_kind in _RESOLVE_EVENT_KINDS:
|
|
1171
|
+
return True
|
|
1172
|
+
return draft.resolve_type is not None
|