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 std::sync::Arc;
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/// Create a new API key (Syndic or SuperAdmin only)
104#[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    // Verify permissions
111    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    // Validate permissions
118    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    // Validate name
136    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    // Generate key
143    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    // Ensure rate_limit is reasonable
148    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            // Log audit event
177            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/// List API keys for organization (key bodies hidden)
212#[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 a specific API key (hidden body)
270#[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/// Update an API key (name, description, rate limit, expiration)
323#[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    // Verify authorization (only the creator or superadmin can update)
341    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            // Log audit event
395            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/// Revoke an API key (disable it)
430#[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            // Log audit event
457            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/// Rotate an API key (generate a new one, disable old one)
488/// Note: This is a placeholder for future implementation
489#[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    // TODO: Implement key rotation
506    // 1. Generate new key
507    // 2. Mark old key as rotated
508    // 3. Return new key (only shown once)
509
510    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); // SHA-256 is 64 hex chars
526        assert!(full_key != generate_api_key().0); // Keys should be unique
527    }
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}