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::ctx::WorkflowCtx;
28use crate::store::ApiKeyRecord;
29use crate::store::WorkflowStore;
30
31pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
32 Router::new()
33 .route("/api-keys", post(create_api_key).get(list_api_keys))
34 .route("/api-keys/{prefix}", delete(revoke_api_key))
35}
36
37#[derive(Deserialize, ToSchema)]
38pub struct CreateApiKeyRequest {
39 #[serde(default)]
43 pub label: Option<String>,
44
45 #[serde(default)]
50 pub idempotent: bool,
51}
52
53#[derive(Serialize, ToSchema)]
54pub struct CreateApiKeyResponse {
55 #[serde(skip_serializing_if = "Option::is_none")]
58 pub plaintext: Option<String>,
59 pub prefix: String,
60 pub label: Option<String>,
61 pub created_at: f64,
62}
63
64#[utoipa::path(
65 post, path = "/api/v1/api-keys",
66 tag = "api-keys",
67 request_body = CreateApiKeyRequest,
68 responses(
69 (status = 201, description = "New API key minted", body = CreateApiKeyResponse),
70 (status = 200, description = "Idempotent: existing key with this label returned (no plaintext)", body = CreateApiKeyResponse),
71 (status = 500, description = "Internal error"),
72 ),
73)]
74pub async fn create_api_key<S: WorkflowStore>(
75 State(state): State<Arc<WorkflowCtx<S>>>,
76 Json(req): Json<CreateApiKeyRequest>,
77) -> Result<(axum::http::StatusCode, Json<CreateApiKeyResponse>), AppError> {
78 if req.idempotent
79 && let Some(label) = req.label.as_deref()
80 && let Some(existing) = state.store().get_api_key_by_label(label).await?
81 {
82 return Ok((
83 axum::http::StatusCode::OK,
84 Json(CreateApiKeyResponse {
85 plaintext: None,
86 prefix: existing.prefix,
87 label: existing.label,
88 created_at: existing.created_at,
89 }),
90 ));
91 }
92
93 let plaintext = generate_api_key();
94 let hash = hash_api_key(&plaintext);
95 let prefix = key_prefix(&plaintext);
96 let now = std::time::SystemTime::now()
97 .duration_since(std::time::UNIX_EPOCH)
98 .map(|d| d.as_secs_f64())
99 .unwrap_or(0.0);
100
101 state
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<WorkflowCtx<S>>>,
126) -> Result<Json<Vec<ApiKeyRecord>>, AppError> {
127 let keys = state.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<WorkflowCtx<S>>>,
142 axum::extract::Path(prefix): axum::extract::Path<String>,
143) -> Result<axum::http::StatusCode, AppError> {
144 let removed = state.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}