CMIS layer into an honest CMIS 1.1

This commit is contained in:
2026-05-07 04:11:09 +02:00
parent ebace73761
commit 7855a8bfd0
13 changed files with 498 additions and 87 deletions

View File

@@ -12,7 +12,7 @@ from typing import Any
from .actors import ActorType, OperationContext
from .assets import AssetRepresentation, KnowledgeAsset, RepresentationKind
from .metadata import Sensitivity
from .metadata import LifecycleState, Sensitivity
from .policy import PolicyDecision
from .provenance import AssetVersion
from .relationships import CoreRelationship, RelationshipTargetKind
@@ -92,6 +92,24 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = {
CMISAction.APPLY_POLICY: CMISCapability.POLICY,
CMISAction.BULK_UPDATE_PROPERTIES: CMISCapability.BULK_UPDATE,
}
IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset(
{
CMISAction.GET_REPOSITORY_INFO,
CMISAction.GET_TYPE_DEFINITION,
CMISAction.GET_CHILDREN,
CMISAction.GET_OBJECT_PARENTS,
CMISAction.GET_OBJECT,
CMISAction.GET_CONTENT_STREAM,
CMISAction.GET_ACL,
CMISAction.QUERY,
CMISAction.GET_RELATIONSHIPS,
CMISAction.GET_CHANGE_LOG,
CMISAction.CREATE_DOCUMENT,
CMISAction.UPDATE_PROPERTIES,
CMISAction.DELETE_OBJECT,
CMISAction.SET_CONTENT_STREAM,
}
)
MUTATION_ACTIONS = {
CMISAction.CREATE_DOCUMENT,
CMISAction.UPDATE_PROPERTIES,
@@ -101,6 +119,90 @@ MUTATION_ACTIONS = {
CMISAction.APPLY_POLICY,
CMISAction.BULK_UPDATE_PROPERTIES,
}
NEW_TYPE_SETTABLE_ATTRIBUTES: dict[str, bool] = {
"id": False,
"local_name": False,
"local_namespace": False,
"display_name": False,
"query_name": False,
"description": False,
"creatable": False,
"fileable": False,
"queryable": False,
"fulltext_indexed": False,
"included_in_supertype_query": False,
"controllable_policy": False,
"controllable_acl": False,
}
UNSUPPORTED_FEATURES: dict[str, dict[str, Any]] = {
"atompub": {"status": "unsupported", "reason": "binding_not_supported", "intent": "deferred"},
"web_services": {"status": "unsupported", "reason": "binding_not_supported", "intent": "deferred"},
"get_descendants": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_get_descendants",
},
"get_folder_tree": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_get_folder_tree",
},
"multifiling": {
"status": "projection_only",
"reason": "mutation_capability_not_supported",
"standard_flag": "capability_multifiling",
},
"unfiling": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_unfiling",
},
"versioning_services": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "document_type.versionable",
},
"private_working_copy": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flags": ["capability_pwc_searchable", "capability_pwc_updatable"],
},
"all_versions_search": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_all_versions_searchable",
},
"full_cmis_sql_joins": {
"status": "unsupported",
"reason": "query_not_supported",
"standard_flag": "capability_join",
},
"order_by": {"status": "unsupported", "reason": "query_not_supported", "standard_flag": "capability_order_by"},
"append_content_stream": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_content_stream_updatability",
},
"apply_acl": {"status": "unsupported", "reason": "operation_not_implemented", "standard_flag": "capability_acl"},
"apply_policy": {"status": "unsupported", "reason": "capability_not_supported"},
"remove_policy": {"status": "unsupported", "reason": "capability_not_supported"},
"retention_hold_mutation": {"status": "unsupported", "reason": "capability_not_supported"},
"bulk_update_properties": {
"status": "unsupported",
"reason": "operation_not_implemented",
"standard_service": "bulkUpdateProperties",
},
"rendition_streams": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_renditions",
},
"type_mutability": {
"status": "unsupported",
"reason": "capability_not_supported",
"standard_flag": "capability_new_type_settable_attributes",
},
}
@dataclass(frozen=True)
@@ -170,11 +272,7 @@ class CMISAccessProfile:
def admin_export(cls) -> "CMISAccessProfile":
return cls(
name="admin-export",
capabilities=_read_capabilities()
+ (
CMISCapability.RENDITIONS,
CMISCapability.RETENTION_HOLD,
),
capabilities=_read_capabilities(),
allow_mutations=False,
visible_sensitivities=(
Sensitivity.PUBLIC,
@@ -203,13 +301,28 @@ class CMISAccessProfile:
return CMISCapability(capability) in self.capabilities
def allows_action(self, action: CMISAction | str) -> bool:
cmis_action = CMISAction(action)
return self.action_denial(cmis_action) is None
def action_denial(self, action: CMISAction | str) -> tuple[str, dict[str, Any]] | None:
cmis_action = CMISAction(action)
capability = ACTION_CAPABILITIES[cmis_action]
if not self.has_capability(capability):
return False
if cmis_action in MUTATION_ACTIONS and not self.allow_mutations:
return False
return True
return (
"cmis_mutation_not_allowed",
{"profile": self.name, "capability": capability.value, "mutation": cmis_action.value},
)
if not self.has_capability(capability):
return (
"cmis_capability_not_supported",
{"profile": self.name, "capability": capability.value},
)
if cmis_action not in IMPLEMENTED_CMIS_ACTIONS:
return (
"cmis_operation_not_implemented",
{"profile": self.name, "capability": capability.value, "operation": cmis_action.value},
)
return None
def decide_action(
self,
@@ -234,14 +347,16 @@ class CMISAccessProfile:
resource,
context={"profile": self.name},
)
capability = ACTION_CAPABILITIES[cmis_action]
reason = "cmis_mutation_not_allowed" if cmis_action in MUTATION_ACTIONS else "cmis_capability_not_supported"
reason, denial_context = self.action_denial(cmis_action) or (
"cmis_capability_not_supported",
{"profile": self.name},
)
return PolicyDecision.deny(
context.actor.id,
cmis_action.value,
resource,
reason=reason,
context={"profile": self.name, "capability": capability.value},
context=denial_context,
)
def allows_actor(self, context: OperationContext) -> bool:
@@ -265,6 +380,14 @@ class CMISAccessProfile:
context={"profile": self.name},
)
classification = asset.classification
if asset.lifecycle == LifecycleState.DELETE_REQUESTED:
return PolicyDecision.deny(
context.actor.id,
"cmis.expose_asset",
resource,
reason="cmis_lifecycle_not_visible",
context={"profile": self.name, "lifecycle": _enum_value(asset.lifecycle)},
)
if classification.sensitivity in self.denied_sensitivities:
return PolicyDecision.deny(
context.actor.id,
@@ -466,13 +589,27 @@ class CMISDomainMapper:
return {
"repository_id": self.access_point.repository_id,
"repository_name": self.access_point.metadata.get("repository_name", self.access_point.repository_id),
"repository_description": self.access_point.metadata.get(
"repository_description",
"Kontextual Engine CMIS Browser Binding access point",
),
"cmis_version_supported": "1.1",
"root_folder_id": self.access_point.root_folder_id,
"principal_anonymous": "anonymous",
"principal_anyone": "anyone",
"vendor_name": "Kontextual",
"product_name": "kontextual-engine",
"product_version": self.access_point.metadata.get("product_version", "0.1.0"),
"binding": profile.binding.value,
"capabilities": self.capability_flags(),
"repository_features": self.repository_features(),
"unsupported_features": self.unsupported_features(),
"compliance": {
"standard": "CMIS 1.1",
"binding": profile.binding.value,
"posture": "declared-browser-binding-subset",
"overclaim_policy": "unsupported_optional_capabilities_are_advertised_as false/none",
},
"profile": profile.name,
}
@@ -480,27 +617,53 @@ class CMISDomainMapper:
profile = self.access_point.profile
return {
"capability_content_stream_updatability": (
"anytime" if profile.has_capability(CMISCapability.CONTENT_STREAM_WRITE) else "none"
"anytime"
if profile.allow_mutations and profile.has_capability(CMISCapability.CONTENT_STREAM_WRITE)
else "none"
),
"capability_changes": "objectidsonly"
if profile.has_capability(CMISCapability.CHANGE_LOG)
else "none",
"capability_renditions": "read" if profile.has_capability(CMISCapability.RENDITIONS) else "none",
"capability_get_descendants": profile.has_capability(CMISCapability.NAVIGATION),
"capability_get_folder_tree": profile.has_capability(CMISCapability.NAVIGATION),
"capability_multifiling": profile.has_capability(CMISCapability.NAVIGATION),
"capability_renditions": "none",
"capability_get_descendants": False,
"capability_get_folder_tree": False,
"capability_order_by": "none",
"capability_multifiling": False,
"capability_unfiling": False,
"capability_version_specific_filing": False,
"capability_pwc_searchable": False,
"capability_pwc_updatable": False,
"capability_all_versions_searchable": profile.has_capability(CMISCapability.VERSIONING),
"capability_all_versions_searchable": False,
"capability_query": "metadataonly"
if profile.has_capability(CMISCapability.DISCOVERY_QUERY)
else "none",
"capability_join": "none",
"capability_acl": "discover" if profile.has_capability(CMISCapability.ACL) else "none",
"capability_new_type_settable_attributes": dict(NEW_TYPE_SETTABLE_ATTRIBUTES),
}
def repository_features(self) -> list[dict[str, Any]]:
return [
{
"id": "urn:kontextual:cmis:feature:profiled-access-points",
"common_name": "Profiled CMIS access points",
"version_label": "1.0",
"description": "Multiple Browser Binding access points can expose different governed profile slices.",
},
{
"id": "urn:kontextual:cmis:feature:projection-parentage",
"common_name": "Projection-only parent folder maps",
"version_label": "1.0",
"description": (
"Assets may appear under multiple virtual folder projections; CMIS multi-filing mutation "
"services are not advertised."
),
},
]
def unsupported_features(self) -> dict[str, dict[str, Any]]:
return {key: dict(value) for key, value in UNSUPPORTED_FEATURES.items()}
def type_definitions(self) -> list[dict[str, Any]]:
can_write = self.access_point.profile.allow_mutations
return [
@@ -537,7 +700,7 @@ class CMISDomainMapper:
type_id=f"kontextual:{asset.classification.asset_type}",
name=asset.title,
path=self.asset_path(asset),
properties=self.asset_properties(asset, metadata_records=metadata_records),
properties=self.asset_properties(asset, metadata_records=metadata_records, content_stream=content_stream),
allowable_actions=self.allowable_actions(context, has_content_stream=content_stream is not None),
content_stream=content_stream,
version=self.version_properties(asset, current_version, versions),
@@ -682,6 +845,7 @@ class CMISDomainMapper:
asset: KnowledgeAsset,
*,
metadata_records: list[Any] | tuple[Any, ...] = (),
content_stream: dict[str, Any] | None = None,
) -> dict[str, Any]:
classification = asset.classification
properties = {
@@ -694,6 +858,10 @@ class CMISDomainMapper:
"cmis:creationDate": asset.created_at,
"cmis:lastModificationDate": asset.updated_at,
"cmis:changeToken": asset.current_version_id,
"cmis:contentStreamLength": content_stream.get("length") if content_stream else None,
"cmis:contentStreamMimeType": content_stream.get("mime_type") if content_stream else None,
"cmis:contentStreamFileName": content_stream.get("file_name") if content_stream else None,
"cmis:contentStreamId": content_stream.get("stream_id") if content_stream else None,
"kontextual:assetId": asset.id,
"kontextual:assetType": classification.asset_type,
"kontextual:sensitivity": _enum_value(classification.sensitivity),
@@ -785,7 +953,6 @@ def _read_capabilities() -> tuple[CMISCapability, ...]:
CMISCapability.NAVIGATION,
CMISCapability.OBJECT_READ,
CMISCapability.CONTENT_STREAM_READ,
CMISCapability.VERSIONING,
CMISCapability.DISCOVERY_QUERY,
CMISCapability.RELATIONSHIPS,
CMISCapability.ACL,
@@ -803,19 +970,20 @@ def _type_definition(
display_name: str,
can_write: bool,
) -> dict[str, Any]:
is_document = base_type_id == CMISBaseType.DOCUMENT
return {
"id": type_id,
"local_name": type_id.split(":", 1)[-1],
"display_name": display_name,
"base_type_id": base_type_id.value,
"queryable": True,
"controllable_acl": base_type_id in {CMISBaseType.DOCUMENT, CMISBaseType.FOLDER},
"queryable": is_document,
"controllable_acl": is_document,
"controllable_policy": False,
"creatable": can_write and base_type_id == CMISBaseType.DOCUMENT,
"fileable": base_type_id == CMISBaseType.DOCUMENT,
"creatable": can_write and is_document,
"fileable": is_document,
"fulltext_indexed": False,
"included_in_supertype_query": True,
"versionable": base_type_id == CMISBaseType.DOCUMENT,
"included_in_supertype_query": is_document,
"versionable": False,
"property_definitions": _property_definitions(base_type_id),
}
@@ -829,6 +997,31 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any
"kontextual:sensitivity": {"property_type": "string", "cardinality": "single", "required": False},
"kontextual:lifecycle": {"property_type": "string", "cardinality": "single", "required": False},
}
if base_type_id == CMISBaseType.DOCUMENT:
definitions.update(
{
"cmis:contentStreamLength": {
"property_type": "integer",
"cardinality": "single",
"required": False,
},
"cmis:contentStreamMimeType": {
"property_type": "string",
"cardinality": "single",
"required": False,
},
"cmis:contentStreamFileName": {
"property_type": "string",
"cardinality": "single",
"required": False,
},
"cmis:contentStreamId": {
"property_type": "id",
"cardinality": "single",
"required": False,
},
}
)
if base_type_id == CMISBaseType.RELATIONSHIP:
definitions["cmis:sourceId"] = {"property_type": "id", "cardinality": "single", "required": True}
definitions["cmis:targetId"] = {"property_type": "id", "cardinality": "single", "required": True}