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.
@@ -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