use chrono::{DateTime, Utc};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use trust_tasks_rs::validate::ValidatedPayload;
use crate::{HolderKey, MAX_DOCUMENT_RESPONSE_BYTES, VtcClient, VtcError, decode_payload};
pub use trust_tasks_rs::specs::acl::{
change_role::v0_1 as change_role, grant::v0_1 as grant, list::v0_1 as list,
revoke::v0_1 as revoke, show::v0_1 as show, update::v0_1 as update,
};
pub mod task {
use trust_tasks_rs::Payload;
pub const LIST: &str = <super::list::Payload as Payload>::TYPE_URI;
pub const SHOW: &str = <super::show::Payload as Payload>::TYPE_URI;
pub const GRANT: &str = <super::grant::Payload as Payload>::TYPE_URI;
pub const UPDATE: &str = <super::update::Payload as Payload>::TYPE_URI;
pub const CHANGE_ROLE: &str = <super::change_role::Payload as Payload>::TYPE_URI;
pub const REVOKE: &str = <super::revoke::Payload as Payload>::TYPE_URI;
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AclListFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_size: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AclGrant {
pub subject: String,
pub role: String,
pub scopes: Vec<String>,
pub label: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AclUpdate {
pub subject: String,
pub label: Option<Option<String>>,
pub scopes: Option<Vec<String>>,
pub expires_at: Option<Option<DateTime<Utc>>>,
pub reason: Option<String>,
}
impl VtcClient {
pub async fn acl_list(
&self,
filter: &AclListFilter,
key: &HolderKey,
) -> Result<list::Response, VtcError> {
let payload = checked::<list::Payload>(serde_json::to_value(filter).map_err(bad)?)?;
self.acl_task(task::LIST, payload, key, &[]).await
}
pub async fn acl_list_all(
&self,
filter: &AclListFilter,
key: &HolderKey,
) -> Result<Vec<list::AclEntry>, VtcError> {
let mut filter = filter.clone();
let mut out = Vec::new();
loop {
let page = self.acl_list(&filter, key).await?;
out.extend(page.entries);
match (page.truncated, page.cursor) {
(true, Some(cursor)) => filter.cursor = Some(cursor),
(true, None) => {
return Err(VtcError::Http {
status: 200,
body: "acl/list answered a truncated page with no cursor".into(),
});
}
(false, _) => return Ok(out),
}
}
}
pub async fn acl_show(
&self,
subject: &str,
key: &HolderKey,
) -> Result<show::Response, VtcError> {
let payload = checked::<show::Payload>(serde_json::json!({ "subject": subject }))?;
self.acl_task(task::SHOW, payload, key, &[]).await
}
pub async fn acl_grant(
&self,
grant: &AclGrant,
key: &HolderKey,
) -> Result<grant::Response, VtcError> {
let mut entry = serde_json::json!({
"subject": grant.subject,
"role": grant.role,
"scopes": grant.scopes,
});
if let Some(label) = &grant.label {
entry["label"] = serde_json::json!(label);
}
if let Some(at) = grant.expires_at {
entry["expiresAt"] = serde_json::json!(at.to_rfc3339());
}
let mut body = serde_json::json!({ "entry": entry });
if let Some(reason) = &grant.reason {
body["reason"] = serde_json::json!(reason);
}
let payload = checked::<grant::Payload>(body)?;
self.acl_task(task::GRANT, payload, key, &[]).await
}
pub async fn acl_update(
&self,
update: &AclUpdate,
key: &HolderKey,
) -> Result<update::Response, VtcError> {
let mut body = serde_json::json!({ "subject": update.subject });
if let Some(label) = &update.label {
body["label"] = serde_json::json!(label);
}
if let Some(scopes) = &update.scopes {
body["scopes"] = serde_json::json!(scopes);
}
if let Some(at) = &update.expires_at {
body["expiresAt"] = serde_json::json!(at.map(|t| t.to_rfc3339()));
}
if let Some(reason) = &update.reason {
body["reason"] = serde_json::json!(reason);
}
let payload = checked::<update::Payload>(body)?;
self.acl_task(task::UPDATE, payload, key, update::ERROR_CODES)
.await
}
pub async fn acl_change_role(
&self,
subject: &str,
from_role: &str,
to_role: &str,
reason: Option<&str>,
key: &HolderKey,
) -> Result<change_role::Response, VtcError> {
let mut body = serde_json::json!({
"subject": subject,
"fromRole": from_role,
"toRole": to_role,
});
if let Some(reason) = reason {
body["reason"] = serde_json::json!(reason);
}
let payload = checked::<change_role::Payload>(body)?;
self.acl_task(task::CHANGE_ROLE, payload, key, &[]).await
}
pub async fn acl_revoke(
&self,
subject: &str,
scopes: Option<&[String]>,
reason: Option<&str>,
key: &HolderKey,
) -> Result<revoke::Response, VtcError> {
let mut body = serde_json::json!({ "subject": subject });
if let Some(scopes) = scopes {
body["scopes"] = serde_json::json!(scopes);
}
if let Some(reason) = reason {
body["reason"] = serde_json::json!(reason);
}
let payload = checked::<revoke::Payload>(body)?;
self.acl_task(task::REVOKE, payload, key, revoke::ERROR_CODES)
.await
}
async fn acl_task<R: DeserializeOwned>(
&self,
type_uri: &str,
payload: Value,
key: &HolderKey,
declared: &[trust_tasks_rs::DeclaredErrorCode],
) -> Result<R, VtcError> {
#[cfg(feature = "didcomm")]
if self.documents.is_some() {
return self
.acl_over_session(type_uri, payload, key.holder_did())
.await;
}
let doc =
vta_sdk::trust_task_sign::build_signed_with(type_uri, payload, key, &self.vtc_did)
.await
.map_err(|e| VtcError::Signing(e.to_string()))?;
let reply = self
.post_document(doc, declared, MAX_DOCUMENT_RESPONSE_BYTES)
.await?;
decode_payload(reply, type_uri)
}
#[cfg(feature = "didcomm")]
async fn acl_over_session<R: DeserializeOwned>(
&self,
type_uri: &str,
payload: Value,
signer_did: &str,
) -> Result<R, VtcError> {
let (Some(documents), Some(session_did)) = (&self.documents, &self.session_did) else {
return Err(VtcError::Session("this client holds no session".into()));
};
let base = |d: &str| d.split('#').next().unwrap_or(d).to_string();
if base(signer_did) != base(session_did) {
return Err(VtcError::Signing(format!(
"{type_uri} is to be signed as {signer_did}, but this client's session is \
{session_did}; over a session the VTC accepts a document only from the DID \
that signed it"
)));
}
let reply = documents
.dispatch_trust_task_document(type_uri, payload, crate::SESSION_TIMEOUT_SECS)
.await
.map_err(|e| VtcError::Session(e.to_string()))?;
let refused = reply
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| t.starts_with("https://trusttasks.org/spec/trust-task-error/"));
if refused {
return Err(VtcError::Refused {
document: reply.to_string(),
});
}
let payload = reply.get("payload").cloned().unwrap_or(Value::Null);
serde_json::from_value(payload).map_err(|e| VtcError::Http {
status: 200,
body: format!("{type_uri} response does not fit its schema: {e}"),
})
}
}
fn checked<P: ValidatedPayload>(payload: serde_json::Value) -> Result<serde_json::Value, VtcError> {
P::validate_value(&payload).map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
Ok(payload)
}
fn bad(e: serde_json::Error) -> VtcError {
VtcError::InvalidPayload(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_list_filter_is_the_canonical_payload() {
let filter = AclListFilter {
scope: Some("ctx-a".into()),
direction: Some("subtree".into()),
page_size: Some(10),
..Default::default()
};
let v = checked::<list::Payload>(serde_json::to_value(&filter).unwrap()).unwrap();
assert_eq!(
v,
serde_json::json!({ "scope": "ctx-a", "direction": "subtree", "pageSize": 10 })
);
}
#[test]
fn an_unknown_direction_is_refused_before_sending() {
let filter = AclListFilter {
direction: Some("sideways".into()),
..Default::default()
};
assert!(checked::<list::Payload>(serde_json::to_value(&filter).unwrap()).is_err());
}
#[test]
fn an_empty_scope_reduction_is_refused_before_sending() {
let body = serde_json::json!({ "subject": "did:key:z6MkA", "scopes": [] });
assert!(checked::<revoke::Payload>(body).is_err());
}
#[test]
fn the_task_uris_are_the_canonical_family() {
for uri in [
task::LIST,
task::SHOW,
task::GRANT,
task::UPDATE,
task::CHANGE_ROLE,
task::REVOKE,
] {
assert!(uri.starts_with("https://trusttasks.org/spec/acl/"), "{uri}");
}
}
}