Skip to main content

mockforge_registry_server/handlers/
token_rotation.rs

1//! API Token rotation handlers
2//!
3//! Provides endpoints for rotating API tokens and checking rotation status
4
5use axum::{
6    extract::{Path, Query, State},
7    http::HeaderMap,
8    Json,
9};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::{
14    email::EmailService,
15    error::{ApiError, ApiResult},
16    middleware::{resolve_org_context, AuthUser},
17    models::{ApiToken, AuditEventType, Organization, User},
18    AppState,
19};
20
21#[derive(Debug, Deserialize)]
22pub struct RotateTokenRequest {
23    pub new_name: Option<String>,
24    pub delete_old: Option<bool>, // Default: false (keep old token)
25}
26
27#[derive(Debug, Serialize)]
28pub struct RotateTokenResponse {
29    pub success: bool,
30    pub new_token: String, // Only shown once!
31    pub new_token_id: Uuid,
32    pub new_token_prefix: String,
33    pub old_token_deleted: bool,
34    pub message: String,
35}
36
37/// Rotate an API token (create new, optionally delete old)
38pub async fn rotate_token(
39    State(state): State<AppState>,
40    AuthUser(user_id): AuthUser,
41    headers: HeaderMap,
42    Path(token_id): Path<Uuid>,
43    Json(request): Json<RotateTokenRequest>,
44) -> ApiResult<Json<RotateTokenResponse>> {
45    // Resolve org context
46    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
47        .await
48        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
49
50    // Verify token belongs to org
51    let old_token = state
52        .store
53        .find_api_token_by_id(token_id)
54        .await?
55        .ok_or_else(|| ApiError::InvalidRequest("Token not found".to_string()))?;
56
57    if old_token.org_id != org_ctx.org_id {
58        return Err(ApiError::InvalidRequest(
59            "Token does not belong to this organization".to_string(),
60        ));
61    }
62
63    // Rotate token
64    let delete_old = request.delete_old.unwrap_or(false);
65    let (new_full_token, new_token, _deleted_token) = state
66        .store
67        .rotate_api_token(token_id, request.new_name.as_deref(), delete_old)
68        .await?;
69
70    // Record audit log
71    let ip_address = headers
72        .get("X-Forwarded-For")
73        .or_else(|| headers.get("X-Real-IP"))
74        .and_then(|h| h.to_str().ok())
75        .map(|s| s.split(',').next().unwrap_or(s).trim());
76    let user_agent = headers.get("User-Agent").and_then(|h| h.to_str().ok());
77
78    state
79        .store
80        .record_audit_event(
81            org_ctx.org_id,
82            Some(user_id),
83            AuditEventType::ApiTokenRotated,
84            format!(
85                "API token '{}' rotated{}",
86                old_token.name,
87                if delete_old {
88                    " (old token deleted)"
89                } else {
90                    ""
91                }
92            ),
93            Some(serde_json::json!({
94                "old_token_id": token_id,
95                "new_token_id": new_token.id,
96                "old_token_name": old_token.name,
97                "new_token_name": new_token.name,
98                "delete_old": delete_old,
99            })),
100            ip_address,
101            user_agent,
102        )
103        .await;
104
105    Ok(Json(RotateTokenResponse {
106        success: true,
107        new_token: new_full_token, // Show full token only once!
108        new_token_id: new_token.id,
109        new_token_prefix: new_token.token_prefix,
110        old_token_deleted: delete_old,
111        message: if delete_old {
112            format!("Token '{}' rotated and old token deleted", old_token.name)
113        } else {
114            format!("Token '{}' rotated. Old token is still active.", old_token.name)
115        },
116    }))
117}
118
119#[derive(Debug, Serialize)]
120pub struct TokenRotationStatus {
121    pub token_id: Uuid,
122    pub name: String,
123    pub token_prefix: String,
124    pub age_days: i64,
125    pub needs_rotation: bool,
126    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
127    pub created_at: chrono::DateTime<chrono::Utc>,
128}
129
130#[derive(Debug, Serialize)]
131pub struct TokenRotationStatusResponse {
132    pub tokens_needing_rotation: Vec<TokenRotationStatus>,
133    pub rotation_threshold_days: i64,
134}
135
136#[derive(Debug, Deserialize)]
137pub struct TokenRotationStatusQuery {
138    pub threshold_days: Option<i64>, // Default: 90
139}
140
141/// Get tokens that need rotation
142pub async fn get_tokens_needing_rotation(
143    State(state): State<AppState>,
144    AuthUser(user_id): AuthUser,
145    headers: HeaderMap,
146    Query(query): Query<TokenRotationStatusQuery>,
147) -> ApiResult<Json<TokenRotationStatusResponse>> {
148    // Resolve org context
149    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
150        .await
151        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
152
153    let threshold_days = query.threshold_days.unwrap_or(90);
154
155    // Get all tokens for org
156    let all_tokens = state.store.list_api_tokens_by_org(org_ctx.org_id).await?;
157
158    // Filter tokens needing rotation
159    let tokens_needing_rotation: Vec<TokenRotationStatus> = all_tokens
160        .into_iter()
161        .filter(|token| token.needs_rotation(threshold_days))
162        .map(|token| {
163            let age_days = token.age_days();
164            TokenRotationStatus {
165                token_id: token.id,
166                name: token.name,
167                token_prefix: token.token_prefix,
168                age_days,
169                needs_rotation: true,
170                last_used_at: token.last_used_at,
171                created_at: token.created_at,
172            }
173        })
174        .collect();
175
176    Ok(Json(TokenRotationStatusResponse {
177        tokens_needing_rotation,
178        rotation_threshold_days: threshold_days,
179    }))
180}
181
182/// Background task: Send rotation reminders for tokens older than threshold
183/// This should be called periodically (e.g., daily via cron or scheduled task)
184pub async fn send_rotation_reminders(
185    pool: &sqlx::PgPool,
186    threshold_days: i64,
187) -> Result<usize, anyhow::Error> {
188    // Find all tokens needing rotation
189    let tokens = ApiToken::find_tokens_needing_rotation(pool, None, threshold_days).await?;
190
191    let email_service = EmailService::from_env()?;
192    let mut reminders_sent = 0;
193
194    for token in tokens {
195        // Get org to find owner
196        let org = Organization::find_by_id(pool, token.org_id)
197            .await?
198            .ok_or_else(|| anyhow::anyhow!("Organization not found"))?;
199
200        // Get user (owner or token creator)
201        let user_id = token.user_id.unwrap_or(org.owner_id);
202        let user = User::find_by_id(pool, user_id)
203            .await?
204            .ok_or_else(|| anyhow::anyhow!("User not found"))?;
205
206        // Respect the user's opt-out — this is a non-critical reminder.
207        if !user.email_notifications {
208            tracing::debug!(
209                "Skipping rotation reminder for token {}: user {} has email notifications disabled",
210                token.id,
211                user.id
212            );
213            continue;
214        }
215
216        // Build rotation URL
217        let rotation_url = format!(
218            "{}/settings/api-tokens/rotate/{}",
219            std::env::var("APP_BASE_URL")
220                .unwrap_or_else(|_| "https://app.mockforge.dev".to_string()),
221            token.id
222        );
223
224        // Send reminder email
225        let email_msg = EmailService::generate_token_rotation_reminder(
226            &user.username,
227            &user.email,
228            &token.name,
229            token.age_days(),
230            &rotation_url,
231        );
232
233        if let Err(e) = email_service.send(email_msg).await {
234            tracing::warn!("Failed to send rotation reminder for token {}: {}", token.id, e);
235        } else {
236            reminders_sent += 1;
237        }
238    }
239
240    Ok(reminders_sent)
241}