generated from coulomb/repo-seed
profile-scoped ACL policy and redaction
This commit is contained in:
@@ -105,6 +105,21 @@ These routes delegate to existing engine services:
|
||||
Read-only profiles reject the same mutations with CMIS-shaped authorization
|
||||
diagnostics before touching engine services.
|
||||
|
||||
## ACL And Redaction Slice
|
||||
|
||||
The Browser Binding adapter now projects profile-derived ACLs through
|
||||
`GET /cmis/{access_point_id}/browser/acl/{object_id}`. ACL entries are derived
|
||||
from the access profile and actor context:
|
||||
|
||||
- visible objects grant the current actor `cmis:read`,
|
||||
- authoring profiles also project `cmis:write` and `cmis:delete`,
|
||||
- public objects include a read-only `anyone` ACE,
|
||||
- hidden objects return `not found` rather than partial metadata.
|
||||
|
||||
Relationship listings and change logs now apply the same asset visibility gates
|
||||
as object reads. This prevents indirect leakage of confidential or restricted
|
||||
asset IDs through relationship targets or audit-backed change entries.
|
||||
|
||||
Route-level tests are present but skip when the optional FastAPI/httpx service
|
||||
dependencies are not installed. Runtime-level Browser Binding tests cover the
|
||||
same behavior in the default Python test suite.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -83,6 +83,7 @@ def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> N
|
||||
assert "/cmis/{access_point_id}/browser/children" in paths
|
||||
assert "/cmis/{access_point_id}/browser/object/{object_id}" in paths
|
||||
assert "/cmis/{access_point_id}/browser/content/{object_id}" in paths
|
||||
assert "/cmis/{access_point_id}/browser/acl/{object_id}" in paths
|
||||
assert "/cmis/{access_point_id}/browser/query" in paths
|
||||
assert "/cmis/{access_point_id}/browser/relationships" in paths
|
||||
assert "/cmis/{access_point_id}/browser/changes" in paths
|
||||
|
||||
@@ -65,6 +65,16 @@ def cmis_runtime() -> tuple[ServiceRuntime, object]:
|
||||
},
|
||||
context,
|
||||
)
|
||||
runtime.create_relationship(
|
||||
{
|
||||
"source_asset_id": "asset-runtime-source",
|
||||
"target_id": "asset-runtime-confidential",
|
||||
"predicate": "mentions_sensitive",
|
||||
"target_kind": "asset",
|
||||
"confidence": 0.5,
|
||||
},
|
||||
context,
|
||||
)
|
||||
return runtime, context
|
||||
|
||||
|
||||
@@ -105,6 +115,7 @@ def test_runtime_cmis_browser_content_query_relationships_and_changes(cmis_runti
|
||||
assert relationships["count"] == 1
|
||||
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-runtime-public"
|
||||
assert changes["total_num_items"] >= 3
|
||||
assert all(change["object_id"] != "cmis:asset:asset-runtime-confidential" for change in changes["changes"])
|
||||
|
||||
|
||||
def test_runtime_cmis_browser_rejects_unsupported_query_subset(cmis_runtime) -> None:
|
||||
@@ -173,3 +184,21 @@ def test_runtime_cmis_readonly_profile_rejects_mutations(cmis_runtime) -> None:
|
||||
)
|
||||
|
||||
assert "CMIS operation denied" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_cmis_acl_projection_and_redaction(cmis_runtime) -> None:
|
||||
runtime, context = cmis_runtime
|
||||
|
||||
public_acl = runtime.cmis_acl("readonly-browser", "cmis:asset:asset-runtime-public", context)
|
||||
internal_acl = runtime.cmis_acl("governed-authoring", "cmis:asset:asset-runtime-source", context)
|
||||
|
||||
assert public_acl["is_exact"] is True
|
||||
assert {entry["principal_id"] for entry in public_acl["aces"]} == {"cmis-runtime", "anyone"}
|
||||
assert ["cmis:read", "cmis:write", "cmis:delete"] in [
|
||||
entry["permissions"] for entry in internal_acl["aces"]
|
||||
]
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
runtime.cmis_acl("readonly-browser", "cmis:asset:asset-runtime-confidential", context)
|
||||
|
||||
assert "CMIS object not found" in str(exc_info.value)
|
||||
|
||||
@@ -657,6 +657,7 @@ def test_service_health_readiness_version_and_openapi_contracts(client) -> None:
|
||||
assert "/cmis" in paths
|
||||
assert "/cmis/{access_point_id}/browser" in paths
|
||||
assert "/cmis/{access_point_id}/browser/children" in paths
|
||||
assert "/cmis/{access_point_id}/browser/acl/{object_id}" in paths
|
||||
assert "/cmis/{access_point_id}/browser/document" in paths
|
||||
assert "/cmis/{access_point_id}/browser/object/{object_id}/properties" in paths
|
||||
assert "/api/v1/assets" in paths
|
||||
|
||||
@@ -125,7 +125,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0012-T005
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "64289d84-d7a2-4c03-8fa6-5f439bc233fe"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user