Implemented profile-scoped CMIS Browser Binding routes

This commit is contained in:
2026-05-07 01:12:38 +02:00
parent 41da61896b
commit 7e168e93d3
6 changed files with 650 additions and 1 deletions

View File

@@ -19,6 +19,10 @@ from kontextual_engine.core import (
AuditEvent,
AuditOutcome,
Classification,
CMISAccessPoint,
CMISAccessProfile,
CMISAction,
CMISDomainMapper,
ContextEntity,
ContextEntityType,
IngestionIdentityPolicy,
@@ -316,6 +320,203 @@ class ServiceRuntime:
"openapi_version": OPENAPI_VERSION,
}
def cmis_access_points(self) -> dict[str, Any]:
access_points = [_cmis_access_point(profile) for profile in _cmis_profiles()]
return {"items": [access_point.to_dict() for access_point in access_points], "count": len(access_points)}
def cmis_repository_info(self, access_point_id: str) -> dict[str, Any]:
return self._cmis_mapper(access_point_id).repository_info()
def cmis_type_definitions(self, access_point_id: str) -> dict[str, Any]:
definitions = self._cmis_mapper(access_point_id).type_definitions()
return {"items": definitions, "count": len(definitions)}
def cmis_children(
self,
access_point_id: str,
context: OperationContext,
*,
folder_id: str | None = None,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_CHILDREN, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getChildren")
projections = [
projection.to_dict()
for asset in self.repository.list_assets()
if (
projection := mapper.map_asset(
asset,
context,
representations=self.repository.list_representations(asset_id=asset.id),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
for relationship in self.repository.list_relationships(source_id=asset.id)
],
metadata_records=self.repository.list_metadata_records(asset.id),
)
)
]
paged = projections[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
return {
"folder_id": folder_id or mapper.access_point.root_folder_id,
"objects": paged,
"num_items": len(paged),
"has_more_items": len(projections) > max(skip_count, 0) + len(paged),
"total_num_items": len(projections),
}
def cmis_object(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getObject")
asset_id = _cmis_asset_id(object_id)
asset = self.repository.get_asset(asset_id)
projection = mapper.map_asset(
asset,
context,
representations=self.repository.list_representations(asset_id=asset.id),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
for relationship in self.repository.list_relationships(source_id=asset.id)
],
metadata_records=self.repository.list_metadata_records(asset.id),
)
if projection is None:
raise NotFoundError(
"CMIS object not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
return projection.to_dict()
def cmis_content_stream(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_CONTENT_STREAM, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getContentStream")
object_projection = self.cmis_object(access_point_id, object_id, context)
content_stream = object_projection.get("content_stream")
if not content_stream:
raise NotFoundError(
"CMIS content stream not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
return content_stream
def cmis_query(
self,
access_point_id: str,
query: str,
context: OperationContext,
*,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.QUERY, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "query")
normalized = query.strip().lower()
if normalized not in {"select * from cmis:document", "select * from kontextual:document"}:
raise ValidationError(
"Unsupported CMIS query subset",
details={
"query": query,
"supported": ["SELECT * FROM cmis:document", "SELECT * FROM kontextual:document"],
},
)
children = self.cmis_children(
access_point_id,
context,
skip_count=skip_count,
max_items=max_items,
)
return {
"query": query,
"results": children["objects"],
"num_items": children["num_items"],
"has_more_items": children["has_more_items"],
"total_num_items": children["total_num_items"],
}
def cmis_relationships(
self,
access_point_id: str,
context: OperationContext,
*,
object_id: str | None = None,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_RELATIONSHIPS, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getRelationships")
source_id = _cmis_asset_id(object_id) if object_id else None
projections = [
projection.to_dict()
for relationship in self.repository.list_relationships(source_id=source_id)
if (projection := mapper.map_relationship(relationship, context))
]
return {"items": projections, "count": len(projections)}
def cmis_change_log(
self,
access_point_id: str,
context: OperationContext,
*,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_CHANGE_LOG, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getContentChanges")
events = self.repository.list_audit_events()
changes = [
{
"change_id": event.event_id,
"change_type": _cmis_change_type(event.operation),
"object_id": event.target.replace("asset:", "cmis:asset:", 1),
"change_time": event.occurred_at,
"actor_id": event.actor_id,
"correlation_id": event.correlation_id,
}
for event in events
if event.target.startswith("asset:")
]
paged = changes[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
return {
"change_log_token": changes[-1]["change_id"] if changes else None,
"changes": paged,
"num_items": len(paged),
"has_more_items": len(changes) > max(skip_count, 0) + len(paged),
"total_num_items": len(changes),
}
def _cmis_mapper(self, access_point_id: str) -> CMISDomainMapper:
for profile in _cmis_profiles():
if profile.name == access_point_id:
return CMISDomainMapper(_cmis_access_point(profile))
raise NotFoundError(
"CMIS access point not found",
details={"access_point_id": access_point_id, "available": [profile.name for profile in _cmis_profiles()]},
)
def create_asset(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]:
classification = Classification.from_dict(payload["classification"])
result = self.asset_service().create_asset(
@@ -1718,6 +1919,91 @@ def create_app(runtime: ServiceRuntime | None = None):
) -> dict[str, Any]:
return context.to_dict()
@app.get("/cmis", tags=["cmis"])
def cmis_access_points() -> dict[str, Any]:
return response(runtime.cmis_access_points)
@app.get("/cmis/{access_point_id}/browser", tags=["cmis"])
def cmis_repository_info(access_point_id: str) -> dict[str, Any]:
return response(runtime.cmis_repository_info, access_point_id)
@app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"])
def cmis_types(access_point_id: str) -> dict[str, Any]:
return response(runtime.cmis_type_definitions, access_point_id)
@app.get("/cmis/{access_point_id}/browser/children", tags=["cmis"])
def cmis_children(
access_point_id: str,
folder_id: str | None = Query(None),
skip_count: int = Query(0),
max_items: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(
runtime.cmis_children,
access_point_id,
context,
folder_id=folder_id,
skip_count=skip_count,
max_items=max_items,
)
@app.get("/cmis/{access_point_id}/browser/object/{object_id:path}", tags=["cmis"])
def cmis_object(
access_point_id: str,
object_id: str,
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_object, access_point_id, object_id, context)
@app.get("/cmis/{access_point_id}/browser/content/{object_id:path}", tags=["cmis"])
def cmis_content_stream(
access_point_id: str,
object_id: str,
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_content_stream, access_point_id, object_id, context)
@app.get("/cmis/{access_point_id}/browser/query", tags=["cmis"])
def cmis_query(
access_point_id: str,
q: str = Query("SELECT * FROM cmis:document"),
skip_count: int = Query(0),
max_items: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(
runtime.cmis_query,
access_point_id,
q,
context,
skip_count=skip_count,
max_items=max_items,
)
@app.get("/cmis/{access_point_id}/browser/relationships", tags=["cmis"])
def cmis_relationships(
access_point_id: str,
object_id: str | None = Query(None),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_relationships, access_point_id, context, object_id=object_id)
@app.get("/cmis/{access_point_id}/browser/changes", tags=["cmis"])
def cmis_changes(
access_point_id: str,
skip_count: int = Query(0),
max_items: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(
runtime.cmis_change_log,
access_point_id,
context,
skip_count=skip_count,
max_items=max_items,
)
@app.post(f"{prefix}/assets", tags=["assets"])
def create_asset(
payload: dict[str, Any],
@@ -2105,6 +2391,57 @@ def create_app(runtime: ServiceRuntime | None = None):
return app
def _cmis_profiles() -> tuple[CMISAccessProfile, ...]:
return (
CMISAccessProfile.readonly_browser(),
CMISAccessProfile.governed_authoring(),
CMISAccessProfile.admin_export(),
CMISAccessProfile.compat_tck(),
)
def _cmis_access_point(profile: CMISAccessProfile) -> CMISAccessPoint:
return CMISAccessPoint(
access_point_id=profile.name,
repository_id=f"kontextual-{profile.name}",
profile=profile,
base_path=f"/cmis/{profile.name}/browser",
metadata={"repository_name": f"Kontextual Engine {profile.name}"},
)
def _cmis_asset_id(object_id: str | None) -> str:
if not object_id:
raise ValidationError("CMIS object id is required", details={"field": "object_id"})
normalized = object_id.strip("/")
if normalized.startswith("cmis:asset:"):
return normalized.removeprefix("cmis:asset:")
if normalized.startswith("asset:"):
return normalized.removeprefix("asset:")
return normalized
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
return AuthorizationError(
"CMIS operation denied by access-point profile",
details={
"operation": operation,
"policy_decision": decision.to_dict(),
"code": "cmis.permission_denied",
},
)
def _cmis_change_type(operation: str) -> str:
if operation.endswith(".create") or operation == "asset.create":
return "created"
if "delete" in operation:
return "deleted"
if "metadata" in operation or "content" in operation or "lifecycle" in operation:
return "updated"
return "security"
def _age_seconds(start: str, end: str) -> float:
try:
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))