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