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>
431 lines
15 KiB
Rust
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);
|
|
}
|
|
}
|