1use actix_web::{delete, get, post, put, web, HttpResponse};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use uuid::Uuid;
11
12use crate::infrastructure::web::middleware::AuthenticatedUser;
13use crate::infrastructure::web::AppState;
14
15const VALID_PERMISSIONS: &[&str] = &[
17 "read:buildings",
18 "read:expenses",
19 "read:owners",
20 "read:meetings",
21 "read:etats-dates",
22 "write:etats-dates",
23 "read:energy-campaigns",
24 "read:documents",
25 "read:financial-reports",
26 "webhooks:subscribe",
27];
28
29#[derive(Debug, Serialize, Deserialize, Clone)]
30pub struct CreateApiKeyRequest {
31 pub name: String,
32 pub description: Option<String>,
33 pub permissions: Vec<String>,
34 pub rate_limit: Option<i32>,
35 pub expires_at: Option<DateTime<Utc>>,
36}
37
38#[derive(Debug, Serialize)]
39pub struct ApiKeyCreatedResponse {
40 pub id: Uuid,
41 pub name: String,
42 pub key: String, pub key_prefix: String,
44 pub permissions: Vec<String>,
45 pub rate_limit: i32,
46 pub expires_at: Option<DateTime<Utc>>,
47 pub created_at: DateTime<Utc>,
48 pub warning: &'static str,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52pub struct ApiKeyDto {
53 pub id: Uuid,
54 pub name: String,
55 pub key_prefix: String,
56 pub permissions: Vec<String>,
57 pub rate_limit: i32,
58 pub last_used_at: Option<DateTime<Utc>>,
59 pub expires_at: Option<DateTime<Utc>>,
60 pub is_active: bool,
61 pub created_at: DateTime<Utc>,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65pub struct UpdateApiKeyRequest {
66 pub name: Option<String>,
67 pub description: Option<String>,
68 pub rate_limit: Option<i32>,
69 pub expires_at: Option<DateTime<Utc>>,
70}
71
72#[derive(Debug, Serialize)]
73pub struct ApiKeyResponse {
74 pub success: bool,
75 pub message: String,
76}
77
78#[derive(Debug, Serialize)]
79pub struct ApiKeyListResponse {
80 pub data: Vec<ApiKeyDto>,
81 pub total: i64,
82}
83
84fn generate_api_key() -> (String, String, String) {
86 use sha2::{Digest, Sha256};
87
88 let random_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
90
91 let key_body = hex::encode(&random_bytes);
92 let full_key = format!("kpg_live_{}", key_body);
93 let prefix = "kpg_live_".to_string();
94
95 let mut hasher = Sha256::new();
97 hasher.update(full_key.as_bytes());
98 let hash = format!("{:x}", hasher.finalize());
99
100 (full_key, prefix, hash)
101}
102
103#[post("/api-keys")]
105pub async fn create_api_key(
106 claims: AuthenticatedUser,
107 state: web::Data<Arc<AppState>>,
108 body: web::Json<CreateApiKeyRequest>,
109) -> HttpResponse {
110 if claims.role != "SYNDIC" && claims.role != "SUPERADMIN" {
112 return HttpResponse::Forbidden().json(serde_json::json!({
113 "error": "Only syndics and admins can create API keys"
114 }));
115 }
116
117 for perm in &body.permissions {
119 if !VALID_PERMISSIONS.contains(&perm.as_str()) {
120 return HttpResponse::BadRequest().json(serde_json::json!({
121 "error": format!("Invalid permission: {}. Valid permissions: {:?}", perm, VALID_PERMISSIONS)
122 }));
123 }
124 }
125
126 let org_id = match claims.organization_id {
127 Some(id) => id,
128 None => {
129 return HttpResponse::BadRequest().json(serde_json::json!({
130 "error": "organization_id required"
131 }))
132 }
133 };
134
135 if body.name.is_empty() || body.name.len() > 255 {
137 return HttpResponse::BadRequest().json(serde_json::json!({
138 "error": "API key name must be between 1 and 255 characters"
139 }));
140 }
141
142 let (full_key, prefix, hash) = generate_api_key();
144 let key_id = Uuid::new_v4();
145 let rate_limit = body.rate_limit.unwrap_or(100);
146
147 if rate_limit < 1 || rate_limit > 10000 {
149 return HttpResponse::BadRequest().json(serde_json::json!({
150 "error": "Rate limit must be between 1 and 10,000 requests per minute"
151 }));
152 }
153
154 let result = sqlx::query!(
155 r#"
156 INSERT INTO api_keys (id, organization_id, created_by, key_prefix, key_hash, name, description, permissions, rate_limit, expires_at)
157 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
158 RETURNING id, created_at
159 "#,
160 key_id,
161 org_id,
162 claims.user_id,
163 prefix,
164 hash,
165 body.name,
166 body.description,
167 &body.permissions,
168 rate_limit,
169 body.expires_at,
170 )
171 .fetch_one(&state.pool)
172 .await;
173
174 match result {
175 Ok(row) => {
176 let _ = sqlx::query!(
178 r#"
179 INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
180 VALUES ($1, $2, $3, $4)
181 "#,
182 key_id,
183 "created",
184 claims.user_id,
185 Some(format!("API key created by {}", claims.user_id))
186 )
187 .execute(&state.pool)
188 .await;
189
190 HttpResponse::Created().json(ApiKeyCreatedResponse {
191 id: row.id,
192 name: body.name.clone(),
193 key: full_key,
194 key_prefix: prefix,
195 permissions: body.permissions.clone(),
196 rate_limit,
197 expires_at: body.expires_at,
198 created_at: row.created_at,
199 warning: "This key will never be displayed again. Store it securely.",
200 })
201 }
202 Err(e) => {
203 eprintln!("Database error creating API key: {}", e);
204 HttpResponse::InternalServerError().json(serde_json::json!({
205 "error": "Failed to create API key"
206 }))
207 }
208 }
209}
210
211#[get("/api-keys")]
213pub async fn list_api_keys(
214 claims: AuthenticatedUser,
215 state: web::Data<Arc<AppState>>,
216) -> HttpResponse {
217 let org_id = match claims.organization_id {
218 Some(id) => id,
219 None => {
220 return HttpResponse::BadRequest().json(serde_json::json!({
221 "error": "organization_id required"
222 }))
223 }
224 };
225
226 let rows = sqlx::query!(
227 r#"
228 SELECT id, name, key_prefix, permissions, rate_limit, last_used_at, expires_at, is_active, created_at
229 FROM api_keys
230 WHERE organization_id = $1
231 ORDER BY created_at DESC
232 "#,
233 org_id,
234 )
235 .fetch_all(&state.pool)
236 .await;
237
238 match rows {
239 Ok(keys) => {
240 let dtos: Vec<ApiKeyDto> = keys
241 .into_iter()
242 .map(|row| ApiKeyDto {
243 id: row.id,
244 name: row.name,
245 key_prefix: row.key_prefix,
246 permissions: row.permissions,
247 rate_limit: row.rate_limit,
248 last_used_at: row.last_used_at,
249 expires_at: row.expires_at,
250 is_active: row.is_active,
251 created_at: row.created_at,
252 })
253 .collect();
254
255 HttpResponse::Ok().json(ApiKeyListResponse {
256 total: dtos.len() as i64,
257 data: dtos,
258 })
259 }
260 Err(e) => {
261 eprintln!("Database error listing API keys: {}", e);
262 HttpResponse::InternalServerError().json(serde_json::json!({
263 "error": "Failed to list API keys"
264 }))
265 }
266 }
267}
268
269#[get("/api-keys/{id}")]
271pub async fn get_api_key(
272 claims: AuthenticatedUser,
273 state: web::Data<Arc<AppState>>,
274 path: web::Path<Uuid>,
275) -> HttpResponse {
276 let key_id = path.into_inner();
277 let org_id = match claims.organization_id {
278 Some(id) => id,
279 None => {
280 return HttpResponse::BadRequest().json(serde_json::json!({
281 "error": "organization_id required"
282 }))
283 }
284 };
285
286 let row = sqlx::query!(
287 r#"
288 SELECT id, name, key_prefix, permissions, rate_limit, last_used_at, expires_at, is_active, created_at
289 FROM api_keys
290 WHERE id = $1 AND organization_id = $2
291 "#,
292 key_id,
293 org_id,
294 )
295 .fetch_optional(&state.pool)
296 .await;
297
298 match row {
299 Ok(Some(key)) => HttpResponse::Ok().json(ApiKeyDto {
300 id: key.id,
301 name: key.name,
302 key_prefix: key.key_prefix,
303 permissions: key.permissions,
304 rate_limit: key.rate_limit,
305 last_used_at: key.last_used_at,
306 expires_at: key.expires_at,
307 is_active: key.is_active,
308 created_at: key.created_at,
309 }),
310 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
311 "error": "API key not found"
312 })),
313 Err(e) => {
314 eprintln!("Database error fetching API key: {}", e);
315 HttpResponse::InternalServerError().json(serde_json::json!({
316 "error": "Failed to fetch API key"
317 }))
318 }
319 }
320}
321
322#[put("/api-keys/{id}")]
324pub async fn update_api_key(
325 claims: AuthenticatedUser,
326 state: web::Data<Arc<AppState>>,
327 path: web::Path<Uuid>,
328 body: web::Json<UpdateApiKeyRequest>,
329) -> HttpResponse {
330 let key_id = path.into_inner();
331 let org_id = match claims.organization_id {
332 Some(id) => id,
333 None => {
334 return HttpResponse::BadRequest().json(serde_json::json!({
335 "error": "organization_id required"
336 }))
337 }
338 };
339
340 let existing = sqlx::query!(
342 "SELECT created_by FROM api_keys WHERE id = $1 AND organization_id = $2",
343 key_id,
344 org_id,
345 )
346 .fetch_optional(&state.pool)
347 .await;
348
349 match existing {
350 Ok(Some(key)) => {
351 if key.created_by != claims.user_id && claims.role != "SUPERADMIN" {
352 return HttpResponse::Forbidden().json(serde_json::json!({
353 "error": "Only the API key creator can update it"
354 }));
355 }
356 }
357 Ok(None) => {
358 return HttpResponse::NotFound().json(serde_json::json!({
359 "error": "API key not found"
360 }))
361 }
362 Err(e) => {
363 eprintln!("Database error checking API key: {}", e);
364 return HttpResponse::InternalServerError().json(serde_json::json!({
365 "error": "Failed to update API key"
366 }));
367 }
368 }
369
370 let result = sqlx::query!(
371 r#"
372 UPDATE api_keys
373 SET
374 name = COALESCE($1, name),
375 description = COALESCE($2, description),
376 rate_limit = COALESCE($3, rate_limit),
377 expires_at = COALESCE($4, expires_at),
378 updated_at = NOW()
379 WHERE id = $5 AND organization_id = $6
380 RETURNING id, name, key_prefix, permissions, rate_limit, last_used_at, expires_at, is_active, created_at
381 "#,
382 body.name,
383 body.description,
384 body.rate_limit,
385 body.expires_at,
386 key_id,
387 org_id,
388 )
389 .fetch_one(&state.pool)
390 .await;
391
392 match result {
393 Ok(row) => {
394 let _ = sqlx::query!(
396 r#"
397 INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
398 VALUES ($1, $2, $3, $4)
399 "#,
400 key_id,
401 "updated",
402 claims.user_id,
403 Some(format!("API key updated by {}", claims.user_id))
404 )
405 .execute(&state.pool)
406 .await;
407
408 HttpResponse::Ok().json(ApiKeyDto {
409 id: row.id,
410 name: row.name,
411 key_prefix: row.key_prefix,
412 permissions: row.permissions,
413 rate_limit: row.rate_limit,
414 last_used_at: row.last_used_at,
415 expires_at: row.expires_at,
416 is_active: row.is_active,
417 created_at: row.created_at,
418 })
419 }
420 Err(e) => {
421 eprintln!("Database error updating API key: {}", e);
422 HttpResponse::InternalServerError().json(serde_json::json!({
423 "error": "Failed to update API key"
424 }))
425 }
426 }
427}
428
429#[delete("/api-keys/{id}")]
431pub async fn revoke_api_key(
432 claims: AuthenticatedUser,
433 state: web::Data<Arc<AppState>>,
434 path: web::Path<Uuid>,
435) -> HttpResponse {
436 let key_id = path.into_inner();
437 let org_id = match claims.organization_id {
438 Some(id) => id,
439 None => {
440 return HttpResponse::BadRequest().json(serde_json::json!({
441 "error": "organization_id required"
442 }))
443 }
444 };
445
446 let result = sqlx::query!(
447 "UPDATE api_keys SET is_active = FALSE, updated_at = NOW() WHERE id = $1 AND organization_id = $2",
448 key_id,
449 org_id,
450 )
451 .execute(&state.pool)
452 .await;
453
454 match result {
455 Ok(r) if r.rows_affected() > 0 => {
456 let _ = sqlx::query!(
458 r#"
459 INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
460 VALUES ($1, $2, $3, $4)
461 "#,
462 key_id,
463 "revoked",
464 claims.user_id,
465 Some(format!("API key revoked by {}", claims.user_id))
466 )
467 .execute(&state.pool)
468 .await;
469
470 HttpResponse::Ok().json(ApiKeyResponse {
471 success: true,
472 message: "API key revoked successfully".to_string(),
473 })
474 }
475 Ok(_) => HttpResponse::NotFound().json(serde_json::json!({
476 "error": "API key not found"
477 })),
478 Err(e) => {
479 eprintln!("Database error revoking API key: {}", e);
480 HttpResponse::InternalServerError().json(serde_json::json!({
481 "error": "Failed to revoke API key"
482 }))
483 }
484 }
485}
486
487#[post("/api-keys/{id}/rotate")]
490pub async fn rotate_api_key(
491 claims: AuthenticatedUser,
492 _state: web::Data<Arc<AppState>>,
493 path: web::Path<Uuid>,
494) -> HttpResponse {
495 let _key_id = path.into_inner();
496 let _org_id = match claims.organization_id {
497 Some(id) => id,
498 None => {
499 return HttpResponse::BadRequest().json(serde_json::json!({
500 "error": "organization_id required"
501 }))
502 }
503 };
504
505 HttpResponse::NotImplemented().json(serde_json::json!({
511 "error": "Key rotation not yet implemented"
512 }))
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
520 fn test_generate_api_key() {
521 let (full_key, prefix, hash) = generate_api_key();
522
523 assert!(full_key.starts_with("kpg_live_"));
524 assert_eq!(prefix, "kpg_live_");
525 assert_eq!(hash.len(), 64); assert!(full_key != generate_api_key().0); }
528
529 #[test]
530 fn test_validate_permissions() {
531 let valid_perms = vec![
532 "read:buildings".to_string(),
533 "write:etats-dates".to_string(),
534 ];
535 for perm in valid_perms {
536 assert!(VALID_PERMISSIONS.contains(&perm.as_str()));
537 }
538
539 let invalid_perm = "invalid:permission".to_string();
540 assert!(!VALID_PERMISSIONS.contains(&invalid_perm.as_str()));
541 }
542}