From 06e3654aaaeb6a7585764995ab0ecba69e741829 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 8 May 2026 16:47:30 +0200 Subject: [PATCH] Scoped CMIS workspace folders with create, list, parent, path lookup, delete, and delete-tree behavior --- ...-tck-wp0014-evidence-2026-05-08T134432Z.md | 123 ++++ src/kontextual_engine/api/app.py | 606 +++++++++++++++++- src/kontextual_engine/core/cmis.py | 173 ++++- tests/cmis/test_cmis_browser_binding_api.py | 112 ++++ .../cmis/test_cmis_runtime_browser_binding.py | 61 ++ ...NT-WP-0014-cmis-object-content-maturity.md | 51 +- 6 files changed, 1103 insertions(+), 23 deletions(-) create mode 100644 docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md diff --git a/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md new file mode 100644 index 0000000..c3e36f3 --- /dev/null +++ b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md @@ -0,0 +1,123 @@ +# CMIS WP-0014 OpenCMIS Evidence - 2026-05-08T13:44:32Z + +## Scope + +This note records the WP-0014 implementation evidence for raising CMIS 1.1 +Browser Binding object/content maturity in `kontextual-engine`. + +CMIS remains an adapter over native engine services. The work in this pass +focused on compatibility features that map naturally to existing assets, +representations, metadata, policy gates, and projection folders. + +## Implemented + +- Added profile-scoped adapter-managed workspace folders for CMIS-created + folders. +- Added Browser Binding `createFolder`, `createDocument`, `deleteTree`, and + singular `parent` selector handling. +- Added form-url-encoded and multipart Browser Binding action parsing without + introducing a new multipart dependency. +- Added document/folder path projection and Browser Binding `getObjectByPath` + support through the root selector. +- Tightened folder lifecycle so deleted workspace folders stop resolving as + phantom virtual folders. +- Returned full folder projections from `getObjectParents`, including + `cmis:path` for OpenCMIS `getPaths()`. +- Declared emitted document/folder custom properties in CMIS type metadata. +- Included CMIS document version/read-only properties expected by common CMIS + clients while keeping versioning operations unsupported. +- Prevented CMIS-authored documents from appearing multifiled when the + repository advertises `capabilityMultifiling=false`. +- Corrected document allowable actions so non-folder objects do not advertise + `canGetFolderParent`. + +## Local Verification + +Focused CMIS tests: + +```bash +.venv/bin/python -m pytest tests/cmis --perf-history-disable +``` + +Result: + +- `47 passed` + +## OpenCMIS Run + +Command shape: + +```bash +cd /home/worsch/guide-board +source /home/worsch/open-cmis-tck/.local/toolchains/env.sh +PYTHONPATH=src python3 -m guide_board \ + --extension-dir ../open-cmis-tck \ + run \ + --target /tmp/kontextual-cmis-compat-8010.json \ + --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \ + --output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z +``` + +Result: + +- Run ID: `run-20260508T134448Z` +- Run directory: `/tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z` +- Harness status: `infrastructure_error` +- Scorecard: `/tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z/reports/cmis-maturity-scorecard.md` + +The scorecard still classifies the two mapped groups as infrastructure-blocked, +but the raw OpenCMIS output shows meaningful progress inside the test groups. + +## Evidence Progression + +Earlier WP-0014 runs stopped at foundational Browser Binding gaps: + +- Unsupported `parent` selector. +- Folder custom properties declared by type metadata but missing from root + folder objects. +- Multipart `createDocument` action bodies not parsed because multipart + boundaries were lowercased. +- `deleteTree` unsupported during OpenCMIS cleanup. +- CMIS-created folders still resolving after deletion as generic virtual + folders. +- Document projections exposing undeclared custom fields such as + `kontextual:assetId`. +- OpenCMIS `getPaths()` failing because parent folder objects lacked + `cmis:path`. + +The final run reaches deeper object/content behaviors: + +- Repository info checks pass. +- Root folder test passes. +- Type definition enumeration runs. +- Document creation proceeds far enough to exercise update, move, delete-tree, + operation-context, and async object lookup tests. +- Delete-tree tests create many documents and report MIME warnings rather than + basic object-creation failures. + +## Current Frontier + +Remaining natural CMIS maturity items: + +- Browser Binding action aliases: OpenCMIS sends `cmisaction=update` and + `cmisaction=move`; we currently expose `updateProperties` and do not support + move. +- Operation-context fidelity: `getObject()` and `getChildren()` currently + return more properties, ACLs, allowable actions, and path segments than + requested. +- MIME normalization: OpenCMIS expects `text/plain`; some created streams are + reported as `text/plain; charset=utf-8`. +- Async `getObjectByPath`: OpenCMIS async child/folder checks still hit a + `Not Found` path lookup case. +- Copy/move and secondary type mutation remain unsupported unless a later + workplan admits them. + +## Interpretation + +The adapter foundation is sounder after this pass. The failures are no longer +basic session, repository, folder-creatable, multipart action parsing, or parent +path hydration issues. They are now mostly CMIS client polish around action +aliases, response filtering, and optional ECM behaviors. + +WP-0014 should remain active for follow-up maturity work, but the core +folder/object-content compatibility foundation is implemented. diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py index 19aab12..0569558 100644 --- a/src/kontextual_engine/api/app.py +++ b/src/kontextual_engine/api/app.py @@ -7,10 +7,13 @@ requests into service/runtime contracts and must not own domain behavior. from __future__ import annotations import json -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime +from email import policy +from email.parser import BytesParser from importlib import metadata from typing import Any +from urllib.parse import parse_qs from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository, InMemoryBlobStorage from kontextual_engine.core import ( @@ -23,6 +26,7 @@ from kontextual_engine.core import ( CMISAccessPoint, CMISAccessProfile, CMISAction, + CMISBaseType, CMISDomainMapper, ContextEntity, ContextEntityType, @@ -188,11 +192,25 @@ AGENT_OPERATION_CATALOG: tuple[dict[str, Any], ...] = ( ) +@dataclass +class CMISWorkspaceFolder: + access_point_id: str + object_id: str + path: str + name: str + parent_id: str + created_by: str + created_at: str + updated_at: str + lifecycle: str = LifecycleState.ACTIVE.value + + @dataclass class ServiceRuntime: repository: AssetRegistryRepository = field(default_factory=InMemoryAssetRegistryRepository) blob_storage: BlobStorage = field(default_factory=InMemoryBlobStorage) policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway) + cmis_workspace_folders: dict[str, dict[str, CMISWorkspaceFolder]] = field(default_factory=dict) api_version: str = API_VERSION service_name: str = "kontextual-engine" started_at: str = field(default_factory=lambda: utc_now().isoformat()) @@ -427,9 +445,23 @@ class ServiceRuntime: return self.cmis_browser_root_object(access_point_id) if object_id.startswith("cmis:folder:"): folder_path = _cmis_folder_path(object_id) or "/" - return cmis_browser_object(self._cmis_mapper(access_point_id).folder_projection(folder_path)) + mapper = self._cmis_mapper(access_point_id) + if not self._cmis_folder_exists(mapper, context, folder_path): + raise NotFoundError( + "CMIS folder not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + return cmis_browser_object(self._cmis_folder_projection(access_point_id, folder_path)) return cmis_browser_object(self.cmis_object(access_point_id, object_id, context)) + def cmis_browser_object_by_path( + self, + access_point_id: str, + path: str, + context: OperationContext, + ) -> dict[str, Any]: + return cmis_browser_object(self.cmis_object_by_path(access_point_id, path, context)) + def cmis_browser_children( self, access_point_id: str, @@ -456,6 +488,20 @@ class ServiceRuntime: ) -> list[dict[str, Any]]: return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context)) + def cmis_browser_parent( + self, + access_point_id: str, + object_id: str, + context: OperationContext, + ) -> dict[str, Any]: + parents = self.cmis_object_parents(access_point_id, object_id, context).get("parents", []) + if not parents: + raise NotFoundError( + "CMIS folder parent not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + return cmis_browser_object(parents[0]) + def cmis_browser_query( self, access_point_id: str, @@ -510,6 +556,14 @@ class ServiceRuntime: decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT, context, resource=object_id) if not decision.allowed: raise _cmis_authorization_error(decision, "getObject") + if object_id.startswith("cmis:folder:"): + folder_path = _cmis_folder_path(object_id) or "/" + if not self._cmis_folder_exists(mapper, context, folder_path): + raise NotFoundError( + "CMIS folder not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + return self._cmis_folder_projection(access_point_id, folder_path) asset_id = _cmis_asset_id(object_id) asset = self.repository.get_asset(asset_id) projection = mapper.map_asset( @@ -530,6 +584,53 @@ class ServiceRuntime: ) return projection.to_dict() + def cmis_object_by_path( + self, + access_point_id: str, + path: str, + context: OperationContext, + ) -> dict[str, Any]: + mapper = self._cmis_mapper(access_point_id) + normalized = _normalize_cmis_path(path) + if normalized == "/": + return cmis_browser_root_folder(mapper.access_point) + workspace_folder = self._cmis_workspace_folder_map(access_point_id).get(normalized) + if workspace_folder is not None: + return self._cmis_workspace_folder_projection(mapper, workspace_folder) + for asset in self.repository.list_assets(): + if not mapper.access_point.exposes_asset(asset, context): + continue + if normalized not in mapper.asset_paths(asset): + continue + 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: + return projection.to_dict() + if any( + folder.path != normalized and _path_contains(normalized, folder.path) + for folder in self._cmis_workspace_folder_map(access_point_id).values() + ): + return mapper.folder_projection(normalized) + for asset in self.repository.list_assets(): + if not mapper.access_point.exposes_asset(asset, context): + continue + if any(asset_path != normalized and _path_contains(normalized, asset_path) for asset_path in mapper.asset_paths(asset)): + return mapper.folder_projection(normalized) + raise NotFoundError( + "CMIS object path not found", + details={"path": normalized, "access_point_id": access_point_id}, + ) + def cmis_content_stream( self, access_point_id: str, @@ -598,6 +699,18 @@ class ServiceRuntime: decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT_PARENTS, context, resource=object_id) if not decision.allowed: raise _cmis_authorization_error(decision, "getObjectParents") + if object_id.startswith("cmis:folder:"): + folder_path = _cmis_folder_path(object_id) or "/" + if not self._cmis_folder_exists(mapper, context, folder_path): + raise NotFoundError( + "CMIS folder not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + if folder_path == "/": + return {"object_id": object_id, "parents": [], "count": 0} + parent_path = _path_parent(folder_path) + parent = self._cmis_folder_projection(access_point_id, parent_path) + return {"object_id": object_id, "parents": [parent], "count": 1} asset_id = _cmis_asset_id(object_id) asset = self.repository.get_asset(asset_id) if not mapper.access_point.exposes_asset(asset, context): @@ -605,9 +718,73 @@ class ServiceRuntime: "CMIS object not found", details={"object_id": object_id, "access_point_id": access_point_id}, ) - parents = list(mapper.parent_folders_for_asset(asset)) + explicit_cmis_path = asset.metadata.get("cmis_path") + parent_paths = ( + [_path_parent(str(explicit_cmis_path))] + if explicit_cmis_path + else [parent["path"] for parent in mapper.parent_folders_for_asset(asset)] + ) + parents = [self._cmis_folder_projection(access_point_id, path) for path in dict.fromkeys(parent_paths)] return {"object_id": mapper.asset_object_id(asset.id), "parents": parents, "count": len(parents)} + def cmis_create_folder( + self, + access_point_id: str, + payload: dict[str, Any], + context: OperationContext, + *, + parent_folder_id: str | None = None, + ) -> dict[str, Any]: + mapper = self._cmis_mapper(access_point_id) + decision = mapper.access_point.decide_action(CMISAction.CREATE_FOLDER, context) + if not decision.allowed: + raise _cmis_authorization_error(decision, "createFolder") + properties = dict(payload.get("properties", {})) + name = str(payload.get("name") or properties.get("cmis:name") or "").strip() + if not name: + raise ValidationError("CMIS folder name is required", details={"operation": "createFolder"}) + type_id = properties.get("cmis:objectTypeId", payload.get("type_id", CMISBaseType.FOLDER.value)) + if type_id not in {CMISBaseType.FOLDER.value, "kontextual:folder"}: + raise ValidationError( + "Unsupported CMIS folder type", + details={"operation": "createFolder", "type_id": type_id, "supported": [CMISBaseType.FOLDER.value]}, + ) + parent_id = parent_folder_id or payload.get("folder_id") or payload.get("folderId") or "cmis-root" + parent_path = _cmis_folder_path(parent_id) or "/" + folder_path = _normalize_cmis_path(f"{parent_path}/{name}") + folders = self._cmis_workspace_folder_map(access_point_id) + if folder_path in folders: + raise ValidationError( + "CMIS folder already exists", + details={"operation": "createFolder", "path": folder_path}, + ) + parent_object_id = "cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path) + now = utc_now().isoformat() + folder = CMISWorkspaceFolder( + access_point_id=access_point_id, + object_id=mapper.folder_object_id(folder_path), + path=folder_path, + name=_path_name(folder_path), + parent_id=parent_object_id, + created_by=context.actor.id, + created_at=now, + updated_at=now, + ) + folders[folder.path] = folder + return self._cmis_workspace_folder_projection(mapper, folder) + + def cmis_browser_create_folder( + self, + access_point_id: str, + payload: dict[str, Any], + context: OperationContext, + *, + parent_folder_id: str | None = None, + ) -> dict[str, Any]: + return cmis_browser_object( + self.cmis_create_folder(access_point_id, payload, context, parent_folder_id=parent_folder_id) + ) + def cmis_create_document( self, access_point_id: str, @@ -618,6 +795,10 @@ class ServiceRuntime: decision = mapper.access_point.decide_action(CMISAction.CREATE_DOCUMENT, context) if not decision.allowed: raise _cmis_authorization_error(decision, "createDocument") + properties = dict(payload.get("properties", {})) + name = payload.get("name") or properties.get("cmis:name") + if not name: + raise ValidationError("CMIS document name is required", details={"operation": "createDocument"}) classification = Classification.from_dict( { "asset_type": payload.get("asset_type", "document"), @@ -640,7 +821,7 @@ class ServiceRuntime: ) representations.append(representation) result = self.asset_service().create_asset( - payload["name"], + str(name), classification, context, asset_id=asset_id, @@ -648,8 +829,34 @@ class ServiceRuntime: metadata_records=[_metadata_record(item) for item in payload.get("metadata_records", [])], idempotency_key=payload.get("idempotency_key"), ) + folder_id = payload.get("folder_id") or payload.get("folderId") + if folder_id: + folder_path = _cmis_folder_path(folder_id) or "/" + asset_path = _normalize_cmis_path(f"{folder_path}/{name}") + metadata = { + **result.asset.metadata, + "cmis_path": asset_path, + "cmis_parent_folder_id": "cmis-root" + if folder_path == "/" + else mapper.folder_object_id(folder_path), + "file_name": str(name), + } + self.repository.save_asset(replace(result.asset, metadata=metadata)) return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context) + def cmis_browser_create_document( + self, + access_point_id: str, + payload: dict[str, Any], + context: OperationContext, + *, + parent_folder_id: str | None = None, + ) -> dict[str, Any]: + payload = dict(payload) + if parent_folder_id and "folder_id" not in payload and "folderId" not in payload: + payload["folder_id"] = parent_folder_id + return cmis_browser_object(self.cmis_create_document(access_point_id, payload, context)) + def cmis_update_properties( self, access_point_id: str, @@ -729,6 +936,29 @@ class ServiceRuntime: decision = mapper.access_point.decide_action(CMISAction.DELETE_OBJECT, context, resource=object_id) if not decision.allowed: raise _cmis_authorization_error(decision, "deleteObject") + if object_id.startswith("cmis:folder:"): + folder_path = _cmis_folder_path(object_id) or "/" + if folder_path == "/": + raise ValidationError("CMIS root folder cannot be deleted", details={"operation": "deleteObject"}) + folders = self._cmis_workspace_folder_map(access_point_id) + if folder_path not in folders: + raise NotFoundError( + "CMIS folder not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + children = self._cmis_children_for_folder(mapper, context, folder_path=folder_path) + if children: + raise ValidationError( + "CMIS folder is not empty", + details={"operation": "deleteObject", "object_id": object_id, "child_count": len(children)}, + ) + del folders[folder_path] + return { + "object_id": object_id, + "deleted": True, + "lifecycle": LifecycleState.DELETE_REQUESTED.value, + "profile": access_point_id, + } asset_id = _cmis_asset_id(object_id) result = self.asset_service().request_delete( asset_id, @@ -744,6 +974,56 @@ class ServiceRuntime: "policy_decision": result.policy_decision.to_dict(), } + def cmis_delete_tree( + self, + access_point_id: str, + object_id: str, + payload: dict[str, Any], + context: OperationContext, + ) -> dict[str, Any]: + mapper = self._cmis_mapper(access_point_id) + decision = mapper.access_point.decide_action(CMISAction.DELETE_TREE, context, resource=object_id) + if not decision.allowed: + raise _cmis_authorization_error(decision, "deleteTree") + folder_path = _cmis_folder_path(object_id) + if folder_path in (None, "/"): + raise ValidationError("CMIS root folder cannot be deleteTree target", details={"operation": "deleteTree"}) + + failed_to_delete: list[str] = [] + deleted_assets = 0 + for asset in list(self.repository.list_assets()): + if not mapper.access_point.exposes_asset(asset, context): + continue + if not any(_path_contains(folder_path, path) for path in mapper.asset_paths(asset)): + continue + try: + self.asset_service().request_delete( + asset.id, + context, + expected_current_version_id=payload.get("expected_current_version_id"), + ) + deleted_assets += 1 + except Exception: + failed_to_delete.append(mapper.asset_object_id(asset.id)) + + folders = self._cmis_workspace_folder_map(access_point_id) + folder_paths = [ + path + for path in folders + if path == folder_path or _path_contains(folder_path, path) + ] + for path in sorted(folder_paths, key=lambda item: item.count("/"), reverse=True): + del folders[path] + + return { + "failedToDelete": failed_to_delete, + "failed_to_delete": failed_to_delete, + "deleted": len(folder_paths) + deleted_assets, + "deleted_folders": len(folder_paths), + "deleted_assets": deleted_assets, + "profile": access_point_id, + } + def cmis_query( self, access_point_id: str, @@ -878,6 +1158,8 @@ class ServiceRuntime: for asset in self.repository.list_assets() if mapper.access_point.exposes_asset(asset, context) ] + access_point_id = mapper.access_point.access_point_id + workspace_folders = self._cmis_workspace_folder_map(access_point_id) if folder_path in (None, "/"): child_folder_paths = set() for asset in assets: @@ -885,10 +1167,23 @@ class ServiceRuntime: first = path.strip("/").split("/")[0] if first: child_folder_paths.add("/" + first) - return [mapper.folder_projection(path) for path in sorted(child_folder_paths)] + workspace_children = [ + self._cmis_workspace_folder_projection(mapper, folder) + for folder in workspace_folders.values() + if _path_parent(folder.path) == "/" + ] + projection_children = [ + mapper.folder_projection(path) + for path in sorted(child_folder_paths) + if path not in workspace_folders + ] + return sorted(workspace_children + projection_children, key=lambda item: item["path"]) children: list[dict[str, Any]] = [] folder_path = _normalize_cmis_path(folder_path) child_folder_paths: set[str] = set() + for folder in workspace_folders.values(): + if _path_parent(folder.path) == folder_path: + children.append(self._cmis_workspace_folder_projection(mapper, folder)) for asset in assets: for path in mapper.asset_paths(asset): parent = _path_parent(path) @@ -909,7 +1204,90 @@ class ServiceRuntime: 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 + projection_children = [ + mapper.folder_projection(path) + for path in sorted(child_folder_paths) + if path not in workspace_folders + ] + return projection_children + children + + def _cmis_workspace_folder_map(self, access_point_id: str) -> dict[str, CMISWorkspaceFolder]: + return self.cmis_workspace_folders.setdefault(access_point_id, {}) + + def _cmis_folder_projection(self, access_point_id: str, folder_path: str) -> dict[str, Any]: + mapper = self._cmis_mapper(access_point_id) + normalized = _normalize_cmis_path(folder_path) + if normalized == "/": + return cmis_browser_root_folder(mapper.access_point) + folder = self._cmis_workspace_folder_map(access_point_id).get(normalized) + if folder is not None: + return self._cmis_workspace_folder_projection(mapper, folder) + return mapper.folder_projection(normalized) + + def _cmis_folder_exists( + self, + mapper: CMISDomainMapper, + context: OperationContext, + folder_path: str, + ) -> bool: + normalized = _normalize_cmis_path(folder_path) + if normalized == "/": + return True + workspace_folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id) + if normalized in workspace_folders: + return True + if any(_path_contains(normalized, folder.path) for folder in workspace_folders.values()): + return True + for asset in self.repository.list_assets(): + if not mapper.access_point.exposes_asset(asset, context): + continue + if any(_path_contains(normalized, path) for path in mapper.asset_paths(asset)): + return True + return False + + def _cmis_workspace_folder_projection( + self, + mapper: CMISDomainMapper, + folder: CMISWorkspaceFolder, + ) -> dict[str, Any]: + projection = mapper.folder_projection(folder.path) + projection["object_id"] = folder.object_id + projection["name"] = folder.name + projection["path"] = folder.path + projection["properties"].update( + { + "cmis:objectId": folder.object_id, + "cmis:name": folder.name, + "cmis:objectTypeId": CMISBaseType.FOLDER.value, + "cmis:createdBy": folder.created_by, + "cmis:lastModifiedBy": folder.created_by, + "cmis:creationDate": folder.created_at, + "cmis:lastModificationDate": folder.updated_at, + "cmis:changeToken": f"folder:{folder.updated_at}", + "cmis:parentId": folder.parent_id, + "cmis:description": "Adapter-managed CMIS workspace folder", + "kontextual:workspaceFolder": True, + } + ) + actions = set(projection.get("allowable_actions", [])) + actions.update( + { + CMISAction.GET_OBJECT.value, + CMISAction.GET_CHILDREN.value, + CMISAction.GET_OBJECT_PARENTS.value, + } + ) + if mapper.access_point.profile.allow_mutations: + actions.update( + { + CMISAction.CREATE_DOCUMENT.value, + CMISAction.CREATE_FOLDER.value, + CMISAction.DELETE_OBJECT.value, + CMISAction.DELETE_TREE.value, + } + ) + projection["allowable_actions"] = sorted(actions) + return projection def _cmis_document_projections( self, @@ -2361,6 +2739,7 @@ def create_app(runtime: ServiceRuntime | None = None): "query", "object", "children", + "parent", "parents", "properties", "allowableActions", @@ -2370,6 +2749,99 @@ def create_app(runtime: ServiceRuntime | None = None): }, ) + async def browser_action_payload(request: Request) -> dict[str, Any]: + payload: dict[str, Any] = dict(request.query_params) + content_type = request.headers.get("content-type", "") + content_type_lower = content_type.lower() + body = await request.body() + if body: + if "application/json" in content_type_lower: + payload.update(json.loads(body.decode("utf-8"))) + elif "multipart/form-data" in content_type_lower: + form_values, file_values = _parse_multipart_form(content_type, body) + payload.update(_flatten_form_values(form_values)) + for field_name, file_value in file_values.items(): + payload[field_name] = file_value["content"] + if field_name in {"content", "contentStream", "file"} or "content" not in payload: + payload["content"] = file_value["content"] + payload.setdefault("media_type", file_value.get("content_type") or "application/octet-stream") + payload.setdefault("content_filename", file_value.get("filename")) + else: + parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + payload.update(_flatten_form_values(parsed)) + properties = _cmis_browser_properties(payload) + if properties: + payload["properties"] = properties + payload.setdefault("name", properties.get("cmis:name")) + payload.setdefault("type_id", properties.get("cmis:objectTypeId")) + return payload + + async def cmis_browser_post_action( + access_point_id: str, + request: Request, + *, + default_object_id: str | None, + context: OperationContext, + ) -> Any: + payload = await browser_action_payload(request) + action = payload.get("cmisaction") or payload.get("cmisAction") or payload.get("action") + if action is None: + type_id = payload.get("type_id") or payload.get("properties", {}).get("cmis:objectTypeId") + if type_id in {CMISBaseType.DOCUMENT.value, "kontextual:document"}: + action = "createDocument" + elif type_id in {CMISBaseType.FOLDER.value, "kontextual:folder"}: + action = "createFolder" + object_id = payload.get("objectId") or payload.get("object_id") or default_object_id + if action == "createFolder": + return response( + runtime.cmis_browser_create_folder, + access_point_id, + payload, + context, + parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root", + ) + if action == "createDocument": + return response( + runtime.cmis_browser_create_document, + access_point_id, + payload, + context, + parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root", + ) + if action in {"delete", "deleteObject"}: + if not object_id: + raise ValidationError("CMIS object id is required", details={"operation": "deleteObject"}) + return response(runtime.cmis_delete_object, access_point_id, object_id, payload, context) + if action == "deleteTree": + if not object_id: + raise ValidationError("CMIS object id is required", details={"operation": "deleteTree"}) + return response(runtime.cmis_delete_tree, access_point_id, object_id, payload, context) + if action == "updateProperties": + if not object_id: + raise ValidationError("CMIS object id is required", details={"operation": "updateProperties"}) + response(runtime.cmis_update_properties, access_point_id, object_id, payload, context) + return response(runtime.cmis_browser_object, access_point_id, object_id, context) + if action == "setContentStream": + if not object_id: + raise ValidationError("CMIS object id is required", details={"operation": "setContentStream"}) + response(runtime.cmis_set_content_stream, access_point_id, object_id, payload, context) + return response(runtime.cmis_browser_object, access_point_id, object_id, context) + raise ValidationError( + "Unsupported CMIS Browser Binding action", + details={ + "cmisaction": action, + "supported": [ + "createFolder", + "createDocument", + "delete", + "deleteObject", + "deleteTree", + "updateProperties", + "setContentStream", + ], + }, + ) + @app.get("/cmis/{access_point_id}/browser", tags=["cmis"]) def cmis_browser_entry( access_point_id: str, @@ -2419,16 +2891,33 @@ def create_app(runtime: ServiceRuntime | None = None): ) return unsupported_browser_selector(cmisselector) + @app.post("/cmis/{access_point_id}/browser", tags=["cmis"]) + async def cmis_browser_entry_action( + access_point_id: str, + request: Request, + objectId: str | None = Query(None), + context: OperationContext = Depends(context_from_headers), + ) -> Any: + return await cmis_browser_post_action( + access_point_id, + request, + default_object_id=objectId, + context=context, + ) + @app.get("/cmis/{access_point_id}/browser/root", tags=["cmis"]) def cmis_browser_root( access_point_id: str, cmisselector: str | None = Query(None), objectId: str | None = Query(None), + path: str | None = Query(None), skipCount: int = Query(0), maxItems: int = Query(100), context: OperationContext = Depends(context_from_headers), ) -> Any: if cmisselector in (None, "", "object"): + if path and not objectId: + return response(runtime.cmis_browser_object_by_path, access_point_id, path, context) return response(runtime.cmis_browser_object, access_point_id, objectId, context) if cmisselector == "children": return response( @@ -2439,13 +2928,21 @@ def create_app(runtime: ServiceRuntime | None = None): skip_count=skipCount, max_items=maxItems, ) + if cmisselector == "parent": + if not objectId: + return unsupported_browser_selector(cmisselector) + return response(runtime.cmis_browser_parent, access_point_id, objectId, context) if cmisselector == "parents": if not objectId: return [] return response(runtime.cmis_browser_parents, access_point_id, objectId, context) if cmisselector == "properties": + if path and not objectId: + return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["properties"] return response(runtime.cmis_browser_object, access_point_id, objectId, context)["properties"] if cmisselector == "allowableActions": + if path and not objectId: + return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["allowableActions"] return response(runtime.cmis_browser_object, access_point_id, objectId, context)["allowableActions"] if cmisselector == "policies": return [] @@ -2466,6 +2963,20 @@ def create_app(runtime: ServiceRuntime | None = None): ) return unsupported_browser_selector(cmisselector) + @app.post("/cmis/{access_point_id}/browser/root", tags=["cmis"]) + async def cmis_browser_root_action( + access_point_id: str, + request: Request, + objectId: str | None = Query(None), + context: OperationContext = Depends(context_from_headers), + ) -> Any: + return await cmis_browser_post_action( + access_point_id, + request, + default_object_id=objectId or "cmis-root", + context=context, + ) + @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) @@ -2546,6 +3057,14 @@ def create_app(runtime: ServiceRuntime | None = None): ) -> dict[str, Any]: return response(runtime.cmis_create_document, access_point_id, payload, context) + @app.post("/cmis/{access_point_id}/browser/folder", tags=["cmis"]) + def cmis_create_folder( + access_point_id: str, + payload: dict[str, Any], + context: OperationContext = Depends(context_from_headers), + ) -> dict[str, Any]: + return response(runtime.cmis_create_folder, access_point_id, payload, context) + @app.post("/cmis/{access_point_id}/browser/object/{object_id:path}/properties", tags=["cmis"]) def cmis_update_properties( access_point_id: str, @@ -3073,6 +3592,81 @@ def _path_parent(path: str) -> str: return "/" + "/".join(parts[:-1]) +def _path_name(path: str) -> str: + normalized = _normalize_cmis_path(path) + if normalized == "/": + return "root" + return normalized.rstrip("/").rsplit("/", 1)[-1] + + +def _path_contains(parent_path: str, candidate_path: str) -> bool: + parent = _normalize_cmis_path(parent_path) + candidate = _normalize_cmis_path(candidate_path) + if parent == "/": + return candidate != "/" + return candidate == parent or candidate.startswith(parent.rstrip("/") + "/") + + +def _parse_multipart_form(content_type: str, body: bytes) -> tuple[dict[str, list[Any]], dict[str, dict[str, Any]]]: + message = BytesParser(policy=policy.default).parsebytes( + b"Content-Type: " + content_type.encode("utf-8") + b"\r\nMIME-Version: 1.0\r\n\r\n" + body + ) + values: dict[str, list[Any]] = {} + files: dict[str, dict[str, Any]] = {} + if not message.is_multipart(): + return values, files + for part in message.iter_parts(): + disposition = part.get("content-disposition", "") + if "form-data" not in disposition: + continue + field_name = part.get_param("name", header="content-disposition") + if not field_name: + continue + content = part.get_payload(decode=True) or b"" + filename = part.get_filename() + if filename is not None: + files[field_name] = { + "filename": filename, + "content": content, + "content_type": part.get_content_type(), + } + continue + charset = part.get_content_charset() or "utf-8" + values.setdefault(field_name, []).append(content.decode(charset, errors="replace")) + return values, files + + +def _flatten_form_values(values: dict[str, list[Any]]) -> dict[str, Any]: + flattened: dict[str, Any] = {} + for key, items in values.items(): + if len(items) == 1: + flattened[key] = items[0] + else: + flattened[key] = items + return flattened + + +def _cmis_browser_properties(payload: dict[str, Any]) -> dict[str, Any]: + properties = dict(payload.get("properties", {})) + property_ids: dict[str, str] = {} + property_values: dict[str, Any] = {} + for key, value in payload.items(): + if key.startswith("propertyId["): + index = key[len("propertyId[") :].split("]", 1)[0] + property_ids[index] = str(value) + elif key.startswith("propertyValue["): + index = key[len("propertyValue[") :].split("]", 1)[0] + property_values[index] = value + for index, property_id in property_ids.items(): + if property_id: + properties[property_id] = property_values.get(index) + if "cmis:name" not in properties and payload.get("name"): + properties["cmis:name"] = payload["name"] + if "cmis:objectTypeId" not in properties and payload.get("type_id"): + properties["cmis:objectTypeId"] = payload["type_id"] + return properties + + 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 b301cee..cc4dd97 100644 --- a/src/kontextual_engine/core/cmis.py +++ b/src/kontextual_engine/core/cmis.py @@ -55,9 +55,11 @@ class CMISAction(str, Enum): QUERY = "query" GET_RELATIONSHIPS = "get_relationships" GET_CHANGE_LOG = "get_change_log" + CREATE_FOLDER = "create_folder" CREATE_DOCUMENT = "create_document" UPDATE_PROPERTIES = "update_properties" DELETE_OBJECT = "delete_object" + DELETE_TREE = "delete_tree" SET_CONTENT_STREAM = "set_content_stream" APPLY_ACL = "apply_acl" APPLY_POLICY = "apply_policy" @@ -84,9 +86,11 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = { CMISAction.QUERY: CMISCapability.DISCOVERY_QUERY, CMISAction.GET_RELATIONSHIPS: CMISCapability.RELATIONSHIPS, CMISAction.GET_CHANGE_LOG: CMISCapability.CHANGE_LOG, + CMISAction.CREATE_FOLDER: CMISCapability.OBJECT_WRITE, CMISAction.CREATE_DOCUMENT: CMISCapability.OBJECT_WRITE, CMISAction.UPDATE_PROPERTIES: CMISCapability.OBJECT_WRITE, CMISAction.DELETE_OBJECT: CMISCapability.OBJECT_WRITE, + CMISAction.DELETE_TREE: CMISCapability.OBJECT_WRITE, CMISAction.SET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_WRITE, CMISAction.APPLY_ACL: CMISCapability.ACL, CMISAction.APPLY_POLICY: CMISCapability.POLICY, @@ -104,16 +108,20 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset( CMISAction.QUERY, CMISAction.GET_RELATIONSHIPS, CMISAction.GET_CHANGE_LOG, + CMISAction.CREATE_FOLDER, CMISAction.CREATE_DOCUMENT, CMISAction.UPDATE_PROPERTIES, CMISAction.DELETE_OBJECT, + CMISAction.DELETE_TREE, CMISAction.SET_CONTENT_STREAM, } ) MUTATION_ACTIONS = { CMISAction.CREATE_DOCUMENT, + CMISAction.CREATE_FOLDER, CMISAction.UPDATE_PROPERTIES, CMISAction.DELETE_OBJECT, + CMISAction.DELETE_TREE, CMISAction.SET_CONTENT_STREAM, CMISAction.APPLY_ACL, CMISAction.APPLY_POLICY, @@ -670,7 +678,7 @@ class CMISDomainMapper: can_write = self.access_point.profile.allow_mutations return [ _type_definition(CMISBaseType.DOCUMENT, "kontextual:document", "Kontextual Document", can_write), - _type_definition(CMISBaseType.FOLDER, "kontextual:folder", "Kontextual Folder", False), + _type_definition(CMISBaseType.FOLDER, "kontextual:folder", "Kontextual Folder", can_write), _type_definition( CMISBaseType.RELATIONSHIP, "kontextual:relationship", @@ -874,6 +882,9 @@ class CMISDomainMapper: "cmis:creationDate": asset.created_at, "cmis:lastModificationDate": asset.updated_at, "cmis:changeToken": asset.current_version_id, + "cmis:description": asset.metadata.get("description", asset.title), + "cmis:secondaryObjectTypeIds": list(asset.metadata.get("cmis_secondary_object_type_ids", ())), + "cmis:path": self.asset_path(asset), "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, @@ -890,7 +901,14 @@ class CMISDomainMapper: key = getattr(record, "key", None) if key: properties[f"kontextual:metadata:{key}"] = getattr(record, "value", None) - return compact_dict(properties) + compacted = compact_dict(properties) + compacted.setdefault("cmis:createdBy", "system") + compacted.setdefault("cmis:lastModifiedBy", "system") + compacted.setdefault("cmis:secondaryObjectTypeIds", []) + compacted.setdefault("kontextual:owner", "") + compacted.setdefault("kontextual:topics", []) + compacted.setdefault("kontextual:reviewState", "") + return compacted def map_content_stream( self, @@ -925,6 +943,12 @@ class CMISDomainMapper: "cmis:isLatestMajorVersion": True, "cmis:versionSeriesId": f"cmis:version-series:{asset.id}", "cmis:versionLabel": "1", + "cmis:isVersionSeriesCheckedOut": False, + "cmis:isPrivateWorkingCopy": False, + "cmis:versionSeriesCheckedOutBy": None, + "cmis:versionSeriesCheckedOutId": None, + "cmis:checkinComment": "", + "cmis:isImmutable": False, } latest_sequence = max((version.sequence for version in versions), default=current_version.sequence) return { @@ -933,6 +957,12 @@ class CMISDomainMapper: "cmis:isLatestMajorVersion": current_version.sequence == latest_sequence, "cmis:versionSeriesId": f"cmis:version-series:{asset.id}", "cmis:versionLabel": str(current_version.sequence), + "cmis:isVersionSeriesCheckedOut": False, + "cmis:isPrivateWorkingCopy": False, + "cmis:versionSeriesCheckedOutBy": None, + "cmis:versionSeriesCheckedOutId": None, + "cmis:checkinComment": "", + "cmis:isImmutable": False, "kontextual:versionId": current_version.version_id, "kontextual:versionChangeType": current_version.change_type.value, } @@ -1187,7 +1217,10 @@ def cmis_browser_object(projection: dict[str, Any]) -> dict[str, Any]: for key, value in properties.items() }, "succinctProperties": properties, - "allowableActions": cmis_browser_allowable_actions(projection.get("allowable_actions", [])), + "allowableActions": cmis_browser_allowable_actions( + projection.get("allowable_actions", []), + base_type_id=properties.get("cmis:baseTypeId"), + ), } acl = projection.get("acl") if isinstance(acl, dict) and acl: @@ -1292,8 +1325,13 @@ def cmis_browser_property_value(property_id: str, value: Any) -> dict[str, Any]: } -def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict[str, bool]: +def cmis_browser_allowable_actions( + actions: list[str] | tuple[str, ...], + *, + base_type_id: str | None = None, +) -> dict[str, bool]: native = set(actions) + is_folder = base_type_id == CMISBaseType.FOLDER.value return { "canGetObjectParents": CMISAction.GET_OBJECT_PARENTS.value in native, "canGetProperties": CMISAction.GET_OBJECT.value in native, @@ -1305,13 +1343,13 @@ def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict "canSetContentStream": CMISAction.SET_CONTENT_STREAM.value in native, "canGetChildren": CMISAction.GET_CHILDREN.value in native, "canCreateDocument": CMISAction.CREATE_DOCUMENT.value in native, - "canCreateFolder": False, + "canCreateFolder": CMISAction.CREATE_FOLDER.value in native, "canCreateRelationship": False, "canCreateItem": False, - "canDeleteTree": False, + "canDeleteTree": CMISAction.DELETE_TREE.value in native, "canGetDescendants": False, "canGetFolderTree": False, - "canGetFolderParent": False, + "canGetFolderParent": is_folder and CMISAction.GET_OBJECT_PARENTS.value in native, "canGetRenditions": False, "canMoveObject": False, "canAddObjectToFolder": False, @@ -1329,6 +1367,9 @@ def cmis_browser_allowable_actions(actions: list[str] | tuple[str, ...]) -> dict def cmis_browser_root_folder(access_point: CMISAccessPoint) -> dict[str, Any]: + allowable_actions = [CMISAction.GET_OBJECT.value, CMISAction.GET_CHILDREN.value] + if access_point.profile.allow_mutations: + allowable_actions.extend([CMISAction.CREATE_DOCUMENT.value, CMISAction.CREATE_FOLDER.value]) return { "object_id": access_point.root_folder_id, "base_type_id": CMISBaseType.FOLDER.value, @@ -1352,8 +1393,10 @@ def cmis_browser_root_folder(access_point: CMISAccessPoint) -> dict[str, Any]: "cmis:allowedChildObjectTypeIds": [CMISBaseType.DOCUMENT.value, CMISBaseType.FOLDER.value], "kontextual:sensitivity": "internal", "kontextual:lifecycle": LifecycleState.ACTIVE.value, + "kontextual:filingSource": "root", + "kontextual:workspaceFolder": False, }, - "allowable_actions": [CMISAction.GET_OBJECT.value, CMISAction.GET_CHILDREN.value], + "allowable_actions": allowable_actions, } @@ -1545,6 +1588,7 @@ def _browser_propdef( def _browser_object_properties(projection: dict[str, Any]) -> dict[str, Any]: properties = dict(projection.get("properties", {})) + properties.update(projection.get("version", {})) base_type = projection.get("base_type_id") or properties.get("cmis:baseTypeId") properties["cmis:objectId"] = projection.get("object_id", properties.get("cmis:objectId")) properties["cmis:name"] = projection.get("name", properties.get("cmis:name")) @@ -1612,8 +1656,8 @@ def _type_definition( "queryable": is_document, "controllable_acl": is_document, "controllable_policy": False, - "creatable": can_write and is_document, - "fileable": is_document, + "creatable": can_write and base_type_id in {CMISBaseType.DOCUMENT, CMISBaseType.FOLDER}, + "fileable": base_type_id in {CMISBaseType.DOCUMENT, CMISBaseType.FOLDER}, "fulltext_indexed": False, "included_in_supertype_query": is_document, "versionable": False, @@ -1627,6 +1671,14 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any "cmis:name": {"property_type": "string", "cardinality": "single", "required": True}, "cmis:baseTypeId": {"property_type": "id", "cardinality": "single", "required": True}, "cmis:objectTypeId": {"property_type": "id", "cardinality": "single", "required": True}, + "cmis:createdBy": {"property_type": "string", "cardinality": "single", "required": False}, + "cmis:lastModifiedBy": {"property_type": "string", "cardinality": "single", "required": False}, + "cmis:creationDate": {"property_type": "datetime", "cardinality": "single", "required": False}, + "cmis:lastModificationDate": {"property_type": "datetime", "cardinality": "single", "required": False}, + "cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False}, + "cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False}, + "cmis:path": {"property_type": "string", "cardinality": "single", "required": False}, + "cmis:description": {"property_type": "string", "cardinality": "single", "required": False}, "kontextual:sensitivity": {"property_type": "string", "cardinality": "single", "required": False}, "kontextual:lifecycle": {"property_type": "string", "cardinality": "single", "required": False}, } @@ -1653,8 +1705,109 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any "cardinality": "single", "required": False, }, + "kontextual:assetId": { + "property_type": "id", + "cardinality": "single", + "required": False, + }, + "kontextual:assetType": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, + "kontextual:owner": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, + "kontextual:topics": { + "property_type": "string", + "cardinality": "multi", + "required": False, + }, + "kontextual:reviewState": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, + "cmis:isImmutable": { + "property_type": "boolean", + "cardinality": "single", + "required": False, + }, + "cmis:isLatestVersion": { + "property_type": "boolean", + "cardinality": "single", + "required": False, + }, + "cmis:isMajorVersion": { + "property_type": "boolean", + "cardinality": "single", + "required": False, + }, + "cmis:isLatestMajorVersion": { + "property_type": "boolean", + "cardinality": "single", + "required": False, + }, + "cmis:versionLabel": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, + "cmis:versionSeriesId": { + "property_type": "id", + "cardinality": "single", + "required": False, + }, + "cmis:isVersionSeriesCheckedOut": { + "property_type": "boolean", + "cardinality": "single", + "required": False, + }, + "cmis:isPrivateWorkingCopy": { + "property_type": "boolean", + "cardinality": "single", + "required": False, + }, + "cmis:versionSeriesCheckedOutBy": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, + "cmis:versionSeriesCheckedOutId": { + "property_type": "id", + "cardinality": "single", + "required": False, + }, + "cmis:checkinComment": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, + "kontextual:versionId": { + "property_type": "id", + "cardinality": "single", + "required": False, + }, + "kontextual:versionChangeType": { + "property_type": "string", + "cardinality": "single", + "required": False, + }, } ) + if base_type_id == CMISBaseType.FOLDER: + definitions["kontextual:filingSource"] = { + "property_type": "string", + "cardinality": "single", + "required": False, + } + definitions["kontextual:workspaceFolder"] = { + "property_type": "boolean", + "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} diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py index 62dd3e8..0f8c7aa 100644 --- a/tests/cmis/test_cmis_browser_binding_api.py +++ b/tests/cmis/test_cmis_browser_binding_api.py @@ -90,6 +90,7 @@ def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> N 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 + assert "/cmis/{access_point_id}/browser/folder" in paths def test_cmis_repository_info_and_type_definitions(cmis_client) -> None: @@ -117,6 +118,10 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None: "/cmis/readonly-browser/browser/root", params={"cmisselector": "policies"}, ).json() + root_object = cmis_client.get( + "/cmis/readonly-browser/browser/root", + params={"cmisselector": "object"}, + ).json() assert access_points["count"] == 4 assert repository["repositoryId"] == "kontextual-readonly-browser" @@ -133,6 +138,11 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None: assert "propertyDefinitions" in browser_type_definition assert browser_type_descendants[0]["children"] == [] assert root_policies == [] + assert root_object["properties"]["kontextual:filingSource"]["value"] == "root" + assert root_object["properties"]["kontextual:workspaceFolder"]["value"] is False + assert "kontextual:assetId" in browser_type_definition["propertyDefinitions"] + assert browser_type_definition["propertyDefinitions"]["kontextual:topics"]["cardinality"] == "multi" + assert "cmis:path" in browser_type_definition["propertyDefinitions"] assert {item["base_type_id"] for item in types["items"]} >= { "cmis:document", "cmis:folder", @@ -244,13 +254,115 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) -> assert deleted.json()["lifecycle"] == "delete_requested" +def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None: + created = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "createFolder", + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:folder", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "Action Workspace", + }, + ) + folder = created.json() + folder_id = folder["properties"]["cmis:objectId"]["value"] + root_children = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "children"}, + ).json() + fetched = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "object", "objectId": folder_id}, + ).json() + parent = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "parent", "objectId": folder_id}, + ).json() + document = cmis_client.post( + "/cmis/compat-tck/browser/root", + params={"objectId": folder_id}, + data={ + "cmisaction": "createDocument", + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:document", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "Multipart Document", + }, + files={"content": ("multipart.txt", b"Multipart content", "text/plain")}, + ) + document_path = document.json()["properties"]["cmis:path"]["value"] + fetched_document_by_path = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "object", "path": document_path}, + ).json() + document_parents = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "parents", "objectId": document.json()["properties"]["cmis:objectId"]["value"]}, + ).json() + fetched_folder_by_path = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "object", "path": "/Action Workspace"}, + ).json() + deleted_tree = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={"cmisaction": "deleteTree", "objectId": folder_id}, + ) + root_children_after_delete = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "children"}, + ).json() + fetched_after_delete = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "object", "objectId": folder_id}, + ) + deleted = cmis_client.post( + "/cmis/compat-tck/browser", + data={"cmisaction": "delete", "objectId": folder_id}, + ) + + assert created.status_code == 200 + assert folder["properties"]["cmis:name"]["value"] == "Action Workspace" + assert folder["properties"]["kontextual:workspaceFolder"]["value"] is True + assert folder["allowableActions"]["canDeleteTree"] is True + assert any(item["object"]["properties"]["cmis:objectId"]["value"] == folder_id for item in root_children["objects"]) + assert fetched["properties"]["cmis:path"]["value"] == "/Action Workspace" + assert parent["properties"]["cmis:objectId"]["value"] == "cmis-root" + assert document.status_code == 200 + assert document.json()["properties"]["cmis:name"]["value"] == "Multipart Document" + assert document.json()["properties"]["cmis:contentStreamLength"]["value"] == 17 + assert document.json()["properties"]["cmis:isLatestVersion"]["value"] is True + assert document.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == [] + assert document.json()["allowableActions"]["canGetFolderParent"] is False + assert document_path == "/Action Workspace/Multipart Document" + assert fetched_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document" + assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Action Workspace" + assert fetched_folder_by_path["properties"]["cmis:objectId"]["value"] == folder_id + assert deleted_tree.json()["failedToDelete"] == [] + assert all( + item["object"]["properties"]["cmis:objectId"]["value"] != folder_id + for item in root_children_after_delete["objects"] + ) + assert fetched_after_delete.status_code == 404 + assert deleted.status_code == 404 + + def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None: response = cmis_client.post( "/cmis/readonly-browser/browser/document", json={"asset_id": "asset-api-readonly-denied", "name": "Denied"}, ) + folder_response = cmis_client.post( + "/cmis/readonly-browser/browser/root", + data={ + "cmisaction": "createFolder", + "propertyId[0]": "cmis:name", + "propertyValue[0]": "Denied Folder", + }, + ) assert response.status_code == 403 + assert folder_response.status_code == 403 def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis_client) -> None: diff --git a/tests/cmis/test_cmis_runtime_browser_binding.py b/tests/cmis/test_cmis_runtime_browser_binding.py index c5ffcfa..6991d27 100644 --- a/tests/cmis/test_cmis_runtime_browser_binding.py +++ b/tests/cmis/test_cmis_runtime_browser_binding.py @@ -190,6 +190,58 @@ def test_runtime_cmis_governed_authoring_allows_selected_mutations(cmis_runtime) assert "CMIS object not found" in str(exc_info.value) +def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_runtime) -> None: + runtime, context = cmis_runtime + + folder = runtime.cmis_create_folder( + "compat-tck", + {"name": "TCK Workspace", "properties": {"cmis:objectTypeId": "cmis:folder"}}, + context, + ) + folder_object_id = folder["object_id"] + root_children = runtime.cmis_children("compat-tck", context) + fetched = runtime.cmis_object("compat-tck", folder_object_id, context) + parents = runtime.cmis_object_parents("compat-tck", folder_object_id, context) + document = runtime.cmis_create_document( + "compat-tck", + { + "name": "Workspace Document", + "folder_id": folder_object_id, + "content": "Workspace content", + "media_type": "text/plain", + }, + context, + ) + folder_children = runtime.cmis_children("compat-tck", context, folder_id=folder_object_id) + document_by_path = runtime.cmis_object_by_path("compat-tck", "/TCK Workspace/Workspace Document", context) + document_parents = runtime.cmis_object_parents("compat-tck", document["object_id"], context) + + assert folder["path"] == "/TCK Workspace" + assert folder["properties"]["kontextual:workspaceFolder"] is True + assert folder_object_id in {item["object_id"] for item in root_children["objects"]} + assert fetched["properties"]["cmis:path"] == "/TCK Workspace" + assert parents["parents"][0]["object_id"] == "cmis-root" + assert document["path"] == "/TCK Workspace/Workspace Document" + assert document["properties"]["cmis:path"] == "/TCK Workspace/Workspace Document" + assert document_by_path["object_id"] == document["object_id"] + assert document_parents["count"] == 1 + assert document_parents["parents"][0]["properties"]["cmis:path"] == "/TCK Workspace" + assert document["object_id"] in {item["object_id"] for item in folder_children["objects"]} + + with pytest.raises(Exception) as exc_info: + runtime.cmis_delete_object("compat-tck", folder_object_id, {}, context) + assert "CMIS folder is not empty" in str(exc_info.value) + + deleted_tree = runtime.cmis_delete_tree("compat-tck", folder_object_id, {}, context) + root_children_after_delete = runtime.cmis_children("compat-tck", context) + + assert deleted_tree["failedToDelete"] == [] + assert folder_object_id not in {item["object_id"] for item in root_children_after_delete["objects"]} + with pytest.raises(Exception) as exc_info: + runtime.cmis_object("compat-tck", folder_object_id, context) + assert "CMIS folder not found" in str(exc_info.value) + + def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime) -> None: runtime, context = cmis_runtime @@ -216,6 +268,15 @@ def test_runtime_cmis_readonly_profile_rejects_mutations(cmis_runtime) -> None: assert "CMIS operation denied" in str(exc_info.value) + with pytest.raises(Exception) as exc_info: + runtime.cmis_create_folder( + "readonly-browser", + {"name": "Denied Folder"}, + context, + ) + + assert "CMIS operation denied" in str(exc_info.value) + def test_runtime_cmis_acl_projection_and_redaction(cmis_runtime) -> None: runtime, context = cmis_runtime diff --git a/workplans/KONT-WP-0014-cmis-object-content-maturity.md b/workplans/KONT-WP-0014-cmis-object-content-maturity.md index 8972f13..964500d 100644 --- a/workplans/KONT-WP-0014-cmis-object-content-maturity.md +++ b/workplans/KONT-WP-0014-cmis-object-content-maturity.md @@ -4,7 +4,7 @@ type: workplan title: "CMIS Object/Content Maturity Expansion" domain: markitect repo: kontextual-engine -status: planned +status: active owner: codex topic_slug: markitect planning_priority: high @@ -90,11 +90,48 @@ Disallowed architectural moves: - The maturity scorecard is updated from fresh TCK evidence, with remaining unsupported features explicitly classified. +## Implementation Evidence - 2026-05-08 + +Evidence file: + +- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T134432Z.md` + +Implemented in this pass: + +- Profile-scoped CMIS workspace folder registry. +- Browser Binding `createFolder`, multipart/form `createDocument`, + `deleteTree`, `parent`, and `getObjectByPath` support. +- Workspace folder deletion that removes adapter-managed folders rather than + falling back to phantom virtual folder projections. +- Full parent folder projections for `getObjectParents`, including `cmis:path` + for OpenCMIS `getPaths()`. +- Document/folder type metadata alignment for emitted CMIS and Kontextual + properties. +- CMIS document version/read-side property projection while keeping versioning + operations unsupported. +- Single-parent projection for CMIS-authored documents when repository + multifiling remains advertised as unsupported. + +Latest verification: + +- Internal: `.venv/bin/python -m pytest tests/cmis --perf-history-disable` + -> `47 passed`. +- OpenCMIS: `run-20260508T134448Z` in + `/tmp/open-cmis-tck-kontextual-wp14-20260508T134432Z`. + +Current external frontier: + +- OpenCMIS now reaches action/operation-context maturity gaps: + `cmisaction=update`, `cmisaction=move`, filter trimming, async + `getObjectByPath`, and MIME normalization. + These are follow-up maturity items rather than the original folder-creatable + blocker. + ## D14.1 - Define CMIS maturity boundary and TCK profile semantics ```task id: KONT-WP-0014-T001 -status: todo +status: done priority: high state_hub_task_id: "333f8ea0-0582-467d-a52d-7ef5cf6f34c0" ``` @@ -112,7 +149,7 @@ Acceptance: ```task id: KONT-WP-0014-T002 -status: todo +status: done priority: high state_hub_task_id: "30d02544-0325-490e-84d7-ebaa3825ee78" ``` @@ -131,7 +168,7 @@ Acceptance: ```task id: KONT-WP-0014-T003 -status: todo +status: done priority: high state_hub_task_id: "b5324bcb-67fe-4f28-9591-83e6361bfd01" ``` @@ -150,7 +187,7 @@ Acceptance: ```task id: KONT-WP-0014-T004 -status: todo +status: in_progress priority: high state_hub_task_id: "f9323c25-4d81-42cd-b7e6-e40d7e0487cd" ``` @@ -190,7 +227,7 @@ Acceptance: ```task id: KONT-WP-0014-T006 -status: todo +status: in_progress priority: medium state_hub_task_id: "b1562023-807b-4fed-b794-6930fcc2274e" ``` @@ -228,7 +265,7 @@ Acceptance: ```task id: KONT-WP-0014-T008 -status: todo +status: done priority: high state_hub_task_id: "c9514345-793c-489e-9dcc-86776db47cf4" ```