nl2data-admin-service 0.1.0__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.
- nl2data_admin_service/__init__.py +21 -0
- nl2data_admin_service/assembly_admin.py +358 -0
- nl2data_admin_service/audit_admin.py +89 -0
- nl2data_admin_service/auth.py +90 -0
- nl2data_admin_service/authoring_admin.py +166 -0
- nl2data_admin_service/bundle_admin.py +428 -0
- nl2data_admin_service/common.py +268 -0
- nl2data_admin_service/config.py +40 -0
- nl2data_admin_service/dtos.py +729 -0
- nl2data_admin_service/errors.py +91 -0
- nl2data_admin_service/lint_admin.py +139 -0
- nl2data_admin_service/metadata_admin.py +301 -0
- nl2data_admin_service/protocols.py +364 -0
- nl2data_admin_service/schema.py +101 -0
- nl2data_admin_service/service.py +469 -0
- nl2data_admin_service/verification_admin.py +257 -0
- nl2data_admin_service-0.1.0.dist-info/METADATA +127 -0
- nl2data_admin_service-0.1.0.dist-info/RECORD +20 -0
- nl2data_admin_service-0.1.0.dist-info/WHEEL +5 -0
- nl2data_admin_service-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Optional transport-neutral admin control-plane service for nl2data-core.
|
|
2
|
+
|
|
3
|
+
The ``nl2data-admin-service`` package exposes a framework-neutral application
|
|
4
|
+
service for the metadata-to-Bundle lifecycle. It intentionally has no HTTP,
|
|
5
|
+
authentication, web-framework, or database-driver dependencies so that hosts
|
|
6
|
+
can wrap it in their chosen transport (HTTP/CLI/UI) without coupling core to
|
|
7
|
+
any transport technology.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .config import AdminServiceConfig, AdminServiceProfile
|
|
13
|
+
from .protocols import AuthoringAdminService
|
|
14
|
+
from .service import AdminService
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AdminService",
|
|
18
|
+
"AdminServiceConfig",
|
|
19
|
+
"AdminServiceProfile",
|
|
20
|
+
"AuthoringAdminService",
|
|
21
|
+
]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Assembly draft lifecycle Admin capability service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nl2data_core.assembly import (
|
|
6
|
+
AssemblyDraft,
|
|
7
|
+
AssemblyState,
|
|
8
|
+
AssertionProvenance,
|
|
9
|
+
AssertionProvenanceKind,
|
|
10
|
+
DeploymentBinding,
|
|
11
|
+
LifecycleAction,
|
|
12
|
+
LifecycleRole,
|
|
13
|
+
ReviewState,
|
|
14
|
+
SemanticAssertion,
|
|
15
|
+
require_lifecycle_authorization,
|
|
16
|
+
)
|
|
17
|
+
from nl2data_core.assembly import approve_draft as core_approve_draft
|
|
18
|
+
from nl2data_core.assembly import create_discovery_draft as core_create_discovery_draft
|
|
19
|
+
from nl2data_core.assembly import decide_assertion as core_decide_assertion
|
|
20
|
+
from nl2data_core.assembly import edit_assertion as core_edit_assertion
|
|
21
|
+
from nl2data_core.assembly import submit_for_review as core_submit_for_review
|
|
22
|
+
|
|
23
|
+
from .auth import AuthContext, Permission
|
|
24
|
+
from .common import (
|
|
25
|
+
AdminDependencyAccess,
|
|
26
|
+
lifecycle_context,
|
|
27
|
+
load_draft,
|
|
28
|
+
require_permission,
|
|
29
|
+
require_source,
|
|
30
|
+
store_draft,
|
|
31
|
+
)
|
|
32
|
+
from .common import (
|
|
33
|
+
normalize_errors as _normalize_errors,
|
|
34
|
+
)
|
|
35
|
+
from .config import AdminServiceConfig
|
|
36
|
+
from .dtos import (
|
|
37
|
+
AssemblyAssertionSummary,
|
|
38
|
+
AssemblyDraftDetail,
|
|
39
|
+
AssemblyDraftSummary,
|
|
40
|
+
AssertionDecisionAction,
|
|
41
|
+
AssertionDecisionCommand,
|
|
42
|
+
DeploymentBindingSummary,
|
|
43
|
+
DraftMutationResult,
|
|
44
|
+
DraftRevisionCommand,
|
|
45
|
+
)
|
|
46
|
+
from .errors import ConflictError, NotFoundError, ValidationError
|
|
47
|
+
from .protocols import AdminServiceDependencies
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _draft_to_summary(draft: AssemblyDraft) -> AssemblyDraftSummary:
|
|
51
|
+
states = [assertion.review_state for assertion in draft.assertions]
|
|
52
|
+
return AssemblyDraftSummary(
|
|
53
|
+
draft_id=draft.draft_id,
|
|
54
|
+
bundle_id=draft.bundle_id,
|
|
55
|
+
model_version=draft.model_version,
|
|
56
|
+
state=draft.state.value,
|
|
57
|
+
draft_revision=draft.draft_revision,
|
|
58
|
+
assertion_count=len(draft.assertions),
|
|
59
|
+
pending_count=states.count(ReviewState.PENDING),
|
|
60
|
+
approved_count=states.count(ReviewState.APPROVED),
|
|
61
|
+
rejected_count=states.count(ReviewState.REJECTED),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _draft_to_detail(draft: AssemblyDraft) -> AssemblyDraftDetail:
|
|
66
|
+
summary = _draft_to_summary(draft)
|
|
67
|
+
return AssemblyDraftDetail(
|
|
68
|
+
**summary.model_dump(),
|
|
69
|
+
assertions=tuple(
|
|
70
|
+
AssemblyAssertionSummary(
|
|
71
|
+
assertion_id=assertion.id,
|
|
72
|
+
assertion_type=assertion.type.value,
|
|
73
|
+
review_state=assertion.review_state.value,
|
|
74
|
+
payload_hash=assertion.payload_hash(),
|
|
75
|
+
provenance_kind=assertion.provenance.kind.value,
|
|
76
|
+
)
|
|
77
|
+
for assertion in draft.assertions
|
|
78
|
+
),
|
|
79
|
+
deployment_bindings=tuple(
|
|
80
|
+
DeploymentBindingSummary(
|
|
81
|
+
binding_id=binding.binding_id,
|
|
82
|
+
environment=binding.environment,
|
|
83
|
+
source_id=binding.source_id,
|
|
84
|
+
reference_scheme=binding.reference_scheme,
|
|
85
|
+
)
|
|
86
|
+
for binding in draft.deployment_bindings
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AssemblyLifecycleAdminCapability:
|
|
92
|
+
"""Assembly draft creation, editing, review, approval, and provenance."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, dependencies: AdminServiceDependencies, config: AdminServiceConfig) -> None:
|
|
95
|
+
self._deps = dependencies
|
|
96
|
+
self._access = AdminDependencyAccess(dependencies)
|
|
97
|
+
self._config = config
|
|
98
|
+
|
|
99
|
+
@_normalize_errors
|
|
100
|
+
def create_draft(
|
|
101
|
+
self,
|
|
102
|
+
draft: AssemblyDraft,
|
|
103
|
+
*,
|
|
104
|
+
auth_context: AuthContext,
|
|
105
|
+
) -> DraftMutationResult:
|
|
106
|
+
require_permission(auth_context, Permission.ASSEMBLY_WRITE)
|
|
107
|
+
require_source(auth_context, draft.source_id)
|
|
108
|
+
authorization = lifecycle_context(self._deps, auth_context, draft.source_id)
|
|
109
|
+
require_lifecycle_authorization(
|
|
110
|
+
context=authorization,
|
|
111
|
+
authorizer=self._access.lifecycle_authorizer(),
|
|
112
|
+
required_role=LifecycleRole.AUTHOR,
|
|
113
|
+
action=LifecycleAction.CREATE_DRAFT,
|
|
114
|
+
resource_id=draft.draft_id,
|
|
115
|
+
)
|
|
116
|
+
if draft.state is not AssemblyState.DRAFT or draft.draft_revision != 0:
|
|
117
|
+
raise ValidationError("new assembly drafts must start at draft revision 0")
|
|
118
|
+
if draft.review_submitted_by is not None or draft.approved_by is not None:
|
|
119
|
+
raise ValidationError("new assembly drafts cannot carry review metadata")
|
|
120
|
+
if any(
|
|
121
|
+
assertion.review_state is not ReviewState.PENDING
|
|
122
|
+
or assertion.review_binding is not None
|
|
123
|
+
for assertion in draft.assertions
|
|
124
|
+
):
|
|
125
|
+
raise ValidationError("new assembly draft assertions must be pending")
|
|
126
|
+
draft = AssemblyDraft.model_validate(
|
|
127
|
+
{
|
|
128
|
+
**draft.model_dump(mode="python", by_alias=True),
|
|
129
|
+
"author_reference": authorization.operator_reference,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
self._access.draft_store().create(
|
|
134
|
+
draft,
|
|
135
|
+
tenant_scope_fingerprint=auth_context.tenant_scope_fingerprint,
|
|
136
|
+
)
|
|
137
|
+
except ValueError as error:
|
|
138
|
+
raise ConflictError("Assembly draft already exists") from error
|
|
139
|
+
return DraftMutationResult(
|
|
140
|
+
draft=_draft_to_summary(draft),
|
|
141
|
+
audit_reference=authorization.operator_reference,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@_normalize_errors
|
|
145
|
+
def get_draft(
|
|
146
|
+
self,
|
|
147
|
+
draft_id: str,
|
|
148
|
+
*,
|
|
149
|
+
auth_context: AuthContext,
|
|
150
|
+
) -> AssemblyDraftDetail:
|
|
151
|
+
require_permission(auth_context, Permission.ASSEMBLY_READ)
|
|
152
|
+
return _draft_to_detail(load_draft(self._access, draft_id, auth_context))
|
|
153
|
+
|
|
154
|
+
@_normalize_errors
|
|
155
|
+
def edit_draft(
|
|
156
|
+
self,
|
|
157
|
+
draft_id: str,
|
|
158
|
+
*,
|
|
159
|
+
expected_revision: int,
|
|
160
|
+
assertions: tuple[SemanticAssertion, ...] | None = None,
|
|
161
|
+
deployment_bindings: tuple[DeploymentBinding, ...] | None = None,
|
|
162
|
+
auth_context: AuthContext,
|
|
163
|
+
) -> DraftMutationResult:
|
|
164
|
+
require_permission(auth_context, Permission.ASSEMBLY_WRITE)
|
|
165
|
+
current = load_draft(self._access, draft_id, auth_context)
|
|
166
|
+
authorization = lifecycle_context(self._deps, auth_context, current.source_id)
|
|
167
|
+
require_lifecycle_authorization(
|
|
168
|
+
context=authorization,
|
|
169
|
+
authorizer=self._access.lifecycle_authorizer(),
|
|
170
|
+
required_role=LifecycleRole.AUTHOR,
|
|
171
|
+
action=LifecycleAction.EDIT_DRAFT,
|
|
172
|
+
resource_id=draft_id,
|
|
173
|
+
)
|
|
174
|
+
changes: dict[str, object] = {}
|
|
175
|
+
if assertions is not None:
|
|
176
|
+
current_by_id = {assertion.id: assertion for assertion in current.assertions}
|
|
177
|
+
normalized: list[SemanticAssertion] = []
|
|
178
|
+
for assertion in assertions:
|
|
179
|
+
authoritative = current_by_id.get(assertion.id)
|
|
180
|
+
if (
|
|
181
|
+
authoritative is not None
|
|
182
|
+
and authoritative.type is assertion.type
|
|
183
|
+
and authoritative.payload_hash() == assertion.payload_hash()
|
|
184
|
+
and authoritative.provenance == assertion.provenance
|
|
185
|
+
):
|
|
186
|
+
normalized.append(authoritative)
|
|
187
|
+
continue
|
|
188
|
+
normalized.append(
|
|
189
|
+
SemanticAssertion.create(
|
|
190
|
+
type=assertion.type,
|
|
191
|
+
payload=assertion.payload,
|
|
192
|
+
provenance=AssertionProvenance(
|
|
193
|
+
kind=AssertionProvenanceKind.MANUAL,
|
|
194
|
+
source_reference=(
|
|
195
|
+
f"seed:{authoritative.id}"
|
|
196
|
+
if authoritative is not None
|
|
197
|
+
else None
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
changes["assertions"] = tuple(normalized)
|
|
203
|
+
if deployment_bindings is not None:
|
|
204
|
+
changes["deployment_bindings"] = deployment_bindings
|
|
205
|
+
updated = current.mutate(expected_revision=expected_revision, **changes)
|
|
206
|
+
store_draft(
|
|
207
|
+
self._access,
|
|
208
|
+
updated,
|
|
209
|
+
expected_revision=expected_revision,
|
|
210
|
+
auth_context=auth_context,
|
|
211
|
+
)
|
|
212
|
+
return DraftMutationResult(
|
|
213
|
+
draft=_draft_to_summary(updated),
|
|
214
|
+
audit_reference=authorization.operator_reference,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@_normalize_errors
|
|
218
|
+
def submit_draft_for_review(
|
|
219
|
+
self,
|
|
220
|
+
draft_id: str,
|
|
221
|
+
command: DraftRevisionCommand,
|
|
222
|
+
*,
|
|
223
|
+
auth_context: AuthContext,
|
|
224
|
+
) -> DraftMutationResult:
|
|
225
|
+
require_permission(auth_context, Permission.ASSEMBLY_WRITE)
|
|
226
|
+
current = load_draft(self._access, draft_id, auth_context)
|
|
227
|
+
authorization = lifecycle_context(self._deps, auth_context, current.source_id)
|
|
228
|
+
outcome = core_submit_for_review(
|
|
229
|
+
current,
|
|
230
|
+
expected_revision=command.expected_revision,
|
|
231
|
+
authorization=authorization,
|
|
232
|
+
authorizer=self._access.lifecycle_authorizer(),
|
|
233
|
+
)
|
|
234
|
+
store_draft(
|
|
235
|
+
self._access,
|
|
236
|
+
outcome.draft,
|
|
237
|
+
expected_revision=command.expected_revision,
|
|
238
|
+
auth_context=auth_context,
|
|
239
|
+
)
|
|
240
|
+
return DraftMutationResult(
|
|
241
|
+
draft=_draft_to_summary(outcome.draft),
|
|
242
|
+
audit_reference=authorization.operator_reference,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@_normalize_errors
|
|
246
|
+
def decide_draft_assertion(
|
|
247
|
+
self,
|
|
248
|
+
draft_id: str,
|
|
249
|
+
command: AssertionDecisionCommand,
|
|
250
|
+
*,
|
|
251
|
+
auth_context: AuthContext,
|
|
252
|
+
) -> DraftMutationResult:
|
|
253
|
+
require_permission(auth_context, Permission.ASSEMBLY_REVIEW)
|
|
254
|
+
current = load_draft(self._access, draft_id, auth_context)
|
|
255
|
+
authorization = lifecycle_context(self._deps, auth_context, current.source_id)
|
|
256
|
+
if command.action is AssertionDecisionAction.EDIT:
|
|
257
|
+
if command.semantic_payload is None:
|
|
258
|
+
raise ValidationError("assertion edit requires semantic_payload")
|
|
259
|
+
outcome = core_edit_assertion(
|
|
260
|
+
current,
|
|
261
|
+
assertion_id=command.assertion_id,
|
|
262
|
+
payload=command.semantic_payload,
|
|
263
|
+
expected_revision=command.expected_revision,
|
|
264
|
+
authorization=authorization,
|
|
265
|
+
authorizer=self._access.lifecycle_authorizer(),
|
|
266
|
+
reason=command.reason,
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
decision = (
|
|
270
|
+
ReviewState.APPROVED
|
|
271
|
+
if command.action is AssertionDecisionAction.APPROVE
|
|
272
|
+
else ReviewState.REJECTED
|
|
273
|
+
)
|
|
274
|
+
outcome = core_decide_assertion(
|
|
275
|
+
current,
|
|
276
|
+
assertion_id=command.assertion_id,
|
|
277
|
+
decision=decision,
|
|
278
|
+
expected_revision=command.expected_revision,
|
|
279
|
+
authorization=authorization,
|
|
280
|
+
authorizer=self._access.lifecycle_authorizer(),
|
|
281
|
+
reason=command.reason,
|
|
282
|
+
)
|
|
283
|
+
store_draft(
|
|
284
|
+
self._access,
|
|
285
|
+
outcome.draft,
|
|
286
|
+
expected_revision=command.expected_revision,
|
|
287
|
+
auth_context=auth_context,
|
|
288
|
+
)
|
|
289
|
+
return DraftMutationResult(
|
|
290
|
+
draft=_draft_to_summary(outcome.draft),
|
|
291
|
+
audit_reference=authorization.operator_reference,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
@_normalize_errors
|
|
295
|
+
def approve_assembly_draft(
|
|
296
|
+
self,
|
|
297
|
+
draft_id: str,
|
|
298
|
+
command: DraftRevisionCommand,
|
|
299
|
+
*,
|
|
300
|
+
auth_context: AuthContext,
|
|
301
|
+
) -> DraftMutationResult:
|
|
302
|
+
require_permission(auth_context, Permission.ASSEMBLY_APPROVE)
|
|
303
|
+
current = load_draft(self._access, draft_id, auth_context)
|
|
304
|
+
authorization = lifecycle_context(self._deps, auth_context, current.source_id)
|
|
305
|
+
outcome = core_approve_draft(
|
|
306
|
+
current,
|
|
307
|
+
expected_revision=command.expected_revision,
|
|
308
|
+
authorization=authorization,
|
|
309
|
+
authorizer=self._access.lifecycle_authorizer(),
|
|
310
|
+
)
|
|
311
|
+
store_draft(
|
|
312
|
+
self._access,
|
|
313
|
+
outcome.draft,
|
|
314
|
+
expected_revision=command.expected_revision,
|
|
315
|
+
auth_context=auth_context,
|
|
316
|
+
)
|
|
317
|
+
return DraftMutationResult(
|
|
318
|
+
draft=_draft_to_summary(outcome.draft),
|
|
319
|
+
audit_reference=authorization.operator_reference,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@_normalize_errors
|
|
323
|
+
def create_draft_from_proposals(
|
|
324
|
+
self,
|
|
325
|
+
snapshot_fingerprint: str,
|
|
326
|
+
*,
|
|
327
|
+
descriptor_id: str,
|
|
328
|
+
draft_id: str,
|
|
329
|
+
bundle_id: str,
|
|
330
|
+
model_version: str,
|
|
331
|
+
auth_context: AuthContext,
|
|
332
|
+
) -> DraftMutationResult:
|
|
333
|
+
require_permission(auth_context, Permission.ASSEMBLY_WRITE)
|
|
334
|
+
catalog = self._access.catalog()
|
|
335
|
+
proposal_set = catalog.proposal_set(
|
|
336
|
+
snapshot_fingerprint,
|
|
337
|
+
tenant_scope_fingerprint=auth_context.tenant_scope_fingerprint,
|
|
338
|
+
)
|
|
339
|
+
if proposal_set is None:
|
|
340
|
+
raise NotFoundError("proposal set")
|
|
341
|
+
snapshot = catalog.snapshot(
|
|
342
|
+
snapshot_fingerprint,
|
|
343
|
+
tenant_scope_fingerprint=auth_context.tenant_scope_fingerprint,
|
|
344
|
+
)
|
|
345
|
+
if snapshot is None:
|
|
346
|
+
raise NotFoundError("snapshot")
|
|
347
|
+
require_source(auth_context, snapshot.source.source_id)
|
|
348
|
+
draft = core_create_discovery_draft(
|
|
349
|
+
proposal_set,
|
|
350
|
+
descriptor_id=descriptor_id,
|
|
351
|
+
draft_id=draft_id,
|
|
352
|
+
bundle_id=bundle_id,
|
|
353
|
+
source_id=snapshot.source.source_id,
|
|
354
|
+
model_version=model_version,
|
|
355
|
+
author_reference=auth_context.audit_reference or self._deps.audit_reference,
|
|
356
|
+
expected_snapshot_fingerprint=snapshot_fingerprint,
|
|
357
|
+
)
|
|
358
|
+
return self.create_draft(draft, auth_context=auth_context)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Audit-evidence inspection Admin capability service.
|
|
2
|
+
|
|
3
|
+
Inspection is side-effect-free: it only reads bounded, tenant/source
|
|
4
|
+
scoped, redacted audit-evidence trails from the lifecycle catalog and
|
|
5
|
+
never creates lifecycle events, review decisions, verification evidence,
|
|
6
|
+
publications, activations, rollbacks, or retention changes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from nl2data_core.assembly import AssemblyAuditEvidenceEntry, AuditTrail, redact_audit_entry
|
|
12
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
13
|
+
|
|
14
|
+
from .auth import AuthContext, Permission
|
|
15
|
+
from .common import AdminDependencyAccess, load_draft, require_permission
|
|
16
|
+
from .common import (
|
|
17
|
+
normalize_errors as _normalize_errors,
|
|
18
|
+
)
|
|
19
|
+
from .config import AdminServiceConfig
|
|
20
|
+
from .dtos import AuditEntryView, AuditTrailPage, AuditTrailQuery
|
|
21
|
+
from .errors import AuthorizationDeniedError
|
|
22
|
+
from .protocols import AdminServiceDependencies
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _entry_view(entry: AssemblyAuditEvidenceEntry) -> AuditEntryView:
|
|
26
|
+
"""Project one entry into a redacted, scope-free inspection view."""
|
|
27
|
+
payload = redact_audit_entry(entry)
|
|
28
|
+
del payload["tenant_scope_fingerprint"]
|
|
29
|
+
del payload["source_scope_fingerprint"]
|
|
30
|
+
return AuditEntryView(**payload)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuditInspectionAdminCapability:
|
|
34
|
+
"""Bounded, scoped, redacted audit-evidence inspection operations."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, dependencies: AdminServiceDependencies, config: AdminServiceConfig) -> None:
|
|
37
|
+
self._deps = dependencies
|
|
38
|
+
self._access = AdminDependencyAccess(dependencies)
|
|
39
|
+
self._config = config
|
|
40
|
+
|
|
41
|
+
@_normalize_errors
|
|
42
|
+
def inspect_audit_trail(
|
|
43
|
+
self,
|
|
44
|
+
query: AuditTrailQuery,
|
|
45
|
+
*,
|
|
46
|
+
auth_context: AuthContext,
|
|
47
|
+
) -> AuditTrailPage:
|
|
48
|
+
"""Return one deterministic bounded page of audit evidence."""
|
|
49
|
+
require_permission(auth_context, Permission.ASSEMBLY_AUDIT)
|
|
50
|
+
if query.draft_id is not None:
|
|
51
|
+
# Resolves the subject within the caller's tenant scope and
|
|
52
|
+
# enforces source authorization; unknown drafts fail safely.
|
|
53
|
+
load_draft(self._access, query.draft_id, auth_context)
|
|
54
|
+
catalog = self._access.lifecycle_catalog()
|
|
55
|
+
trail: AuditTrail = catalog.audit_entries(
|
|
56
|
+
tenant_scope_fingerprint=auth_context.tenant_scope_fingerprint,
|
|
57
|
+
draft_id=query.draft_id,
|
|
58
|
+
draft_revision_min=query.draft_revision_min,
|
|
59
|
+
draft_revision_max=query.draft_revision_max,
|
|
60
|
+
assertion_id=query.assertion_id,
|
|
61
|
+
bundle_fingerprint=query.bundle_fingerprint,
|
|
62
|
+
lifecycle_reference=query.lifecycle_reference,
|
|
63
|
+
predecessor_event_id=query.predecessor_event_id,
|
|
64
|
+
limit=query.limit,
|
|
65
|
+
cursor=query.cursor,
|
|
66
|
+
)
|
|
67
|
+
self._require_authorized_source_scope(trail.entries, auth_context)
|
|
68
|
+
return AuditTrailPage(
|
|
69
|
+
entries=tuple(_entry_view(entry) for entry in trail.entries),
|
|
70
|
+
total_count=trail.total_count,
|
|
71
|
+
next_cursor=trail.next_cursor,
|
|
72
|
+
has_more=trail.has_more,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _require_authorized_source_scope(
|
|
77
|
+
entries: tuple[AssemblyAuditEvidenceEntry, ...],
|
|
78
|
+
auth_context: AuthContext,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Fail closed when any entry falls outside the caller's source scope."""
|
|
81
|
+
authorized = frozenset(
|
|
82
|
+
strict_sha256_fingerprint({"source_id": source_id})
|
|
83
|
+
for source_id in auth_context.source_ids
|
|
84
|
+
)
|
|
85
|
+
if not authorized:
|
|
86
|
+
return
|
|
87
|
+
for entry in entries:
|
|
88
|
+
if entry.source_scope_fingerprint not in authorized:
|
|
89
|
+
raise AuthorizationDeniedError("Source not authorized")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Host-supplied authentication and authorization context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from nl2data_core.assembly import LifecycleRole
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Permission(StrEnum):
|
|
14
|
+
"""Admin service permissions a host may grant to an operator."""
|
|
15
|
+
|
|
16
|
+
DISCOVERY_READ = "discovery:read"
|
|
17
|
+
DISCOVERY_RUN = "discovery:run"
|
|
18
|
+
SNAPSHOT_READ = "snapshot:read"
|
|
19
|
+
PROPOSAL_READ = "proposal:read"
|
|
20
|
+
PROPOSAL_REVIEW = "proposal:review"
|
|
21
|
+
BUNDLE_READ = "bundle:read"
|
|
22
|
+
BUNDLE_VALIDATE = "bundle:validate"
|
|
23
|
+
BUNDLE_PUBLISH = "bundle:publish"
|
|
24
|
+
BUNDLE_ACTIVATE = "bundle:activate"
|
|
25
|
+
BUNDLE_ROLLBACK = "bundle:rollback"
|
|
26
|
+
ASSEMBLY_READ = "assembly:read"
|
|
27
|
+
ASSEMBLY_WRITE = "assembly:write"
|
|
28
|
+
ASSEMBLY_REVIEW = "assembly:review"
|
|
29
|
+
ASSEMBLY_APPROVE = "assembly:approve"
|
|
30
|
+
ASSEMBLY_VERIFY = "assembly:verify"
|
|
31
|
+
ASSEMBLY_AUDIT = "assembly:audit"
|
|
32
|
+
DRIFT_READ = "drift:read"
|
|
33
|
+
JOB_READ = "job:read"
|
|
34
|
+
JOB_CANCEL = "job:cancel"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthContext(BaseModel):
|
|
38
|
+
"""Trusted operator authorization context supplied by the host.
|
|
39
|
+
|
|
40
|
+
The admin service never validates tokens or identities itself; it relies on
|
|
41
|
+
the host to provide a trusted context with resolved tenant/source scope
|
|
42
|
+
and permissions. Client-provided scope values are routing input only and
|
|
43
|
+
must match an authorized scope before any read or mutation proceeds.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
47
|
+
|
|
48
|
+
operator_id: str = Field(min_length=1, max_length=256)
|
|
49
|
+
tenant_scope_fingerprint: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
50
|
+
source_ids: frozenset[str] = Field(default_factory=frozenset, max_length=1_024)
|
|
51
|
+
permissions: frozenset[Permission] = Field(default_factory=frozenset)
|
|
52
|
+
lifecycle_roles: frozenset[LifecycleRole] = Field(default_factory=frozenset, max_length=4)
|
|
53
|
+
audit_reference: str = Field(default="", max_length=512)
|
|
54
|
+
authenticated_at: datetime | None = None
|
|
55
|
+
|
|
56
|
+
def is_allowed(self, permission: Permission) -> bool:
|
|
57
|
+
"""Whether the context carries the requested permission."""
|
|
58
|
+
return permission in self.permissions
|
|
59
|
+
|
|
60
|
+
def is_source_allowed(self, source_id: str) -> bool:
|
|
61
|
+
"""Whether the given source is in the authorized source set.
|
|
62
|
+
|
|
63
|
+
An empty source set is treated as ``all sources within the tenant
|
|
64
|
+
scope`` for hosts that centralize source authorization elsewhere.
|
|
65
|
+
"""
|
|
66
|
+
return not self.source_ids or source_id in self.source_ids
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuthContextProvider(Protocol):
|
|
70
|
+
"""Host-implemented callable that returns a trusted :class:`AuthContext`."""
|
|
71
|
+
|
|
72
|
+
def __call__(self) -> AuthContext | None:
|
|
73
|
+
"""Return a trusted context, or ``None`` when authentication is absent."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AuthorizationError(Exception):
|
|
78
|
+
"""Raised when a request lacks required authentication or authorization."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.message = message
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AuthenticationError(Exception):
|
|
86
|
+
"""Raised when a trusted authentication context is missing."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: str) -> None:
|
|
89
|
+
super().__init__(message)
|
|
90
|
+
self.message = message
|