use serde_json::Value;
use trust_tasks_rs::TrustTask;
use vta_sdk::protocols::memory::{
MemoryDeleteBody, MemoryDeleteResponse, MemoryListBody, MemoryListResponse, MemoryPutBody,
MemoryPutResponse,
};
use vti_common::acl::Capability;
use crate::audit;
use crate::auth::AuthClaims;
use crate::operations::memory;
use crate::server::AppState;
use super::helpers::{TRANSPORT_TRUST_TASK, app_error_to_reject, parse_payload, success_response};
async fn require_cap(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
cap: Capability,
action: &str,
) -> Result<(), super::helpers::TrustTaskOutcome> {
super::helpers::require_capability(state, auth, doc, cap, &format!("memory {action}")).await
}
pub(super) async fn handle_put(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> super::helpers::TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::MemoryWrite, "put").await {
return r;
}
let req: MemoryPutBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = auth.require_context(&req.context_id) {
return app_error_to_reject(&doc, e);
}
if let Err(e) = memory::put(&state.memory_ks, &req.context_id, &req.key, &req.value).await {
return app_error_to_reject(&doc, e);
}
audit_memory(state, "memory.put", auth, &req.key, &req.context_id).await;
success_response(&doc, MemoryPutResponse { key: req.key })
}
pub(super) async fn handle_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> super::helpers::TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::MemoryRead, "list").await {
return r;
}
let req: MemoryListBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = auth.require_context(&req.context_id) {
return app_error_to_reject(&doc, e);
}
let items = match memory::list(&state.memory_ks, &req.context_id).await {
Ok(items) => items,
Err(e) => return app_error_to_reject(&doc, e),
};
audit_memory(state, "memory.list", auth, &req.context_id, &req.context_id).await;
success_response(&doc, MemoryListResponse { items })
}
pub(super) async fn handle_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> super::helpers::TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::MemoryWrite, "delete").await {
return r;
}
let req: MemoryDeleteBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = auth.require_context(&req.context_id) {
return app_error_to_reject(&doc, e);
}
if let Err(e) = memory::delete(&state.memory_ks, &req.context_id, &req.key).await {
return app_error_to_reject(&doc, e);
}
audit_memory(state, "memory.delete", auth, &req.key, &req.context_id).await;
success_response(&doc, MemoryDeleteResponse { key: req.key })
}
async fn audit_memory(
state: &AppState,
action: &str,
auth: &AuthClaims,
resource: &str,
context_id: &str,
) {
if let Err(e) = audit::record(
&state.audit_sink,
action,
&auth.did,
Some(resource),
"success",
Some(TRANSPORT_TRUST_TASK),
Some(context_id),
)
.await
{
tracing::warn!(error = %e, action = %action, "audit record failed for memory task");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Role;
use crate::test_support::build_signing_test_app_state;
use serde_json::json;
use trust_tasks_rs::TypeUri;
use vta_sdk::trust_tasks::{
TASK_VTA_MEMORY_DELETE_0_1, TASK_VTA_MEMORY_LIST_0_1, TASK_VTA_MEMORY_PUT_0_1,
};
fn claims_for(role: Role, ctx: &str) -> AuthClaims {
AuthClaims {
did: format!("did:key:z{role}"),
role,
allowed_contexts: vec![ctx.to_string()],
session_id: "test-session".into(),
access_expires_at: 0,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn admin_of(ctx: &str) -> AuthClaims {
AuthClaims {
did: "did:key:zCtxAdmin".into(),
role: Role::Admin,
allowed_contexts: vec![ctx.to_string()],
session_id: "test-session".into(),
access_expires_at: 0,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn doc(uri: &str, payload: Value) -> TrustTask<Value> {
let uri: TypeUri = uri.parse().expect("memory uri");
TrustTask::new(format!("urn:uuid:{}", uuid::Uuid::new_v4()), uri, payload)
}
fn put_doc(ctx: &str, key: &str, value: &str) -> TrustTask<Value> {
doc(
TASK_VTA_MEMORY_PUT_0_1,
json!({ "contextId": ctx, "key": key, "value": value }),
)
}
fn list_doc(ctx: &str) -> TrustTask<Value> {
doc(TASK_VTA_MEMORY_LIST_0_1, json!({ "contextId": ctx }))
}
fn delete_doc(ctx: &str, key: &str) -> TrustTask<Value> {
doc(
TASK_VTA_MEMORY_DELETE_0_1,
json!({ "contextId": ctx, "key": key }),
)
}
fn is_denied(out: &super::super::helpers::TrustTaskOutcome) -> bool {
let doc: Value = serde_json::from_slice(&out.body).expect("response is JSON");
!out.status.is_success()
|| doc
.get("payload")
.and_then(|p| p.get("reason"))
.and_then(Value::as_str)
.is_some_and(|r| r.contains("denied"))
}
fn response_payload(out: &super::super::helpers::TrustTaskOutcome) -> Value {
let doc: Value = serde_json::from_slice(&out.body).expect("response is JSON");
doc.get("payload").cloned().unwrap_or(Value::Null)
}
#[tokio::test]
async fn put_then_list_returns_the_item() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = admin_of("acme");
let out = handle_put(&state, &auth, put_doc("acme", "name", "Ada")).await;
assert!(out.status.is_success(), "put should succeed");
assert_eq!(
response_payload(&out).get("key").and_then(Value::as_str),
Some("name")
);
let list = handle_list(&state, &auth, list_doc("acme")).await;
assert!(list.status.is_success());
let items = response_payload(&list)
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(items.len(), 1);
assert_eq!(items[0].get("key").and_then(Value::as_str), Some("name"));
assert_eq!(items[0].get("value").and_then(Value::as_str), Some("Ada"));
}
#[tokio::test]
async fn a_narrowed_entry_loses_what_its_role_still_carries() {
use vti_common::acl::{AclEntry, Capability, store_acl_entry};
let (state, _dir) = build_signing_test_app_state().await;
let auth = admin_of("acme");
assert!(
handle_put(&state, &auth, put_doc("acme", "before", "ok"))
.await
.status
.is_success()
);
let narrowed = AclEntry::new(&auth.did, auth.role.clone(), "did:key:zAdmin")
.with_contexts(vec!["acme".to_string()])
.with_capabilities(vec![Capability::MemoryRead]);
store_acl_entry(&state.acl_ks, &narrowed)
.await
.expect("store the narrowed entry");
let refused = handle_put(&state, &auth, put_doc("acme", "after", "no")).await;
assert!(
!refused.status.is_success(),
"a memory-read-only entry must not write"
);
assert!(
handle_list(&state, &auth, list_doc("acme"))
.await
.status
.is_success(),
"…and must still read: narrowing removes one capability, not the entry"
);
}
#[tokio::test]
async fn naming_a_capability_the_role_lacks_does_not_grant_it() {
use vti_common::acl::{AclEntry, Capability, store_acl_entry};
let (state, _dir) = build_signing_test_app_state().await;
let auth = claims_for(Role::Reader, "acme");
let entry = AclEntry::new(&auth.did, Role::Reader, "did:key:zAdmin")
.with_contexts(vec!["acme".to_string()])
.with_capabilities(vec![Capability::MemoryRead, Capability::MemoryWrite]);
store_acl_entry(&state.acl_ks, &entry)
.await
.expect("store the entry");
assert!(
!handle_put(&state, &auth, put_doc("acme", "k", "v"))
.await
.status
.is_success(),
"a reader naming memory-write must still not write"
);
assert!(
handle_list(&state, &auth, list_doc("acme"))
.await
.status
.is_success(),
"the capability the role does carry is unaffected"
);
}
#[tokio::test]
async fn put_same_key_twice_upserts() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = admin_of("acme");
handle_put(&state, &auth, put_doc("acme", "name", "Ada")).await;
handle_put(&state, &auth, put_doc("acme", "name", "Grace")).await;
let list = handle_list(&state, &auth, list_doc("acme")).await;
let items = response_payload(&list)
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(items.len(), 1, "re-put must replace, not append");
assert_eq!(items[0].get("value").and_then(Value::as_str), Some("Grace"));
}
#[tokio::test]
async fn delete_removes_then_unknown_is_not_found() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = admin_of("acme");
handle_put(&state, &auth, put_doc("acme", "k", "v")).await;
let del = handle_delete(&state, &auth, delete_doc("acme", "k")).await;
assert!(del.status.is_success(), "delete of present key succeeds");
assert!(
handle_list(&state, &auth, list_doc("acme"))
.await
.status
.is_success()
);
let again = handle_delete(&state, &auth, delete_doc("acme", "k")).await;
assert!(!again.status.is_success(), "delete of absent key must fail");
let body = String::from_utf8_lossy(&again.body);
assert!(
body.contains("not found"),
"unknown key should report not-found, got: {body}"
);
}
#[tokio::test]
async fn caller_without_context_access_is_refused() {
let (state, _dir) = build_signing_test_app_state().await;
let intruder = admin_of("other");
let out = handle_put(&state, &intruder, put_doc("acme", "k", "v")).await;
assert!(
!out.status.is_success(),
"a caller without access to the context must be refused"
);
let body = String::from_utf8_lossy(&out.body);
assert!(
body.contains("permissionDenied"),
"context refusal should carry the permissionDenied reject code, got: {body}"
);
let list = handle_list(&state, &admin_of("acme"), list_doc("acme")).await;
let items = response_payload(&list)
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(items.is_empty(), "refused put must not have written");
}
#[tokio::test]
async fn memory_in_context_a_is_not_listed_for_context_b() {
let (state, _dir) = build_signing_test_app_state().await;
handle_put(
&state,
&admin_of("ctx-a"),
put_doc("ctx-a", "secret", "a-only"),
)
.await;
handle_put(
&state,
&admin_of("ctx-b"),
put_doc("ctx-b", "secret", "b-only"),
)
.await;
let a = handle_list(&state, &admin_of("ctx-a"), list_doc("ctx-a")).await;
let items = response_payload(&a)
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(items.len(), 1, "context A lists only its own entry");
assert_eq!(
items[0].get("value").and_then(Value::as_str),
Some("a-only")
);
}
#[tokio::test]
async fn a_reader_can_list_memory_but_not_write_it() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = claims_for(Role::Reader, "ctx-a");
let out = handle_list(&state, &auth, list_doc("ctx-a")).await;
assert!(
out.status.is_success(),
"reader holds MemoryRead, so listing must succeed"
);
let out = handle_put(&state, &auth, put_doc("ctx-a", "user/name", "Ada")).await;
assert!(
is_denied(&out),
"reader does not hold MemoryWrite, so put must be refused"
);
let out = handle_delete(&state, &auth, delete_doc("ctx-a", "user/name")).await;
assert!(
is_denied(&out),
"reader does not hold MemoryWrite, so delete must be refused"
);
}
#[tokio::test]
async fn a_monitor_reaches_no_memory_at_all() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = claims_for(Role::Monitor, "ctx-a");
assert!(is_denied(
&handle_list(&state, &auth, list_doc("ctx-a")).await
));
assert!(is_denied(
&handle_put(&state, &auth, put_doc("ctx-a", "user/name", "Ada")).await
));
}
#[tokio::test]
async fn an_application_keeps_full_memory_access() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = claims_for(Role::Application, "ctx-a");
assert!(
handle_put(&state, &auth, put_doc("ctx-a", "user/name", "Ada"))
.await
.status
.is_success()
);
assert!(
handle_list(&state, &auth, list_doc("ctx-a"))
.await
.status
.is_success()
);
assert!(
handle_delete(&state, &auth, delete_doc("ctx-a", "user/name"))
.await
.status
.is_success()
);
}
#[tokio::test]
async fn the_capability_gate_runs_before_the_context_gate() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = claims_for(Role::Monitor, "ctx-a");
let out = handle_list(&state, &auth, list_doc("ctx-they-cannot-reach")).await;
assert!(
is_denied(&out),
"denied either way — the point is it is refused on the capability, \
so the reason text never depends on whether that context exists"
);
}
}