CMIS compliance/test foundation

This commit is contained in:
2026-05-07 00:35:33 +02:00
parent 28420c68d1
commit 241522e74d
14 changed files with 1080 additions and 12 deletions

View File

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

View File

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

View File

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

15
examples/cmis/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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