mockforge_registry_server/handlers/
token_rotation.rs1use 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>, }
26
27#[derive(Debug, Serialize)]
28pub struct RotateTokenResponse {
29 pub success: bool,
30 pub new_token: String, pub new_token_id: Uuid,
32 pub new_token_prefix: String,
33 pub old_token_deleted: bool,
34 pub message: String,
35}
36
37pub 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 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 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 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 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, 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>, }
140
141pub 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 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 let all_tokens = state.store.list_api_tokens_by_org(org_ctx.org_id).await?;
157
158 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
182pub async fn send_rotation_reminders(
185 pool: &sqlx::PgPool,
186 threshold_days: i64,
187) -> Result<usize, anyhow::Error> {
188 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 let org = Organization::find_by_id(pool, token.org_id)
197 .await?
198 .ok_or_else(|| anyhow::anyhow!("Organization not found"))?;
199
200 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 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 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 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}