use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;
use webauthn_rs::Webauthn;
use webauthn_rs::prelude::{PublicKeyCredential, RequestChallengeResponse};
use vti_common::audit::{AdminPromotedData, AuditEvent};
use vti_common::auth::passkey::PasskeyState;
use vti_common::auth::passkey::store::{
get_passkey_user_by_did, store_auth_state, take_auth_state,
};
use crate::acl::admin::{AdminEntry, get_admin_entry, store_admin_entry};
use crate::acl::{VtcRole, get_acl_entry};
use crate::auth::AdminAuth;
use crate::error::AppError;
use crate::members::get_member;
use crate::server::AppState;
static PROMOTE_LOCK: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PromoteStartResponse {
pub registration_id: Uuid,
#[schema(value_type = Object)]
pub options: RequestChallengeResponse,
}
#[utoipa::path(
post, path = "/members/{did}/promote-to-admin/start", tag = "members",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Member DID being promoted")),
responses(
(status = 200, description = "UV challenge issued", body = PromoteStartResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin / has no registered passkeys"),
(status = 404, description = "Member not found"),
),
)]
pub async fn promote_start(
auth: AdminAuth,
State(state): State<AppState>,
Path(target_did): Path<String>,
) -> Result<Json<PromoteStartResponse>, AppError> {
vti_common::identifier::validate_did("did", &target_did)?;
if auth.0.did == target_did {
return Err(AppError::Validation(
"you cannot promote yourself; the promotion endpoint requires a separate admin caller"
.into(),
));
}
let webauthn = require_webauthn(&state)?;
get_member(&state.members_ks, &target_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {target_did}")))?;
let target_acl = get_acl_entry(&state.acl_ks, &target_did)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("member not found (no ACL row): {target_did}"))
})?;
if matches!(target_acl.role, VtcRole::Admin) {
return Err(AppError::Conflict(format!(
"{target_did} is already an admin"
)));
}
let pk_user = get_passkey_user_by_did(&state.passkey_ks, &auth.0.did)
.await?
.ok_or_else(|| {
AppError::Forbidden(format!(
"caller {} has no registered passkeys; cannot authorise step-up UV",
auth.0.did
))
})?;
let (uv_options, uv_state) = webauthn
.start_passkey_authentication(&pk_user.credentials)
.map_err(|e| AppError::Internal(format!("webauthn UV start: {e}")))?;
let registration_id = Uuid::new_v4();
store_auth_state(&state.passkey_ks, ®istration_id.to_string(), &uv_state).await?;
Ok(Json(PromoteStartResponse {
registration_id,
options: uv_options,
}))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PromoteFinishRequest {
pub registration_id: Uuid,
#[schema(value_type = Object)]
pub uv_response: PublicKeyCredential,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PromoteFinishResponse {
pub did: String,
pub event_id: Uuid,
}
#[utoipa::path(
post, path = "/members/{did}/promote-to-admin/finish", tag = "members",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Member DID being promoted")),
request_body = PromoteFinishRequest,
responses(
(status = 200, description = "Member promoted to admin", body = PromoteFinishResponse),
(status = 401, description = "Missing or invalid bearer token / step-up UV failed"),
(status = 403, description = "Caller is not an admin / promotion denied by policy"),
(status = 404, description = "Member not found"),
),
)]
pub async fn promote_finish(
auth: AdminAuth,
State(state): State<AppState>,
Path(target_did): Path<String>,
Json(req): Json<PromoteFinishRequest>,
) -> Result<Json<PromoteFinishResponse>, AppError> {
vti_common::identifier::validate_did("did", &target_did)?;
if auth.0.did == target_did {
return Err(AppError::Validation("you cannot promote yourself".into()));
}
let webauthn = require_webauthn(&state)?;
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let uv_state = take_auth_state(&state.passkey_ks, &req.registration_id.to_string())
.await?
.ok_or_else(|| AppError::Unauthorized("UV challenge not found or expired".into()))?;
let uv_result = webauthn
.finish_passkey_authentication(&req.uv_response, &uv_state)
.map_err(|_| AppError::Unauthorized("step-up UV failed".into()))?;
if !uv_result.user_verified() {
return Err(AppError::Unauthorized(
"passkey did not assert user verification (UV); cannot authorise admin promotion"
.into(),
));
}
let cred_id_hex = hex::encode(<_ as AsRef<[u8]>>::as_ref(uv_result.cred_id()));
let _guard = PROMOTE_LOCK.lock().await;
let target_acl = get_acl_entry(&state.acl_ks, &target_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {target_did}")))?;
get_member(&state.members_ks, &target_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {target_did}")))?;
if matches!(target_acl.role, VtcRole::Admin) {
return Err(AppError::Conflict(format!(
"{target_did} is already an admin"
)));
}
let granted = crate::ceremony::role_change_via_pipeline(
&state,
&auth.0.did,
&target_did,
&target_acl.role.to_string(),
"admin",
true,
)
.await?;
let already_exists = get_admin_entry(&state.passkey_ks, &target_did)
.await?
.is_some();
if !already_exists {
store_admin_entry(
&state.passkey_ks,
&AdminEntry {
did: target_did.clone(),
passkeys: Vec::new(),
extensions: serde_json::Value::Null,
created_at: Utc::now(),
},
)
.await?;
}
let envelope = audit_writer
.write(
&auth.0.did,
Some(&target_did),
AuditEvent::AdminPromoted(AdminPromotedData {
previous_role: granted.previous_role,
authorising_credential_id: cred_id_hex,
}),
)
.await?;
Ok(Json(PromoteFinishResponse {
did: target_did,
event_id: envelope.event_id,
}))
}
fn require_webauthn(state: &AppState) -> Result<Arc<Webauthn>, AppError> {
state.webauthn().cloned().ok_or_else(|| {
AppError::Internal(
"webauthn not configured (public_url unset); promote-to-admin unavailable".into(),
)
})
}