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 # 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 ## Reference Standard
@@ -42,35 +43,36 @@ Practical strategy:
| CMIS capability | Current engine availability | Gap | Demand | | 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 | | Repository service | Implemented. | Repository info includes CMIS 1.1 identity, complete conservative optional capability flags, repository features, and unsupported feature diagnostics. | Low |
| Type definitions | Asset classifications, metadata schemas, relationship target kinds. | Need CMIS base types, property definitions, type mutability flags, secondary type projection. | Medium | | 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 | Relationships and context graph exist, but no folder tree model. | Need root folder, folder children, descendants/tree, parent relationships, path semantics. | High | | 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 | 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 read | Implemented subset. | Object envelopes, allowable actions, content stream descriptors, content stream properties, visibility redaction, and relationship IDs are covered. | Low |
| Object service write | Asset create, metadata add, lifecycle transition, relationship create, ingestion. | Need createDocument/createFolder/updateProperties/deleteObject/moveObject mapping and CMIS change tokens. | High | | 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 | Source, normalized, derived representations store content hashes and storage refs. | Need getContentStream/setContentStream/deleteContentStream/appendContentStream semantics and streaming endpoints. | Medium-High | | Content streams | Implemented subset. | Descriptor and byte-stream routes exist; `setContentStream` writes through deduplicating blob storage. Append/delete content stream are unsupported. | Low |
| Versioning | Asset versions and transformation/workflow lineage exist. | Need CMIS checkout, PWC, checkin, cancelCheckout, version series semantics, latest/major flags. | High | | 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 | Governed retrieval, lexical search, filters, relationships. | Need CMIS SQL-like query grammar or supported subset, query result shape, joins/capability flags. | High | | 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 | Core relationships exist. | Need CMIS relationship object mapping and relationship type capability exposure. | Medium | | Relationships | Implemented subset. | Relationship object projections and source filters are covered and profile-gated. | Low |
| ACL service | Policy gateway and authorization decisions exist. | Need CMIS ACL model, principals, direct/inherited ACEs, applyACL, exact capability flags. | High | | ACL service | Discover only. | ACL projection is supported; `applyACL` is not authorized even for authoring profiles and returns an unimplemented diagnostic. | Low |
| Policy service | Policy decisions and governance reports exist. | Need CMIS policy objects/applyPolicy/removePolicy/getAppliedPolicies mapping or explicit unsupported profile. | Medium | | Policy service | Unsupported. | `applyPolicy`/`removePolicy` are explicitly unsupported; engine policy remains native, not CMIS policy objects. | Low |
| Change log | Audit events and correlation IDs exist. | Need CMIS change events, change tokens, object change entries, paging. | Medium | | 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 | Not modeled directly. | Need folder membership model or profile-level unsupported flags. | High if full support, Low if unsupported | | Multi-filing/unfiling | Projection only. | Multiple virtual parents are exposed as a Kontextual repository feature, while CMIS `capabilityMultifiling` and unfiling stay false. | Low |
| Renditions | Representations exist, no rendition taxonomy. | Need rendition metadata and stream mapping for thumbnails/previews. | Medium | | Renditions | Unsupported. | Capability is `none`; derived representations are not exposed as CMIS rendition streams. | Low |
| 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 | | Retention and hold | Unsupported. | Not advertised; left as native governance metadata until a real integration requires CMIS legal-hold semantics. | Low |
| Bulk update | Metadata update pathways exist. | Need bulkUpdateProperties semantics, partial failure reporting, change tokens. | Medium | | 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 | | 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 | | 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 | | Web Services binding | No SOAP stack. | Need WSDL/SOAP implementation. | Very High |
## Recommended Compliance Profile Strategy ## 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, - Repository, type, object read, content stream read, query subset,
relationships, change log, and navigation over a synthetic root/folder relationships, change log, and navigation over a synthetic root/folder
projection. projection.
- Explicitly unsupported or read-only: AtomPub, Web Services, full ACL mutation, - Explicitly unsupported or read-only: AtomPub, Web Services, descendants/tree,
retention/hold, multifiling/unfiling, and full CMIS SQL joins. 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: Then expand by profile:
@@ -83,11 +85,12 @@ Then expand by profile:
## Risk Summary ## Risk Summary
The engine already has strong foundations for asset identity, metadata, The engine has a sound Browser Binding subset so long as clients trust the
representations, relationships, versions, audit, policy, retrieval, and advertised capabilities instead of assuming broad ECM behavior. The remaining
service APIs. The hard parts are not storage; they are CMIS protocol semantics: hard parts are optional CMIS semantics that we intentionally do not advertise:
folder/path behavior, versioning/PWC semantics, CMIS query grammar, ACL shape, folder tree/descendant services, mutating filing services, PWC/versioning
content stream actions, and binding-specific compatibility. services, broad query grammar, ACL mutation, renditions, retention/hold, and
legacy bindings.
Best estimate: Best estimate:

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
Date: 2026-05-06 Date: 2026-05-06
Status: Browser Binding MVP implemented. Status: Browser Binding subset implemented and capability-hardened.
## Implemented Slice ## Implemented Slice
@@ -61,6 +61,11 @@ model.
- relationship primitives as CMIS relationship objects, - relationship primitives as CMIS relationship objects,
- profile-derived allowable actions. - 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 The mapper returns `None` for assets or relationships that the access-point
profile must not expose. It does not fetch from repositories directly; callers profile must not expose. It does not fetch from repositories directly; callers
provide the asset, representations, versions, metadata records, and 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 Read-only profiles reject the same mutations with CMIS-shaped authorization
diagnostics before touching engine services. 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 ## ACL And Redaction Slice
The Browser Binding adapter now projects profile-derived ACLs through 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 ## 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, listed under several derived folder paths, including source system, topics,
owner, lifecycle, and asset type. These folders are navigation projections; they owner, lifecycle, and asset type. These folders are navigation projections; they
do not duplicate assets and do not become canonical storage locations. 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` parent folders for one asset. `GET /cmis/{access_point_id}/browser/children`
supports folder-scoped navigation through those projected paths. 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 ## Fixture And Optional TCK Integration
CMIS fixtures now act as active compatibility contracts: CMIS fixtures now act as active compatibility contracts:

View File

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

View File

@@ -534,7 +534,14 @@ class ServiceRuntime:
expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None)) expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None))
for key, value in properties.items(): for key, value in properties.items():
if key.startswith("cmis:"): 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( self.asset_service().add_metadata_record(
asset_id, asset_id,
MetadataRecord(key=_cmis_metadata_key(key), value=value, confirmed=bool(payload.get("confirmed", True))), 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 .actors import ActorType, OperationContext
from .assets import AssetRepresentation, KnowledgeAsset, RepresentationKind from .assets import AssetRepresentation, KnowledgeAsset, RepresentationKind
from .metadata import Sensitivity from .metadata import LifecycleState, Sensitivity
from .policy import PolicyDecision from .policy import PolicyDecision
from .provenance import AssetVersion from .provenance import AssetVersion
from .relationships import CoreRelationship, RelationshipTargetKind from .relationships import CoreRelationship, RelationshipTargetKind
@@ -92,6 +92,24 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = {
CMISAction.APPLY_POLICY: CMISCapability.POLICY, CMISAction.APPLY_POLICY: CMISCapability.POLICY,
CMISAction.BULK_UPDATE_PROPERTIES: CMISCapability.BULK_UPDATE, 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 = { MUTATION_ACTIONS = {
CMISAction.CREATE_DOCUMENT, CMISAction.CREATE_DOCUMENT,
CMISAction.UPDATE_PROPERTIES, CMISAction.UPDATE_PROPERTIES,
@@ -101,6 +119,90 @@ MUTATION_ACTIONS = {
CMISAction.APPLY_POLICY, CMISAction.APPLY_POLICY,
CMISAction.BULK_UPDATE_PROPERTIES, 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) @dataclass(frozen=True)
@@ -170,11 +272,7 @@ class CMISAccessProfile:
def admin_export(cls) -> "CMISAccessProfile": def admin_export(cls) -> "CMISAccessProfile":
return cls( return cls(
name="admin-export", name="admin-export",
capabilities=_read_capabilities() capabilities=_read_capabilities(),
+ (
CMISCapability.RENDITIONS,
CMISCapability.RETENTION_HOLD,
),
allow_mutations=False, allow_mutations=False,
visible_sensitivities=( visible_sensitivities=(
Sensitivity.PUBLIC, Sensitivity.PUBLIC,
@@ -203,13 +301,28 @@ class CMISAccessProfile:
return CMISCapability(capability) in self.capabilities return CMISCapability(capability) in self.capabilities
def allows_action(self, action: CMISAction | str) -> bool: 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) cmis_action = CMISAction(action)
capability = ACTION_CAPABILITIES[cmis_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: if cmis_action in MUTATION_ACTIONS and not self.allow_mutations:
return False return (
return True "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( def decide_action(
self, self,
@@ -234,14 +347,16 @@ class CMISAccessProfile:
resource, resource,
context={"profile": self.name}, context={"profile": self.name},
) )
capability = ACTION_CAPABILITIES[cmis_action] reason, denial_context = self.action_denial(cmis_action) or (
reason = "cmis_mutation_not_allowed" if cmis_action in MUTATION_ACTIONS else "cmis_capability_not_supported" "cmis_capability_not_supported",
{"profile": self.name},
)
return PolicyDecision.deny( return PolicyDecision.deny(
context.actor.id, context.actor.id,
cmis_action.value, cmis_action.value,
resource, resource,
reason=reason, reason=reason,
context={"profile": self.name, "capability": capability.value}, context=denial_context,
) )
def allows_actor(self, context: OperationContext) -> bool: def allows_actor(self, context: OperationContext) -> bool:
@@ -265,6 +380,14 @@ class CMISAccessProfile:
context={"profile": self.name}, context={"profile": self.name},
) )
classification = asset.classification 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: if classification.sensitivity in self.denied_sensitivities:
return PolicyDecision.deny( return PolicyDecision.deny(
context.actor.id, context.actor.id,
@@ -466,13 +589,27 @@ class CMISDomainMapper:
return { return {
"repository_id": self.access_point.repository_id, "repository_id": self.access_point.repository_id,
"repository_name": self.access_point.metadata.get("repository_name", 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", "cmis_version_supported": "1.1",
"root_folder_id": self.access_point.root_folder_id, "root_folder_id": self.access_point.root_folder_id,
"principal_anonymous": "anonymous", "principal_anonymous": "anonymous",
"principal_anyone": "anyone", "principal_anyone": "anyone",
"vendor_name": "Kontextual",
"product_name": "kontextual-engine", "product_name": "kontextual-engine",
"product_version": self.access_point.metadata.get("product_version", "0.1.0"),
"binding": profile.binding.value, "binding": profile.binding.value,
"capabilities": self.capability_flags(), "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, "profile": profile.name,
} }
@@ -480,27 +617,53 @@ class CMISDomainMapper:
profile = self.access_point.profile profile = self.access_point.profile
return { return {
"capability_content_stream_updatability": ( "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" "capability_changes": "objectidsonly"
if profile.has_capability(CMISCapability.CHANGE_LOG) if profile.has_capability(CMISCapability.CHANGE_LOG)
else "none", else "none",
"capability_renditions": "read" if profile.has_capability(CMISCapability.RENDITIONS) else "none", "capability_renditions": "none",
"capability_get_descendants": profile.has_capability(CMISCapability.NAVIGATION), "capability_get_descendants": False,
"capability_get_folder_tree": profile.has_capability(CMISCapability.NAVIGATION), "capability_get_folder_tree": False,
"capability_multifiling": profile.has_capability(CMISCapability.NAVIGATION), "capability_order_by": "none",
"capability_multifiling": False,
"capability_unfiling": False, "capability_unfiling": False,
"capability_version_specific_filing": False, "capability_version_specific_filing": False,
"capability_pwc_searchable": False, "capability_pwc_searchable": False,
"capability_pwc_updatable": False, "capability_pwc_updatable": False,
"capability_all_versions_searchable": profile.has_capability(CMISCapability.VERSIONING), "capability_all_versions_searchable": False,
"capability_query": "metadataonly" "capability_query": "metadataonly"
if profile.has_capability(CMISCapability.DISCOVERY_QUERY) if profile.has_capability(CMISCapability.DISCOVERY_QUERY)
else "none", else "none",
"capability_join": "none", "capability_join": "none",
"capability_acl": "discover" if profile.has_capability(CMISCapability.ACL) else "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]]: def type_definitions(self) -> list[dict[str, Any]]:
can_write = self.access_point.profile.allow_mutations can_write = self.access_point.profile.allow_mutations
return [ return [
@@ -537,7 +700,7 @@ class CMISDomainMapper:
type_id=f"kontextual:{asset.classification.asset_type}", type_id=f"kontextual:{asset.classification.asset_type}",
name=asset.title, name=asset.title,
path=self.asset_path(asset), 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), allowable_actions=self.allowable_actions(context, has_content_stream=content_stream is not None),
content_stream=content_stream, content_stream=content_stream,
version=self.version_properties(asset, current_version, versions), version=self.version_properties(asset, current_version, versions),
@@ -682,6 +845,7 @@ class CMISDomainMapper:
asset: KnowledgeAsset, asset: KnowledgeAsset,
*, *,
metadata_records: list[Any] | tuple[Any, ...] = (), metadata_records: list[Any] | tuple[Any, ...] = (),
content_stream: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
classification = asset.classification classification = asset.classification
properties = { properties = {
@@ -694,6 +858,10 @@ class CMISDomainMapper:
"cmis:creationDate": asset.created_at, "cmis:creationDate": asset.created_at,
"cmis:lastModificationDate": asset.updated_at, "cmis:lastModificationDate": asset.updated_at,
"cmis:changeToken": asset.current_version_id, "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:assetId": asset.id,
"kontextual:assetType": classification.asset_type, "kontextual:assetType": classification.asset_type,
"kontextual:sensitivity": _enum_value(classification.sensitivity), "kontextual:sensitivity": _enum_value(classification.sensitivity),
@@ -785,7 +953,6 @@ def _read_capabilities() -> tuple[CMISCapability, ...]:
CMISCapability.NAVIGATION, CMISCapability.NAVIGATION,
CMISCapability.OBJECT_READ, CMISCapability.OBJECT_READ,
CMISCapability.CONTENT_STREAM_READ, CMISCapability.CONTENT_STREAM_READ,
CMISCapability.VERSIONING,
CMISCapability.DISCOVERY_QUERY, CMISCapability.DISCOVERY_QUERY,
CMISCapability.RELATIONSHIPS, CMISCapability.RELATIONSHIPS,
CMISCapability.ACL, CMISCapability.ACL,
@@ -803,19 +970,20 @@ def _type_definition(
display_name: str, display_name: str,
can_write: bool, can_write: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
is_document = base_type_id == CMISBaseType.DOCUMENT
return { return {
"id": type_id, "id": type_id,
"local_name": type_id.split(":", 1)[-1], "local_name": type_id.split(":", 1)[-1],
"display_name": display_name, "display_name": display_name,
"base_type_id": base_type_id.value, "base_type_id": base_type_id.value,
"queryable": True, "queryable": is_document,
"controllable_acl": base_type_id in {CMISBaseType.DOCUMENT, CMISBaseType.FOLDER}, "controllable_acl": is_document,
"controllable_policy": False, "controllable_policy": False,
"creatable": can_write and base_type_id == CMISBaseType.DOCUMENT, "creatable": can_write and is_document,
"fileable": base_type_id == CMISBaseType.DOCUMENT, "fileable": is_document,
"fulltext_indexed": False, "fulltext_indexed": False,
"included_in_supertype_query": True, "included_in_supertype_query": is_document,
"versionable": base_type_id == CMISBaseType.DOCUMENT, "versionable": False,
"property_definitions": _property_definitions(base_type_id), "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:sensitivity": {"property_type": "string", "cardinality": "single", "required": False},
"kontextual:lifecycle": {"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: if base_type_id == CMISBaseType.RELATIONSHIP:
definitions["cmis:sourceId"] = {"property_type": "id", "cardinality": "single", "required": True} definitions["cmis:sourceId"] = {"property_type": "id", "cardinality": "single", "required": True}
definitions["cmis:targetId"] = {"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", "capability_group": "navigation",
"tck_groups": ["NavigationTestGroup"], "tck_groups": ["NavigationTestGroup"],
"expected": "selected-pass" "expected": "partial-pass",
"known_gaps": ["get_descendants", "get_folder_tree", "multifiling", "unfiling"]
}, },
{ {
"capability_group": "object-content", "capability_group": "object-content",
@@ -30,14 +31,14 @@
{ {
"capability_group": "versioning", "capability_group": "versioning",
"tck_groups": ["VersioningTestGroup"], "tck_groups": ["VersioningTestGroup"],
"expected": "partial-pass", "expected": "skip",
"known_gaps": ["private_working_copy"] "known_gaps": ["versioning_services", "private_working_copy", "all_versions_search"]
}, },
{ {
"capability_group": "discovery-query", "capability_group": "discovery-query",
"tck_groups": ["QueryTestGroup"], "tck_groups": ["QueryTestGroup"],
"expected": "partial-pass", "expected": "partial-pass",
"known_gaps": ["full_cmis_sql_joins"] "known_gaps": ["full_cmis_sql_joins", "order_by"]
}, },
{ {
"capability_group": "relationships", "capability_group": "relationships",
@@ -48,7 +49,7 @@
"capability_group": "acl-policy", "capability_group": "acl-policy",
"tck_groups": ["AclTestGroup", "PolicyTestGroup"], "tck_groups": ["AclTestGroup", "PolicyTestGroup"],
"expected": "partial-pass", "expected": "partial-pass",
"known_gaps": ["apply_policy", "remove_policy"] "known_gaps": ["apply_acl", "apply_policy", "remove_policy"]
}, },
{ {
"capability_group": "change-log", "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.CREATE_DOCUMENT, context).allowed is True
assert profile.decide_action(CMISAction.SET_CONTENT_STREAM, 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.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: 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.decide_action(CMISAction.GET_OBJECT, context).reason == "cmis_access_point_disabled"
assert access_point.exposes_asset(_asset("asset-public", "public"), context) is False 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["repository_id"] == "kontextual-readonly-browser"
assert repository["cmis_version_supported"] == "1.1" assert repository["cmis_version_supported"] == "1.1"
assert repository["capabilities"]["capability_query"] == "metadataonly" 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"]} >= { assert {item["base_type_id"] for item in types["items"]} >= {
"cmis:document", "cmis:document",
"cmis:folder", "cmis:folder",
@@ -208,3 +210,14 @@ def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None:
) )
assert response.status_code == 403 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["cmis_version_supported"] == "1.1"
assert repository["binding"] == "browser" assert repository["binding"] == "browser"
assert repository["capabilities"]["capability_query"] == "metadataonly" 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) == { assert set(types) == {
"cmis:document", "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"]["cmis:objectTypeId"] == "kontextual:document"
assert serialized["properties"]["kontextual:metadata:status"] == "accepted" assert serialized["properties"]["kontextual:metadata:status"] == "accepted"
assert serialized["content_stream"]["mime_type"] == "text/markdown" 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["version"]["cmis:versionLabel"] == "3"
assert serialized["relationships"] == ["cmis:relationship:rel-derived"] assert serialized["relationships"] == ["cmis:relationship:rel-derived"]
assert CMISAction.GET_CONTENT_STREAM.value in serialized["allowable_actions"] 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 access_points["count"] == 4
assert repository["repository_id"] == "kontextual-readonly-browser" 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"} assert {item["base_type_id"] for item in types["items"]} >= {"cmis:document", "cmis:folder"}
root_paths = {item["path"] for item in children["objects"]} root_paths = {item["path"] for item in children["objects"]}
topic_object_ids = {item["object_id"] for item in topic_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 stream_bytes.representation.storage_ref.startswith("blob://memory/")
assert deleted["deleted"] is False assert deleted["deleted"] is False
assert deleted["lifecycle"] == "delete_requested" 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: def test_runtime_cmis_readonly_profile_rejects_mutations(cmis_runtime) -> None: