stemedb/crates/stemedb-api/src/handlers/api_keys.rs
jordan 422e2d4416 feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1
Claims now flow through StemeDB's append-only knowledge graph instead of
mutable TOML files. This resolves all 6 critical claim-bypass code paths:

- Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping)
- LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index
- EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation)
- CLI handlers: all claim commands read/write through StemeDB
- Scanner: loads claims from StemeDB with auto-migration fallback to TOML
- Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON

Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations,
fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes
pre-existing clippy warnings (too_many_arguments, filter_next→rfind).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 02:02:51 -07:00

431 lines
15 KiB
Rust

//! Handlers for API key management (P4.2 Authentication).
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use tracing::{info, instrument};
use crate::{
dto::{
CreateApiKeyRequest, CreateApiKeyResponse, ErrorResponse, ListApiKeysResponse,
RevokeApiKeyResponse, RotateApiKeyResponse, UpdateApiKeyRequest, UpdateApiKeyResponse,
},
error::{ApiError, Result},
state::AppState,
};
use stemedb_storage::{ApiKeyRecord, ApiKeyRole, ApiKeyStore, DEFAULT_API_KEY_RATE_LIMIT};
/// Generate a new API key with the given environment prefix.
///
/// Format: `steme_{env}_{random_hex_48}` where random_hex_48 is 24 bytes of entropy.
fn generate_api_key(environment: &str) -> std::result::Result<String, ApiError> {
let prefix = if environment == "test" { "test" } else { "live" };
let mut random_bytes = [0u8; 24];
getrandom::getrandom(&mut random_bytes)
.map_err(|e| ApiError::Internal(format!("RNG failed: {}", e)))?;
Ok(format!("steme_{}_{}", prefix, hex::encode(random_bytes)))
}
/// Hash an API key using BLAKE3.
fn hash_api_key(raw_key: &str) -> [u8; 32] {
*blake3::hash(raw_key.as_bytes()).as_bytes()
}
/// Create a new API key.
///
/// # Request
///
/// ```json
/// {
/// "environment": "live", // or "test"
/// "role": "admin", // "read_only", "write_agent", or "admin"
/// "label": "production_api",
/// "rate_limit": 10000, // optional, requests per hour
/// "expires_at": 1735689600 // optional, Unix timestamp
/// }
/// ```
///
/// # Response
///
/// Returns the raw API key. **Store this securely - it cannot be retrieved later.**
#[utoipa::path(
post,
path = "/v1/admin/api-keys",
request_body = CreateApiKeyRequest,
responses(
(status = 201, description = "API key created successfully", body = CreateApiKeyResponse),
(status = 400, description = "Invalid request", body = ErrorResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 403, description = "Forbidden", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "admin"
)]
#[instrument(skip(state, req), fields(label = %req.label, role = %req.role, environment = %req.environment))]
pub async fn create_api_key(
State(state): State<AppState>,
Json(req): Json<CreateApiKeyRequest>,
) -> Result<(StatusCode, Json<CreateApiKeyResponse>)> {
let start = std::time::Instant::now();
metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/api-keys").increment(1);
// Validate environment
if req.environment != "live" && req.environment != "test" {
return Err(ApiError::InvalidRequest("environment must be 'live' or 'test'".to_string()));
}
// Parse role
let role: ApiKeyRole =
req.role.parse().map_err(|e: stemedb_storage::ParseApiKeyRoleError| {
ApiError::InvalidRequest(e.to_string())
})?;
// Validate label
if req.label.is_empty() {
return Err(ApiError::InvalidRequest("label cannot be empty".to_string()));
}
if req.label.len() > 100 {
return Err(ApiError::InvalidRequest("label cannot exceed 100 characters".to_string()));
}
// Generate the key
let raw_key = generate_api_key(&req.environment)?;
let key_hash = hash_api_key(&raw_key);
let key_prefix: String = raw_key.chars().take(12).collect();
// Get current timestamp
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Create record
let mut record = ApiKeyRecord::new(key_hash, key_prefix.clone(), role, req.label.clone(), now);
record.rate_limit = req.rate_limit;
record.expires_at = req.expires_at;
// Store in database
state.api_key_store.put_key(&key_hash, &record).await?;
info!(
label = %req.label,
role = %role,
key_hash = %hex::encode(&key_hash[..8]),
"Created API key"
);
let rate_limit = record.rate_limit.unwrap_or(DEFAULT_API_KEY_RATE_LIMIT);
// Track request duration (success case)
metrics::histogram!("stemedb_http_request_duration_seconds",
"method" => "POST",
"path" => "/v1/admin/api-keys",
"status" => "201"
)
.record(start.elapsed().as_secs_f64());
Ok((
StatusCode::CREATED,
Json(CreateApiKeyResponse {
key: raw_key,
key_prefix,
key_hash: hex::encode(key_hash),
role: role.as_str().to_string(),
label: req.label,
rate_limit,
expires_at: req.expires_at,
}),
))
}
/// List all API keys.
///
/// Returns metadata about all keys but not the raw keys themselves.
#[utoipa::path(
get,
path = "/v1/admin/api-keys",
responses(
(status = 200, description = "List of API keys", body = ListApiKeysResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 403, description = "Forbidden", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "admin"
)]
#[instrument(skip(state))]
pub async fn list_api_keys(State(state): State<AppState>) -> Result<Json<ListApiKeysResponse>> {
let records = state.api_key_store.list_keys().await?;
let total = records.len();
// Filter out rate limit entries (they have ":RATE:" in their key path)
// and convert to DTOs
let keys = records.into_iter().map(|r| r.into()).collect();
Ok(Json(ListApiKeysResponse { keys, total }))
}
/// Revoke (delete) an API key.
///
/// Once revoked, the key can no longer be used for authentication.
#[utoipa::path(
delete,
path = "/v1/admin/api-keys/{key_hash}",
params(
("key_hash" = String, Path, description = "Hex-encoded BLAKE3 hash of the key to revoke")
),
responses(
(status = 200, description = "API key revoked", body = RevokeApiKeyResponse),
(status = 400, description = "Invalid key hash", body = ErrorResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 403, description = "Forbidden", body = ErrorResponse),
(status = 404, description = "Key not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "admin"
)]
#[instrument(skip(state), fields(key_hash = %key_hash_hex))]
pub async fn revoke_api_key(
State(state): State<AppState>,
Path(key_hash_hex): Path<String>,
) -> Result<Json<RevokeApiKeyResponse>> {
let start = std::time::Instant::now();
metrics::counter!("stemedb_http_requests_total", "method" => "DELETE", "path" => "/v1/admin/api-keys/{id}").increment(1);
// Parse key hash
let key_hash_bytes = hex::decode(&key_hash_hex)
.map_err(|e| ApiError::InvalidHex(format!("Invalid key hash: {}", e)))?;
if key_hash_bytes.len() != 32 {
return Err(ApiError::InvalidHashLength { expected: 32, actual: key_hash_bytes.len() });
}
let mut key_hash = [0u8; 32];
key_hash.copy_from_slice(&key_hash_bytes);
// Check if key exists
let record = state.api_key_store.get_key_by_hash(&key_hash).await?;
if record.is_none() {
return Err(ApiError::NotFound(format!("API key not found: {}", key_hash_hex)));
}
// Delete the key
state.api_key_store.delete_key(&key_hash).await?;
info!(key_hash = %key_hash_hex, "Revoked API key");
// Track request duration (success case)
metrics::histogram!("stemedb_http_request_duration_seconds",
"method" => "DELETE",
"path" => "/v1/admin/api-keys/{id}",
"status" => "200"
)
.record(start.elapsed().as_secs_f64());
Ok(Json(RevokeApiKeyResponse { revoked: true, key_hash: key_hash_hex }))
}
/// Rotate an API key (revoke old, create new with same settings).
///
/// Creates a new key with the same role, label, and rate limit as the old key,
/// then revokes the old key.
#[utoipa::path(
post,
path = "/v1/admin/api-keys/{key_hash}/rotate",
params(
("key_hash" = String, Path, description = "Hex-encoded BLAKE3 hash of the key to rotate")
),
responses(
(status = 200, description = "API key rotated", body = RotateApiKeyResponse),
(status = 400, description = "Invalid key hash", body = ErrorResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 403, description = "Forbidden", body = ErrorResponse),
(status = 404, description = "Key not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "admin"
)]
#[instrument(skip(state), fields(key_hash = %key_hash_hex))]
pub async fn rotate_api_key(
State(state): State<AppState>,
Path(key_hash_hex): Path<String>,
) -> Result<Json<RotateApiKeyResponse>> {
let start = std::time::Instant::now();
metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/api-keys/{id}/rotate").increment(1);
// Parse key hash
let key_hash_bytes = hex::decode(&key_hash_hex)
.map_err(|e| ApiError::InvalidHex(format!("Invalid key hash: {}", e)))?;
if key_hash_bytes.len() != 32 {
return Err(ApiError::InvalidHashLength { expected: 32, actual: key_hash_bytes.len() });
}
let mut old_key_hash = [0u8; 32];
old_key_hash.copy_from_slice(&key_hash_bytes);
// Get existing key
let old_record = state
.api_key_store
.get_key_by_hash(&old_key_hash)
.await?
.ok_or_else(|| ApiError::NotFound(format!("API key not found: {}", key_hash_hex)))?;
// Determine environment from prefix
let environment = if old_record.key_prefix.contains("test") { "test" } else { "live" };
// Generate new key
let new_raw_key = generate_api_key(environment)?;
let new_key_hash = hash_api_key(&new_raw_key);
let new_key_prefix: String = new_raw_key.chars().take(12).collect();
// Get current timestamp
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Create new record with same settings
let mut new_record = ApiKeyRecord::new(
new_key_hash,
new_key_prefix.clone(),
old_record.role,
old_record.label.clone(),
now,
);
new_record.rate_limit = old_record.rate_limit;
// Don't copy expires_at - new key gets fresh settings
// Store new key
state.api_key_store.put_key(&new_key_hash, &new_record).await?;
// Delete old key
state.api_key_store.delete_key(&old_key_hash).await?;
info!(
old_key_hash = %key_hash_hex,
new_key_hash = %hex::encode(&new_key_hash[..8]),
label = %old_record.label,
"Rotated API key"
);
// Track request duration (success case)
metrics::histogram!("stemedb_http_request_duration_seconds",
"method" => "POST",
"path" => "/v1/admin/api-keys/{id}/rotate",
"status" => "200"
)
.record(start.elapsed().as_secs_f64());
Ok(Json(RotateApiKeyResponse {
new_key: new_raw_key,
new_key_prefix,
new_key_hash: hex::encode(new_key_hash),
old_key_hash: key_hash_hex,
role: old_record.role.as_str().to_string(),
label: old_record.label,
}))
}
/// Update an API key's enabled status.
///
/// Allows disabling a key without deleting it.
#[utoipa::path(
patch,
path = "/v1/admin/api-keys/{key_hash}",
params(
("key_hash" = String, Path, description = "Hex-encoded BLAKE3 hash of the key to update")
),
request_body = UpdateApiKeyRequest,
responses(
(status = 200, description = "API key updated", body = UpdateApiKeyResponse),
(status = 400, description = "Invalid key hash", body = ErrorResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 403, description = "Forbidden", body = ErrorResponse),
(status = 404, description = "Key not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "admin"
)]
#[instrument(skip(state, req), fields(key_hash = %key_hash_hex, enabled = %req.enabled))]
pub async fn update_api_key(
State(state): State<AppState>,
Path(key_hash_hex): Path<String>,
Json(req): Json<UpdateApiKeyRequest>,
) -> Result<Json<UpdateApiKeyResponse>> {
let start = std::time::Instant::now();
metrics::counter!("stemedb_http_requests_total", "method" => "PATCH", "path" => "/v1/admin/api-keys/{id}").increment(1);
// Parse key hash
let key_hash_bytes = hex::decode(&key_hash_hex)
.map_err(|e| ApiError::InvalidHex(format!("Invalid key hash: {}", e)))?;
if key_hash_bytes.len() != 32 {
return Err(ApiError::InvalidHashLength { expected: 32, actual: key_hash_bytes.len() });
}
let mut key_hash = [0u8; 32];
key_hash.copy_from_slice(&key_hash_bytes);
// Check if key exists
let record = state.api_key_store.get_key_by_hash(&key_hash).await?;
if record.is_none() {
return Err(ApiError::NotFound(format!("API key not found: {}", key_hash_hex)));
}
// Update enabled state
state.api_key_store.set_enabled(&key_hash, req.enabled).await?;
let action = if req.enabled { "enabled" } else { "disabled" };
info!(key_hash = %key_hash_hex, "{} API key", action);
// Track request duration (success case)
metrics::histogram!("stemedb_http_request_duration_seconds",
"method" => "PATCH",
"path" => "/v1/admin/api-keys/{id}",
"status" => "200"
)
.record(start.elapsed().as_secs_f64());
Ok(Json(UpdateApiKeyResponse { updated: true, key_hash: key_hash_hex, enabled: req.enabled }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_api_key_format() {
let key = generate_api_key("live").expect("key generation");
assert!(key.starts_with("steme_live_"));
assert_eq!(key.len(), 11 + 48); // "steme_live_" + 48 hex chars
let test_key = generate_api_key("test").expect("key generation");
assert!(test_key.starts_with("steme_test_"));
}
#[test]
fn test_generate_api_key_uniqueness() {
let key1 = generate_api_key("live").expect("key1");
let key2 = generate_api_key("live").expect("key2");
assert_ne!(key1, key2);
}
#[test]
fn test_hash_api_key_deterministic() {
let key = "steme_test_abcdef123456";
let hash1 = hash_api_key(key);
let hash2 = hash_api_key(key);
assert_eq!(hash1, hash2);
}
#[test]
fn test_hash_api_key_different_keys() {
let hash1 = hash_api_key("steme_test_abc");
let hash2 = hash_api_key("steme_test_def");
assert_ne!(hash1, hash2);
}
}