use std::sync::Arc;
use axum::http::StatusCode;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize};
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AdminInviteData, AuditEvent};
use vti_common::auth::extractor::AuthClaims;
use vti_common::error::AppError;
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry, store_acl_entry};
use crate::auth::session::now_epoch;
use crate::error::TaskError;
use crate::install::{
INSTALL_TOKEN_DEFAULT_TTL_SECS, InstallTokenSigner, InstallTokenState, claim_secret,
mint_install_token,
};
use crate::server::AppState;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct CreateInviteRequest {
pub did: String,
#[serde(default)]
pub ttl_seconds: Option<u64>,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct CreateInviteResponse {
pub jti: String,
pub install_url: String,
pub claim_code: String,
pub expires_at: DateTime<Utc>,
pub acl_entry_created: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct InviteSummary {
pub jti: String,
pub status: InviteStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub consumed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[derive(utoipa::ToSchema)]
pub enum InviteStatus {
Issued,
Consumed,
Expired,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ListInvitesResponse {
pub invites: Vec<InviteSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RevokeInviteResponse {
pub jti: String,
}
pub(crate) const MAX_TTL_SECONDS: u64 = 24 * 60 * 60;
pub const CREATE_INVITE_ERR_TTL_TOO_LONG: &str =
trust_tasks_rs::specs::vtc::admin::invites::create::v0_1::error_codes::TTL_TOO_LONG.code;
pub const REVOKE_INVITE_ERR_NOT_FOUND: &str =
trust_tasks_rs::specs::vtc::admin::invites::revoke::v0_1::error_codes::NOT_FOUND.code;
#[derive(Debug)]
pub(crate) struct InvitePlan {
ttl_seconds: u64,
pub(crate) grants_admin: bool,
}
pub(crate) async fn check_invite(
state: &AppState,
actor: &AuthClaims,
req: &CreateInviteRequest,
) -> Result<InvitePlan, TaskError> {
if !req.did.starts_with("did:") {
return Err(AppError::Validation(format!(
"did must start with 'did:' (got '{}')",
req.did
))
.into());
}
let ttl_seconds = req.ttl_seconds.unwrap_or(INSTALL_TOKEN_DEFAULT_TTL_SECS);
let out_of_range = || {
AppError::Validation(format!(
"ttl_seconds must be between 1 and {MAX_TTL_SECONDS}",
))
};
if ttl_seconds > MAX_TTL_SECONDS {
return Err(TaskError::declared(
CREATE_INVITE_ERR_TTL_TOO_LONG,
out_of_range(),
));
}
if ttl_seconds == 0 {
return Err(out_of_range().into());
}
let inviter = crate::acl::get_acl_entry(&state.acl_ks, &actor.did)
.await?
.ok_or_else(|| AppError::Forbidden(format!("{} has no ACL entry", actor.did)))?;
let mut invited = VtcAclEntry::new(
req.did.clone(),
VtcRole::Admin,
crate::acl::AdminAuthority::community_admin(),
actor.did.clone(),
);
invited.expires_at = None;
if let Err(refusal) =
crate::acl::granting::check_write(&inviter, &invited, crate::auth::session::now_epoch())
{
return Err(AppError::Forbidden(format!(
"inviting an administrator grants a community administrator: {refusal}"
))
.into());
}
require_install_signer(state)?;
require_public_url(state).await?;
require_vtc_did(state).await?;
let grants_admin = match get_acl_entry(&state.acl_ks, &req.did).await? {
Some(existing) if existing.is_community_admin() => false,
Some(_) => {
return Err(AppError::Conflict(format!(
"did {} already has a non-admin ACL grant; revoke it first \
(acl/revoke) before inviting",
req.did
))
.into());
}
None => true,
};
Ok(InvitePlan {
ttl_seconds,
grants_admin,
})
}
pub(crate) async fn commit_invite(
state: &AppState,
actor: &AuthClaims,
req: CreateInviteRequest,
plan: InvitePlan,
) -> Result<CreateInviteResponse, TaskError> {
let InvitePlan {
ttl_seconds,
grants_admin,
} = plan;
let signer = require_install_signer(state)?;
let base_url = require_public_url(state).await?;
let vtc_did = require_vtc_did(state).await?;
let acl_entry_created = if grants_admin {
if get_acl_entry(&state.acl_ks, &req.did).await?.is_some() {
return Err(AppError::Conflict(format!(
"did {} gained an ACL entry while this invite was being approved; send it again",
req.did
))
.into());
}
let label = req
.label
.clone()
.unwrap_or_else(|| "admin invite (web)".into());
let entry = VtcAclEntry {
did: req.did.clone(),
role: VtcRole::Admin,
label: Some(label),
admin: crate::acl::AdminAuthority::community_admin(),
delegated_by: Some(actor.did.clone()),
created_at: now_epoch(),
created_by: format!("admin-ui/{}", env!("CARGO_PKG_VERSION")),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
};
store_acl_entry(&state.acl_ks, &entry).await?;
true
} else {
false
};
let minted = mint_install_token(signer.as_ref(), &vtc_did, &req.did, ttl_seconds)?;
let claim_code = claim_secret::generate();
let claim_code_hash = {
let claim_code = claim_code.clone();
tokio::task::spawn_blocking(move || claim_secret::hash(&claim_code))
.await
.map_err(|e| AppError::Internal(format!("claim-secret hash task failed: {e}")))??
};
let expires_at = Utc::now() + ChronoDuration::seconds(ttl_seconds as i64);
state
.install_store
.record_issued(
&minted.jti,
minted.cnonce_bytes,
*minted.ephemeral_signing_key,
expires_at,
Some(claim_code_hash),
Some(req.did.clone()),
)
.await?;
crate::admin_actions::record_effect(state).await;
let install_url = format!(
"{}/admin/install?token={}",
base_url.trim_end_matches('/'),
minted.jwt
);
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&actor.did,
Some(&req.did),
AuditEvent::AdminInviteCreated(AdminInviteData {
jti: minted.jti.to_string(),
admin_did: Some(req.did.clone()),
}),
)
.await?;
}
info!(
target_did = %req.did,
jti = %minted.jti,
ttl_seconds,
"admin invite minted"
);
Ok(CreateInviteResponse {
jti: minted.jti.to_string(),
install_url,
claim_code,
expires_at,
acl_entry_created,
})
}
pub(crate) async fn list_invites(state: &AppState) -> Result<ListInvitesResponse, AppError> {
let now = Utc::now();
let mut invites: Vec<InviteSummary> = state
.install_store
.list_tokens()
.await?
.into_iter()
.map(|(jti, state)| summarise(jti, state, now))
.collect();
invites.sort_by(|a, b| {
let a_ts = a.consumed_at.or(a.expires_at);
let b_ts = b.consumed_at.or(b.expires_at);
b_ts.cmp(&a_ts).then_with(|| a.jti.cmp(&b.jti))
});
Ok(ListInvitesResponse { invites })
}
pub(crate) async fn revoke_invite(
state: &AppState,
actor: &str,
jti_str: &str,
) -> Result<RevokeInviteResponse, TaskError> {
let jti = jti_str
.parse::<Uuid>()
.map_err(|_| AppError::Validation(format!("invalid jti: '{jti_str}'")))?;
let not_found = || {
TaskError::declared(
REVOKE_INVITE_ERR_NOT_FOUND,
AppError::NotFound(format!("no invite for jti {jti}")),
)
};
let existed = state.install_store.get_token(&jti).await?;
if existed.is_none() {
return Err(not_found());
}
if !state.install_store.delete_token(&jti).await? {
return Err(not_found());
}
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor,
None,
AuditEvent::AdminInviteRevoked(AdminInviteData {
jti: jti.to_string(),
admin_did: None,
}),
)
.await?;
}
info!(%jti, "admin invite removed");
Ok(RevokeInviteResponse {
jti: jti.to_string(),
})
}
fn summarise(jti: Uuid, state: InstallTokenState, now: DateTime<Utc>) -> InviteSummary {
match state {
InstallTokenState::Issued { exp, admin_did, .. } => {
let status = if exp < now {
InviteStatus::Expired
} else {
InviteStatus::Issued
};
InviteSummary {
jti: jti.to_string(),
status,
target_did: admin_did,
expires_at: Some(exp),
consumed_at: None,
}
}
InstallTokenState::Consumed { at, admin_did } => InviteSummary {
jti: jti.to_string(),
status: InviteStatus::Consumed,
target_did: admin_did,
expires_at: None,
consumed_at: Some(at),
},
}
}
fn require_install_signer(state: &AppState) -> Result<&Arc<InstallTokenSigner>, AppError> {
state
.install_signer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "install signer not configured (run setup first)".into(),
})
}
async fn require_public_url(state: &AppState) -> Result<String, AppError> {
state
.config
.read()
.await
.public_url
.clone()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "public_url not configured; cannot build install URL".into(),
})
}
async fn require_vtc_did(state: &AppState) -> Result<String, AppError> {
state
.config
.read()
.await
.vtc_did
.clone()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "vtc_did not configured (run setup first)".into(),
})
}