profile-scoped ACL policy and redaction

This commit is contained in:
2026-05-07 01:51:44 +02:00
parent e02f78d7e3
commit 88f9df6288
7 changed files with 136 additions and 1 deletions

View File

@@ -421,6 +421,26 @@ class ServiceRuntime:
)
return content_stream
def cmis_acl(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_ACL, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getACL")
asset_id = _cmis_asset_id(object_id)
asset = self.repository.get_asset(asset_id)
acl = mapper.acl_for_asset(asset, context)
if acl is None:
raise NotFoundError(
"CMIS object not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
return acl
def cmis_create_document(
self,
access_point_id: str,
@@ -593,6 +613,7 @@ class ServiceRuntime:
projections = [
projection.to_dict()
for relationship in self.repository.list_relationships(source_id=source_id)
if self._cmis_relationship_visible(mapper, relationship, context)
if (projection := mapper.map_relationship(relationship, context))
]
return {"items": projections, "count": len(projections)}
@@ -621,6 +642,7 @@ class ServiceRuntime:
}
for event in events
if event.target.startswith("asset:")
if self._cmis_asset_visible(mapper, event.target.removeprefix("asset:"), context)
]
paged = changes[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
return {
@@ -640,6 +662,29 @@ class ServiceRuntime:
details={"access_point_id": access_point_id, "available": [profile.name for profile in _cmis_profiles()]},
)
def _cmis_asset_visible(
self,
mapper: CMISDomainMapper,
asset_id: str,
context: OperationContext,
) -> bool:
try:
return mapper.access_point.exposes_asset(self.repository.get_asset(asset_id), context)
except NotFoundError:
return False
def _cmis_relationship_visible(
self,
mapper: CMISDomainMapper,
relationship: Any,
context: OperationContext,
) -> bool:
if not self._cmis_asset_visible(mapper, relationship.source_id, context):
return False
if relationship.target_kind == RelationshipTargetKind.ASSET:
return self._cmis_asset_visible(mapper, relationship.target_id, context)
return True
def create_asset(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]:
classification = Classification.from_dict(payload["classification"])
result = self.asset_service().create_asset(
@@ -2087,6 +2132,14 @@ def create_app(runtime: ServiceRuntime | None = None):
) -> dict[str, Any]:
return response(runtime.cmis_content_stream, access_point_id, object_id, context)
@app.get("/cmis/{access_point_id}/browser/acl/{object_id:path}", tags=["cmis"])
def cmis_acl(
access_point_id: str,
object_id: str,
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_acl, access_point_id, object_id, context)
@app.post("/cmis/{access_point_id}/browser/document", tags=["cmis"])
def cmis_create_document(
access_point_id: str,

View File

@@ -50,6 +50,7 @@ class CMISAction(str, Enum):
GET_CHILDREN = "get_children"
GET_OBJECT = "get_object"
GET_CONTENT_STREAM = "get_content_stream"
GET_ACL = "get_acl"
QUERY = "query"
GET_RELATIONSHIPS = "get_relationships"
GET_CHANGE_LOG = "get_change_log"
@@ -77,6 +78,7 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = {
CMISAction.GET_CHILDREN: CMISCapability.NAVIGATION,
CMISAction.GET_OBJECT: CMISCapability.OBJECT_READ,
CMISAction.GET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_READ,
CMISAction.GET_ACL: CMISCapability.ACL,
CMISAction.QUERY: CMISCapability.DISCOVERY_QUERY,
CMISAction.GET_RELATIONSHIPS: CMISCapability.RELATIONSHIPS,
CMISAction.GET_CHANGE_LOG: CMISCapability.CHANGE_LOG,
@@ -431,6 +433,7 @@ class CMISObjectProjection:
content_stream: dict[str, Any] | None = None
version: dict[str, Any] | None = None
relationships: tuple[str, ...] = ()
acl: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
return compact_dict(
@@ -445,6 +448,7 @@ class CMISObjectProjection:
"content_stream": dict(self.content_stream or {}),
"version": dict(self.version or {}),
"relationships": list(self.relationships),
"acl": dict(self.acl or {}),
}
)
@@ -536,6 +540,7 @@ class CMISDomainMapper:
content_stream=content_stream,
version=self.version_properties(asset, current_version, versions),
relationships=tuple(relationship_ids),
acl=self.acl_for_asset(asset, context),
)
def map_relationship(
@@ -575,6 +580,36 @@ class CMISDomainMapper:
allowable_actions=(CMISAction.GET_OBJECT, CMISAction.GET_RELATIONSHIPS),
)
def acl_for_asset(self, asset: KnowledgeAsset, context: OperationContext) -> dict[str, Any] | None:
visibility = self.access_point.profile.decide_asset_visibility(asset, context)
if not visibility.allowed:
return None
permissions = ["cmis:read"]
if self.access_point.profile.allow_mutations:
permissions.extend(["cmis:write", "cmis:delete"])
entries = [
{
"principal_id": context.actor.id,
"permissions": permissions,
"direct": True,
}
]
if asset.classification.sensitivity == Sensitivity.PUBLIC:
entries.append(
{
"principal_id": "anyone",
"permissions": ["cmis:read"],
"direct": False,
}
)
return {
"object_id": self.asset_object_id(asset.id),
"is_exact": True,
"aces": entries,
"derived_from": "kontextual-profile-policy",
"profile": self.access_point.profile.name,
}
def asset_object_id(self, asset_id: str) -> str:
return f"cmis:asset:{asset_id}"
@@ -679,6 +714,7 @@ class CMISDomainMapper:
candidates = [
CMISAction.GET_OBJECT,
CMISAction.GET_CONTENT_STREAM,
CMISAction.GET_ACL,
CMISAction.GET_RELATIONSHIPS,
CMISAction.UPDATE_PROPERTIES,
CMISAction.DELETE_OBJECT,