diff --git a/docs/cmis-compliance-test-foundation.md b/docs/cmis-compliance-test-foundation.md index a96aebe..89371ee 100644 --- a/docs/cmis-compliance-test-foundation.md +++ b/docs/cmis-compliance-test-foundation.md @@ -2,7 +2,7 @@ Date: 2026-05-06 -Status: planned test foundation for CMIS access-point work. +Status: initial test foundation established for CMIS access-point work. ## Purpose @@ -17,9 +17,9 @@ harness. Planned harness shape: -- `tests/cmis/examples/` contains deterministic fixture descriptions grouped by - CMIS service capability. -- `tests/cmis/test_cmis_contract_examples.py` validates mapper and profile +- `examples/cmis/` contains deterministic fixture descriptions grouped by CMIS + service capability. +- `tests/cmis/test_cmis_contract_examples.py` validates fixture and profile behavior without external Java dependencies. - `tests/cmis/opencmis-tck/` contains optional harness config, Maven invocation notes, and selected TCK group mapping. @@ -188,3 +188,10 @@ Validates: No current OASIS certification service was identified during planning. The practical reusable foundation is OpenCMIS TCK/Workbench, plus our own capability-profile contract tests. + +## Established Artifacts + +- `examples/cmis/capability-fixtures.json` +- `tests/cmis/test_cmis_contract_examples.py` +- `tests/cmis/opencmis-tck/README.md` +- `docs/cmis-readiness-gate.md` diff --git a/docs/cmis-profiled-access-points-implementation.md b/docs/cmis-profiled-access-points-implementation.md new file mode 100644 index 0000000..2a10151 --- /dev/null +++ b/docs/cmis-profiled-access-points-implementation.md @@ -0,0 +1,48 @@ +# CMIS Profiled Access Points Implementation + +Date: 2026-05-06 + +Status: first implementation slice started. + +## Implemented Slice + +`src/kontextual_engine/core/cmis.py` defines the CMIS profile and access-point +boundary used by the future API adapter: + +- `CMISBinding` +- `CMISCapability` +- `CMISAction` +- `CMISAccessProfile` +- `CMISAccessPoint` + +The layer is intentionally small. It decides whether a CMIS action is allowed +for a profile and whether an engine asset may be exposed through an access +point. It does not implement CMIS routes and does not duplicate asset storage, +metadata, relationship, policy, or audit services. + +## Built-In Profiles + +- `readonly-browser`: Browser Binding read profile over public/internal assets. +- `governed-authoring`: Browser Binding profile with selected create/update + and content stream mutations. +- `admin-export`: service-account-only export profile with broad visibility. +- `compat-tck`: Browser Binding profile intended for selected OpenCMIS TCK + compatibility tests. + +## Enforcement Boundary + +Profiles can restrict exposure by: + +- CMIS capability, +- mutation allowance, +- actor type, +- sensitivity, +- asset type, +- topic, +- source system, +- metadata deny rules. + +Decisions return existing `PolicyDecision` objects so later CMIS routes can +emit compatible diagnostics and audit records without inventing another policy +model. + diff --git a/docs/cmis-readiness-gate.md b/docs/cmis-readiness-gate.md new file mode 100644 index 0000000..d9d2648 --- /dev/null +++ b/docs/cmis-readiness-gate.md @@ -0,0 +1,37 @@ +# CMIS Implementation Readiness Gate + +Date: 2026-05-06 + +Status: ready for `KONT-WP-0012` implementation planning. + +## Required Before Implementation + +- CMIS target version is fixed at OASIS CMIS 1.1. +- Browser Binding is the first implementation target. +- AtomPub and Web Services bindings are explicitly deferred. +- Capability examples are grouped in `examples/cmis/capability-fixtures.json`. +- Internal fixture contract tests validate profile expectations. +- OpenCMIS TCK is documented as an optional external harness. +- The first implementation profile is constrained to profile-scoped Browser + Binding behavior rather than full CMIS certification claims. + +## First Implementation Slice + +Implement the profile and mapper layer before routes: + +1. Profile model and access-point configuration. +2. CMIS object/type/capability projection over existing engine services. +3. Profile-scoped visibility denial and mutation policy. +4. Browser Binding read routes. +5. Governed mutation routes. +6. Optional OpenCMIS TCK compatibility profile. + +## Non-Goals For The First Slice + +- Full AtomPub support. +- Full SOAP/Web Services support. +- Full CMIS SQL grammar. +- Full private-working-copy versioning semantics. +- Legal hold or retention mutation semantics. +- General-purpose CMIS certification claim. + diff --git a/examples/cmis/README.md b/examples/cmis/README.md new file mode 100644 index 0000000..3e35eb4 --- /dev/null +++ b/examples/cmis/README.md @@ -0,0 +1,15 @@ +# CMIS Example Fixtures + +These fixtures define the expected CMIS behavior before the CMIS adapter is +implemented. They are grouped by CMIS capability so internal contract tests and +optional OpenCMIS TCK runs can validate profile behavior consistently. + +The fixtures are not a second domain model. They describe how existing +`kontextual-engine` assets, relationships, versions, policy decisions, audit +events, and representations should be projected through CMIS access points. + +Files: + +- `capability-fixtures.json`: capability groups, example object inventory, + profile expectations, and unsupported-operation diagnostics. + diff --git a/examples/cmis/capability-fixtures.json b/examples/cmis/capability-fixtures.json new file mode 100644 index 0000000..240a8f6 --- /dev/null +++ b/examples/cmis/capability-fixtures.json @@ -0,0 +1,203 @@ +{ + "schema_version": 1, + "standard": { + "name": "CMIS", + "version": "1.1", + "primary_binding": "browser", + "deferred_bindings": ["atompub", "web_services"] + }, + "profiles": { + "readonly-browser": { + "description": "Read-only Browser Binding access point for governed content access.", + "binding": "browser", + "mutations": false, + "visibility": ["public", "internal"], + "deny": ["confidential"], + "expected_tck_posture": "repository/type/read/navigation/query/content-read subset" + }, + "governed-authoring": { + "description": "Browser Binding access point with selected governed mutations.", + "binding": "browser", + "mutations": true, + "visibility": ["public", "internal"], + "deny": ["confidential"], + "expected_tck_posture": "readonly subset plus selected create/update/delete checks" + }, + "admin-export": { + "description": "Service-account export profile for broad governance inspection.", + "binding": "browser", + "mutations": false, + "visibility": ["public", "internal", "confidential"], + "deny": [], + "expected_tck_posture": "internal contract tests, not general client compatibility" + }, + "compat-tck": { + "description": "Compatibility profile tuned for selected OpenCMIS TCK groups.", + "binding": "browser", + "mutations": true, + "visibility": ["public", "internal"], + "deny": ["confidential"], + "expected_tck_posture": "selected OpenCMIS TCK Browser Binding subset" + } + }, + "objects": [ + { + "id": "doc-public-source", + "kind": "document", + "title": "Public source document", + "sensitivity": "public", + "content_stream": true, + "media_type": "text/markdown", + "version_series": "vs-public-source", + "version_label": "1.0", + "folder_path": "/sources/public" + }, + { + "id": "doc-internal-normalized", + "kind": "document", + "title": "Internal normalized representation", + "sensitivity": "internal", + "content_stream": true, + "media_type": "application/json", + "version_series": "vs-internal-normalized", + "version_label": "2.0", + "folder_path": "/representations/normalized" + }, + { + "id": "doc-confidential-risk", + "kind": "document", + "title": "Confidential risk note", + "sensitivity": "confidential", + "content_stream": true, + "media_type": "text/markdown", + "version_series": "vs-confidential-risk", + "version_label": "1.0", + "folder_path": "/sources/confidential" + }, + { + "id": "rel-derived-from", + "kind": "relationship", + "source_id": "doc-internal-normalized", + "target_id": "doc-public-source", + "relationship_type": "derived_from" + }, + { + "id": "event-content-updated", + "kind": "change_event", + "change_type": "updated", + "object_id": "doc-internal-normalized", + "change_token": "chg-0002" + } + ], + "capability_groups": [ + { + "id": "repository-type", + "service": "repository", + "examples": ["repo-browser", "readonly-browser", "governed-authoring"], + "supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"], + "must_validate": ["repository_info", "capability_flags", "base_type_definitions", "property_definitions"], + "unsupported": [] + }, + { + "id": "navigation", + "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": ["multifiling", "unfiling"] + }, + { + "id": "object-content", + "service": "object", + "examples": ["source-document", "normalized-representation", "metadata-rich-document", "streamless-document"], + "supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"], + "must_validate": ["get_object", "get_properties", "get_content_stream", "allowable_actions"], + "unsupported": ["append_content_stream"] + }, + { + "id": "versioning", + "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"] + }, + { + "id": "discovery-query", + "service": "discovery", + "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"] + }, + { + "id": "relationships", + "service": "relationship", + "examples": ["asset-to-asset", "asset-to-context-entity", "lineage-relation"], + "supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"], + "must_validate": ["relationship_projection", "source_filter", "target_filter", "profile_visibility"], + "unsupported": [] + }, + { + "id": "acl-policy", + "service": "acl", + "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"] + }, + { + "id": "change-log", + "service": "change_log", + "examples": ["asset-create", "metadata-update", "content-update", "transformation-output", "workflow-run", "policy-denial"], + "supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"], + "must_validate": ["change_tokens", "event_ordering", "audit_correlation", "paging"], + "unsupported": [] + }, + { + "id": "retention-renditions-bulk", + "service": "extension", + "examples": ["retention-metadata", "legal-hold-metadata", "preview-representation", "bulk-metadata-update"], + "supported_profiles": ["admin-export"], + "must_validate": ["explicit_capability_flags", "structured_unsupported_operation"], + "unsupported": ["retention_hold_mutation", "bulk_update_properties", "rendition_streams"] + } + ], + "unsupported_diagnostics": { + "atompub": "binding_not_supported", + "web_services": "binding_not_supported", + "multifiling": "capability_not_supported", + "unfiling": "capability_not_supported", + "append_content_stream": "capability_not_supported", + "private_working_copy": "capability_not_supported", + "full_cmis_sql_joins": "query_not_supported", + "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" + }, + "profile_expectations": { + "readonly-browser": { + "must_expose": ["repository-type", "navigation", "object-content", "versioning", "discovery-query", "relationships", "acl-policy", "change-log"], + "must_not_expose_objects": ["doc-confidential-risk"], + "must_reject_actions": ["create_document", "update_properties", "delete_object", "set_content_stream"] + }, + "governed-authoring": { + "must_expose": ["repository-type", "navigation", "object-content", "versioning", "discovery-query", "relationships", "acl-policy", "change-log"], + "must_not_expose_objects": ["doc-confidential-risk"], + "must_authorize_actions": ["create_document", "update_properties", "delete_object", "set_content_stream"] + }, + "admin-export": { + "must_expose": ["repository-type", "navigation", "object-content", "versioning", "discovery-query", "relationships", "acl-policy", "change-log", "retention-renditions-bulk"], + "must_not_expose_objects": [], + "must_reject_actions": ["create_document", "update_properties", "delete_object", "set_content_stream"] + }, + "compat-tck": { + "must_expose": ["repository-type", "navigation", "object-content", "versioning", "discovery-query", "relationships", "acl-policy", "change-log"], + "must_not_expose_objects": ["doc-confidential-risk"], + "must_authorize_actions": ["create_document", "update_properties", "delete_object", "set_content_stream"] + } + } +} + diff --git a/pyproject.toml b/pyproject.toml index 7403ce3..4fe2d92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,4 +42,5 @@ markers = [ "integration: tests that exercise optional external package contracts", "markitect_tool: tests for the optional markitect-tool adapter boundary", "capacity: opt-in capacity sentinel tests for bottleneck and scaling risks", + "cmis: tests for CMIS compatibility fixtures and adapter contracts", ] diff --git a/src/kontextual_engine/__init__.py b/src/kontextual_engine/__init__.py index 91484f0..31c62c7 100644 --- a/src/kontextual_engine/__init__.py +++ b/src/kontextual_engine/__init__.py @@ -23,6 +23,11 @@ from .core import ( AuditEvent, AuditOutcome, Classification, + CMISAccessPoint, + CMISAccessProfile, + CMISAction, + CMISBinding, + CMISCapability, ConnectorCapability, ContextEntity, ContextEntityType, @@ -167,6 +172,11 @@ __all__ = [ "BatchItemResult", "BatchOperationResult", "Classification", + "CMISAccessPoint", + "CMISAccessProfile", + "CMISAction", + "CMISBinding", + "CMISCapability", "ConnectorCapability", "Collection", "ContextAssembler", diff --git a/src/kontextual_engine/core/__init__.py b/src/kontextual_engine/core/__init__.py index 52425f0..92263b7 100644 --- a/src/kontextual_engine/core/__init__.py +++ b/src/kontextual_engine/core/__init__.py @@ -3,6 +3,13 @@ from .actors import Actor, ActorType, OperationContext from .assets import AssetRepresentation, KnowledgeAsset, RepresentationKind from .audit import AuditEvent, AuditOutcome +from .cmis import ( + CMISAccessPoint, + CMISAccessProfile, + CMISAction, + CMISBinding, + CMISCapability, +) from .idempotency import IdempotencyRecord, IdempotencyStatus from .ingestion import ( ConnectorCapability, @@ -67,6 +74,11 @@ __all__ = [ "AuditEvent", "AuditOutcome", "Classification", + "CMISAccessPoint", + "CMISAccessProfile", + "CMISAction", + "CMISBinding", + "CMISCapability", "ConnectorCapability", "ContextEntity", "ContextEntityType", diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py new file mode 100644 index 0000000..85c0211 --- /dev/null +++ b/src/kontextual_engine/core/cmis.py @@ -0,0 +1,427 @@ +"""CMIS access-point profile primitives. + +These classes model how a CMIS adapter may expose engine capabilities without +turning CMIS into a second domain model. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from .actors import ActorType, OperationContext +from .assets import KnowledgeAsset +from .metadata import Sensitivity +from .policy import PolicyDecision +from .primitives import compact_dict + + +class CMISBinding(str, Enum): + BROWSER = "browser" + ATOMPUB = "atompub" + WEB_SERVICES = "web_services" + + +class CMISCapability(str, Enum): + REPOSITORY = "repository" + TYPE_DEFINITIONS = "type_definitions" + NAVIGATION = "navigation" + OBJECT_READ = "object_read" + OBJECT_WRITE = "object_write" + CONTENT_STREAM_READ = "content_stream_read" + CONTENT_STREAM_WRITE = "content_stream_write" + VERSIONING = "versioning" + DISCOVERY_QUERY = "discovery_query" + RELATIONSHIPS = "relationships" + ACL = "acl" + POLICY = "policy" + CHANGE_LOG = "change_log" + RENDITIONS = "renditions" + RETENTION_HOLD = "retention_hold" + BULK_UPDATE = "bulk_update" + + +class CMISAction(str, Enum): + GET_REPOSITORY_INFO = "get_repository_info" + GET_TYPE_DEFINITION = "get_type_definition" + GET_CHILDREN = "get_children" + GET_OBJECT = "get_object" + GET_CONTENT_STREAM = "get_content_stream" + QUERY = "query" + GET_RELATIONSHIPS = "get_relationships" + GET_CHANGE_LOG = "get_change_log" + CREATE_DOCUMENT = "create_document" + UPDATE_PROPERTIES = "update_properties" + DELETE_OBJECT = "delete_object" + SET_CONTENT_STREAM = "set_content_stream" + APPLY_ACL = "apply_acl" + APPLY_POLICY = "apply_policy" + BULK_UPDATE_PROPERTIES = "bulk_update_properties" + + +ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = { + CMISAction.GET_REPOSITORY_INFO: CMISCapability.REPOSITORY, + CMISAction.GET_TYPE_DEFINITION: CMISCapability.TYPE_DEFINITIONS, + CMISAction.GET_CHILDREN: CMISCapability.NAVIGATION, + CMISAction.GET_OBJECT: CMISCapability.OBJECT_READ, + CMISAction.GET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_READ, + CMISAction.QUERY: CMISCapability.DISCOVERY_QUERY, + CMISAction.GET_RELATIONSHIPS: CMISCapability.RELATIONSHIPS, + CMISAction.GET_CHANGE_LOG: CMISCapability.CHANGE_LOG, + CMISAction.CREATE_DOCUMENT: CMISCapability.OBJECT_WRITE, + CMISAction.UPDATE_PROPERTIES: CMISCapability.OBJECT_WRITE, + CMISAction.DELETE_OBJECT: CMISCapability.OBJECT_WRITE, + CMISAction.SET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_WRITE, + CMISAction.APPLY_ACL: CMISCapability.ACL, + CMISAction.APPLY_POLICY: CMISCapability.POLICY, + CMISAction.BULK_UPDATE_PROPERTIES: CMISCapability.BULK_UPDATE, +} +MUTATION_ACTIONS = { + CMISAction.CREATE_DOCUMENT, + CMISAction.UPDATE_PROPERTIES, + CMISAction.DELETE_OBJECT, + CMISAction.SET_CONTENT_STREAM, + CMISAction.APPLY_ACL, + CMISAction.APPLY_POLICY, + CMISAction.BULK_UPDATE_PROPERTIES, +} + + +@dataclass(frozen=True) +class CMISAccessProfile: + name: str + binding: CMISBinding | str = CMISBinding.BROWSER + capabilities: tuple[CMISCapability | str, ...] = () + allow_mutations: bool = False + visible_sensitivities: tuple[Sensitivity | str, ...] = (Sensitivity.PUBLIC, Sensitivity.INTERNAL) + denied_sensitivities: tuple[Sensitivity | str, ...] = (Sensitivity.CONFIDENTIAL, Sensitivity.RESTRICTED) + visible_asset_types: tuple[str, ...] = () + visible_topics: tuple[str, ...] = () + visible_source_systems: tuple[str, ...] = () + denied_metadata: dict[str, tuple[Any, ...]] = field(default_factory=dict) + required_actor_types: tuple[ActorType | str, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "binding", CMISBinding(self.binding)) + object.__setattr__( + self, + "capabilities", + tuple(CMISCapability(capability) for capability in self.capabilities), + ) + object.__setattr__( + self, + "visible_sensitivities", + tuple(Sensitivity(value) for value in self.visible_sensitivities), + ) + object.__setattr__( + self, + "denied_sensitivities", + tuple(Sensitivity(value) for value in self.denied_sensitivities), + ) + object.__setattr__( + self, + "required_actor_types", + tuple(ActorType(value) for value in self.required_actor_types), + ) + object.__setattr__( + self, + "denied_metadata", + {key: tuple(values) for key, values in self.denied_metadata.items()}, + ) + + @classmethod + def readonly_browser(cls) -> "CMISAccessProfile": + return cls( + name="readonly-browser", + capabilities=_read_capabilities(), + allow_mutations=False, + ) + + @classmethod + def governed_authoring(cls) -> "CMISAccessProfile": + return cls( + name="governed-authoring", + capabilities=_read_capabilities() + + ( + CMISCapability.OBJECT_WRITE, + CMISCapability.CONTENT_STREAM_WRITE, + ), + allow_mutations=True, + ) + + @classmethod + def admin_export(cls) -> "CMISAccessProfile": + return cls( + name="admin-export", + capabilities=_read_capabilities() + + ( + CMISCapability.RENDITIONS, + CMISCapability.RETENTION_HOLD, + ), + allow_mutations=False, + visible_sensitivities=( + Sensitivity.PUBLIC, + Sensitivity.INTERNAL, + Sensitivity.CONFIDENTIAL, + Sensitivity.RESTRICTED, + ), + denied_sensitivities=(), + required_actor_types=(ActorType.SERVICE_ACCOUNT,), + ) + + @classmethod + def compat_tck(cls) -> "CMISAccessProfile": + return cls( + name="compat-tck", + capabilities=_read_capabilities() + + ( + CMISCapability.OBJECT_WRITE, + CMISCapability.CONTENT_STREAM_WRITE, + ), + allow_mutations=True, + metadata={"compatibility": "selected-opencmis-tck-browser-subset"}, + ) + + def has_capability(self, capability: CMISCapability | str) -> bool: + return CMISCapability(capability) in self.capabilities + + def allows_action(self, action: CMISAction | str) -> bool: + 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 + + def decide_action( + self, + action: CMISAction | str, + context: OperationContext, + *, + resource: str = "cmis:repository", + ) -> PolicyDecision: + cmis_action = CMISAction(action) + if not self.allows_actor(context): + return PolicyDecision.deny( + context.actor.id, + cmis_action.value, + resource, + reason="actor_type_not_allowed_for_cmis_profile", + context={"profile": self.name}, + ) + if self.allows_action(cmis_action): + return PolicyDecision.allow( + context.actor.id, + cmis_action.value, + 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" + return PolicyDecision.deny( + context.actor.id, + cmis_action.value, + resource, + reason=reason, + context={"profile": self.name, "capability": capability.value}, + ) + + def allows_actor(self, context: OperationContext) -> bool: + return not self.required_actor_types or context.actor.actor_type in self.required_actor_types + + def exposes_asset(self, asset: KnowledgeAsset, context: OperationContext) -> bool: + return self.decide_asset_visibility(asset, context).allowed + + def decide_asset_visibility( + self, + asset: KnowledgeAsset, + context: OperationContext, + ) -> PolicyDecision: + resource = f"asset:{asset.id}" + if not self.allows_actor(context): + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="actor_type_not_allowed_for_cmis_profile", + context={"profile": self.name}, + ) + classification = asset.classification + if classification.sensitivity in self.denied_sensitivities: + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="cmis_sensitivity_denied", + context={"profile": self.name, "sensitivity": _enum_value(classification.sensitivity)}, + ) + if classification.sensitivity not in self.visible_sensitivities: + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="cmis_sensitivity_not_visible", + context={"profile": self.name, "sensitivity": _enum_value(classification.sensitivity)}, + ) + if self.visible_asset_types and classification.asset_type not in self.visible_asset_types: + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="cmis_asset_type_not_visible", + context={"profile": self.name, "asset_type": classification.asset_type}, + ) + if self.visible_topics and not set(classification.topics).intersection(self.visible_topics): + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="cmis_topic_not_visible", + context={"profile": self.name, "topics": list(classification.topics)}, + ) + source_system = asset.metadata.get("source_system") or classification.metadata.get("source_system") + if self.visible_source_systems and source_system not in self.visible_source_systems: + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="cmis_source_system_not_visible", + context={"profile": self.name, "source_system": source_system}, + ) + for key, denied_values in self.denied_metadata.items(): + value = asset.metadata.get(key, classification.metadata.get(key)) + if value in denied_values: + return PolicyDecision.deny( + context.actor.id, + "cmis.expose_asset", + resource, + reason="cmis_metadata_denied", + context={"profile": self.name, "metadata_key": key}, + ) + return PolicyDecision.allow( + context.actor.id, + "cmis.expose_asset", + resource, + context={"profile": self.name}, + ) + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "name": self.name, + "binding": self.binding.value, + "capabilities": [capability.value for capability in self.capabilities], + "allow_mutations": self.allow_mutations, + "visible_sensitivities": [item.value for item in self.visible_sensitivities], + "denied_sensitivities": [item.value for item in self.denied_sensitivities], + "visible_asset_types": list(self.visible_asset_types), + "visible_topics": list(self.visible_topics), + "visible_source_systems": list(self.visible_source_systems), + "denied_metadata": {key: list(values) for key, values in self.denied_metadata.items()}, + "required_actor_types": [item.value for item in self.required_actor_types], + "metadata": dict(self.metadata), + } + ) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CMISAccessProfile": + return cls( + name=data["name"], + binding=data.get("binding", CMISBinding.BROWSER.value), + capabilities=tuple(data.get("capabilities", [])), + allow_mutations=bool(data.get("allow_mutations", False)), + visible_sensitivities=tuple(data.get("visible_sensitivities", [])), + denied_sensitivities=tuple(data.get("denied_sensitivities", [])), + visible_asset_types=tuple(data.get("visible_asset_types", [])), + visible_topics=tuple(data.get("visible_topics", [])), + visible_source_systems=tuple(data.get("visible_source_systems", [])), + denied_metadata=dict(data.get("denied_metadata", {})), + required_actor_types=tuple(data.get("required_actor_types", [])), + metadata=dict(data.get("metadata", {})), + ) + + +@dataclass(frozen=True) +class CMISAccessPoint: + access_point_id: str + repository_id: str + profile: CMISAccessProfile + base_path: str + root_folder_id: str = "cmis-root" + enabled: bool = True + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + normalized = "/" + self.base_path.strip("/") + object.__setattr__(self, "base_path", normalized) + + def decide_action( + self, + action: CMISAction | str, + context: OperationContext, + *, + resource: str | None = None, + ) -> PolicyDecision: + if not self.enabled: + return PolicyDecision.deny( + context.actor.id, + CMISAction(action).value, + resource or f"cmis:{self.repository_id}", + reason="cmis_access_point_disabled", + context={"access_point_id": self.access_point_id, "profile": self.profile.name}, + ) + return self.profile.decide_action( + action, + context, + resource=resource or f"cmis:{self.repository_id}", + ) + + def exposes_asset(self, asset: KnowledgeAsset, context: OperationContext) -> bool: + return self.enabled and self.profile.exposes_asset(asset, context) + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "access_point_id": self.access_point_id, + "repository_id": self.repository_id, + "profile": self.profile.to_dict(), + "base_path": self.base_path, + "root_folder_id": self.root_folder_id, + "enabled": self.enabled, + "metadata": dict(self.metadata), + } + ) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CMISAccessPoint": + return cls( + access_point_id=data["access_point_id"], + repository_id=data["repository_id"], + profile=CMISAccessProfile.from_dict(data["profile"]), + base_path=data["base_path"], + root_folder_id=data.get("root_folder_id", "cmis-root"), + enabled=bool(data.get("enabled", True)), + metadata=dict(data.get("metadata", {})), + ) + + +def _read_capabilities() -> tuple[CMISCapability, ...]: + return ( + CMISCapability.REPOSITORY, + CMISCapability.TYPE_DEFINITIONS, + CMISCapability.NAVIGATION, + CMISCapability.OBJECT_READ, + CMISCapability.CONTENT_STREAM_READ, + CMISCapability.VERSIONING, + CMISCapability.DISCOVERY_QUERY, + CMISCapability.RELATIONSHIPS, + CMISCapability.ACL, + CMISCapability.CHANGE_LOG, + ) + + +def _enum_value(value: Any) -> Any: + return getattr(value, "value", value) diff --git a/tests/cmis/opencmis-tck/README.md b/tests/cmis/opencmis-tck/README.md new file mode 100644 index 0000000..4321cc4 --- /dev/null +++ b/tests/cmis/opencmis-tck/README.md @@ -0,0 +1,28 @@ +# Optional OpenCMIS TCK Harness + +This directory reserves the integration-test boundary for Apache Chemistry +OpenCMIS TCK 1.1.0. The harness is intentionally optional because Java/Maven +tooling is not part of the default Python test suite. + +Planned invocation shape: + +```sh +mvn -Dcmis.browser.url=http://127.0.0.1:8000/cmis/compat-tck/browser \ + -Dcmis.repository.id=kontextual-compat-tck \ + test +``` + +The first target is a selected Browser Binding subset: + +- repository information, +- type definitions, +- navigation, +- object reads, +- content stream reads, +- query subset, +- relationships, +- change log where supported. + +Mutation groups should only be enabled for `governed-authoring` or +`compat-tck` profiles after the policy and audit gates are implemented. + diff --git a/tests/cmis/test_cmis_access_profiles.py b/tests/cmis/test_cmis_access_profiles.py new file mode 100644 index 0000000..9b1a4e9 --- /dev/null +++ b/tests/cmis/test_cmis_access_profiles.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from kontextual_engine import ( + Actor, + ActorType, + CMISAccessPoint, + CMISAccessProfile, + CMISAction, + CMISCapability, + Classification, + KnowledgeAsset, + OperationContext, +) + + +def _context(actor_type: ActorType = ActorType.HUMAN) -> OperationContext: + return OperationContext.create( + Actor.create(actor_type, actor_id=f"actor-{actor_type.value}"), + correlation_id="corr-cmis-profile", + ) + + +def _asset( + asset_id: str, + sensitivity: str, + *, + asset_type: str = "document", + topics: tuple[str, ...] = ("cmis",), + metadata: dict | None = None, +) -> KnowledgeAsset: + return KnowledgeAsset.create( + f"Asset {asset_id}", + Classification(asset_type=asset_type, sensitivity=sensitivity, topics=topics), + asset_id=asset_id, + metadata=metadata, + ) + + +def test_readonly_profile_exposes_read_capabilities_and_rejects_mutations() -> None: + profile = CMISAccessProfile.readonly_browser() + context = _context() + + assert profile.has_capability(CMISCapability.REPOSITORY) + assert profile.has_capability(CMISCapability.CONTENT_STREAM_READ) + assert profile.decide_action(CMISAction.GET_OBJECT, context).allowed is True + + mutation = profile.decide_action(CMISAction.CREATE_DOCUMENT, context) + assert mutation.allowed is False + assert mutation.reason == "cmis_mutation_not_allowed" + + +def test_governed_authoring_profile_allows_selected_write_actions() -> None: + profile = CMISAccessProfile.governed_authoring() + context = _context() + + 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 + + +def test_profiles_hide_denied_sensitivities_without_partial_exposure() -> None: + profile = CMISAccessProfile.readonly_browser() + context = _context() + public = _asset("asset-public", "public") + confidential = _asset("asset-confidential", "confidential") + + assert profile.exposes_asset(public, context) is True + + denied = profile.decide_asset_visibility(confidential, context) + assert denied.allowed is False + assert denied.reason == "cmis_sensitivity_denied" + assert denied.context["sensitivity"] == "confidential" + + +def test_profile_visibility_can_be_scoped_by_type_topic_source_and_metadata() -> None: + profile = CMISAccessProfile( + name="topic-source-scope", + capabilities=(CMISCapability.OBJECT_READ,), + visible_asset_types=("document",), + visible_topics=("approved",), + visible_source_systems=("sharepoint",), + denied_metadata={"export_blocked": (True,)}, + ) + context = _context() + + allowed = _asset( + "asset-allowed", + "internal", + topics=("approved",), + metadata={"source_system": "sharepoint"}, + ) + wrong_topic = _asset( + "asset-wrong-topic", + "internal", + topics=("draft",), + metadata={"source_system": "sharepoint"}, + ) + blocked = _asset( + "asset-blocked", + "internal", + topics=("approved",), + metadata={"source_system": "sharepoint", "export_blocked": True}, + ) + + assert profile.exposes_asset(allowed, context) is True + assert profile.decide_asset_visibility(wrong_topic, context).reason == "cmis_topic_not_visible" + assert profile.decide_asset_visibility(blocked, context).reason == "cmis_metadata_denied" + + +def test_admin_export_requires_service_account_actor() -> None: + profile = CMISAccessProfile.admin_export() + human_context = _context(ActorType.HUMAN) + service_context = _context(ActorType.SERVICE_ACCOUNT) + confidential = _asset("asset-confidential", "confidential") + + assert profile.decide_action(CMISAction.GET_OBJECT, human_context).allowed is False + assert profile.exposes_asset(confidential, human_context) is False + assert profile.decide_action(CMISAction.GET_OBJECT, service_context).allowed is True + assert profile.exposes_asset(confidential, service_context) is True + + +def test_access_point_normalizes_base_path_and_round_trips() -> None: + access_point = CMISAccessPoint( + access_point_id="cmis-readonly", + repository_id="kontextual-readonly", + profile=CMISAccessProfile.readonly_browser(), + base_path="cmis/readonly/browser", + metadata={"owner": "codex"}, + ) + + serialized = access_point.to_dict() + restored = CMISAccessPoint.from_dict(serialized) + + assert access_point.base_path == "/cmis/readonly/browser" + assert restored == access_point + assert restored.decide_action(CMISAction.GET_REPOSITORY_INFO, _context()).allowed is True + + +def test_disabled_access_point_denies_all_actions_and_visibility() -> None: + access_point = CMISAccessPoint( + access_point_id="cmis-disabled", + repository_id="kontextual-disabled", + profile=CMISAccessProfile.governed_authoring(), + base_path="/cmis/disabled/browser", + enabled=False, + ) + context = _context() + + 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_contract_examples.py b/tests/cmis/test_cmis_contract_examples.py new file mode 100644 index 0000000..72806ad --- /dev/null +++ b/tests/cmis/test_cmis_contract_examples.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +FIXTURE_PATH = ( + Path(__file__).resolve().parents[2] / "examples" / "cmis" / "capability-fixtures.json" +) + +REQUIRED_GROUPS = { + "repository-type", + "navigation", + "object-content", + "versioning", + "discovery-query", + "relationships", + "acl-policy", + "change-log", + "retention-renditions-bulk", +} +REQUIRED_PROFILES = {"readonly-browser", "governed-authoring", "admin-export", "compat-tck"} + + +def _catalog() -> dict: + return json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + + +def test_cmis_fixture_catalog_declares_target_standard_and_profiles() -> None: + catalog = _catalog() + + assert catalog["standard"] == { + "name": "CMIS", + "version": "1.1", + "primary_binding": "browser", + "deferred_bindings": ["atompub", "web_services"], + } + assert set(catalog["profiles"]) == REQUIRED_PROFILES + assert all(profile["binding"] == "browser" for profile in catalog["profiles"].values()) + + +def test_cmis_fixture_groups_cover_required_capabilities() -> None: + catalog = _catalog() + groups = {group["id"]: group for group in catalog["capability_groups"]} + + assert set(groups) == REQUIRED_GROUPS + for group in groups.values(): + assert group["examples"], group["id"] + assert group["must_validate"], group["id"] + assert set(group["supported_profiles"]).issubset(REQUIRED_PROFILES) + + +def test_unsupported_cmis_features_have_structured_diagnostics() -> None: + catalog = _catalog() + diagnostics = catalog["unsupported_diagnostics"] + + unsupported = { + feature + for group in catalog["capability_groups"] + for feature in group.get("unsupported", []) + } + unsupported.update(catalog["standard"]["deferred_bindings"]) + + assert unsupported <= set(diagnostics) + assert all(diagnostics[feature] for feature in unsupported) + + +def test_profile_visibility_and_mutation_expectations_are_explicit() -> None: + catalog = _catalog() + groups = {group["id"] for group in catalog["capability_groups"]} + + for profile_name, profile in catalog["profiles"].items(): + expectations = catalog["profile_expectations"][profile_name] + + assert set(expectations["must_expose"]).issubset(groups) + assert "must_not_expose_objects" in expectations + if profile["mutations"]: + assert expectations["must_authorize_actions"] == [ + "create_document", + "update_properties", + "delete_object", + "set_content_stream", + ] + else: + assert expectations["must_reject_actions"] == [ + "create_document", + "update_properties", + "delete_object", + "set_content_stream", + ] + + +def test_readonly_and_governed_profiles_hide_confidential_fixture_objects() -> None: + catalog = _catalog() + objects = {item["id"]: item for item in catalog["objects"]} + confidential = { + object_id + for object_id, item in objects.items() + if item.get("sensitivity") == "confidential" + } + + assert confidential + for profile_name in ["readonly-browser", "governed-authoring", "compat-tck"]: + denied = set(catalog["profile_expectations"][profile_name]["must_not_expose_objects"]) + assert confidential <= denied + + +def test_admin_export_is_the_only_profile_for_deferred_extension_group() -> None: + catalog = _catalog() + extension = next( + group + for group in catalog["capability_groups"] + if group["id"] == "retention-renditions-bulk" + ) + + assert extension["supported_profiles"] == ["admin-export"] + for profile_name, expectations in catalog["profile_expectations"].items(): + exposes_extension = "retention-renditions-bulk" in expectations["must_expose"] + assert exposes_extension is (profile_name == "admin-export") + diff --git a/workplans/KONT-WP-0011-cmis-compliance-assessment-test-foundation.md b/workplans/KONT-WP-0011-cmis-compliance-assessment-test-foundation.md index 1c6d423..a2ee430 100644 --- a/workplans/KONT-WP-0011-cmis-compliance-assessment-test-foundation.md +++ b/workplans/KONT-WP-0011-cmis-compliance-assessment-test-foundation.md @@ -4,7 +4,7 @@ type: workplan title: "CMIS Compliance Assessment And Test Foundation" domain: markitect repo: kontextual-engine -status: active +status: completed owner: codex topic_slug: markitect planning_priority: high @@ -27,6 +27,9 @@ compliance tests. - `docs/cmis-compliance-assessment.md` - `docs/cmis-compliance-test-foundation.md` +- `docs/cmis-readiness-gate.md` +- `examples/cmis/capability-fixtures.json` +- `tests/cmis/test_cmis_contract_examples.py` - OASIS CMIS 1.1 standard and errata. - Apache Chemistry OpenCMIS TCK 1.1.0 and CMIS Workbench as optional external validation tools. @@ -41,7 +44,7 @@ fixtures, profile matrix, and test harness that will govern implementation in ```task id: KONT-WP-0011-T001 -status: todo +status: done priority: high state_hub_task_id: "1f4ed133-74f7-46df-9266-277813b5399a" ``` @@ -56,7 +59,7 @@ Acceptance: ```task id: KONT-WP-0011-T002 -status: todo +status: done priority: high state_hub_task_id: "02651630-16e6-4c5f-879d-988a55fb7227" ``` @@ -71,7 +74,7 @@ Acceptance: ```task id: KONT-WP-0011-T003 -status: todo +status: done priority: high state_hub_task_id: "a895218c-61b1-4944-8774-fd21c5416580" ``` @@ -88,7 +91,7 @@ Acceptance: ```task id: KONT-WP-0011-T004 -status: todo +status: done priority: high state_hub_task_id: "710da9b6-2034-4948-8bc7-16230cc839cf" ``` @@ -104,7 +107,7 @@ Acceptance: ```task id: KONT-WP-0011-T005 -status: todo +status: done priority: medium state_hub_task_id: "85b321a1-b9b5-469e-bc45-b207dc39ad7a" ``` @@ -119,7 +122,7 @@ Acceptance: ```task id: KONT-WP-0011-T006 -status: todo +status: done priority: medium state_hub_task_id: "604e2f06-fde5-4d88-a13e-f3b725177696" ``` diff --git a/workplans/KONT-WP-0012-cmis-profiled-access-points.md b/workplans/KONT-WP-0012-cmis-profiled-access-points.md index 13d2222..fa88c94 100644 --- a/workplans/KONT-WP-0012-cmis-profiled-access-points.md +++ b/workplans/KONT-WP-0012-cmis-profiled-access-points.md @@ -38,6 +38,12 @@ Implementation must begin after the assessment, examples, and test foundation from `KONT-WP-0011` are sufficient to define the first profile and regression suite. +## Implementation Notes + +- `docs/cmis-profiled-access-points-implementation.md` +- `src/kontextual_engine/core/cmis.py` +- `tests/cmis/test_cmis_access_profiles.py` + ## Architecture Constraint CMIS routes are adapters over engine services and policy gates. They must not @@ -49,7 +55,7 @@ emits audit events. ```task id: KONT-WP-0012-T001 -status: todo +status: done priority: high state_hub_task_id: "031c3ce5-bb56-41fb-a014-6a496c280d20" ```