use serde_json::Value;
use trust_tasks_rs::TrustTask;
use vta_sdk::protocols::credentials_issuance::{
IssueCredentialBody, IssueCredentialResponse, RevokeCredentialBody, RevokeCredentialResponse,
};
use crate::audit;
use crate::auth::AuthClaims;
use crate::operations::credentials::{self, IssueParams};
use crate::server::AppState;
use super::helpers::{TRANSPORT_TRUST_TASK, app_error_to_reject, parse_payload, success_response};
pub(super) async fn handle_issue(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> super::helpers::TrustTaskOutcome {
if let Err(e) = auth.require_admin() {
return app_error_to_reject(&doc, e);
}
let req: IssueCredentialBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let record = match credentials::issue_credential(
state,
IssueParams {
holder: &req.holder,
claims: &req.claims,
credential_type: req.credential_type.as_deref(),
validity_seconds: req.validity_seconds,
},
)
.await
{
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(e) = audit::record_with_detail(
&state.audit_ks,
"credentials.issue",
&auth.did,
Some(&record.id),
"success",
Some(TRANSPORT_TRUST_TASK),
None,
req.purpose.as_deref(),
)
.await
{
tracing::warn!(error = %e, "audit record failed for credentials.issue");
}
success_response(
&doc,
IssueCredentialResponse {
credential_id: record.id,
credential: record.credential,
expires_at: record.expires_at,
},
)
}
pub(super) async fn handle_revoke(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> super::helpers::TrustTaskOutcome {
if let Err(e) = auth.require_admin() {
return app_error_to_reject(&doc, e);
}
let req: RevokeCredentialBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let revoked_at = match credentials::revoke_credential(
state,
&req.credential_id,
req.reason.as_deref(),
)
.await
{
Ok(ts) => ts,
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(e) = audit::record_with_detail(
&state.audit_ks,
"credentials.revoke",
&auth.did,
Some(&req.credential_id),
"success",
Some(TRANSPORT_TRUST_TASK),
None,
req.reason.as_deref(),
)
.await
{
tracing::warn!(error = %e, "audit record failed for credentials.revoke");
}
success_response(
&doc,
RevokeCredentialResponse {
credential_id: req.credential_id,
revoked_at,
},
)
}
#[cfg(any(test, feature = "test-support"))]
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Role;
use crate::test_support::{build_signing_test_app_state, super_admin_claims};
use serde_json::json;
use trust_tasks_rs::TypeUri;
use vta_sdk::trust_tasks::{TASK_VTA_CREDENTIALS_ISSUE_0_1, TASK_VTA_CREDENTIALS_REVOKE_0_1};
fn stepped_up_admin() -> AuthClaims {
AuthClaims {
acr: "aal2".to_string(),
..super_admin_claims()
}
}
fn issue_doc(payload: Value) -> TrustTask<Value> {
let uri: TypeUri = TASK_VTA_CREDENTIALS_ISSUE_0_1.parse().expect("issue uri");
TrustTask::new(format!("urn:uuid:{}", uuid::Uuid::new_v4()), uri, payload)
}
fn revoke_doc(payload: Value) -> TrustTask<Value> {
let uri: TypeUri = TASK_VTA_CREDENTIALS_REVOKE_0_1.parse().expect("revoke uri");
TrustTask::new(format!("urn:uuid:{}", uuid::Uuid::new_v4()), uri, payload)
}
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)
}
async fn require_issue_step_up(state: &AppState) {
const STEPUP_ISSUE: &str = "package vta.policy\nimport rego.v1\n\
decision := {\"decision\": \"requireStepUp\"} if input.consumer.acr != \"aal2\"\n\
decision := {\"decision\": \"allow\"} if input.consumer.acr == \"aal2\"";
state.config.write().await.policy.enforcement = true;
crate::policy::storage::store_policy(
&state.policy_ks,
&crate::policy::types::PolicyModule {
id: "issue-stepup".into(),
name: "issue-stepup".into(),
description: None,
module: STEPUP_ISSUE.into(),
applies_to: vec![],
priority: 0,
enabled: true,
version: 1,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
ext: Value::Null,
},
)
.await
.expect("store the step-up rule");
}
#[tokio::test]
async fn issue_at_aal1_is_rejected_by_the_gate() {
let (state, _dir) = build_signing_test_app_state().await;
require_issue_step_up(&state).await;
let auth = super_admin_claims();
let doc = issue_doc(json!({
"holder": "did:key:zHolder",
"claims": { "role": "member" },
"validitySeconds": 3600u64,
}));
let out = super::super::policy_gate::policy_gate(
&state,
&auth,
vta_sdk::trust_tasks::TASK_VTA_CREDENTIALS_ISSUE_0_1,
&doc,
&mut Vec::new(),
)
.await
.expect("the credentials/issue floor must reject an AAL1 caller at the gate");
assert!(!out.status.is_success(), "got {}", out.status);
let body = String::from_utf8_lossy(&out.body);
assert!(
body.contains("step_up_required"),
"rejection should carry the step-up code, got: {body}"
);
}
#[tokio::test]
async fn issue_non_admin_is_rejected() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = AuthClaims {
role: Role::Reader,
acr: "aal2".to_string(),
..super_admin_claims()
};
let doc = issue_doc(json!({
"holder": "did:key:zHolder",
"claims": { "role": "member" },
"validitySeconds": 3600u64,
}));
let out = handle_issue(&state, &auth, doc).await;
assert!(
!out.status.is_success(),
"non-admin issue must be rejected by the capability gate"
);
}
#[tokio::test]
async fn issue_with_step_up_succeeds_and_binds_holder() {
let (state, _dir) = build_signing_test_app_state().await;
let holder = "did:key:zHolderBindMe";
let doc = issue_doc(json!({
"holder": holder,
"claims": { "role": "member", "level": 2 },
"credentialType": "MembershipCredential",
"validitySeconds": 3600u64,
"purpose": "tier-2 access",
}));
let out = handle_issue(&state, &stepped_up_admin(), doc).await;
assert!(out.status.is_success(), "stepped-up issue should succeed");
let payload = response_payload(&out);
let cred_id = payload
.get("credentialId")
.and_then(Value::as_str)
.expect("credentialId present");
assert!(
cred_id.starts_with("urn:uuid:"),
"id is a urn:uuid: {cred_id}"
);
let credential = payload.get("credential").expect("credential present");
assert_eq!(
credential
.get("credentialSubject")
.and_then(|s| s.get("id"))
.and_then(Value::as_str),
Some(holder),
"credentialSubject.id must equal the holder DID"
);
assert!(credential.get("proof").is_some(), "VC has a proof");
let types: Vec<&str> = credential
.get("type")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
assert!(types.contains(&"VerifiableCredential"));
assert!(types.contains(&"MembershipCredential"));
}
#[tokio::test]
async fn revoke_known_id_succeeds_then_double_revoke_conflicts() {
let (state, _dir) = build_signing_test_app_state().await;
let issue_out = handle_issue(
&state,
&stepped_up_admin(),
issue_doc(json!({
"holder": "did:key:zHolder",
"claims": { "role": "member" },
"validitySeconds": 3600u64,
})),
)
.await;
assert!(issue_out.status.is_success());
let cred_id = response_payload(&issue_out)
.get("credentialId")
.and_then(Value::as_str)
.expect("credentialId")
.to_string();
let revoke_out = handle_revoke(
&state,
&stepped_up_admin(),
revoke_doc(json!({ "credentialId": cred_id, "reason": "policy change" })),
)
.await;
assert!(
revoke_out.status.is_success(),
"first revoke should succeed"
);
assert!(
response_payload(&revoke_out).get("revokedAt").is_some(),
"revoke response carries revokedAt"
);
let again = handle_revoke(
&state,
&stepped_up_admin(),
revoke_doc(json!({ "credentialId": cred_id })),
)
.await;
assert!(!again.status.is_success(), "double revoke must be rejected");
let body = String::from_utf8_lossy(&again.body);
assert!(
body.contains("already revoked"),
"second revoke should report already-revoked, got: {body}"
);
}
#[tokio::test]
async fn revoke_unknown_id_is_not_found() {
let (state, _dir) = build_signing_test_app_state().await;
let out = handle_revoke(
&state,
&stepped_up_admin(),
revoke_doc(json!({ "credentialId": "urn:uuid:does-not-exist" })),
)
.await;
assert!(!out.status.is_success(), "unknown id must be rejected");
let body = String::from_utf8_lossy(&out.body);
assert!(
body.contains("not found"),
"unknown id should report not-found, got: {body}"
);
}
}