use super::helpers::TrustTaskOutcome;
use serde_json::{Value, json};
use trust_tasks_rs::specs::auth::revoke_session::v0_2 as revoke_session_spec;
use trust_tasks_rs::{RejectReason, TrustTask};
use vta_sdk::protocols::auth::epoch_to_rfc3339;
use crate::acl::{check_acl_entry, effective_capabilities};
use crate::audit::audit;
use crate::auth::AuthClaims;
use crate::auth::session::{SessionState, delete_session, get_session, list_sessions, now_epoch};
use crate::server::AppState;
use super::helpers::{app_error_to_reject, parse_payload, reject_with, success_response};
enum RevokeTarget {
Session(String),
Subject(String),
}
fn revoke_target(payload: &Value, caller: &str) -> Result<(RevokeTarget, Option<String>), String> {
use trust_tasks_rs::validate::ValidatedPayload as _;
revoke_session_spec::Payload::validate_value(payload)
.map_err(|e| format!("revoke-session payload: {e}"))?;
let obj = payload
.as_object()
.ok_or_else(|| "revoke-session payload must be an object".to_string())?;
let forms = ["sessionId", "all", "subject"]
.iter()
.filter(|k| obj.contains_key(**k))
.count();
if forms != 1 {
return Err(
"revoke-session takes exactly one of `sessionId`, `all` or `subject`".to_string(),
);
}
let reason = obj
.get("reason")
.and_then(Value::as_str)
.map(str::to_string);
let target = if let Some(id) = obj.get("sessionId").and_then(Value::as_str) {
RevokeTarget::Session(id.to_string())
} else if let Some(all) = obj.get("all").and_then(Value::as_bool) {
if !all {
return Err(
"`all: false` targets nothing; send `all: true`, `sessionId` or `subject`"
.to_string(),
);
}
RevokeTarget::Subject(caller.to_string())
} else if let Some(subject) = obj.get("subject").and_then(Value::as_str) {
RevokeTarget::Subject(subject.to_string())
} else {
return Err(
"revoke-session: `sessionId` and `subject` are strings, `all` a boolean".into(),
);
};
Ok((target, reason))
}
pub(super) async fn handle_revoke_session(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let (target, reason) = match revoke_target(&doc.payload, &auth.did) {
Ok(t) => t,
Err(reason) => {
return reject_with(&doc, RejectReason::MalformedRequest { reason });
}
};
if let Err(resp) = parse_payload::<revoke_session_spec::Payload>(&doc) {
return resp;
}
match target {
RevokeTarget::Session(session_id) => {
revoke_one(state, auth, &doc, &session_id, reason.as_deref()).await
}
RevokeTarget::Subject(subject) => {
revoke_subject(state, auth, &doc, &subject, reason.as_deref()).await
}
}
}
async fn revoke_one(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
session_id: &str,
reason: Option<&str>,
) -> TrustTaskOutcome {
let session = match get_session(&state.sessions_ks, session_id).await {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "session lookup failed in revoke-session");
return reject_with(
doc,
RejectReason::InternalError {
reason: format!("session lookup: {e}"),
},
);
}
};
let permitted = match &session {
Some(s) => {
match crate::operations::acl::may_manage_subject(&state.acl_ks, auth, &s.did).await {
Ok(p) => p,
Err(e) => return app_error_to_reject(doc, e),
}
}
None => false,
};
if !permitted {
tracing::warn!(
caller = %auth.did,
session_id = %session_id,
"revoke-session: no session revoked (absent, or outside the caller's authority)"
);
audit!(
"session.revoke",
actor = &auth.did,
resource = session_id,
outcome = "no-op"
);
if let Some(s) = &session {
crate::audit::record_with_detail_best_effort(
&state.audit_sink,
"session.revoke",
&auth.did,
Some(session_id),
"denied",
Some(super::helpers::TRANSPORT_TRUST_TASK),
None,
Some(&format!(
"session of {} is outside the caller's authority",
s.did
)),
)
.await;
}
return success_response(doc, json!({ "revokedCount": 0 }));
}
let subject = session.map(|s| s.did).unwrap_or_default();
if let Err(e) = delete_session(&state.sessions_ks, session_id).await {
tracing::error!(error = %e, session_id = %session_id, "session delete failed");
return reject_with(
doc,
RejectReason::InternalError {
reason: format!("session delete: {e}"),
},
);
}
audit!(
"session.revoke",
actor = &auth.did,
resource = session_id,
outcome = "success"
);
record_revocation(
state,
auth,
"session.revoke",
session_id,
&subject,
1,
reason,
)
.await;
tracing::info!(caller = %auth.did, session_id = %session_id, "session revoked via trust-task");
success_response(doc, json!({ "revokedCount": 1 }))
}
async fn revoke_subject(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
subject: &str,
reason: Option<&str>,
) -> TrustTaskOutcome {
let permitted =
match crate::operations::acl::may_manage_subject(&state.acl_ks, auth, subject).await {
Ok(p) => p,
Err(e) => return app_error_to_reject(doc, e),
};
if !permitted {
tracing::warn!(
audit = true,
security_alert = true,
caller = %auth.did,
subject,
"revoke-session refused: the subject is outside the caller's authority"
);
crate::audit::record_with_detail_best_effort(
&state.audit_sink,
"session.revoke_by_did",
&auth.did,
Some(subject),
"denied",
Some(super::helpers::TRANSPORT_TRUST_TASK),
None,
Some("the subject is outside the caller's authority"),
)
.await;
return reject_with(
doc,
RejectReason::PermissionDenied {
reason: "the named subject is outside your authority".into(),
},
);
}
let sessions = match list_sessions(&state.sessions_ks).await {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "session list failed in revoke-session");
return reject_with(
doc,
RejectReason::InternalError {
reason: format!("session list: {e}"),
},
);
}
};
let mut revoked = 0u64;
for session in sessions.into_iter().filter(|s| s.did == subject) {
if let Err(e) = delete_session(&state.sessions_ks, &session.session_id).await {
tracing::error!(error = %e, "session delete failed in revoke-session");
return reject_with(
doc,
RejectReason::InternalError {
reason: format!("session delete: {e}"),
},
);
}
revoked += 1;
}
audit!(
"session.revoke_by_did",
actor = &auth.did,
resource = subject,
outcome = "success"
);
record_revocation(
state,
auth,
"session.revoke_by_did",
subject,
subject,
revoked,
reason,
)
.await;
tracing::info!(caller = %auth.did, subject, revoked, "sessions revoked via trust-task");
success_response(doc, json!({ "revokedCount": revoked }))
}
async fn record_revocation(
state: &AppState,
auth: &AuthClaims,
action: &str,
resource: &str,
subject: &str,
revoked: u64,
reason: Option<&str>,
) {
let detail = match reason {
Some(r) => format!("subject {subject}; {revoked} session(s); reason: {r}"),
None => format!("subject {subject}; {revoked} session(s)"),
};
if let Err(e) = crate::audit::record_with_detail(
&state.audit_sink,
action,
&auth.did,
Some(resource),
"success",
Some(super::helpers::TRANSPORT_TRUST_TASK),
None,
Some(&detail),
)
.await
{
tracing::warn!(error = %e, "audit record failed for {action}");
}
}
pub(super) async fn handle_whoami(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let session = match get_session(&state.sessions_ks, &auth.session_id).await {
Ok(Some(s)) => s,
Ok(None) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!("session not found: {}", auth.session_id),
details: None,
},
);
}
Err(e) => {
tracing::error!(error = %e, "session lookup failed in whoami");
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("session lookup: {e}"),
},
);
}
};
let entry = match check_acl_entry(&state.acl_ks, &auth.did).await {
Ok(entry) => entry,
Err(e) => return app_error_to_reject(&doc, e),
};
let (role, contexts) = (entry.role.clone(), entry.allowed_contexts.clone());
let mut session_info = json!({
"id": auth.session_id,
"subject": auth.did,
"issuedAt": epoch_to_rfc3339(session.created_at),
"expiresAt": epoch_to_rfc3339(auth.access_expires_at),
"amr": session.amr,
});
if !session.acr.is_empty() {
session_info["acr"] = Value::String(session.acr.clone());
}
let scopes: Vec<String> = contexts.iter().map(|c| format!("ctx:{c}")).collect();
let capabilities: Vec<String> = effective_capabilities(&entry.role, &entry.capabilities)
.into_iter()
.map(|c| {
serde_json::to_value(c)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{c:?}"))
})
.collect();
let body = json!({
"session": session_info,
"roles": [role.to_string()],
"scopes": scopes,
"capabilities": capabilities,
});
audit!(
"auth.whoami",
actor = &auth.did,
resource = &auth.session_id,
outcome = "success"
);
success_response(&doc, body)
}
pub(super) async fn handle_sessions_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let all = match list_sessions(&state.sessions_ks).await {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "session list failed in sessions/list");
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("session list: {e}"),
},
);
}
};
let now = now_epoch();
let sessions: Vec<Value> = all
.into_iter()
.filter(|s| {
s.did == auth.did
&& s.state == SessionState::Authenticated
&& s.refresh_expires_at.is_none_or(|exp| exp > now)
})
.map(|s| {
let expires_at = s.refresh_expires_at.unwrap_or(s.created_at);
let mut item = json!({
"id": s.session_id,
"subject": s.did,
"issuedAt": epoch_to_rfc3339(s.created_at),
"expiresAt": epoch_to_rfc3339(expires_at),
"amr": s.amr,
});
if !s.acr.is_empty() {
item["acr"] = Value::String(s.acr);
}
item
})
.collect();
audit!(
"auth.sessions-list",
actor = &auth.did,
resource = &auth.session_id,
outcome = "success"
);
success_response(&doc, json!({ "sessions": sessions }))
}
#[cfg(test)]
mod revoke_form_tests {
use serde_json::{Value, json};
use trust_tasks_rs::TrustTask;
#[tokio::test]
async fn the_handler_refuses_two_forms_or_none_on_its_own() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let claims = crate::test_support::super_admin_claims();
for payload in [
json!({ "sessionId": "s-1", "all": true }),
json!({ "sessionId": "s-1", "subject": "did:key:z6MkOther" }),
json!({ "all": true, "subject": "did:key:z6MkOther" }),
json!({}),
json!({ "all": false }),
] {
let doc: TrustTask<Value> = serde_json::from_value(json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": vta_sdk::trust_tasks::TASK_AUTH_REVOKE_SESSION_0_2,
"payload": payload.clone(),
}))
.unwrap();
let out = super::handle_revoke_session(&state, &claims, doc).await;
let body: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
body["payload"]["code"], "malformedRequest",
"{payload}: {body}"
);
}
}
}