- 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
86 lines
2.4 KiB
Rust
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)
|
|
}
|