assay_workflow/api/
api_keys.rs1use std::sync::Arc;
18
19use axum::extract::State;
20use axum::routing::{delete, post};
21use axum::{Json, Router};
22use serde::{Deserialize, Serialize};
23use utoipa::ToSchema;
24
25use crate::api::auth::{generate_api_key, hash_api_key, key_prefix};
26use crate::api::workflows::AppError;
27use crate::api::AppState;
28use crate::store::{ApiKeyRecord, WorkflowStore};
29
30pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<AppState<S>>> {
31 Router::new()
32 .route("/api-keys", post(create_api_key).get(list_api_keys))
33 .route("/api-keys/{prefix}", delete(revoke_api_key))
34}
35
36#[derive(Deserialize, ToSchema)]
37pub struct CreateApiKeyRequest {
38 #[serde(default)]
42 pub label: Option<String>,
43
44 #[serde(default)]
49 pub idempotent: bool,
50}
51
52#[derive(Serialize, ToSchema)]
53pub struct CreateApiKeyResponse {
54 #[serde(skip_serializing_if = "Option::is_none")]
57 pub plaintext: Option<String>,
58 pub prefix: String,
59 pub label: Option<String>,
60 pub created_at: f64,
61}
62
63#[utoipa::path(
64 post, path = "/api/v1/api-keys",
65 tag = "api-keys",
66 request_body = CreateApiKeyRequest,
67 responses(
68 (status = 201, description = "New API key minted", body = CreateApiKeyResponse),
69 (status = 200, description = "Idempotent: existing key with this label returned (no plaintext)", body = CreateApiKeyResponse),
70 (status = 500, description = "Internal error"),
71 ),
72)]
73pub async fn create_api_key<S: WorkflowStore>(
74 State(state): State<Arc<AppState<S>>>,
75 Json(req): Json<CreateApiKeyRequest>,
76) -> Result<(axum::http::StatusCode, Json<CreateApiKeyResponse>), AppError> {
77 if req.idempotent
78 && let Some(label) = req.label.as_deref()
79 && let Some(existing) = state.engine.store().get_api_key_by_label(label).await?
80 {
81 return Ok((
82 axum::http::StatusCode::OK,
83 Json(CreateApiKeyResponse {
84 plaintext: None,
85 prefix: existing.prefix,
86 label: existing.label,
87 created_at: existing.created_at,
88 }),
89 ));
90 }
91
92 let plaintext = generate_api_key();
93 let hash = hash_api_key(&plaintext);
94 let prefix = key_prefix(&plaintext);
95 let now = std::time::SystemTime::now()
96 .duration_since(std::time::UNIX_EPOCH)
97 .map(|d| d.as_secs_f64())
98 .unwrap_or(0.0);
99
100 state
101 .engine
102 .store()
103 .create_api_key(&hash, &prefix, req.label.as_deref(), now)
104 .await?;
105
106 Ok((
107 axum::http::StatusCode::CREATED,
108 Json(CreateApiKeyResponse {
109 plaintext: Some(plaintext),
110 prefix,
111 label: req.label,
112 created_at: now,
113 }),
114 ))
115}
116
117#[utoipa::path(
118 get, path = "/api/v1/api-keys",
119 tag = "api-keys",
120 responses(
121 (status = 200, description = "List of API key metadata (hashes never exposed)", body = Vec<ApiKeyRecord>),
122 ),
123)]
124pub async fn list_api_keys<S: WorkflowStore>(
125 State(state): State<Arc<AppState<S>>>,
126) -> Result<Json<Vec<ApiKeyRecord>>, AppError> {
127 let keys = state.engine.store().list_api_keys().await?;
128 Ok(Json(keys))
129}
130
131#[utoipa::path(
132 delete, path = "/api/v1/api-keys/{prefix}",
133 tag = "api-keys",
134 params(("prefix" = String, Path, description = "Key prefix (e.g. assay_abcd1234...)")),
135 responses(
136 (status = 204, description = "Key revoked"),
137 (status = 404, description = "No key with that prefix"),
138 ),
139)]
140pub async fn revoke_api_key<S: WorkflowStore>(
141 State(state): State<Arc<AppState<S>>>,
142 axum::extract::Path(prefix): axum::extract::Path<String>,
143) -> Result<axum::http::StatusCode, AppError> {
144 let removed = state.engine.store().revoke_api_key(&prefix).await?;
145 if removed {
146 Ok(axum::http::StatusCode::NO_CONTENT)
147 } else {
148 Ok(axum::http::StatusCode::NOT_FOUND)
149 }
150}