diff --git a/docs/cmis-compliance-assessment.md b/docs/cmis-compliance-assessment.md index 001ab31..81d132b 100644 --- a/docs/cmis-compliance-assessment.md +++ b/docs/cmis-compliance-assessment.md @@ -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: diff --git a/docs/cmis-compliance-test-foundation.md b/docs/cmis-compliance-test-foundation.md index 89371ee..89416d8 100644 --- a/docs/cmis-compliance-test-foundation.md +++ b/docs/cmis-compliance-test-foundation.md @@ -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 diff --git a/docs/cmis-deployment-compatibility.md b/docs/cmis-deployment-compatibility.md index 5960825..cc6b62a 100644 --- a/docs/cmis-deployment-compatibility.md +++ b/docs/cmis-deployment-compatibility.md @@ -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:`. ## 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. diff --git a/docs/cmis-profiled-access-points-implementation.md b/docs/cmis-profiled-access-points-implementation.md index 18c08c6..c187dfc 100644 --- a/docs/cmis-profiled-access-points-implementation.md +++ b/docs/cmis-profiled-access-points-implementation.md @@ -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:` +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: diff --git a/examples/cmis/capability-fixtures.json b/examples/cmis/capability-fixtures.json index c4cf0e5..6e14281 100644 --- a/examples/cmis/capability-fixtures.json +++ b/examples/cmis/capability-fixtures.json @@ -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": { diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py index bf58e74..8264e60 100644 --- a/src/kontextual_engine/api/app.py +++ b/src/kontextual_engine/api/app.py @@ -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:"], + }, + ) self.asset_service().add_metadata_record( asset_id, MetadataRecord(key=_cmis_metadata_key(key), value=value, confirmed=bool(payload.get("confirmed", True))), diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py index e96c7a2..d3fe7e5 100644 --- a/src/kontextual_engine/core/cmis.py +++ b/src/kontextual_engine/core/cmis.py @@ -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} diff --git a/tests/cmis/opencmis-tck/tck-subset-map.json b/tests/cmis/opencmis-tck/tck-subset-map.json index 6a6f7e6..415ea0f 100644 --- a/tests/cmis/opencmis-tck/tck-subset-map.json +++ b/tests/cmis/opencmis-tck/tck-subset-map.json @@ -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 @@ } ] } - diff --git a/tests/cmis/test_cmis_access_profiles.py b/tests/cmis/test_cmis_access_profiles.py index 9b1a4e9..7303570 100644 --- a/tests/cmis/test_cmis_access_profiles.py +++ b/tests/cmis/test_cmis_access_profiles.py @@ -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 - diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py index e6e5b8d..6f08cf8 100644 --- a/tests/cmis/test_cmis_browser_binding_api.py +++ b/tests/cmis/test_cmis_browser_binding_api.py @@ -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:"] diff --git a/tests/cmis/test_cmis_compliance_flags.py b/tests/cmis/test_cmis_compliance_flags.py new file mode 100644 index 0000000..b0b8860 --- /dev/null +++ b/tests/cmis/test_cmis_compliance_flags.py @@ -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" diff --git a/tests/cmis/test_cmis_domain_mapper.py b/tests/cmis/test_cmis_domain_mapper.py index 39f14e1..bcf149a 100644 --- a/tests/cmis/test_cmis_domain_mapper.py +++ b/tests/cmis/test_cmis_domain_mapper.py @@ -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"] diff --git a/tests/cmis/test_cmis_runtime_browser_binding.py b/tests/cmis/test_cmis_runtime_browser_binding.py index ac1761a..c5ffcfa 100644 --- a/tests/cmis/test_cmis_runtime_browser_binding.py +++ b/tests/cmis/test_cmis_runtime_browser_binding.py @@ -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: