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::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    /// Optional label to tag the key with. Labels can be arbitrary strings
40    /// but are most useful when unique — combined with `idempotent=true`
41    /// they let a caller provision a named key across reruns.
42    #[serde(default)]
43    pub label: Option<String>,
44
45    /// If true AND a key with this `label` already exists, the handler
46    /// returns `200 OK` with the existing record's metadata (no plaintext).
47    /// If false (default) or no label is supplied, the handler always
48    /// mints a fresh key and returns `201 Created` with the plaintext.
49    #[serde(default)]
50    pub idempotent: bool,
51}
52
53#[derive(Serialize, ToSchema)]
54pub struct CreateApiKeyResponse {
55    /// Plaintext API key. Only present on a fresh mint (`201 Created`).
56    /// Never included when an existing key is returned idempotently.
57    #[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}