use axum::{
extract::State,
http::{header, HeaderMap, StatusCode},
Json,
};
use chrono::Duration;
use mockforge_platform_signing::{RotationError, RotationEvent, RotationPhase};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::platform_signing::{ControllerError, OperatorIdentity};
use crate::AppState;
const MAX_TRANSITION_WINDOW_DAYS: i64 = 365;
fn require_internal_auth(headers: &HeaderMap) -> ApiResult<()> {
let configured = match std::env::var("MOCKFORGE_INTERNAL_API_TOKEN") {
Ok(v) if !v.is_empty() => v,
_ => {
return Err(ApiError::Internal(anyhow::anyhow!(
"MOCKFORGE_INTERNAL_API_TOKEN not configured"
)));
}
};
let provided = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or_else(|| ApiError::InvalidRequest("Not found".into()))?;
if !constant_time_eq(provided.as_bytes(), configured.as_bytes()) {
return Err(ApiError::InvalidRequest("Not found".into()));
}
Ok(())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn operator_context(
headers: &HeaderMap,
body_org_id: Uuid,
body_user_id: Uuid,
) -> OperatorIdentity {
let ip_address = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string());
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
OperatorIdentity {
org_id: body_org_id,
user_id: body_user_id,
ip_address,
user_agent,
}
}
fn controller_error_to_response(err: ControllerError) -> axum::response::Response {
use axum::response::IntoResponse;
match err {
ControllerError::Rotation(rot) => {
let (status, code) = match &rot {
RotationError::WrongPhase { .. } => (StatusCode::CONFLICT, "WRONG_PHASE"),
RotationError::SameKey => (StatusCode::BAD_REQUEST, "SAME_KEY"),
RotationError::InvalidTransitionWindow => {
(StatusCode::BAD_REQUEST, "INVALID_TRANSITION_WINDOW")
}
RotationError::TransitionStillOpen { .. } => {
(StatusCode::CONFLICT, "TRANSITION_STILL_OPEN")
}
RotationError::NoRotationInProgress => {
(StatusCode::CONFLICT, "NO_ROTATION_IN_PROGRESS")
}
RotationError::Encoding(_) | RotationError::Signer(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "ROTATION_ERROR")
}
};
(status, Json(json!({ "error": { "code": code, "message": rot.to_string() } })))
.into_response()
}
ControllerError::Backend(msg) => (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": { "code": "SIGNER_BACKEND_ERROR", "message": msg }
})),
)
.into_response(),
ControllerError::NotConfigured => {
unreachable!("callers must check controller presence before reaching the error path")
}
}
}
fn require_controller(
state: &AppState,
) -> Result<
std::sync::Arc<dyn crate::platform_signing::PlatformSigningController>,
axum::response::Response,
> {
use axum::response::IntoResponse;
state.platform_signing.clone().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": {
"code": "PLATFORM_SIGNING_NOT_CONFIGURED",
"message": "MOCKFORGE_PLATFORM_SIGNING_KMS_KEY_ID is not set on this deployment",
}
})),
)
.into_response()
})
}
#[derive(Debug, Deserialize)]
pub struct BeginHandoverRequest {
pub to_key_id: String,
pub transition_window_days: i64,
pub operator_org_id: Uuid,
pub operator_user_id: Uuid,
}
#[derive(Debug, Serialize)]
pub struct BeginHandoverResponse {
pub event: RotationEvent,
}
pub async fn begin_handover(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<BeginHandoverRequest>,
) -> Result<Json<BeginHandoverResponse>, axum::response::Response> {
use axum::response::IntoResponse;
require_internal_auth(&headers).map_err(|e| e.into_response())?;
let controller = require_controller(&state)?;
if body.transition_window_days <= 0 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": {
"code": "INVALID_TRANSITION_WINDOW",
"message": "transition_window_days must be positive",
}
})),
)
.into_response());
}
if body.transition_window_days > MAX_TRANSITION_WINDOW_DAYS {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": {
"code": "TRANSITION_WINDOW_TOO_LONG",
"message": format!(
"transition_window_days exceeds {MAX_TRANSITION_WINDOW_DAYS}-day cap",
),
}
})),
)
.into_response());
}
if body.to_key_id.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": { "code": "INVALID_KEY_ID", "message": "to_key_id is empty" }
})),
)
.into_response());
}
let operator = operator_context(&headers, body.operator_org_id, body.operator_user_id);
let event = controller
.begin_handover(&operator, &body.to_key_id, Duration::days(body.transition_window_days))
.await
.map_err(controller_error_to_response)?;
Ok(Json(BeginHandoverResponse { event }))
}
#[derive(Debug, Deserialize)]
pub struct RetireOldRequest {
pub operator_org_id: Uuid,
pub operator_user_id: Uuid,
}
pub async fn retire_old(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RetireOldRequest>,
) -> Result<Json<serde_json::Value>, axum::response::Response> {
use axum::response::IntoResponse;
require_internal_auth(&headers).map_err(|e| e.into_response())?;
let controller = require_controller(&state)?;
let operator = operator_context(&headers, body.operator_org_id, body.operator_user_id);
controller.retire_old(&operator).await.map_err(controller_error_to_response)?;
Ok(Json(json!({ "status": "retired" })))
}
#[derive(Debug, Deserialize)]
pub struct EmergencyRevokeRequest {
pub reason: String,
pub operator_org_id: Uuid,
pub operator_user_id: Uuid,
}
pub async fn emergency_revoke(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<EmergencyRevokeRequest>,
) -> Result<Json<serde_json::Value>, axum::response::Response> {
use axum::response::IntoResponse;
require_internal_auth(&headers).map_err(|e| e.into_response())?;
let controller = require_controller(&state)?;
if body.reason.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": {
"code": "REASON_REQUIRED",
"message": "reason is empty — emergency revocation must be justified for audit",
}
})),
)
.into_response());
}
let operator = operator_context(&headers, body.operator_org_id, body.operator_user_id);
controller
.emergency_revoke(&operator, &body.reason)
.await
.map_err(controller_error_to_response)?;
Ok(Json(json!({ "status": "revoked" })))
}
#[derive(Debug, Serialize)]
pub struct PluginRotationEventsResponse {
pub phase: RotationPhase,
pub latest: Option<RotationEvent>,
pub trusted_key_ids: Vec<String>,
}
pub async fn list_rotation_events(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<PluginRotationEventsResponse>, axum::response::Response> {
use axum::response::IntoResponse;
require_internal_auth(&headers).map_err(|e| e.into_response())?;
let controller = require_controller(&state)?;
let phase = controller.phase().await;
let latest = controller.last_event().await;
let trusted_key_ids = controller.trusted_key_ids().await;
Ok(Json(PluginRotationEventsResponse {
phase,
latest,
trusted_key_ids,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_distinguishes_lengths() {
assert!(!constant_time_eq(b"ab", b"abc"));
}
#[test]
fn constant_time_eq_equal_strings() {
assert!(constant_time_eq(b"hello", b"hello"));
}
#[test]
fn constant_time_eq_different_strings() {
assert!(!constant_time_eq(b"hello", b"world"));
}
#[test]
fn operator_context_extracts_ip_and_ua() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "203.0.113.42, 10.0.0.1".parse().unwrap());
headers.insert(header::USER_AGENT, "MockForge-CLI/1.0".parse().unwrap());
let org = Uuid::new_v4();
let user = Uuid::new_v4();
let op = operator_context(&headers, org, user);
assert_eq!(op.org_id, org);
assert_eq!(op.user_id, user);
assert_eq!(op.ip_address.as_deref(), Some("203.0.113.42"));
assert_eq!(op.user_agent.as_deref(), Some("MockForge-CLI/1.0"));
}
#[test]
fn operator_context_tolerates_missing_headers() {
let headers = HeaderMap::new();
let op = operator_context(&headers, Uuid::nil(), Uuid::nil());
assert!(op.ip_address.is_none());
assert!(op.user_agent.is_none());
}
}