generated from coulomb/repo-seed
Governed asset registry slice with asset creation, representations, metadata, lifecycle transitions, policy authorization, fail-closed denial, audit events, and version records
This commit is contained in:
6
src/kontextual_engine/services/__init__.py
Normal file
6
src/kontextual_engine/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Application services for the engine."""
|
||||
|
||||
from .asset_service import AssetChangeResult, AssetRegistryService
|
||||
|
||||
__all__ = ["AssetChangeResult", "AssetRegistryService"]
|
||||
|
||||
260
src/kontextual_engine/services/asset_service.py
Normal file
260
src/kontextual_engine/services/asset_service.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Application service for governed knowledge asset registry operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from kontextual_engine.core import (
|
||||
AssetRepresentation,
|
||||
AssetVersion,
|
||||
AuditEvent,
|
||||
AuditOutcome,
|
||||
Classification,
|
||||
KnowledgeAsset,
|
||||
LifecycleState,
|
||||
MetadataRecord,
|
||||
OperationContext,
|
||||
PolicyDecision,
|
||||
SourceReference,
|
||||
VersionChangeType,
|
||||
)
|
||||
from kontextual_engine.errors import AuthorizationError
|
||||
from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetChangeResult:
|
||||
asset: KnowledgeAsset
|
||||
version: AssetVersion
|
||||
audit_event: AuditEvent
|
||||
policy_decision: PolicyDecision
|
||||
|
||||
|
||||
class AssetRegistryService:
|
||||
def __init__(
|
||||
self,
|
||||
repository: AssetRegistryRepository,
|
||||
*,
|
||||
policy_gateway: PolicyGateway | None = None,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.policy_gateway = policy_gateway or AllowAllPolicyGateway()
|
||||
|
||||
def create_asset(
|
||||
self,
|
||||
title: str,
|
||||
classification: Classification,
|
||||
context: OperationContext,
|
||||
*,
|
||||
source_refs: list[SourceReference] | None = None,
|
||||
representations: list[AssetRepresentation] | None = None,
|
||||
metadata_records: list[MetadataRecord] | None = None,
|
||||
asset_id: str | None = None,
|
||||
) -> AssetChangeResult:
|
||||
asset = KnowledgeAsset.create(
|
||||
title,
|
||||
classification,
|
||||
asset_id=asset_id,
|
||||
source_refs=source_refs,
|
||||
)
|
||||
decision = self._authorize(
|
||||
context,
|
||||
"asset.create",
|
||||
f"asset:{asset.id}",
|
||||
resource_metadata={
|
||||
"asset_type": classification.asset_type,
|
||||
"sensitivity": classification.sensitivity.value,
|
||||
},
|
||||
)
|
||||
version = AssetVersion(
|
||||
asset_id=asset.id,
|
||||
sequence=1,
|
||||
change_type=VersionChangeType.CREATED,
|
||||
representation_ids=tuple(item.representation_id for item in representations or []),
|
||||
actor_id=context.actor.id,
|
||||
lifecycle=classification.lifecycle.value,
|
||||
)
|
||||
asset = asset.with_current_version(version.version_id)
|
||||
self.repository.save_actor(context.actor)
|
||||
self.repository.save_asset(asset)
|
||||
for representation in representations or []:
|
||||
if representation.asset_id != asset.id:
|
||||
representation = replace(representation, asset_id=asset.id)
|
||||
self.repository.save_representation(representation)
|
||||
for record in metadata_records or []:
|
||||
self.repository.save_metadata_record(asset.id, record)
|
||||
self.repository.save_version(version)
|
||||
event = self._audit(
|
||||
"asset.create",
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
decision,
|
||||
details={"version_id": version.version_id},
|
||||
)
|
||||
return AssetChangeResult(asset, version, event, decision)
|
||||
|
||||
def add_metadata_record(
|
||||
self,
|
||||
asset_id: str,
|
||||
record: MetadataRecord,
|
||||
context: OperationContext,
|
||||
) -> AssetChangeResult:
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
decision = self._authorize(context, "asset.metadata.add", f"asset:{asset.id}")
|
||||
next_sequence = self._next_sequence(asset.id)
|
||||
self.repository.save_metadata_record(asset.id, record)
|
||||
version = AssetVersion(
|
||||
asset_id=asset.id,
|
||||
sequence=next_sequence,
|
||||
change_type=VersionChangeType.METADATA_CHANGED,
|
||||
actor_id=context.actor.id,
|
||||
parent_version_id=asset.current_version_id,
|
||||
metadata_delta={record.key: record.value},
|
||||
lifecycle=asset.lifecycle.value,
|
||||
)
|
||||
asset = asset.with_current_version(version.version_id)
|
||||
self.repository.save_asset(asset)
|
||||
self.repository.save_version(version)
|
||||
event = self._audit(
|
||||
"asset.metadata.add",
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
decision,
|
||||
details={"record_id": record.record_id, "version_id": version.version_id},
|
||||
)
|
||||
return AssetChangeResult(asset, version, event, decision)
|
||||
|
||||
def add_representation(
|
||||
self,
|
||||
asset_id: str,
|
||||
representation: AssetRepresentation,
|
||||
context: OperationContext,
|
||||
) -> AssetChangeResult:
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
decision = self._authorize(
|
||||
context,
|
||||
"asset.representation.add",
|
||||
f"asset:{asset.id}",
|
||||
resource_metadata={"kind": representation.kind.value, "media_type": representation.media_type},
|
||||
)
|
||||
if representation.asset_id != asset.id:
|
||||
representation = replace(representation, asset_id=asset.id)
|
||||
self.repository.save_representation(representation)
|
||||
version = AssetVersion(
|
||||
asset_id=asset.id,
|
||||
sequence=self._next_sequence(asset.id),
|
||||
change_type=VersionChangeType.CONTENT_CHANGED,
|
||||
representation_ids=(representation.representation_id,),
|
||||
actor_id=context.actor.id,
|
||||
parent_version_id=asset.current_version_id,
|
||||
lifecycle=asset.lifecycle.value,
|
||||
)
|
||||
asset = asset.with_current_version(version.version_id)
|
||||
self.repository.save_asset(asset)
|
||||
self.repository.save_version(version)
|
||||
event = self._audit(
|
||||
"asset.representation.add",
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
decision,
|
||||
details={"representation_id": representation.representation_id, "version_id": version.version_id},
|
||||
)
|
||||
return AssetChangeResult(asset, version, event, decision)
|
||||
|
||||
def transition_lifecycle(
|
||||
self,
|
||||
asset_id: str,
|
||||
lifecycle: LifecycleState,
|
||||
context: OperationContext,
|
||||
) -> AssetChangeResult:
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
decision = self._authorize(
|
||||
context,
|
||||
"asset.lifecycle.transition",
|
||||
f"asset:{asset.id}",
|
||||
resource_metadata={"from": asset.lifecycle.value, "to": lifecycle.value},
|
||||
)
|
||||
updated = asset.transition_lifecycle(lifecycle)
|
||||
version = AssetVersion(
|
||||
asset_id=asset.id,
|
||||
sequence=self._next_sequence(asset.id),
|
||||
change_type=VersionChangeType.LIFECYCLE_CHANGED,
|
||||
actor_id=context.actor.id,
|
||||
parent_version_id=asset.current_version_id,
|
||||
lifecycle=lifecycle.value,
|
||||
metadata_delta={"lifecycle": {"from": asset.lifecycle.value, "to": lifecycle.value}},
|
||||
)
|
||||
updated = updated.with_current_version(version.version_id)
|
||||
self.repository.save_asset(updated)
|
||||
self.repository.save_version(version)
|
||||
event = self._audit(
|
||||
"asset.lifecycle.transition",
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
decision,
|
||||
details={"version_id": version.version_id, "lifecycle": lifecycle.value},
|
||||
)
|
||||
return AssetChangeResult(updated, version, event, decision)
|
||||
|
||||
def request_delete(self, asset_id: str, context: OperationContext) -> AssetChangeResult:
|
||||
return self.transition_lifecycle(asset_id, LifecycleState.DELETE_REQUESTED, context)
|
||||
|
||||
def get_asset(self, asset_id: str) -> KnowledgeAsset:
|
||||
return self.repository.get_asset(asset_id)
|
||||
|
||||
def _authorize(
|
||||
self,
|
||||
context: OperationContext,
|
||||
action: str,
|
||||
resource: str,
|
||||
*,
|
||||
resource_metadata: dict[str, str] | None = None,
|
||||
) -> PolicyDecision:
|
||||
self.repository.save_actor(context.actor)
|
||||
decision = self.policy_gateway.authorize(
|
||||
context,
|
||||
action,
|
||||
resource,
|
||||
resource_metadata=resource_metadata,
|
||||
)
|
||||
if not decision.allowed:
|
||||
self._audit(action, resource, AuditOutcome.DENIED, context, decision)
|
||||
raise AuthorizationError(
|
||||
"Operation denied by policy",
|
||||
details={
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"correlation_id": context.correlation_id,
|
||||
"policy_decision": decision.to_dict(),
|
||||
},
|
||||
)
|
||||
return decision
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
operation: str,
|
||||
target: str,
|
||||
outcome: AuditOutcome,
|
||||
context: OperationContext,
|
||||
policy_decision: PolicyDecision,
|
||||
*,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> AuditEvent:
|
||||
event = AuditEvent.from_context(
|
||||
operation,
|
||||
target,
|
||||
outcome,
|
||||
context,
|
||||
policy_decision=policy_decision,
|
||||
details=details,
|
||||
)
|
||||
return self.repository.save_audit_event(event)
|
||||
|
||||
def _next_sequence(self, asset_id: str) -> int:
|
||||
versions = self.repository.list_versions(asset_id)
|
||||
return len(versions) + 1
|
||||
|
||||
Reference in New Issue
Block a user