1use 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
14const 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, 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
83fn generate_api_key() -> (String, String, String) {
85 use sha2::{Digest, Sha256};
86
87 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 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#[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 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 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 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 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 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 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#[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("/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#[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 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 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#[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 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#[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 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); assert!(full_key != generate_api_key().0); }
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}