Skip to main content

koprogo_api/infrastructure/web/handlers/
api_key_handlers.rs

1//! API Key Management Handlers — Public API v2 (Issues #111, #232)
2//!
3//! Enables third-party integrations with KoproGo via API keys.
4//! Supports: PropTech, notaries, energy providers, accounting software.
5
6use 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
15/// Available API v2 permissions
16const 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, // Full key — only shown ONCE at creation
43    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
84/// Generate a random API key with secure hashing
85fn generate_api_key() -> (String, String, String) {
86    use sha2::{Digest, Sha256};
87
88    // Generate 32 random bytes for the key body
89    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    // Hash the key for secure storage
96    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/// Le rôle porté par le JWT provient de `UserRole::to_string()`, qui rend des
104/// valeurs en minuscules (`syndic`, `superadmin`). Comparer à une constante en
105/// majuscules ne matche jamais — cf. le bug corrigé ici. Ces deux helpers
106/// centralisent le test pour qu'il ne puisse plus diverger d'un handler à
107/// l'autre.
108fn 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/// Create a new API key (Syndic or SuperAdmin only)
117#[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    // Verify permissions
125    //
126    // #661-audit : la comparaison se faisait contre "SYNDIC"/"SUPERADMIN" en
127    // MAJUSCULES, alors que le JWT porte `UserRole::to_string()`, qui rend
128    // "syndic"/"superadmin" en minuscules. La condition était donc TOUJOURS
129    // vraie : tous les endpoints de clés API répondaient 403, y compris à un
130    // syndic légitime. Bug identifié dès 2026-04 (WBS #331), jamais corrigé,
131    // et invisible parce que `e2e_api_keys.rs` n'a jamais tourné en CI.
132    // Comparaison insensible à la casse pour rester robuste quelle que soit
133    // la source du rôle.
134    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    // Validate permissions
141    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    // Validate name
159    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    // Generate key
166    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    // Ensure rate_limit is reasonable
171    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            // Log audit event
200            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/// List API keys for organization (key bodies hidden)
235#[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 a specific API key (hidden body)
290#[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/// Update an API key (name, description, rate limit, expiration)
343#[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    // Verify authorization (only the creator or superadmin can update)
361    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            // Log audit event
415            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/// Revoke an API key (disable it)
450#[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            // Log audit event
477            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/// Rotate an API key: deactivate the old one and issue a replacement that
508/// inherits name/description/permissions/rate-limit/expiry. The new secret
509/// is returned **once** (#339 / WP-A7). The old key is invalid immediately.
510///
511/// `sqlx::query` (non-macro) is used intentionally: no offline query-cache
512/// regeneration needed (works without a live DB at compile time).
513#[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    // Rotation mints fresh credentials → same privilege gate as creation.
520    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    // Tenant-scoped: a key from another organization is invisible (404),
547    // never a 403 that would confirm its existence (cross-org isolation).
548    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    // Old key is immediately invalid.
582    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    // Issue the replacement (metadata inherited from the rotated key).
598    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    // Audit trail (best effort, same transaction).
631    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); // SHA-256 is 64 hex chars
676        assert!(full_key != generate_api_key().0); // Keys should be unique
677    }
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}