Skip to main content

assay_workflow/api/
api_keys.rs

1//! API key management endpoints.
2//!
3//! Provides REST creation/listing/deletion of engine API keys as an
4//! alternative to the `assay serve --generate-api-key` CLI subcommand.
5//!
6//! `POST /api/v1/api-keys` supports a client-supplied `label` and an
7//! `idempotent` flag. When `idempotent = true` and a key with that label
8//! already exists, the handler returns the existing record's metadata
9//! *without* a plaintext — the plaintext was handed out at generation
10//! time and is never retrievable again.
11//!
12//! The POST endpoint is intentionally callable **without authentication**
13//! when the `api_keys` table is empty (see `api/auth.rs` middleware). This
14//! is the first-ever-key bootstrap window: without it, a freshly deployed
15//! server running in API-key or combined mode has no way to receive its
16//! first credential. The window closes as soon as any key exists.
17use 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    /// Optional label to tag the key with. Labels can be arbitrary strings
39    /// but are most useful when unique — combined with `idempotent=true`
40    /// they let a caller provision a named key across reruns.
41    #[serde(default)]
42    pub label: Option<String>,
43
44    /// If true AND a key with this `label` already exists, the handler
45    /// returns `200 OK` with the existing record's metadata (no plaintext).
46    /// If false (default) or no label is supplied, the handler always
47    /// mints a fresh key and returns `201 Created` with the plaintext.
48    #[serde(default)]
49    pub idempotent: bool,
50}
51
52#[derive(Serialize, ToSchema)]
53pub struct CreateApiKeyResponse {
54    /// Plaintext API key. Only present on a fresh mint (`201 Created`).
55    /// Never included when an existing key is returned idempotently.
56    #[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}