1use actix_web::{delete, get, post, put, web, HttpResponse};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sqlx::Row;
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
103fn is_superadmin(role: &str) -> bool {
109 role.eq_ignore_ascii_case("superadmin")
110}
111
112fn is_syndic_or_admin(role: &str) -> bool {
113 role.eq_ignore_ascii_case("syndic") || is_superadmin(role)
114}
115
116#[post("/api-keys")]
118
119pub async fn create_api_key(
120 claims: AuthenticatedUser,
121 state: web::Data<AppState>,
122 body: web::Json<CreateApiKeyRequest>,
123) -> HttpResponse {
124 if !is_syndic_or_admin(&claims.role) {
135 return HttpResponse::Forbidden().json(serde_json::json!({
136 "error": "Only syndics and admins can create API keys"
137 }));
138 }
139
140 for perm in &body.permissions {
142 if !VALID_PERMISSIONS.contains(&perm.as_str()) {
143 return HttpResponse::BadRequest().json(serde_json::json!({
144 "error": format!("Invalid permission: {}. Valid permissions: {:?}", perm, VALID_PERMISSIONS)
145 }));
146 }
147 }
148
149 let org_id = match claims.organization_id {
150 Some(id) => id,
151 None => {
152 return HttpResponse::BadRequest().json(serde_json::json!({
153 "error": "organization_id required"
154 }))
155 }
156 };
157
158 if body.name.is_empty() || body.name.len() > 255 {
160 return HttpResponse::BadRequest().json(serde_json::json!({
161 "error": "API key name must be between 1 and 255 characters"
162 }));
163 }
164
165 let (full_key, prefix, hash) = generate_api_key();
167 let key_id = Uuid::new_v4();
168 let rate_limit = body.rate_limit.unwrap_or(100);
169
170 if rate_limit < 1 || rate_limit > 10000 {
172 return HttpResponse::BadRequest().json(serde_json::json!({
173 "error": "Rate limit must be between 1 and 10,000 requests per minute"
174 }));
175 }
176
177 let result = sqlx::query!(
178 r#"
179 INSERT INTO api_keys (id, organization_id, created_by, key_prefix, key_hash, name, description, permissions, rate_limit, expires_at)
180 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
181 RETURNING id, created_at
182 "#,
183 key_id,
184 org_id,
185 claims.user_id,
186 prefix,
187 hash,
188 body.name,
189 body.description,
190 &body.permissions,
191 rate_limit,
192 body.expires_at,
193 )
194 .fetch_one(&state.pool)
195 .await;
196
197 match result {
198 Ok(row) => {
199 let _ = sqlx::query!(
201 r#"
202 INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
203 VALUES ($1, $2, $3, $4)
204 "#,
205 key_id,
206 "created",
207 claims.user_id,
208 Some(format!("API key created by {}", claims.user_id))
209 )
210 .execute(&state.pool)
211 .await;
212
213 HttpResponse::Created().json(ApiKeyCreatedResponse {
214 id: row.id,
215 name: body.name.clone(),
216 key: full_key,
217 key_prefix: prefix,
218 permissions: body.permissions.clone(),
219 rate_limit,
220 expires_at: body.expires_at,
221 created_at: row.created_at,
222 warning: "This key will never be displayed again. Store it securely.",
223 })
224 }
225 Err(e) => {
226 eprintln!("Database error creating API key: {}", e);
227 HttpResponse::InternalServerError().json(serde_json::json!({
228 "error": "Failed to create API key"
229 }))
230 }
231 }
232}
233
234#[get("/api-keys")]
236pub async fn list_api_keys(claims: AuthenticatedUser, state: web::Data<AppState>) -> HttpResponse {
237 let org_id = match claims.organization_id {
238 Some(id) => id,
239 None => {
240 return HttpResponse::BadRequest().json(serde_json::json!({
241 "error": "organization_id required"
242 }))
243 }
244 };
245
246 let rows = sqlx::query!(
247 r#"
248 SELECT id, name, key_prefix, permissions, rate_limit, last_used_at, expires_at, is_active, created_at
249 FROM api_keys
250 WHERE organization_id = $1
251 ORDER BY created_at DESC
252 "#,
253 org_id,
254 )
255 .fetch_all(&state.pool)
256 .await;
257
258 match rows {
259 Ok(keys) => {
260 let dtos: Vec<ApiKeyDto> = keys
261 .into_iter()
262 .map(|row| ApiKeyDto {
263 id: row.id,
264 name: row.name,
265 key_prefix: row.key_prefix,
266 permissions: row.permissions,
267 rate_limit: row.rate_limit,
268 last_used_at: row.last_used_at,
269 expires_at: row.expires_at,
270 is_active: row.is_active,
271 created_at: row.created_at,
272 })
273 .collect();
274
275 HttpResponse::Ok().json(ApiKeyListResponse {
276 total: dtos.len() as i64,
277 data: dtos,
278 })
279 }
280 Err(e) => {
281 eprintln!("Database error listing API keys: {}", e);
282 HttpResponse::InternalServerError().json(serde_json::json!({
283 "error": "Failed to list API keys"
284 }))
285 }
286 }
287}
288
289#[get("/api-keys/{id}")]
291pub async fn get_api_key(
292 claims: AuthenticatedUser,
293 state: web::Data<AppState>,
294 path: web::Path<Uuid>,
295) -> HttpResponse {
296 let key_id = path.into_inner();
297 let org_id = match claims.organization_id {
298 Some(id) => id,
299 None => {
300 return HttpResponse::BadRequest().json(serde_json::json!({
301 "error": "organization_id required"
302 }))
303 }
304 };
305
306 let row = sqlx::query!(
307 r#"
308 SELECT id, name, key_prefix, permissions, rate_limit, last_used_at, expires_at, is_active, created_at
309 FROM api_keys
310 WHERE id = $1 AND organization_id = $2
311 "#,
312 key_id,
313 org_id,
314 )
315 .fetch_optional(&state.pool)
316 .await;
317
318 match row {
319 Ok(Some(key)) => HttpResponse::Ok().json(ApiKeyDto {
320 id: key.id,
321 name: key.name,
322 key_prefix: key.key_prefix,
323 permissions: key.permissions,
324 rate_limit: key.rate_limit,
325 last_used_at: key.last_used_at,
326 expires_at: key.expires_at,
327 is_active: key.is_active,
328 created_at: key.created_at,
329 }),
330 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
331 "error": "API key not found"
332 })),
333 Err(e) => {
334 eprintln!("Database error fetching API key: {}", e);
335 HttpResponse::InternalServerError().json(serde_json::json!({
336 "error": "Failed to fetch API key"
337 }))
338 }
339 }
340}
341
342#[put("/api-keys/{id}")]
344pub async fn update_api_key(
345 claims: AuthenticatedUser,
346 state: web::Data<AppState>,
347 path: web::Path<Uuid>,
348 body: web::Json<UpdateApiKeyRequest>,
349) -> HttpResponse {
350 let key_id = path.into_inner();
351 let org_id = match claims.organization_id {
352 Some(id) => id,
353 None => {
354 return HttpResponse::BadRequest().json(serde_json::json!({
355 "error": "organization_id required"
356 }))
357 }
358 };
359
360 let existing = sqlx::query!(
362 "SELECT created_by FROM api_keys WHERE id = $1 AND organization_id = $2",
363 key_id,
364 org_id,
365 )
366 .fetch_optional(&state.pool)
367 .await;
368
369 match existing {
370 Ok(Some(key)) => {
371 if key.created_by != claims.user_id && !is_superadmin(&claims.role) {
372 return HttpResponse::Forbidden().json(serde_json::json!({
373 "error": "Only the API key creator can update it"
374 }));
375 }
376 }
377 Ok(None) => {
378 return HttpResponse::NotFound().json(serde_json::json!({
379 "error": "API key not found"
380 }))
381 }
382 Err(e) => {
383 eprintln!("Database error checking API key: {}", e);
384 return HttpResponse::InternalServerError().json(serde_json::json!({
385 "error": "Failed to update API key"
386 }));
387 }
388 }
389
390 let result = sqlx::query!(
391 r#"
392 UPDATE api_keys
393 SET
394 name = COALESCE($1, name),
395 description = COALESCE($2, description),
396 rate_limit = COALESCE($3, rate_limit),
397 expires_at = COALESCE($4, expires_at),
398 updated_at = NOW()
399 WHERE id = $5 AND organization_id = $6
400 RETURNING id, name, key_prefix, permissions, rate_limit, last_used_at, expires_at, is_active, created_at
401 "#,
402 body.name,
403 body.description,
404 body.rate_limit,
405 body.expires_at,
406 key_id,
407 org_id,
408 )
409 .fetch_one(&state.pool)
410 .await;
411
412 match result {
413 Ok(row) => {
414 let _ = sqlx::query!(
416 r#"
417 INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
418 VALUES ($1, $2, $3, $4)
419 "#,
420 key_id,
421 "updated",
422 claims.user_id,
423 Some(format!("API key updated by {}", claims.user_id))
424 )
425 .execute(&state.pool)
426 .await;
427
428 HttpResponse::Ok().json(ApiKeyDto {
429 id: row.id,
430 name: row.name,
431 key_prefix: row.key_prefix,
432 permissions: row.permissions,
433 rate_limit: row.rate_limit,
434 last_used_at: row.last_used_at,
435 expires_at: row.expires_at,
436 is_active: row.is_active,
437 created_at: row.created_at,
438 })
439 }
440 Err(e) => {
441 eprintln!("Database error updating API key: {}", e);
442 HttpResponse::InternalServerError().json(serde_json::json!({
443 "error": "Failed to update API key"
444 }))
445 }
446 }
447}
448
449#[delete("/api-keys/{id}")]
451pub async fn revoke_api_key(
452 claims: AuthenticatedUser,
453 state: web::Data<AppState>,
454 path: web::Path<Uuid>,
455) -> HttpResponse {
456 let key_id = path.into_inner();
457 let org_id = match claims.organization_id {
458 Some(id) => id,
459 None => {
460 return HttpResponse::BadRequest().json(serde_json::json!({
461 "error": "organization_id required"
462 }))
463 }
464 };
465
466 let result = sqlx::query!(
467 "UPDATE api_keys SET is_active = FALSE, updated_at = NOW() WHERE id = $1 AND organization_id = $2",
468 key_id,
469 org_id,
470 )
471 .execute(&state.pool)
472 .await;
473
474 match result {
475 Ok(r) if r.rows_affected() > 0 => {
476 let _ = sqlx::query!(
478 r#"
479 INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
480 VALUES ($1, $2, $3, $4)
481 "#,
482 key_id,
483 "revoked",
484 claims.user_id,
485 Some(format!("API key revoked by {}", claims.user_id))
486 )
487 .execute(&state.pool)
488 .await;
489
490 HttpResponse::Ok().json(ApiKeyResponse {
491 success: true,
492 message: "API key revoked successfully".to_string(),
493 })
494 }
495 Ok(_) => HttpResponse::NotFound().json(serde_json::json!({
496 "error": "API key not found"
497 })),
498 Err(e) => {
499 eprintln!("Database error revoking API key: {}", e);
500 HttpResponse::InternalServerError().json(serde_json::json!({
501 "error": "Failed to revoke API key"
502 }))
503 }
504 }
505}
506
507#[post("/api-keys/{id}/rotate")]
514pub async fn rotate_api_key(
515 claims: AuthenticatedUser,
516 state: web::Data<AppState>,
517 path: web::Path<Uuid>,
518) -> HttpResponse {
519 if !is_syndic_or_admin(&claims.role) {
521 return HttpResponse::Forbidden().json(serde_json::json!({
522 "error": "Only syndics and admins can rotate API keys"
523 }));
524 }
525
526 let key_id = path.into_inner();
527 let org_id = match claims.organization_id {
528 Some(id) => id,
529 None => {
530 return HttpResponse::BadRequest().json(serde_json::json!({
531 "error": "organization_id required"
532 }))
533 }
534 };
535
536 let mut tx = match state.pool.begin().await {
537 Ok(t) => t,
538 Err(e) => {
539 eprintln!("Database error (rotate begin): {}", e);
540 return HttpResponse::InternalServerError().json(serde_json::json!({
541 "error": "Failed to rotate API key"
542 }));
543 }
544 };
545
546 let old = sqlx::query(
549 r#"
550 SELECT name, description, permissions, rate_limit, expires_at
551 FROM api_keys
552 WHERE id = $1 AND organization_id = $2 AND is_active = TRUE
553 "#,
554 )
555 .bind(key_id)
556 .bind(org_id)
557 .fetch_optional(&mut *tx)
558 .await;
559
560 let row = match old {
561 Ok(Some(r)) => r,
562 Ok(None) => {
563 return HttpResponse::NotFound().json(serde_json::json!({
564 "error": "API key not found"
565 }))
566 }
567 Err(e) => {
568 eprintln!("Database error (rotate fetch): {}", e);
569 return HttpResponse::InternalServerError().json(serde_json::json!({
570 "error": "Failed to rotate API key"
571 }));
572 }
573 };
574
575 let name: String = row.get("name");
576 let description: Option<String> = row.get("description");
577 let permissions: Vec<String> = row.get("permissions");
578 let rate_limit: i32 = row.get("rate_limit");
579 let expires_at: Option<DateTime<Utc>> = row.get("expires_at");
580
581 if let Err(e) = sqlx::query(
583 r#"UPDATE api_keys SET is_active = FALSE, updated_at = NOW()
584 WHERE id = $1 AND organization_id = $2"#,
585 )
586 .bind(key_id)
587 .bind(org_id)
588 .execute(&mut *tx)
589 .await
590 {
591 eprintln!("Database error (rotate deactivate): {}", e);
592 return HttpResponse::InternalServerError().json(serde_json::json!({
593 "error": "Failed to rotate API key"
594 }));
595 }
596
597 let (full_key, prefix, hash) = generate_api_key();
599 let new_id = Uuid::new_v4();
600 let inserted = sqlx::query(
601 r#"
602 INSERT INTO api_keys (id, organization_id, created_by, key_prefix, key_hash, name, description, permissions, rate_limit, expires_at)
603 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
604 RETURNING created_at
605 "#,
606 )
607 .bind(new_id)
608 .bind(org_id)
609 .bind(claims.user_id)
610 .bind(&prefix)
611 .bind(&hash)
612 .bind(&name)
613 .bind(&description)
614 .bind(&permissions)
615 .bind(rate_limit)
616 .bind(expires_at)
617 .fetch_one(&mut *tx)
618 .await;
619
620 let created_at: DateTime<Utc> = match inserted {
621 Ok(r) => r.get("created_at"),
622 Err(e) => {
623 eprintln!("Database error (rotate insert): {}", e);
624 return HttpResponse::InternalServerError().json(serde_json::json!({
625 "error": "Failed to rotate API key"
626 }));
627 }
628 };
629
630 let _ = sqlx::query(
632 r#"INSERT INTO api_key_audit (api_key_id, action, actor_id, reason)
633 VALUES ($1, $2, $3, $4)"#,
634 )
635 .bind(key_id)
636 .bind("rotated")
637 .bind(claims.user_id)
638 .bind(format!(
639 "Rotated by {} — replacement key {}",
640 claims.user_id, new_id
641 ))
642 .execute(&mut *tx)
643 .await;
644
645 if let Err(e) = tx.commit().await {
646 eprintln!("Database error (rotate commit): {}", e);
647 return HttpResponse::InternalServerError().json(serde_json::json!({
648 "error": "Failed to rotate API key"
649 }));
650 }
651
652 HttpResponse::Ok().json(ApiKeyCreatedResponse {
653 id: new_id,
654 name,
655 key: full_key,
656 key_prefix: prefix,
657 permissions,
658 rate_limit,
659 expires_at,
660 created_at,
661 warning: "This key will never be displayed again. Store it securely.",
662 })
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn test_generate_api_key() {
671 let (full_key, prefix, hash) = generate_api_key();
672
673 assert!(full_key.starts_with("kpg_live_"));
674 assert_eq!(prefix, "kpg_live_");
675 assert_eq!(hash.len(), 64); assert!(full_key != generate_api_key().0); }
678
679 #[test]
680 fn test_validate_permissions() {
681 let valid_perms = vec![
682 "read:buildings".to_string(),
683 "write:etats-dates".to_string(),
684 ];
685 for perm in valid_perms {
686 assert!(VALID_PERMISSIONS.contains(&perm.as_str()));
687 }
688
689 let invalid_perm = "invalid:permission".to_string();
690 assert!(!VALID_PERMISSIONS.contains(&invalid_perm.as_str()));
691 }
692}