|
28 | 28 | from powercontext.builtin.persistence.sqlite import SQLiteConfig |
29 | 29 | from powercontext.builtin.runtime import InferenceConfig, RuntimeConfig |
30 | 30 | from powercontext.server.authentication import AuthenticationResult, ProviderReadiness |
31 | | -from powercontext.server.authz import AccessAuditContext, AccessUnavailableError, PrincipalRef, ResourceRef |
| 31 | +from powercontext.server.authz import ( |
| 32 | + AccessAction, |
| 33 | + AccessAuditContext, |
| 34 | + AccessDecision, |
| 35 | + AccessUnavailableError, |
| 36 | + BuiltinAuthorizationProvider, |
| 37 | + PrincipalRef, |
| 38 | + ResourceRef, |
| 39 | +) |
32 | 40 | from powercontext.server.authz.composition import open_builtin_access_control, open_casbin_access_control |
33 | 41 | from powercontext.server.factory import create_server_app |
34 | 42 | from powercontext.server.settings import AccessControlConfig, McpConfig, MetricsConfig, ServerSettings |
@@ -1261,6 +1269,54 @@ async def interleaved_latest(connection, scope_id, family, artifact_id, **kwargs |
1261 | 1269 | asyncio.run(scenario()) |
1262 | 1270 |
|
1263 | 1271 |
|
| 1272 | +def test_atomic_lifecycle_respects_a_custom_builtin_provider_denial(tmp_path): |
| 1273 | + class DenyArtifactWrite(BuiltinAuthorizationProvider): |
| 1274 | + async def check_batch(self, requests, /): |
| 1275 | + decisions = await super().check_batch(requests) |
| 1276 | + return tuple( |
| 1277 | + AccessDecision(False, "custom-denial", decision.policy_revision) |
| 1278 | + if request.action is AccessAction.ARTIFACT_WRITE |
| 1279 | + else decision |
| 1280 | + for request, decision in zip(requests, decisions, strict=True) |
| 1281 | + ) |
| 1282 | + |
| 1283 | + async def scenario(): |
| 1284 | + async with _server(tmp_path) as (_, client, access): |
| 1285 | + scope_id = await _scope(client) |
| 1286 | + remembered = await client.post( |
| 1287 | + "/v1/memory/remember", json={"scope_id": scope_id, "kind": "fact", "text": "Alpha scope fact."} |
| 1288 | + ) |
| 1289 | + assert remembered.status_code == 200, remembered.text |
| 1290 | + identity = remembered.json()["records"][0]["artifact"]["artifact_id"] |
| 1291 | + path = f"/v1/scopes/{scope_id}/artifacts/atomic-memory/{identity}" |
| 1292 | + current = await client.get(path) |
| 1293 | + assert current.status_code == 200, current.text |
| 1294 | + |
| 1295 | + access.provider = DenyArtifactWrite(access.provider._repository, deployment_id=access.deployment_id) |
| 1296 | + edited = await client.put( |
| 1297 | + path, headers={"If-Match": current.headers["ETag"]}, json={"content": {"kind": "fact", "text": "Beta."}} |
| 1298 | + ) |
| 1299 | + assert edited.status_code == 403, edited.text |
| 1300 | + forgotten = await client.post( |
| 1301 | + "/v1/atomic-memory/lifecycle", |
| 1302 | + json={ |
| 1303 | + "scope_id": scope_id, |
| 1304 | + "target": { |
| 1305 | + "artifact": {"family": "atomic-memory", "artifact_id": identity, "revision": 1}, |
| 1306 | + "state_version": 0, |
| 1307 | + }, |
| 1308 | + "state": "forgotten", |
| 1309 | + }, |
| 1310 | + ) |
| 1311 | + assert forgotten.status_code == 403, forgotten.text |
| 1312 | + state = await client.get(path + "/state") |
| 1313 | + assert state.status_code == 200, state.text |
| 1314 | + assert state.json()["state"] == "active" |
| 1315 | + assert state.json()["state_version"] == 0 |
| 1316 | + |
| 1317 | + asyncio.run(scenario()) |
| 1318 | + |
| 1319 | + |
1264 | 1320 | @pytest.mark.parametrize("provider", ["builtin", "casbin", "custom"]) |
1265 | 1321 | def test_atomic_scope_search_and_list_keep_exact_artifact_shares_separate(tmp_path, provider): |
1266 | 1322 | class ConfiguredProvider: |
|
0 commit comments