Scoped CMIS workspace folders with create, list, parent, path lookup, delete, and delete-tree behavior

This commit is contained in:
2026-05-08 16:47:30 +02:00
parent efb6152487
commit 06e3654aaa
6 changed files with 1103 additions and 23 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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}

View File

@@ -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:

View File

@@ -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

View File

@@ -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"
```