use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::{Map, Value as JsonValue};
use trust_tasks_rs::specs::vtc::members::credentials::v0_1 as wire;
use vti_common::audit::{AuditEvent, MemberCredentialsReadData};
use crate::acl::get_acl_entry;
use crate::error::AppError;
use crate::members::{Member, get_member};
use crate::server::AppState;
pub const MEMBER_CREDENTIALS_ERR_NOT_FOUND: &str =
trust_tasks_rs::specs::vtc::members::credentials::v0_1::error_codes::NOT_FOUND.code;
pub enum CredentialsError {
NotFound(String),
Other(AppError),
}
impl From<AppError> for CredentialsError {
fn from(e: AppError) -> Self {
Self::Other(e)
}
}
impl IntoResponse for CredentialsError {
fn into_response(self) -> Response {
match self {
Self::NotFound(message) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("not found: {message}"),
"code": MEMBER_CREDENTIALS_ERR_NOT_FOUND,
})),
)
.into_response(),
Self::Other(e) => e.into_response(),
}
}
}
pub(crate) fn credentials_response(member: &Member) -> Result<wire::Response, AppError> {
let mut builder = wire::Response::builder()
.did(member.did.as_str())
.member_vmc_bound(member.member_vmc_bound);
if let Some(grant) = document(member.current_vmc.as_ref(), "current_vmc", &member.did) {
builder = builder.membership_credential(grant);
}
if let Some(role) = document(
member.current_role_vac.as_ref(),
"current_role_vac",
&member.did,
) {
builder = builder.role_credential(role);
}
let acknowledgement = document(member.member_vmc.as_ref(), "member_vmc", &member.did);
match (acknowledgement, member.member_vmc_received_at) {
(Some(ack), Some(received_at)) => {
builder = builder
.member_vmc(ack)
.member_vmc_received_at(Some(received_at));
}
(Some(_), None) => {
tracing::warn!(
member = %member.did,
"member VMC body stored without a receipt time; omitted from \
members/credentials (the two are paired on the wire)"
);
}
(None, _) => {}
}
wire::Response::try_from(builder)
.map_err(|e| AppError::Internal(format!("members/credentials response: {e}")))
}
fn document(
stored: Option<&JsonValue>,
field: &'static str,
did: &str,
) -> Option<Map<String, JsonValue>> {
match stored? {
JsonValue::Object(map) if !map.is_empty() => Some(map.clone()),
other => {
tracing::warn!(
member = %did,
field,
kind = json_kind(other),
"stored credential is not a non-empty JSON object; omitted from members/credentials"
);
None
}
}
}
fn json_kind(v: &JsonValue) -> &'static str {
match v {
JsonValue::Null => "null",
JsonValue::Bool(_) => "boolean",
JsonValue::Number(_) => "number",
JsonValue::String(_) => "string",
JsonValue::Array(_) => "array",
JsonValue::Object(_) => "empty object",
}
}
fn disclosed(response: &wire::Response) -> Vec<String> {
let mut out = Vec::new();
if !response.membership_credential.is_empty() {
out.push("membershipCredential".to_string());
}
if !response.role_credential.is_empty() {
out.push("roleCredential".to_string());
}
if !response.member_vmc.is_empty() {
out.push("memberVmc".to_string());
}
out
}
pub(crate) async fn read_member_credentials(
state: &AppState,
actor_did: &str,
did: &str,
) -> Result<wire::Response, CredentialsError> {
vti_common::identifier::validate_did("did", did)?;
let member = get_member(&state.members_ks, did)
.await?
.ok_or_else(|| CredentialsError::NotFound(format!("member not found: {did}")))?;
if get_acl_entry(&state.acl_ks, did).await?.is_none() {
return Err(CredentialsError::NotFound(format!(
"member not found (no ACL row): {did}"
)));
}
let response = credentials_response(&member)?;
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
audit_writer
.write(
actor_did,
Some(did),
AuditEvent::MemberCredentialsRead(MemberCredentialsReadData {
disclosed: disclosed(&response),
}),
)
.await?;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
fn vc(id: &str) -> JsonValue {
json!({ "id": id, "type": ["VerifiableCredential"], "proof": { "proofValue": "z1" } })
}
#[test]
fn a_member_holding_nothing_answers_with_bound_false_and_no_documents() {
let v = serde_json::to_value(credentials_response(&Member::fresh(DID)).unwrap()).unwrap();
assert_eq!(v, json!({ "did": DID, "memberVmcBound": false }));
}
#[test]
fn bodies_are_carried_as_stored() {
let mut m = Member::fresh(DID);
m.record_issued_credentials(vc("urn:uuid:grant"), vc("urn:uuid:role"));
m.record_member_vmc("urn:uuid:ack", vc("urn:uuid:ack"), true);
let v = serde_json::to_value(credentials_response(&m).unwrap()).unwrap();
assert_eq!(v["membershipCredential"], vc("urn:uuid:grant"));
assert_eq!(v["roleCredential"], vc("urn:uuid:role"));
assert_eq!(v["memberVmc"], vc("urn:uuid:ack"));
assert!(v["memberVmcReceivedAt"].is_string());
assert_eq!(v["memberVmcBound"], true);
}
#[test]
fn a_receipt_time_without_its_body_is_not_sent() {
let mut m = Member::fresh(DID);
m.member_vmc_id = Some("urn:uuid:ack".into());
m.member_vmc_received_at = Some(chrono::Utc::now());
let v = serde_json::to_value(credentials_response(&m).unwrap()).unwrap();
assert!(v.get("memberVmc").is_none());
assert!(v.get("memberVmcReceivedAt").is_none(), "{v}");
}
#[test]
fn a_body_without_its_receipt_time_is_not_sent() {
let mut m = Member::fresh(DID);
m.member_vmc = Some(vc("urn:uuid:ack"));
let v = serde_json::to_value(credentials_response(&m).unwrap()).unwrap();
assert!(v.get("memberVmc").is_none(), "{v}");
assert!(v.get("memberVmcReceivedAt").is_none());
}
#[test]
fn disclosed_names_only_the_documents_present() {
let mut m = Member::fresh(DID);
m.record_issued_credentials(vc("urn:uuid:grant"), vc("urn:uuid:role"));
let r = credentials_response(&m).unwrap();
assert_eq!(
disclosed(&r),
vec!["membershipCredential", "roleCredential"]
);
}
}