CMIS layer into an honest CMIS 1.1

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

View File

@@ -1,8 +1,9 @@
# CMIS Compliance Assessment
Date: 2026-05-06
Date: 2026-05-07
Status: planning baseline for CMIS compliance and access-point implementation.
Status: Browser Binding subset implemented with conservative CMIS capability
flags and explicit unsupported diagnostics.
## Reference Standard
@@ -42,35 +43,36 @@ Practical strategy:
| CMIS capability | Current engine availability | Gap | Demand |
| --- | --- | --- | --- |
| Repository service | Service health/version, runtime repository state, capability catalogs. | Need CMIS repository info, repository IDs, root folder IDs, capability flags, type summaries. | Low |
| Type definitions | Asset classifications, metadata schemas, relationship target kinds. | Need CMIS base types, property definitions, type mutability flags, secondary type projection. | Medium |
| Navigation service | Relationships and context graph exist, but no folder tree model. | Need root folder, folder children, descendants/tree, parent relationships, path semantics. | High |
| Object service read | Assets, metadata, representations, content refs, audit, versions exist. | Need CMIS object envelopes, allowable actions, path/object-id lookup, property filters, rendition/content stream response shape. | Medium |
| Object service write | Asset create, metadata add, lifecycle transition, relationship create, ingestion. | Need createDocument/createFolder/updateProperties/deleteObject/moveObject mapping and CMIS change tokens. | High |
| Content streams | Source, normalized, derived representations store content hashes and storage refs. | Need getContentStream/setContentStream/deleteContentStream/appendContentStream semantics and streaming endpoints. | Medium-High |
| Versioning | Asset versions and transformation/workflow lineage exist. | Need CMIS checkout, PWC, checkin, cancelCheckout, version series semantics, latest/major flags. | High |
| Discovery/query | Governed retrieval, lexical search, filters, relationships. | Need CMIS SQL-like query grammar or supported subset, query result shape, joins/capability flags. | High |
| Relationships | Core relationships exist. | Need CMIS relationship object mapping and relationship type capability exposure. | Medium |
| ACL service | Policy gateway and authorization decisions exist. | Need CMIS ACL model, principals, direct/inherited ACEs, applyACL, exact capability flags. | High |
| Policy service | Policy decisions and governance reports exist. | Need CMIS policy objects/applyPolicy/removePolicy/getAppliedPolicies mapping or explicit unsupported profile. | Medium |
| Change log | Audit events and correlation IDs exist. | Need CMIS change events, change tokens, object change entries, paging. | Medium |
| Multi-filing/unfiling | Not modeled directly. | Need folder membership model or profile-level unsupported flags. | High if full support, Low if unsupported |
| Renditions | Representations exist, no rendition taxonomy. | Need rendition metadata and stream mapping for thumbnails/previews. | Medium |
| Retention and hold | Metadata/governance hooks exist, no first-class legal hold model. | Need retention/hold capabilities, apply/remove hold, retention date semantics. | High for full support |
| Bulk update | Metadata update pathways exist. | Need bulkUpdateProperties semantics, partial failure reporting, change tokens. | Medium |
| Repository service | Implemented. | Repository info includes CMIS 1.1 identity, complete conservative optional capability flags, repository features, and unsupported feature diagnostics. | Low |
| Type definitions | Implemented subset. | Base type projections exist; type mutability, CMIS versioning, folder ACL control, and non-document querying are explicitly not advertised. | Low |
| Navigation service | Implemented subset. | `getChildren` and projected parents are supported. `getDescendants`, `getFolderTree`, mutating multifiling, and unfiling are explicitly flagged unsupported. | Low unless full folder tree is required |
| Object service read | Implemented subset. | Object envelopes, allowable actions, content stream descriptors, content stream properties, visibility redaction, and relationship IDs are covered. | Low |
| Object service write | Governed subset. | `createDocument`, custom metadata updates, `setContentStream`, and delete-request lifecycle transition are supported by authoring profiles. Unsupported standard property updates now fail with diagnostics. | Medium |
| Content streams | Implemented subset. | Descriptor and byte-stream routes exist; `setContentStream` writes through deduplicating blob storage. Append/delete content stream are unsupported. | Low |
| Versioning | Projection only. | Latest-version properties can be projected from engine versions, but CMIS checkout/PWC/all-versions services are not advertised. | Low if unsupported remains acceptable |
| Discovery/query | Implemented narrow subset. | `SELECT * FROM cmis:document` and `SELECT * FROM kontextual:document` are supported. Joins, order-by, full CMIS SQL predicates, and full-text are flagged unsupported. | Medium |
| Relationships | Implemented subset. | Relationship object projections and source filters are covered and profile-gated. | Low |
| ACL service | Discover only. | ACL projection is supported; `applyACL` is not authorized even for authoring profiles and returns an unimplemented diagnostic. | Low |
| Policy service | Unsupported. | `applyPolicy`/`removePolicy` are explicitly unsupported; engine policy remains native, not CMIS policy objects. | Low |
| Change log | Implemented subset. | Audit-backed object-id change entries and paging are supported; full property-level change details are not advertised. | Low |
| Multi-filing/unfiling | Projection only. | Multiple virtual parents are exposed as a Kontextual repository feature, while CMIS `capabilityMultifiling` and unfiling stay false. | Low |
| Renditions | Unsupported. | Capability is `none`; derived representations are not exposed as CMIS rendition streams. | Low |
| Retention and hold | Unsupported. | Not advertised; left as native governance metadata until a real integration requires CMIS legal-hold semantics. | Low |
| Bulk update | Unsupported. | `bulkUpdateProperties` is explicitly unsupported. | Low |
| Browser JSON binding | FastAPI JSON service already exists. | Need CMIS Browser Binding routes, selectors/actions, multipart/content stream behavior. | High |
| AtomPub binding | No AtomPub/XML binding. | Need XML/Atom feed generation and protocol semantics. | Very High |
| Web Services binding | No SOAP stack. | Need WSDL/SOAP implementation. | Very High |
## Recommended Compliance Profile Strategy
Start with a constrained CMIS 1.1 Browser Binding profile:
Maintain a constrained CMIS 1.1 Browser Binding profile:
- Repository, type, object read, content stream read, query subset,
relationships, change log, and navigation over a synthetic root/folder
projection.
- Explicitly unsupported or read-only: AtomPub, Web Services, full ACL mutation,
retention/hold, multifiling/unfiling, and full CMIS SQL joins.
- Explicitly unsupported or read-only: AtomPub, Web Services, descendants/tree,
full ACL mutation, retention/hold, mutating multifiling/unfiling, PWC/versioning
services, renditions, bulk updates, order-by, and full CMIS SQL joins.
Then expand by profile:
@@ -83,11 +85,12 @@ Then expand by profile:
## Risk Summary
The engine already has strong foundations for asset identity, metadata,
representations, relationships, versions, audit, policy, retrieval, and
service APIs. The hard parts are not storage; they are CMIS protocol semantics:
folder/path behavior, versioning/PWC semantics, CMIS query grammar, ACL shape,
content stream actions, and binding-specific compatibility.
The engine has a sound Browser Binding subset so long as clients trust the
advertised capabilities instead of assuming broad ECM behavior. The remaining
hard parts are optional CMIS semantics that we intentionally do not advertise:
folder tree/descendant services, mutating filing services, PWC/versioning
services, broad query grammar, ACL mutation, renditions, retention/hold, and
legacy bindings.
Best estimate:

View File

@@ -2,7 +2,7 @@
Date: 2026-05-06
Status: initial test foundation established for CMIS access-point work.
Status: active fixture foundation with conservative capability-flag tests.
## Purpose
@@ -57,7 +57,9 @@ Validates:
- root folder children,
- folder path lookup,
- getChildren/getDescendants/getFolderTree,
- getChildren,
- projection-only parent folders,
- explicit unsupported flags for getDescendants and getFolderTree,
- profile restrictions on folder visibility.
### Object And Content Stream Service
@@ -91,8 +93,8 @@ Validates:
- version series projection,
- latest version flags,
- checkout/checkin unsupported or supported per profile,
- version history listing.
- checkout/checkin/PWC unsupported diagnostics,
- all-versions search unsupported flags.
### Discovery Query Service
@@ -108,7 +110,7 @@ Validates:
- query capability flags,
- supported subset behavior,
- paging,
- error diagnostics for unsupported grammar.
- error diagnostics for unsupported grammar, joins, and ordering.
### Relationship Service
@@ -138,7 +140,7 @@ Validates:
- allowable actions,
- ACL projection,
- applyACL/applyPolicy supported or unsupported by profile,
- applyACL/applyPolicy unsupported diagnostics,
- no protected metadata leakage on denial.
### Change Log Events
@@ -171,7 +173,7 @@ Fixtures:
Validates:
- explicit capability flags,
- supported subset behavior,
- unsupported capability diagnostics,
- structured unsupported-operation diagnostics.
## Capability Profile Test Matrix

View File

@@ -2,7 +2,8 @@
Date: 2026-05-07
Status: Browser Binding MVP implemented with profiled access points.
Status: Browser Binding subset implemented with profiled access points,
conservative capability flags, and explicit unsupported diagnostics.
## Endpoint Setup
@@ -62,7 +63,9 @@ Actor context is passed through the existing service headers, especially:
| Browser Binding repository info | yes | yes | yes | yes |
| Type definitions | yes | yes | yes | yes |
| Synthetic navigation | yes | yes | yes | yes |
| Projection-only multifiling | yes | yes | yes | yes |
| Projection-only parent maps | yes | yes | yes | yes |
| CMIS `capabilityMultifiling` | no | no | no | no |
| Descendants/folder tree services | no | no | no | no |
| Object reads | yes | yes | yes | yes |
| Content stream descriptors | yes | yes | yes | yes |
| ACL projection | discover | discover | discover | discover |
@@ -70,7 +73,7 @@ Actor context is passed through the existing service headers, especially:
| Change log projection | yes | yes | yes | yes |
| Query subset | document select only | document select only | document select only | document select only |
| Create document | no | yes | no | yes |
| Update properties | no | yes | no | yes |
| Update properties | no | custom metadata only | no | custom metadata only |
| Set content stream | no | yes | no | yes |
| Delete object | no | delete-request lifecycle transition | no | delete-request lifecycle transition |
| Confidential/restricted visibility | hidden | hidden | service-account visible | hidden |
@@ -104,6 +107,7 @@ It is not yet suitable for clients that require:
- AtomPub,
- SOAP/Web Services,
- `getDescendants` or `getFolderTree`,
- full CMIS SQL,
- mutating multifiling/unfiling,
- private-working-copy semantics,
@@ -111,7 +115,7 @@ It is not yet suitable for clients that require:
- rendition streams,
- bulk update properties,
- apply/remove policy,
- strict byte-stream download semantics instead of content stream descriptors.
- standard CMIS property mutation beyond `kontextual:metadata:<key>`.
## Optional OpenCMIS TCK
@@ -134,13 +138,15 @@ capability groups before treating them as implementation bugs.
## Operational Notes
- Hidden objects should be treated as not found by CMIS clients.
- Multifiling is projection-only: assets may appear under multiple derived
folder paths without changing canonical asset identity.
- Multiple parent folders are projection-only: assets may appear under several
derived folder paths without changing canonical asset identity. The standard
CMIS multifiling capability is advertised as unsupported because no
add/remove filing mutation service is exposed.
- Relationship and change-log responses are filtered through the same visibility
gates as object reads.
- Mutations always pass through engine services and produce normal engine audit
events.
- Delete is currently a governed lifecycle transition to `delete_requested`,
not physical removal.
- Delete is a governed lifecycle transition to `delete_requested`; after the
transition the object is no longer exposed through CMIS reads.
- Compatibility should be discussed per profile and per client rather than as a
repo-wide binary property.

View File

@@ -2,7 +2,7 @@
Date: 2026-05-06
Status: Browser Binding MVP implemented.
Status: Browser Binding subset implemented and capability-hardened.
## Implemented Slice
@@ -61,6 +61,11 @@ model.
- relationship primitives as CMIS relationship objects,
- profile-derived allowable actions.
Repository info now uses conservative standard CMIS flags: optional services we
do not implement are advertised as `false` or `none`, while Kontextual-specific
projection behavior is exposed through repository feature metadata and an
unsupported-feature catalog.
The mapper returns `None` for assets or relationships that the access-point
profile must not expose. It does not fetch from repositories directly; callers
provide the asset, representations, versions, metadata records, and
@@ -105,6 +110,10 @@ These routes delegate to existing engine services:
Read-only profiles reject the same mutations with CMIS-shaped authorization
diagnostics before touching engine services.
The authoring slice intentionally supports only `kontextual:metadata:<key>`
property updates. Attempts to update standard `cmis:*` properties return
structured validation diagnostics instead of being silently ignored.
## ACL And Redaction Slice
The Browser Binding adapter now projects profile-derived ACLs through
@@ -122,7 +131,7 @@ asset IDs through relationship targets or audit-backed change entries.
## Projection-Only Multifiling
CMIS navigation now supports projection-only multifiling. The same asset can be
CMIS navigation now supports projection-only parent maps. The same asset can be
listed under several derived folder paths, including source system, topics,
owner, lifecycle, and asset type. These folders are navigation projections; they
do not duplicate assets and do not become canonical storage locations.
@@ -131,6 +140,10 @@ do not duplicate assets and do not become canonical storage locations.
parent folders for one asset. `GET /cmis/{access_point_id}/browser/children`
supports folder-scoped navigation through those projected paths.
The standard CMIS `capabilityMultifiling` flag remains `false` because the
engine does not expose mutating filing services such as `addObjectToFolder` or
`removeObjectFromFolder`.
## Fixture And Optional TCK Integration
CMIS fixtures now act as active compatibility contracts:

View File

@@ -103,8 +103,8 @@
"service": "navigation",
"examples": ["root-folder", "topic-folder", "source-system-folder", "unfiled-assets"],
"supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"],
"must_validate": ["get_children", "get_descendants", "get_folder_tree", "get_object_by_path"],
"unsupported": ["unfiling"]
"must_validate": ["get_children", "projection_parent_folders", "unsupported_descendants_flag", "unsupported_folder_tree_flag"],
"unsupported": ["get_descendants", "get_folder_tree", "multifiling", "unfiling"]
},
{
"id": "object-content",
@@ -119,8 +119,8 @@
"service": "versioning",
"examples": ["single-version-document", "multi-version-document", "read-only-checkout"],
"supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"],
"must_validate": ["version_series", "latest_version_flags", "version_history"],
"unsupported": ["private_working_copy"]
"must_validate": ["version_properties_projection", "latest_version_flags", "unsupported_versioning_flags"],
"unsupported": ["versioning_services", "private_working_copy", "all_versions_search"]
},
{
"id": "discovery-query",
@@ -128,7 +128,7 @@
"examples": ["lexical-query", "metadata-filter", "relationship-scoped-query", "unsupported-join"],
"supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"],
"must_validate": ["query_capabilities", "paging", "unsupported_grammar_diagnostics"],
"unsupported": ["full_cmis_sql_joins"]
"unsupported": ["full_cmis_sql_joins", "order_by"]
},
{
"id": "relationships",
@@ -144,7 +144,7 @@
"examples": ["public-asset", "internal-asset", "confidential-asset", "denied-actor", "service-account"],
"supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"],
"must_validate": ["allowable_actions", "acl_projection", "denial_omits_object", "no_metadata_leakage"],
"unsupported": ["apply_policy", "remove_policy"]
"unsupported": ["apply_acl", "apply_policy", "remove_policy"]
},
{
"id": "change-log",
@@ -166,16 +166,23 @@
"unsupported_diagnostics": {
"atompub": "binding_not_supported",
"web_services": "binding_not_supported",
"get_descendants": "capability_not_supported",
"get_folder_tree": "capability_not_supported",
"multifiling": "projection_only",
"unfiling": "capability_not_supported",
"append_content_stream": "capability_not_supported",
"versioning_services": "capability_not_supported",
"private_working_copy": "capability_not_supported",
"all_versions_search": "capability_not_supported",
"full_cmis_sql_joins": "query_not_supported",
"order_by": "query_not_supported",
"apply_acl": "operation_not_implemented",
"apply_policy": "capability_not_supported",
"remove_policy": "capability_not_supported",
"retention_hold_mutation": "capability_not_supported",
"bulk_update_properties": "capability_not_supported",
"rendition_streams": "capability_not_supported"
"rendition_streams": "capability_not_supported",
"type_mutability": "capability_not_supported"
},
"profile_expectations": {
"readonly-browser": {

View File

@@ -534,7 +534,14 @@ class ServiceRuntime:
expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None))
for key, value in properties.items():
if key.startswith("cmis:"):
continue
raise ValidationError(
"Unsupported CMIS property update",
details={
"property": key,
"operation": "updateProperties",
"supported": ["kontextual:metadata:<key>"],
},
)
self.asset_service().add_metadata_record(
asset_id,
MetadataRecord(key=_cmis_metadata_key(key), value=value, confirmed=bool(payload.get("confirmed", True))),

View File

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

View File

@@ -20,7 +20,8 @@
{
"capability_group": "navigation",
"tck_groups": ["NavigationTestGroup"],
"expected": "selected-pass"
"expected": "partial-pass",
"known_gaps": ["get_descendants", "get_folder_tree", "multifiling", "unfiling"]
},
{
"capability_group": "object-content",
@@ -30,14 +31,14 @@
{
"capability_group": "versioning",
"tck_groups": ["VersioningTestGroup"],
"expected": "partial-pass",
"known_gaps": ["private_working_copy"]
"expected": "skip",
"known_gaps": ["versioning_services", "private_working_copy", "all_versions_search"]
},
{
"capability_group": "discovery-query",
"tck_groups": ["QueryTestGroup"],
"expected": "partial-pass",
"known_gaps": ["full_cmis_sql_joins"]
"known_gaps": ["full_cmis_sql_joins", "order_by"]
},
{
"capability_group": "relationships",
@@ -48,7 +49,7 @@
"capability_group": "acl-policy",
"tck_groups": ["AclTestGroup", "PolicyTestGroup"],
"expected": "partial-pass",
"known_gaps": ["apply_policy", "remove_policy"]
"known_gaps": ["apply_acl", "apply_policy", "remove_policy"]
},
{
"capability_group": "change-log",
@@ -63,4 +64,3 @@
}
]
}

View File

@@ -56,6 +56,7 @@ def test_governed_authoring_profile_allows_selected_write_actions() -> None:
assert profile.decide_action(CMISAction.CREATE_DOCUMENT, context).allowed is True
assert profile.decide_action(CMISAction.SET_CONTENT_STREAM, context).allowed is True
assert profile.decide_action(CMISAction.BULK_UPDATE_PROPERTIES, context).allowed is False
assert profile.decide_action(CMISAction.APPLY_ACL, context).reason == "cmis_operation_not_implemented"
def test_profiles_hide_denied_sensitivities_without_partial_exposure() -> None:
@@ -148,4 +149,3 @@ def test_disabled_access_point_denies_all_actions_and_visibility() -> None:
assert access_point.decide_action(CMISAction.GET_OBJECT, context).reason == "cmis_access_point_disabled"
assert access_point.exposes_asset(_asset("asset-public", "public"), context) is False

View File

@@ -100,6 +100,8 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
assert repository["repository_id"] == "kontextual-readonly-browser"
assert repository["cmis_version_supported"] == "1.1"
assert repository["capabilities"]["capability_query"] == "metadataonly"
assert repository["capabilities"]["capability_get_descendants"] is False
assert repository["unsupported_features"]["multifiling"]["status"] == "projection_only"
assert {item["base_type_id"] for item in types["items"]} >= {
"cmis:document",
"cmis:folder",
@@ -208,3 +210,14 @@ def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None:
)
assert response.status_code == 403
def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis_client) -> None:
response = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-source/properties",
json={"properties": {"cmis:name": "Renamed"}},
)
assert response.status_code == 422
assert response.json()["detail"]["details"]["property"] == "cmis:name"
assert response.json()["detail"]["details"]["supported"] == ["kontextual:metadata:<key>"]

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from kontextual_engine import (
Actor,
ActorType,
CMISAccessPoint,
CMISAccessProfile,
CMISAction,
CMISBaseType,
CMISCapability,
CMISDomainMapper,
Classification,
KnowledgeAsset,
LifecycleState,
OperationContext,
)
EXPECTED_CAPABILITY_KEYS = {
"capability_content_stream_updatability",
"capability_changes",
"capability_renditions",
"capability_get_descendants",
"capability_get_folder_tree",
"capability_order_by",
"capability_multifiling",
"capability_unfiling",
"capability_version_specific_filing",
"capability_pwc_searchable",
"capability_pwc_updatable",
"capability_all_versions_searchable",
"capability_query",
"capability_join",
"capability_acl",
"capability_new_type_settable_attributes",
}
def _context() -> OperationContext:
return OperationContext.create(
Actor.create(ActorType.HUMAN, actor_id="actor-cmis-compliance"),
correlation_id="corr-cmis-compliance",
)
def _mapper(profile: CMISAccessProfile | None = None) -> CMISDomainMapper:
return CMISDomainMapper(
CMISAccessPoint(
access_point_id="cmis-compliance",
repository_id="kontextual-compliance",
profile=profile or CMISAccessProfile.readonly_browser(),
base_path="/cmis/compliance/browser",
metadata={"repository_name": "Kontextual Compliance Repository"},
)
)
def test_repository_info_uses_complete_conservative_cmis_11_capability_flags() -> None:
repository = _mapper().repository_info()
capabilities = repository["capabilities"]
assert EXPECTED_CAPABILITY_KEYS <= set(capabilities)
assert capabilities["capability_content_stream_updatability"] == "none"
assert capabilities["capability_changes"] == "objectidsonly"
assert capabilities["capability_renditions"] == "none"
assert capabilities["capability_get_descendants"] is False
assert capabilities["capability_get_folder_tree"] is False
assert capabilities["capability_order_by"] == "none"
assert capabilities["capability_multifiling"] is False
assert capabilities["capability_unfiling"] is False
assert capabilities["capability_version_specific_filing"] is False
assert capabilities["capability_pwc_searchable"] is False
assert capabilities["capability_pwc_updatable"] is False
assert capabilities["capability_all_versions_searchable"] is False
assert capabilities["capability_query"] == "metadataonly"
assert capabilities["capability_join"] == "none"
assert capabilities["capability_acl"] == "discover"
assert set(capabilities["capability_new_type_settable_attributes"].values()) == {False}
def test_authoring_profile_only_advertises_supported_content_write_semantics() -> None:
repository = _mapper(CMISAccessProfile.governed_authoring()).repository_info()
capabilities = repository["capabilities"]
assert capabilities["capability_content_stream_updatability"] == "anytime"
assert capabilities["capability_multifiling"] is False
assert capabilities["capability_renditions"] == "none"
def test_repository_info_exposes_nonstandard_projection_feature_and_unsupported_catalog() -> None:
repository = _mapper().repository_info()
feature_ids = {feature["id"] for feature in repository["repository_features"]}
unsupported = repository["unsupported_features"]
assert "urn:kontextual:cmis:feature:projection-parentage" in feature_ids
assert unsupported["multifiling"]["status"] == "projection_only"
assert unsupported["multifiling"]["standard_flag"] == "capability_multifiling"
assert unsupported["get_descendants"]["standard_flag"] == "capability_get_descendants"
assert unsupported["rendition_streams"]["standard_flag"] == "capability_renditions"
assert repository["compliance"]["posture"] == "declared-browser-binding-subset"
def test_type_definitions_do_not_claim_unimplemented_cmis_services() -> None:
types = {definition["base_type_id"]: definition for definition in _mapper().type_definitions()}
document = types[CMISBaseType.DOCUMENT.value]
folder = types[CMISBaseType.FOLDER.value]
relationship = types[CMISBaseType.RELATIONSHIP.value]
policy = types[CMISBaseType.POLICY.value]
assert document["versionable"] is False
assert document["controllable_acl"] is True
assert document["queryable"] is True
assert document["creatable"] is False
assert "cmis:contentStreamLength" in document["property_definitions"]
assert folder["controllable_acl"] is False
assert folder["queryable"] is False
assert relationship["creatable"] is False
assert relationship["queryable"] is False
assert policy["controllable_policy"] is False
def test_profile_denies_unimplemented_mutations_even_when_related_capability_exists() -> None:
profile = CMISAccessProfile(
name="acl-manager-shape",
capabilities=(CMISCapability.ACL,),
allow_mutations=True,
)
decision = profile.decide_action(CMISAction.APPLY_ACL, _context())
assert decision.allowed is False
assert decision.reason == "cmis_operation_not_implemented"
def test_deleted_assets_are_not_exposed_as_live_cmis_objects() -> None:
profile = CMISAccessProfile.readonly_browser()
asset = KnowledgeAsset.create(
"Deleted",
Classification(asset_type="document", sensitivity="internal"),
asset_id="asset-deleted",
).transition_lifecycle(LifecycleState.DELETE_REQUESTED)
decision = profile.decide_asset_visibility(asset, _context())
assert decision.allowed is False
assert decision.reason == "cmis_lifecycle_not_visible"

View File

@@ -73,7 +73,8 @@ def test_mapper_exposes_repository_info_capabilities_and_base_type_definitions()
assert repository["cmis_version_supported"] == "1.1"
assert repository["binding"] == "browser"
assert repository["capabilities"]["capability_query"] == "metadataonly"
assert repository["capabilities"]["capability_multifiling"] is True
assert repository["capabilities"]["capability_multifiling"] is False
assert repository["unsupported_features"]["multifiling"]["status"] == "projection_only"
assert set(types) == {
"cmis:document",
@@ -122,6 +123,8 @@ def test_mapper_projects_asset_to_cmis_document_envelope() -> None:
assert serialized["properties"]["cmis:objectTypeId"] == "kontextual:document"
assert serialized["properties"]["kontextual:metadata:status"] == "accepted"
assert serialized["content_stream"]["mime_type"] == "text/markdown"
assert serialized["properties"]["cmis:contentStreamMimeType"] == "text/markdown"
assert serialized["properties"]["cmis:contentStreamId"] == "repr-source"
assert serialized["version"]["cmis:versionLabel"] == "3"
assert serialized["relationships"] == ["cmis:relationship:rel-derived"]
assert CMISAction.GET_CONTENT_STREAM.value in serialized["allowable_actions"]

View File

@@ -91,7 +91,9 @@ def test_runtime_cmis_browser_repository_types_children_and_object(cmis_runtime)
assert access_points["count"] == 4
assert repository["repository_id"] == "kontextual-readonly-browser"
assert repository["capabilities"]["capability_get_descendants"] is True
assert repository["capabilities"]["capability_get_descendants"] is False
assert repository["capabilities"]["capability_get_folder_tree"] is False
assert repository["unsupported_features"]["get_descendants"]["status"] == "unsupported"
assert {item["base_type_id"] for item in types["items"]} >= {"cmis:document", "cmis:folder"}
root_paths = {item["path"] for item in children["objects"]}
topic_object_ids = {item["object_id"] for item in topic_children["objects"]}
@@ -183,6 +185,23 @@ def test_runtime_cmis_governed_authoring_allows_selected_mutations(cmis_runtime)
assert stream_bytes.representation.storage_ref.startswith("blob://memory/")
assert deleted["deleted"] is False
assert deleted["lifecycle"] == "delete_requested"
with pytest.raises(Exception) as exc_info:
runtime.cmis_object("governed-authoring", "cmis:asset:asset-authored", context)
assert "CMIS object not found" in str(exc_info.value)
def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime) -> None:
runtime, context = cmis_runtime
with pytest.raises(Exception) as exc_info:
runtime.cmis_update_properties(
"governed-authoring",
"cmis:asset:asset-runtime-source",
{"properties": {"cmis:name": "Renamed"}},
context,
)
assert "Unsupported CMIS property update" in str(exc_info.value)
def test_runtime_cmis_readonly_profile_rejects_mutations(cmis_runtime) -> None: