sub-aligator/src/api_tokens.rs
SW 8c69c1731d feat: JSON REST API /api/v1 with DB-managed Bearer tokens
- New endpoints: GET /api/v1/pools, GET /api/v1/pools/{slug}/next,
  GET/POST /api/v1/allocations, GET /api/v1/allocations/{id},
  POST /api/v1/allocations/{id}/release
- Bearer token auth; only SHA-256 hashes stored, plaintext shown once
- Token management GUI at /tokens (create, revoke, delete) linked in footer
- API actions audited with actor api:<token name>
- Fix latent transaction leak: replace manual BEGIN IMMEDIATE/COMMIT with
  begin_with(), which rolls back on drop; previously a failed allocation
  (e.g. disallowed prefix or full pool) returned a connection with an open
  transaction to the pool, causing 'database is locked' on later writes
- README: API documentation with curl examples
2026-10-09 14:25:17 +02:00

86 lines
2.4 KiB
Rust

use crate::models::{now_utc_rfc3339, ApiToken};
use crate::state::AppState;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use rand::{distributions::Alphanumeric, Rng};
use sha2::{Digest, Sha256};
pub const TOKEN_PREFIX_LEN: usize = 12;
/// Generate a new plaintext API token. It is shown to the user exactly once;
/// only its SHA-256 hash is stored in the database.
pub fn generate_api_token() -> String {
let random: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(40)
.map(char::from)
.collect();
format!("sa_{}", random)
}
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
pub fn display_prefix(token: &str) -> String {
token.chars().take(TOKEN_PREFIX_LEN).collect()
}
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "missing or invalid API token" })),
)
.into_response()
}
/// Authenticate an API request via the `Authorization: Bearer <token>` header.
/// Returns the token row on success (its name is used as the audit actor,
/// e.g. `api:my-integration`) or a 401 JSON response.
#[allow(clippy::result_large_err)]
pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<ApiToken, Response> {
let header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = header
.strip_prefix("Bearer ")
.map(str::trim)
.filter(|t| !t.is_empty());
let token = match token {
Some(t) => t,
None => return Err(unauthorized()),
};
let row: Option<ApiToken> = sqlx::query_as(
"SELECT * FROM api_tokens WHERE token_hash = ? AND enabled = true",
)
.bind(hash_token(token))
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let row = match row {
Some(r) if r.is_enabled() => r,
_ => return Err(unauthorized()),
};
sqlx::query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
.bind(now_utc_rfc3339())
.bind(row.id)
.execute(&state.pool)
.await
.ok();
Ok(row)
}
pub fn api_actor(token: &ApiToken) -> String {
format!("api:{}", token.name)
}