use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use serde_json::Value;
use trust_tasks_rs::TrustTask;
use uuid::Uuid;
pub const TRUST_TASK_ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
pub const GIT_TRUST_GRANT_TYPE: &str = "https://trusttasks.org/spec/git-trust/grant/0.1";
pub const GIT_TRUST_REVOKE_TYPE: &str = "https://trusttasks.org/spec/git-trust/revoke/0.1";
#[derive(Debug, thiserror::Error)]
pub enum CapabilityClientError {
#[error("capability document error: {0}")]
Document(String),
#[error("capability document signing failed: {0}")]
Signing(String),
}
pub fn build_document(
issuer_did: &str,
recipient_did: &str,
type_uri: &str,
payload: Value,
) -> Result<TrustTask<Value>, CapabilityClientError> {
let type_uri = type_uri
.parse()
.map_err(|e| CapabilityClientError::Document(format!("invalid type URI: {e}")))?;
let mut doc = TrustTask::new(format!("urn:uuid:{}", Uuid::new_v4()), type_uri, payload);
doc.issuer = Some(issuer_did.to_string());
doc.recipient = Some(recipient_did.to_string());
doc.issued_at = Some(chrono::Utc::now());
Ok(doc)
}
pub fn build_git_trust_grant(
authority_did: &str,
registry_did: &str,
subject_did: &str,
resource: &str,
) -> Result<TrustTask<Value>, CapabilityClientError> {
build_document(
authority_did,
registry_did,
GIT_TRUST_GRANT_TYPE,
serde_json::json!({ "subject": subject_did, "resource": resource }),
)
}
pub fn build_git_trust_revoke(
authority_did: &str,
registry_did: &str,
subject_did: &str,
resource: &str,
reason: Option<&str>,
) -> Result<TrustTask<Value>, CapabilityClientError> {
let mut payload = serde_json::json!({ "subject": subject_did, "resource": resource });
if let Some(reason) = reason {
payload["reason"] = serde_json::json!(reason);
}
build_document(authority_did, registry_did, GIT_TRUST_REVOKE_TYPE, payload)
}
pub async fn sign_document(
doc: &mut TrustTask<Value>,
signing_secret: &Secret,
) -> Result<(), CapabilityClientError> {
let mut doc_value = serde_json::to_value(&*doc)
.map_err(|e| CapabilityClientError::Document(format!("serialise document: {e}")))?;
if let Some(obj) = doc_value.as_object_mut() {
obj.remove("proof");
}
let proof = DataIntegrityProof::sign(&doc_value, signing_secret, SignOptions::default())
.await
.map_err(|e| CapabilityClientError::Signing(e.to_string()))?;
let proof_value = serde_json::to_value(&proof)
.map_err(|e| CapabilityClientError::Signing(format!("serialise proof: {e}")))?;
doc.proof = Some(
serde_json::from_value(proof_value)
.map_err(|e| CapabilityClientError::Signing(format!("convert proof: {e}")))?,
);
Ok(())
}
pub fn parse_envelope_document(body: &Value) -> Option<(String, TrustTask<Value>)> {
let doc: TrustTask<Value> = serde_json::from_value(body.clone()).ok()?;
let thid = doc.thread_id.clone()?;
Some((thid, doc))
}
#[derive(Debug, Clone, PartialEq)]
pub enum WriteOutcome {
Success,
IdempotentSuccess,
Rejected {
code: String,
message: Option<String>,
},
}
pub fn classify_git_trust_reply(doc: &TrustTask<Value>) -> Option<WriteOutcome> {
let slug = doc.type_uri.slug();
if slug == "trust-task-error" {
let code = doc
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let message = doc
.payload
.get("message")
.and_then(Value::as_str)
.map(str::to_string);
let reason = message.as_deref().unwrap_or("");
if code == "taskFailed"
&& (reason.contains("already_granted:") || reason.contains("not_granted:"))
{
return Some(WriteOutcome::IdempotentSuccess);
}
return Some(WriteOutcome::Rejected { code, message });
}
if doc.type_uri.is_response() && matches!(slug, "git-trust/grant" | "git-trust/revoke") {
return Some(WriteOutcome::Success);
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use trust_tasks_rs::RejectReason;
#[test]
fn grant_and_revoke_documents_are_well_formed() {
let grant = build_git_trust_grant(
"did:example:authority",
"did:example:registry",
"did:example:signer",
"openvtc",
)
.unwrap();
assert_eq!(grant.issuer.as_deref(), Some("did:example:authority"));
assert_eq!(grant.recipient.as_deref(), Some("did:example:registry"));
assert_eq!(grant.type_uri.slug(), "git-trust/grant");
assert_eq!(grant.payload["subject"], "did:example:signer");
let revoke = build_git_trust_revoke(
"did:example:authority",
"did:example:registry",
"did:example:signer",
"openvtc",
Some("membership ended"),
)
.unwrap();
assert_eq!(revoke.type_uri.slug(), "git-trust/revoke");
assert_eq!(revoke.payload["reason"], "membership ended");
}
fn reserialize(doc: &trust_tasks_rs::ErrorResponse) -> TrustTask<Value> {
serde_json::from_value(serde_json::to_value(doc).unwrap()).unwrap()
}
#[test]
fn reply_classification_matches_hook_semantics() {
let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org").unwrap();
let ok = grant.respond_with(
"urn:uuid:r".to_string(),
serde_json::json!({ "subject": "did:s", "resource": "org", "granted": true }),
);
assert_eq!(classify_git_trust_reply(&ok), Some(WriteOutcome::Success));
let already = reserialize(
&grant.reject_with(
"urn:uuid:e".to_string(),
RejectReason::TaskFailed {
reason: "already_granted: an active grant exists for this subject and resource"
.to_string(),
details: None,
},
),
);
assert_eq!(
classify_git_trust_reply(&already),
Some(WriteOutcome::IdempotentSuccess)
);
let denied = reserialize(&grant.reject_with(
"urn:uuid:e2".to_string(),
RejectReason::PermissionDenied {
reason: "not on the admin ACL".to_string(),
},
));
assert!(matches!(
classify_git_trust_reply(&denied),
Some(WriteOutcome::Rejected { .. })
));
let foreign = TrustTask::new(
"urn:uuid:f".to_string(),
"https://trusttasks.org/spec/registry/authorization/0.1#response"
.parse()
.unwrap(),
serde_json::json!({}),
);
assert_eq!(classify_git_trust_reply(&foreign), None);
}
#[test]
fn envelope_parse_requires_a_thread_id() {
let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org").unwrap();
let reply = grant.respond_with("urn:uuid:r".to_string(), serde_json::json!({}));
let body = serde_json::to_value(&reply).unwrap();
let (thid, _) = parse_envelope_document(&body).unwrap();
assert_eq!(thid, grant.id);
let unthreaded = serde_json::to_value(&grant).unwrap();
assert!(parse_envelope_document(&unthreaded).is_none());
}
}