From 81e132b33b90de2f3a7a780c2140746fccfd2411 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 7 May 2026 02:52:49 +0200 Subject: [PATCH] projection-only multifiling --- docs/cmis-deployment-compatibility.md | 7 +- ...s-profiled-access-points-implementation.md | 11 ++ examples/cmis/capability-fixtures.json | 4 +- src/kontextual_engine/api/app.py | 155 +++++++++++++++--- src/kontextual_engine/core/cmis.py | 84 +++++++++- tests/cmis/test_cmis_browser_binding_api.py | 1 + tests/cmis/test_cmis_domain_mapper.py | 18 +- .../cmis/test_cmis_runtime_browser_binding.py | 15 +- tests/test_service_api.py | 1 + 9 files changed, 250 insertions(+), 46 deletions(-) diff --git a/docs/cmis-deployment-compatibility.md b/docs/cmis-deployment-compatibility.md index 806e5c1..5960825 100644 --- a/docs/cmis-deployment-compatibility.md +++ b/docs/cmis-deployment-compatibility.md @@ -28,6 +28,7 @@ GET /cmis/{access_point_id}/browser/children GET /cmis/{access_point_id}/browser/object/{object_id} GET /cmis/{access_point_id}/browser/content/{object_id} GET /cmis/{access_point_id}/browser/acl/{object_id} +GET /cmis/{access_point_id}/browser/parents/{object_id} GET /cmis/{access_point_id}/browser/query GET /cmis/{access_point_id}/browser/relationships GET /cmis/{access_point_id}/browser/changes @@ -61,6 +62,7 @@ Actor context is passed through the existing service headers, especially: | Browser Binding repository info | yes | yes | yes | yes | | Type definitions | yes | yes | yes | yes | | Synthetic navigation | yes | yes | yes | yes | +| Projection-only multifiling | yes | yes | yes | yes | | Object reads | yes | yes | yes | yes | | Content stream descriptors | yes | yes | yes | yes | | ACL projection | discover | discover | discover | discover | @@ -103,7 +105,7 @@ It is not yet suitable for clients that require: - AtomPub, - SOAP/Web Services, - full CMIS SQL, -- multifiling/unfiling, +- mutating multifiling/unfiling, - private-working-copy semantics, - retention/hold mutation, - rendition streams, @@ -132,6 +134,8 @@ capability groups before treating them as implementation bugs. ## Operational Notes - Hidden objects should be treated as not found by CMIS clients. +- Multifiling is projection-only: assets may appear under multiple derived + folder paths without changing canonical asset identity. - Relationship and change-log responses are filtered through the same visibility gates as object reads. - Mutations always pass through engine services and produce normal engine audit @@ -140,4 +144,3 @@ capability groups before treating them as implementation bugs. not physical removal. - Compatibility should be discussed per profile and per client rather than as a repo-wide binary property. - diff --git a/docs/cmis-profiled-access-points-implementation.md b/docs/cmis-profiled-access-points-implementation.md index a1e4f08..18c08c6 100644 --- a/docs/cmis-profiled-access-points-implementation.md +++ b/docs/cmis-profiled-access-points-implementation.md @@ -120,6 +120,17 @@ Relationship listings and change logs now apply the same asset visibility gates as object reads. This prevents indirect leakage of confidential or restricted asset IDs through relationship targets or audit-backed change entries. +## Projection-Only Multifiling + +CMIS navigation now supports projection-only multifiling. The same asset can be +listed under several derived folder paths, including source system, topics, +owner, lifecycle, and asset type. These folders are navigation projections; they +do not duplicate assets and do not become canonical storage locations. + +`GET /cmis/{access_point_id}/browser/parents/{object_id}` returns the projected +parent folders for one asset. `GET /cmis/{access_point_id}/browser/children` +supports folder-scoped navigation through those projected paths. + ## Fixture And Optional TCK Integration CMIS fixtures now act as active compatibility contracts: diff --git a/examples/cmis/capability-fixtures.json b/examples/cmis/capability-fixtures.json index 29a4237..c4cf0e5 100644 --- a/examples/cmis/capability-fixtures.json +++ b/examples/cmis/capability-fixtures.json @@ -104,7 +104,7 @@ "examples": ["root-folder", "topic-folder", "source-system-folder", "unfiled-assets"], "supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"], "must_validate": ["get_children", "get_descendants", "get_folder_tree", "get_object_by_path"], - "unsupported": ["multifiling", "unfiling"] + "unsupported": ["unfiling"] }, { "id": "object-content", @@ -166,7 +166,7 @@ "unsupported_diagnostics": { "atompub": "binding_not_supported", "web_services": "binding_not_supported", - "multifiling": "capability_not_supported", + "multifiling": "projection_only", "unfiling": "capability_not_supported", "append_content_stream": "capability_not_supported", "private_working_copy": "capability_not_supported", diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py index ef28865..53d159e 100644 --- a/src/kontextual_engine/api/app.py +++ b/src/kontextual_engine/api/app.py @@ -346,26 +346,12 @@ class ServiceRuntime: 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), - ) - ) - ] + folder_path = _cmis_folder_path(folder_id) + projections = self._cmis_children_for_folder(mapper, context, folder_path=folder_path) 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, + "folder_path": folder_path or "/", "objects": paged, "num_items": len(paged), "has_more_items": len(projections) > max(skip_count, 0) + len(paged), @@ -441,6 +427,26 @@ class ServiceRuntime: ) return acl + def cmis_object_parents( + 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_PARENTS, context, resource=object_id) + if not decision.allowed: + raise _cmis_authorization_error(decision, "getObjectParents") + asset_id = _cmis_asset_id(object_id) + asset = self.repository.get_asset(asset_id) + if not mapper.access_point.exposes_asset(asset, context): + raise NotFoundError( + "CMIS object not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + parents = list(mapper.parent_folders_for_asset(asset)) + return {"object_id": mapper.asset_object_id(asset.id), "parents": parents, "count": len(parents)} + def cmis_create_document( self, access_point_id: str, @@ -584,18 +590,14 @@ class ServiceRuntime: "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, - ) + projections = self._cmis_document_projections(mapper, context) + paged = projections[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)] 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"], + "results": paged, + "num_items": len(paged), + "has_more_items": len(projections) > max(skip_count, 0) + len(paged), + "total_num_items": len(projections), } def cmis_relationships( @@ -685,6 +687,74 @@ class ServiceRuntime: return self._cmis_asset_visible(mapper, relationship.target_id, context) return True + def _cmis_children_for_folder( + self, + mapper: CMISDomainMapper, + context: OperationContext, + *, + folder_path: str | None, + ) -> list[dict[str, Any]]: + assets = [ + asset + for asset in self.repository.list_assets() + if mapper.access_point.exposes_asset(asset, context) + ] + if folder_path in (None, "/"): + child_folder_paths = set() + for asset in assets: + for path in mapper.asset_paths(asset): + first = path.strip("/").split("/")[0] + if first: + child_folder_paths.add("/" + first) + return [mapper.folder_projection(path) for path in sorted(child_folder_paths)] + children: list[dict[str, Any]] = [] + folder_path = _normalize_cmis_path(folder_path) + child_folder_paths: set[str] = set() + for asset in assets: + for path in mapper.asset_paths(asset): + parent = _path_parent(path) + if parent == folder_path: + 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) + if self._cmis_relationship_visible(mapper, relationship, context) + ], + metadata_records=self.repository.list_metadata_records(asset.id), + ) + if projection is not None: + children.append(projection.to_dict()) + elif _path_parent(parent) == folder_path: + child_folder_paths.add(parent) + return [mapper.folder_projection(path) for path in sorted(child_folder_paths)] + children + + def _cmis_document_projections( + self, + mapper: CMISDomainMapper, + context: OperationContext, + ) -> list[dict[str, Any]]: + projections = [] + for asset in self.repository.list_assets(): + 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) + if self._cmis_relationship_visible(mapper, relationship, context) + ], + metadata_records=self.repository.list_metadata_records(asset.id), + ) + if projection is not None: + projections.append(projection.to_dict()) + return projections + 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( @@ -2140,6 +2210,14 @@ def create_app(runtime: ServiceRuntime | None = None): ) -> dict[str, Any]: return response(runtime.cmis_acl, access_point_id, object_id, context) + @app.get("/cmis/{access_point_id}/browser/parents/{object_id:path}", tags=["cmis"]) + def cmis_object_parents( + access_point_id: str, + object_id: str, + context: OperationContext = Depends(context_from_headers), + ) -> dict[str, Any]: + return response(runtime.cmis_object_parents, access_point_id, object_id, context) + @app.post("/cmis/{access_point_id}/browser/document", tags=["cmis"]) def cmis_create_document( access_point_id: str, @@ -2632,6 +2710,29 @@ def _cmis_asset_id(object_id: str | None) -> str: return normalized +def _cmis_folder_path(folder_id: str | None) -> str | None: + if not folder_id: + return None + normalized = folder_id.strip() + if normalized in {"cmis-root", "root", "/"}: + return "/" + if normalized.startswith("cmis:folder:"): + return "/" + normalized.removeprefix("cmis:folder:").replace("::", "/") + return _normalize_cmis_path(normalized) + + +def _normalize_cmis_path(path: str) -> str: + parts = [part.strip().strip("/") for part in path.replace("\\", "/").split("/") if part.strip("/")] + return "/" + "/".join(parts) + + +def _path_parent(path: str) -> str: + parts = _normalize_cmis_path(path).strip("/").split("/") + if len(parts) <= 1: + return "/" + return "/" + "/".join(parts[:-1]) + + def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError: return AuthorizationError( "CMIS operation denied by access-point profile", diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py index 8128f67..b380493 100644 --- a/src/kontextual_engine/core/cmis.py +++ b/src/kontextual_engine/core/cmis.py @@ -48,6 +48,7 @@ class CMISAction(str, Enum): GET_REPOSITORY_INFO = "get_repository_info" GET_TYPE_DEFINITION = "get_type_definition" GET_CHILDREN = "get_children" + GET_OBJECT_PARENTS = "get_object_parents" GET_OBJECT = "get_object" GET_CONTENT_STREAM = "get_content_stream" GET_ACL = "get_acl" @@ -76,6 +77,7 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = { CMISAction.GET_REPOSITORY_INFO: CMISCapability.REPOSITORY, CMISAction.GET_TYPE_DEFINITION: CMISCapability.TYPE_DEFINITIONS, CMISAction.GET_CHILDREN: CMISCapability.NAVIGATION, + CMISAction.GET_OBJECT_PARENTS: CMISCapability.NAVIGATION, CMISAction.GET_OBJECT: CMISCapability.OBJECT_READ, CMISAction.GET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_READ, CMISAction.GET_ACL: CMISCapability.ACL, @@ -486,7 +488,7 @@ class CMISDomainMapper: "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": False, + "capability_multifiling": profile.has_capability(CMISCapability.NAVIGATION), "capability_unfiling": False, "capability_version_specific_filing": False, "capability_pwc_searchable": False, @@ -613,21 +615,67 @@ class CMISDomainMapper: def asset_object_id(self, asset_id: str) -> str: return f"cmis:asset:{asset_id}" + def folder_object_id(self, path: str) -> str: + return "cmis:folder:" + _normalize_path(path).strip("/").replace("/", "::") + def asset_path(self, asset: KnowledgeAsset) -> str: + return self.asset_paths(asset)[0] + + def asset_paths(self, asset: KnowledgeAsset) -> tuple[str, ...]: + paths: list[str] = [] explicit = asset.metadata.get("cmis_path") if explicit: - return _normalize_path(str(explicit)) + paths.append(_normalize_path(str(explicit))) + for value in asset.metadata.get("cmis_paths", ()): + paths.append(_normalize_path(str(value))) if asset.source_refs: source_ref = asset.source_refs[0] source_root = _safe_path_segment(source_ref.source_system) if source_ref.path: - return _normalize_path(f"/sources/{source_root}/{source_ref.path}") + paths.append(_normalize_path(f"/sources/{source_root}/{source_ref.path}")) if source_ref.external_id: - return _normalize_path(f"/sources/{source_root}/{source_ref.external_id}") - topics = asset.classification.topics - if topics: - return _normalize_path(f"/topics/{topics[0]}/{asset.id}") - return _normalize_path(f"/assets/{asset.classification.asset_type}/{asset.id}") + paths.append(_normalize_path(f"/sources/{source_root}/{source_ref.external_id}")) + for topic in asset.classification.topics: + paths.append(_normalize_path(f"/topics/{topic}/{asset.id}")) + if asset.classification.owner: + paths.append(_normalize_path(f"/owners/{asset.classification.owner}/{asset.id}")) + paths.append(_normalize_path(f"/lifecycle/{_enum_value(asset.lifecycle)}/{asset.id}")) + paths.append(_normalize_path(f"/assets/{asset.classification.asset_type}/{asset.id}")) + return tuple(dict.fromkeys(paths)) + + def parent_folders_for_asset(self, asset: KnowledgeAsset) -> tuple[dict[str, Any], ...]: + folders = [] + for path in self.asset_paths(asset): + parent = _parent_path(path) + folders.append( + { + "object_id": self.folder_object_id(parent), + "path": parent, + "name": _path_name(parent), + "base_type_id": CMISBaseType.FOLDER.value, + "type_id": "kontextual:folder", + "filing_source": _filing_source(parent), + } + ) + return tuple({folder["path"]: folder for folder in folders}.values()) + + def folder_projection(self, path: str) -> dict[str, Any]: + normalized = _normalize_path(path) + return { + "object_id": self.folder_object_id(normalized), + "base_type_id": CMISBaseType.FOLDER.value, + "type_id": "kontextual:folder", + "name": _path_name(normalized), + "path": normalized, + "properties": { + "cmis:objectId": self.folder_object_id(normalized), + "cmis:name": _path_name(normalized), + "cmis:baseTypeId": CMISBaseType.FOLDER.value, + "cmis:objectTypeId": "kontextual:folder", + "kontextual:filingSource": _filing_source(normalized), + }, + "allowable_actions": [CMISAction.GET_CHILDREN.value], + } def asset_properties( self, @@ -716,6 +764,7 @@ class CMISDomainMapper: CMISAction.GET_CONTENT_STREAM, CMISAction.GET_ACL, CMISAction.GET_RELATIONSHIPS, + CMISAction.GET_OBJECT_PARENTS, CMISAction.UPDATE_PROPERTIES, CMISAction.DELETE_OBJECT, CMISAction.SET_CONTENT_STREAM, @@ -819,3 +868,22 @@ def _normalize_path(path: str) -> str: def _safe_path_segment(value: str) -> str: return str(value).strip().strip("/") or "_" + + +def _parent_path(path: str) -> str: + parts = _normalize_path(path).strip("/").split("/") + if len(parts) <= 1: + return "/" + return "/" + "/".join(parts[:-1]) + + +def _path_name(path: str) -> str: + normalized = _normalize_path(path) + if normalized == "/": + return "root" + return normalized.rsplit("/", 1)[-1] + + +def _filing_source(path: str) -> str: + parts = _normalize_path(path).strip("/").split("/") + return parts[0] if parts and parts[0] else "root" diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py index 43deecd..41c58c0 100644 --- a/tests/cmis/test_cmis_browser_binding_api.py +++ b/tests/cmis/test_cmis_browser_binding_api.py @@ -84,6 +84,7 @@ def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> N assert "/cmis/{access_point_id}/browser/object/{object_id}" in paths assert "/cmis/{access_point_id}/browser/content/{object_id}" in paths assert "/cmis/{access_point_id}/browser/acl/{object_id}" in paths + assert "/cmis/{access_point_id}/browser/parents/{object_id}" in paths assert "/cmis/{access_point_id}/browser/query" in paths assert "/cmis/{access_point_id}/browser/relationships" in paths assert "/cmis/{access_point_id}/browser/changes" in paths diff --git a/tests/cmis/test_cmis_domain_mapper.py b/tests/cmis/test_cmis_domain_mapper.py index 316650a..39f14e1 100644 --- a/tests/cmis/test_cmis_domain_mapper.py +++ b/tests/cmis/test_cmis_domain_mapper.py @@ -73,7 +73,7 @@ def test_mapper_exposes_repository_info_capabilities_and_base_type_definitions() assert repository["cmis_version_supported"] == "1.1" assert repository["binding"] == "browser" assert repository["capabilities"]["capability_query"] == "metadataonly" - assert repository["capabilities"]["capability_multifiling"] is False + assert repository["capabilities"]["capability_multifiling"] is True assert set(types) == { "cmis:document", @@ -128,6 +128,21 @@ def test_mapper_projects_asset_to_cmis_document_envelope() -> None: assert CMISAction.UPDATE_PROPERTIES.value not in serialized["allowable_actions"] +def test_mapper_projects_multiple_parent_folders_without_duplicate_assets() -> None: + mapper = _mapper() + asset = _asset() + + parents = mapper.parent_folders_for_asset(asset) + parent_paths = {parent["path"] for parent in parents} + + assert "/sources/sharepoint/Architecture" in parent_paths + assert "/topics/architecture" in parent_paths + assert "/topics/cmis" in parent_paths + assert "/owners/Platform Knowledge" in parent_paths + assert "/lifecycle/active" in parent_paths + assert len(parent_paths) == len(parents) + + def test_governed_authoring_projection_includes_write_allowable_actions() -> None: mapper = _mapper(CMISAccessProfile.governed_authoring()) asset = _asset() @@ -175,4 +190,3 @@ def test_mapper_projects_relationship_objects() -> None: assert serialized["properties"]["cmis:sourceId"] == "cmis:asset:asset-source" assert serialized["properties"]["cmis:targetId"] == "cmis:asset:asset-target" assert serialized["properties"]["kontextual:predicate"] == "derived_from" - diff --git a/tests/cmis/test_cmis_runtime_browser_binding.py b/tests/cmis/test_cmis_runtime_browser_binding.py index 9737a3f..316cabd 100644 --- a/tests/cmis/test_cmis_runtime_browser_binding.py +++ b/tests/cmis/test_cmis_runtime_browser_binding.py @@ -25,7 +25,7 @@ def cmis_runtime() -> tuple[ServiceRuntime, object]: asset_type="document", sensitivity=Sensitivity.INTERNAL, owner="Platform Knowledge", - topics=("cmis",), + topics=("cmis", "integration"), ), context, asset_id="asset-runtime-source", @@ -85,16 +85,21 @@ def test_runtime_cmis_browser_repository_types_children_and_object(cmis_runtime) repository = runtime.cmis_repository_info("readonly-browser") types = runtime.cmis_type_definitions("readonly-browser") children = runtime.cmis_children("readonly-browser", context) + topic_children = runtime.cmis_children("readonly-browser", context, folder_id="/topics/cmis") obj = runtime.cmis_object("readonly-browser", "cmis:asset:asset-runtime-source", context) + parents = runtime.cmis_object_parents("readonly-browser", "cmis:asset:asset-runtime-source", context) assert access_points["count"] == 4 assert repository["repository_id"] == "kontextual-readonly-browser" assert repository["capabilities"]["capability_get_descendants"] is True assert {item["base_type_id"] for item in types["items"]} >= {"cmis:document", "cmis:folder"} - object_ids = {item["object_id"] for item in children["objects"]} - assert "cmis:asset:asset-runtime-source" in object_ids - assert "cmis:asset:asset-runtime-public" in object_ids - assert "cmis:asset:asset-runtime-confidential" not in object_ids + root_paths = {item["path"] for item in children["objects"]} + topic_object_ids = {item["object_id"] for item in topic_children["objects"]} + parent_paths = {item["path"] for item in parents["parents"]} + assert "/topics" in root_paths + assert "cmis:asset:asset-runtime-source" in topic_object_ids + assert "cmis:asset:asset-runtime-confidential" not in topic_object_ids + assert {"/topics/cmis", "/topics/integration"} <= parent_paths assert obj["properties"]["kontextual:assetId"] == "asset-runtime-source" diff --git a/tests/test_service_api.py b/tests/test_service_api.py index a58f096..c0975b8 100644 --- a/tests/test_service_api.py +++ b/tests/test_service_api.py @@ -658,6 +658,7 @@ def test_service_health_readiness_version_and_openapi_contracts(client) -> None: assert "/cmis/{access_point_id}/browser" in paths assert "/cmis/{access_point_id}/browser/children" in paths assert "/cmis/{access_point_id}/browser/acl/{object_id}" in paths + assert "/cmis/{access_point_id}/browser/parents/{object_id}" in paths assert "/cmis/{access_point_id}/browser/document" in paths assert "/cmis/{access_point_id}/browser/object/{object_id}/properties" in paths assert "/api/v1/assets" in paths