use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::info;
use vti_common::audit::{
AuditEvent, InvitationDeliveredData, InvitationIssuedData, InvitationRevokedData,
};
use vti_common::error::AppError;
use crate::acl::{VtcRole, get_acl_entry};
use crate::credentials::invitation::{DEFAULT_INVITATION_VALIDITY, issue_invitation};
use crate::credentials::invitation_registry::{
InvitationRecord, get_invitation, list_invitations, store_invitation,
};
use crate::error::TaskError;
use crate::server::AppState;
use crate::status_list;
const MAX_VALIDITY_DAYS: i64 = 90;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssueInvitationBody {
pub subject_did: String,
#[serde(default)]
pub validity_days: Option<u32>,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct IssueInvitationResponse {
pub subject_did: String,
pub valid_until: Option<String>,
pub vic: JsonValue,
}
pub(crate) async fn issue(
state: &AppState,
actor: &str,
body: IssueInvitationBody,
) -> Result<IssueInvitationResponse, TaskError> {
let signer = state
.credential_signer
.as_ref()
.ok_or_else(|| AppError::Internal("credential signer not configured".into()))?;
let acl = crate::acl::require_capability(
&state.acl_ks,
actor,
crate::acl::Capability::InvitationsManage,
None,
)
.await?;
if !body.subject_did.starts_with("did:") {
return Err(AppError::Validation("subjectDid must be a DID".into()).into());
}
if get_acl_entry(&state.acl_ks, &body.subject_did)
.await?
.is_some()
{
return Err(AppError::Conflict(format!(
"{} is already a current member — no invitation needed",
body.subject_did
))
.into());
}
let validity = match body.validity_days {
Some(d) if d == 0 || (d as i64) > MAX_VALIDITY_DAYS => {
return Err(AppError::Validation(format!(
"validityDays must be between 1 and {MAX_VALIDITY_DAYS}"
))
.into());
}
Some(d) => Duration::days(d as i64),
None => DEFAULT_INVITATION_VALIDITY,
};
if let Some(role) = body.role.as_deref() {
let parsed = role.parse::<VtcRole>().map_err(|_| {
TaskError::declared(
INVITATION_ISSUE_ERR_UNKNOWN_ROLE,
AppError::Validation(format!("unknown role `{role}`")),
)
})?;
if !parsed.is_assignable() {
return Err(TaskError::declared(
INVITATION_ISSUE_ERR_UNKNOWN_ROLE,
AppError::Validation(format!(
"`{role}` is not a role an invitation can seat — it marks a non-member \
holding git rights"
)),
));
}
if matches!(parsed, VtcRole::Admin) {
return Err(AppError::Validation(
"an invitation may not grant `admin` (no admin via join)".into(),
)
.into());
}
if !matches!(parsed, VtcRole::Member) {
if !acl.can_any(crate::acl::Capability::RolesAssign) {
return Err(AppError::Forbidden(format!(
"inviting with the role `{role}` assigns a role, which takes \
vtc.roles.assign"
))
.into());
}
let seated = crate::acl::VtcAclEntry::new(
body.subject_did.clone(),
parsed.clone(),
parsed.implied_authority(),
actor,
);
if let Err(refusal) =
crate::acl::granting::check_write(&acl, &seated, crate::auth::session::now_epoch())
{
return Err(AppError::Forbidden(format!(
"an invitation with the role `{role}` grants what it implies: {refusal}"
))
.into());
}
}
}
let vic = issue_invitation(
signer,
&state.status_lists_ks,
&state.schemas_ks,
&body.subject_did,
validity,
body.role.as_deref(),
)
.await?;
let valid_until = vic
.get("validUntil")
.and_then(JsonValue::as_str)
.map(str::to_string);
let id = vic
.get("id")
.and_then(JsonValue::as_str)
.ok_or_else(|| AppError::Internal("issued VIC has no `id`".into()))?
.to_string();
let slot = vic
.pointer("/credentialStatus/statusListIndex")
.and_then(JsonValue::as_str)
.and_then(|s| s.parse::<u32>().ok())
.ok_or_else(|| AppError::Internal("issued VIC has no usable statusListIndex".into()))?;
store_invitation(
&state.invitations_ks,
&InvitationRecord {
id: id.clone(),
subject_did: body.subject_did.clone(),
slot,
role: body.role.clone(),
issued_by: actor.to_string(),
issued_at: Utc::now(),
valid_until: valid_until.clone(),
revoked_at: None,
credential: Some(vic.clone()),
offer_code: None,
},
)
.await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor,
Some(&body.subject_did),
AuditEvent::InvitationIssued(InvitationIssuedData {
invitation_id: id.clone(),
subject_did: body.subject_did.clone(),
role: body.role.clone(),
valid_until: valid_until.clone().unwrap_or_default(),
status_list_index: Some(slot),
}),
)
.await?;
}
info!(
actor = %actor,
subject = %body.subject_did,
vic_id = %id,
"issued an invitation credential (VIC)"
);
Ok(IssueInvitationResponse {
subject_did: body.subject_did,
valid_until,
vic,
})
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct InvitationListItem {
pub id: String,
pub subject_did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub issued_by: String,
pub issued_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
}
impl From<InvitationRecord> for InvitationListItem {
fn from(r: InvitationRecord) -> Self {
let instant =
|t: chrono::DateTime<chrono::Utc>| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
Self {
id: r.id,
subject_did: r.subject_did,
role: r.role,
issued_by: r.issued_by,
issued_at: instant(r.issued_at),
valid_until: r.valid_until,
revoked_at: r.revoked_at.map(instant),
}
}
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct InvitationListResponse {
pub invitations: Vec<InvitationListItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InviterScope {
All,
OwnOnly,
}
impl InviterScope {
fn covers(self, record: &InvitationRecord, actor: &str) -> bool {
self == Self::All || record.issued_by == actor
}
}
async fn require_inviter(state: &AppState, did: &str) -> Result<InviterScope, AppError> {
let acl = crate::acl::require_capability(
&state.acl_ks,
did,
crate::acl::Capability::InvitationsManage,
None,
)
.await?;
Ok(if acl.can(crate::acl::Capability::RolesAssign, None) {
InviterScope::All
} else {
InviterScope::OwnOnly
})
}
pub(crate) async fn list(
state: &AppState,
actor: &str,
) -> Result<InvitationListResponse, AppError> {
let scope = require_inviter(state, actor).await?;
let invitations = list_invitations(&state.invitations_ks)
.await?
.into_iter()
.filter(|r| scope.covers(r, actor))
.map(InvitationListItem::from)
.collect();
Ok(InvitationListResponse { invitations })
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[schema(as = InvitationRevokeResponse)]
#[serde(rename_all = "camelCase")]
pub struct RevokeResponse {
pub id: String,
pub revoked_at: String,
pub newly_revoked: bool,
}
pub(crate) async fn revoke(
state: &AppState,
actor: &str,
id: String,
) -> Result<RevokeResponse, TaskError> {
let scope = require_inviter(state, actor).await?;
let mut record = get_invitation(&state.invitations_ks, &id)
.await?
.filter(|r| scope.covers(r, actor))
.ok_or_else(|| {
TaskError::declared(
INVITATION_REVOKE_ERR_NOT_FOUND,
AppError::NotFound(format!("no invitation with id {id}")),
)
})?;
if let Some(revoked_at) = record.revoked_at {
return Ok(RevokeResponse {
id,
revoked_at: revoked_at.to_rfc3339(),
newly_revoked: false,
});
}
let slot = record.slot;
status_list::with_locked(
&state.status_lists_ks,
affinidi_status_list::StatusPurpose::Revocation,
|sl| {
status_list::flip(sl, slot, true)
.map_err(|e| AppError::Internal(format!("flip revocation bit {slot}: {e}")))
},
)
.await?;
let now = Utc::now();
record.revoked_at = Some(now);
store_invitation(&state.invitations_ks, &record).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor,
Some(&record.subject_did),
AuditEvent::InvitationRevoked(InvitationRevokedData {
invitation_id: id.clone(),
subject_did: Some(record.subject_did.clone()),
newly_revoked: true,
}),
)
.await?;
}
info!(actor = %actor, vic_id = %id, slot, "revoked an invitation credential (VIC)");
Ok(RevokeResponse {
id,
revoked_at: now.to_rfc3339(),
newly_revoked: true,
})
}
pub const INVITATION_ISSUE_ERR_UNKNOWN_ROLE: &str =
trust_tasks_rs::specs::vtc::invitations::issue::v0_1::error_codes::UNKNOWN_ROLE.code;
pub const INVITATION_REVOKE_ERR_NOT_FOUND: &str =
trust_tasks_rs::specs::vtc::invitations::revoke::v0_1::error_codes::NOT_FOUND.code;
use trust_tasks_rs::specs::vtc::invitations::deliver::v0_1::error_codes as deliver_codes;
pub const INVITATION_DELIVER_ERR_NOT_FOUND: &str = deliver_codes::NOT_FOUND.code;
pub const INVITATION_DELIVER_ERR_REVOKED: &str = deliver_codes::REVOKED.code;
pub const INVITATION_DELIVER_ERR_NO_ROUTE: &str = deliver_codes::NO_ROUTE.code;
pub enum DeliverError {
NotFound(String),
Revoked(String),
NoRoute(String),
Other(AppError),
}
impl From<AppError> for DeliverError {
fn from(e: AppError) -> Self {
Self::Other(e)
}
}
impl From<DeliverError> for crate::error::TaskError {
fn from(e: DeliverError) -> Self {
use crate::error::TaskError;
match e {
DeliverError::NotFound(m) => {
TaskError::declared(INVITATION_DELIVER_ERR_NOT_FOUND, AppError::NotFound(m))
}
DeliverError::Revoked(m) => {
TaskError::declared(INVITATION_DELIVER_ERR_REVOKED, AppError::Conflict(m))
}
DeliverError::NoRoute(m) => {
TaskError::declared(INVITATION_DELIVER_ERR_NO_ROUTE, AppError::Validation(m))
}
DeliverError::Other(AppError::Gone(m)) => {
TaskError::declared("expired", AppError::Gone(m))
}
DeliverError::Other(e) => TaskError::App(e),
}
}
}
#[derive(Clone, Copy)]
enum Channel {
Message,
Offer,
}
const DELIVERY_OFFER_TTL: Duration = Duration::days(7);
const VIC_CONFIGURATION: &str = "VIC";
pub(crate) async fn deliver(
state: &AppState,
actor: &str,
body: trust_tasks_rs::specs::vtc::invitations::deliver::v0_1::Payload,
) -> Result<trust_tasks_rs::specs::vtc::invitations::deliver::v0_1::Response, DeliverError> {
use trust_tasks_rs::specs::vtc::invitations::deliver::v0_1 as spec;
let scope = require_inviter(state, actor).await?;
let payload = body;
let id = payload.id.to_string();
let channel = match payload.channel {
spec::PayloadChannel::Message => Channel::Message,
spec::PayloadChannel::Offer => Channel::Offer,
other => {
return Err(AppError::Validation(format!(
"this community does not deliver over channel {other:?}"
))
.into());
}
};
let mut record = get_invitation(&state.invitations_ks, &id)
.await?
.filter(|r| scope.covers(r, actor))
.ok_or_else(|| DeliverError::NotFound(format!("no invitation with id {id}")))?;
if record.is_revoked() {
return Err(DeliverError::Revoked(format!(
"invitation {id} has been revoked"
)));
}
let now = Utc::now();
let lapses = record
.valid_until
.as_deref()
.and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok())
.map(|t| t.with_timezone(&Utc));
if lapses.is_some_and(|t| t <= now) {
return Err(AppError::Gone(format!("expired: invitation {id} has lapsed")).into());
}
let credential = record.credential.clone().ok_or_else(|| {
DeliverError::Other(AppError::Conflict(format!(
"invitation {id} was issued before delivery existed, and its credential was \
returned once and not kept. Issue a new invitation to {} and deliver that.",
record.subject_did
)))
})?;
let vtc_did = state
.config
.read()
.await
.vtc_did
.clone()
.ok_or_else(|| AppError::Internal("VTC DID not configured".into()))?;
if matches!(channel, Channel::Message)
&& !can_authcrypt_to(state.did_resolver.as_ref(), &record.subject_did).await
{
return Err(DeliverError::NoRoute(format!(
"{} does not resolve to a key this community can encrypt the offer to. \
Deliver with channel `offer` and hand it over as a QR code.",
record.subject_did
)));
}
let ttl = match lapses {
Some(t) => (t - now).min(DELIVERY_OFFER_TTL),
None => DELIVERY_OFFER_TTL,
};
if let Some(old) = record.offer_code.take() {
crate::credentials::exchange::withdraw_offer(&state.join_requests_ks, &old).await?;
}
let (offer, code) = crate::credentials::exchange::make_offer(
&state.join_requests_ks,
&vtc_did,
vec![VIC_CONFIGURATION.to_string()],
credential,
&record.subject_did,
ttl,
now,
)
.await?;
record.offer_code = Some(code);
store_invitation(&state.invitations_ks, &record).await?;
let expires_at = now + ttl;
let offer_json = serde_json::to_value(&offer)
.map_err(|e| AppError::Internal(format!("serialise offer: {e}")))?;
let returned_offer = match channel {
Channel::Message => {
if let Err(e) = crate::credentials::delivery::push_document(
state,
&record.subject_did,
vta_sdk::protocols::credential_exchange::OFFER,
serde_json::json!({ "credential_offer": offer_json }),
crate::credentials::delivery::Thread::New,
)
.await
{
if let Some(code) = record.offer_code.take() {
crate::credentials::exchange::withdraw_offer(&state.join_requests_ks, &code)
.await?;
store_invitation(&state.invitations_ks, &record).await?;
}
return Err(e.into());
}
None
}
Channel::Offer => match offer_json {
JsonValue::Object(map) => Some(map),
_ => {
return Err(
AppError::Internal("offer did not serialise to an object".into()).into(),
);
}
},
};
let channel_name = match channel {
Channel::Message => "message",
Channel::Offer => "offer",
};
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor,
Some(&record.subject_did),
AuditEvent::InvitationDelivered(InvitationDeliveredData {
invitation_id: id.clone(),
subject_did: record.subject_did.clone(),
channel: channel_name.to_string(),
expires_at: expires_at.to_rfc3339(),
}),
)
.await?;
}
match channel {
Channel::Message => info!(
actor = %actor,
vic_id = %id,
channel = channel_name,
"queued an invitation offer for delivery to the invitee"
),
Channel::Offer => info!(
actor = %actor,
vic_id = %id,
channel = channel_name,
"returned an invitation offer for out-of-band hand-off"
),
}
let response: spec::Response = spec::Response::builder()
.id(id)
.channel(match channel {
Channel::Message => spec::ResponseChannel::Message,
Channel::Offer => spec::ResponseChannel::Offer,
})
.offer(returned_offer.unwrap_or_default())
.expires_at(expires_at)
.try_into()
.map_err(|e| AppError::Internal(format!("build deliver response: {e}")))?;
Ok(response)
}
async fn can_authcrypt_to(
resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
did: &str,
) -> bool {
let Some(resolver) = resolver else {
return false;
};
let Ok(resolved) = resolver.resolve(did).await else {
return false;
};
serde_json::to_value(&resolved.doc)
.ok()
.and_then(|doc| {
doc.get("keyAgreement")
.and_then(JsonValue::as_array)
.map(|a| !a.is_empty())
})
.unwrap_or(false)
}