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, grant::v0_2 as grant_v0_2,
list::v0_1 as list, list::v0_2 as list_v0_2, revoke::v0_1 as revoke, show::v0_1 as show,
show::v0_2 as show_v0_2, swap_key::v0_1 as swap_key, update::v0_1 as update,
update::v0_2 as update_v0_2,
};
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;
pub const LIST_V0_2: &str = <super::list_v0_2::Payload as Payload>::TYPE_URI;
pub const SHOW_V0_2: &str = <super::show_v0_2::Payload as Payload>::TYPE_URI;
pub const GRANT_V0_2: &str = <super::grant_v0_2::Payload as Payload>::TYPE_URI;
pub const UPDATE_V0_2: &str = <super::update_v0_2::Payload as Payload>::TYPE_URI;
pub const SWAP_KEY: &str = <super::swap_key::Payload as Payload>::TYPE_URI;
}
pub const SWAP_LINK_PROOF_TTL_SECS: u64 = 300;
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AclListFilterV02 {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: 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 AclGrantV02 {
pub subject: String,
pub admin_role: String,
pub capabilities: Option<Vec<String>>,
pub approve: bool,
pub act: bool,
pub community_role: Option<String>,
pub label: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AclUpdateV02 {
pub subject: String,
pub capabilities: Option<Option<Vec<String>>>,
pub approve: Option<bool>,
pub label: Option<Option<String>>,
pub expires_at: Option<Option<DateTime<Utc>>>,
pub reason: Option<String>,
}
pub fn capability_scope(caps: Option<&[String]>) -> serde_json::Value {
match caps {
None => serde_json::json!({ "scope": "ceiling" }),
Some([]) => serde_json::json!({ "scope": "none" }),
Some(list) => serde_json::json!({
"scope": "listed",
"grants": list
.iter()
.map(|c| match c.split_once('@') {
Some((cap, res)) => serde_json::json!({ "capability": cap, "resource": res }),
None => serde_json::json!({ "capability": c }),
})
.collect::<Vec<_>>(),
}),
}
}
fn explicit_scope(on: bool) -> serde_json::Value {
serde_json::json!({ "scope": if on { "all" } else { "none" } })
}
#[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
}
pub async fn acl_list_v0_2(
&self,
filter: &AclListFilterV02,
key: &HolderKey,
) -> Result<list_v0_2::Response, VtcError> {
let payload = checked::<list_v0_2::Payload>(serde_json::to_value(filter).map_err(bad)?)?;
self.acl_task(task::LIST_V0_2, payload, key, list_v0_2::ERROR_CODES)
.await
}
pub async fn acl_list_all_v0_2(
&self,
filter: &AclListFilterV02,
key: &HolderKey,
) -> Result<Vec<list_v0_2::AclEntry>, VtcError> {
let mut filter = filter.clone();
let mut out = Vec::new();
loop {
let page = self.acl_list_v0_2(&filter, key).await?;
out.extend(page.entries);
match (page.truncated, page.cursor) {
(true, Some(cursor)) => filter.cursor = Some(cursor.to_string()),
(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_v0_2(
&self,
subject: &str,
key: &HolderKey,
) -> Result<show_v0_2::Response, VtcError> {
let payload = checked::<show_v0_2::Payload>(serde_json::json!({ "subject": subject }))?;
self.acl_task(task::SHOW_V0_2, payload, key, &[]).await
}
pub async fn acl_grant_v0_2(
&self,
grant: &AclGrantV02,
key: &HolderKey,
) -> Result<grant_v0_2::Response, VtcError> {
let mut entry = serde_json::json!({
"subject": grant.subject,
"role": grant.admin_role,
"act": explicit_scope(grant.act),
"keys": { "scope": "none" },
"capabilities": capability_scope(grant.capabilities.as_deref()),
"approve": explicit_scope(grant.approve),
});
if grant.approve {
entry["approveCapabilities"] = serde_json::json!({ "scope": "ceiling" });
}
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());
}
if let Some(role) = &grant.community_role {
entry["ext"] = serde_json::json!({ "org.openvtc": { "communityRole": role } });
}
let mut body = serde_json::json!({ "entry": entry });
if let Some(reason) = &grant.reason {
body["reason"] = serde_json::json!(reason);
}
let payload = checked::<grant_v0_2::Payload>(body)?;
self.acl_task(task::GRANT_V0_2, payload, key, grant_v0_2::ERROR_CODES)
.await
}
pub async fn acl_update_v0_2(
&self,
update: &AclUpdateV02,
key: &HolderKey,
) -> Result<update_v0_2::Response, VtcError> {
let mut body = serde_json::json!({ "subject": update.subject });
if let Some(caps) = &update.capabilities {
body["capabilities"] = capability_scope(caps.as_deref());
}
if let Some(approve) = update.approve {
body["approve"] = explicit_scope(approve);
body["approveCapabilities"] = if approve {
serde_json::json!({ "scope": "ceiling" })
} else {
serde_json::json!({ "scope": "none" })
};
}
if let Some(label) = &update.label {
body["label"] = serde_json::json!(label);
}
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_v0_2::Payload>(body)?;
self.acl_task(task::UPDATE_V0_2, payload, key, update_v0_2::ERROR_CODES)
.await
}
pub async fn acl_swap_key(
&self,
key: &HolderKey,
new_did: &str,
new_private_key_multibase: &str,
reason: Option<&str>,
) -> Result<swap_key::Response, VtcError> {
let seed = vta_sdk::did_key::decode_private_key_multibase(new_private_key_multibase)
.map_err(|e| VtcError::Signing(format!("the new key does not decode: {e}")))?;
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let derived = format!(
"did:key:{}",
vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes())
);
if derived != new_did {
return Err(VtcError::Signing(format!(
"{new_did} is not the did:key of the new private key ({derived})"
)));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let link_proof = vta_sdk::protocols::acl_management::swap::build_swap_presentation(
&signing,
new_did,
&self.vtc_did,
now,
SWAP_LINK_PROOF_TTL_SECS,
None,
);
let mut body = serde_json::json!({
"currentSubject": key.holder_did(),
"newSubject": new_did,
"linkProof": link_proof,
});
if let Some(reason) = reason {
body["reason"] = serde_json::json!(reason);
}
let payload = checked::<swap_key::Payload>(body)?;
self.acl_task(task::SWAP_KEY, payload, key, swap_key::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);
if let Some(parked) = crate::parked_from_next_step(
reply
.get("type")
.and_then(Value::as_str)
.unwrap_or_default(),
&payload,
) {
return Err(parked);
}
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 a_0_2_grant_states_every_axis() {
let caps = vec![
"git.repo.manage@git-ns:github.com/acme".to_string(),
"git.ns.admin@git-ns:github.com/acme".to_string(),
];
let scope = capability_scope(Some(&caps));
assert_eq!(scope["scope"], "listed");
assert_eq!(scope["grants"][0]["resource"], "git-ns:github.com/acme");
assert_eq!(
capability_scope(None),
serde_json::json!({"scope": "ceiling"})
);
assert_eq!(
capability_scope(Some(&[])),
serde_json::json!({"scope": "none"})
);
let body = serde_json::json!({ "entry": {
"subject": "did:key:z6MkA",
"role": "repo-manager",
"act": explicit_scope(true),
"keys": { "scope": "none" },
"capabilities": scope,
"approve": explicit_scope(false),
}});
assert!(checked::<grant_v0_2::Payload>(body).is_ok());
}
#[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,
task::LIST_V0_2,
task::SHOW_V0_2,
task::GRANT_V0_2,
task::UPDATE_V0_2,
] {
assert!(uri.starts_with("https://trusttasks.org/spec/acl/"), "{uri}");
}
}
}