//! Per-agent key issuance endpoints (admin plane). //! //! Lets an administrator mint, list, and revoke per-agent `cak_` keys for a //! managed machine. These keys are the v2 replacement for the shared //! `AGENT_API_KEY`: each is per-machine, individually revocable, and stored //! only as a SHA-256 hash. //! //! Auth: dashboard JWT + admin role (the [`AdminUser`] extractor). All routes //! are mounted behind the JWT `auth_layer`, so the extractor has the JWT config //! and blacklist available. //! //! SECURITY: //! - The plaintext key is returned exactly once, in the create response. It is //! never persisted and never logged. //! - List responses expose only metadata (id, timestamps) — never the hash and //! never the plaintext. //! - Errors returned to clients use the standard envelope and never surface raw //! `sqlx`/`anyhow` strings (which can leak schema/host detail). use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; use crate::auth::{agent_keys, AdminUser}; use crate::db; use crate::AppState; /// Standard error envelope (see `.claude/standards/api/response-format.md`). #[derive(Debug, Serialize)] pub struct ApiError { pub detail: String, pub error_code: String, pub status_code: u16, } impl ApiError { fn new(status: StatusCode, code: &str, detail: &str) -> (StatusCode, Json) { ( status, Json(ApiError { detail: detail.to_string(), error_code: code.to_string(), status_code: status.as_u16(), }), ) } } type ApiResult = Result)>; /// Response for a freshly minted key. `key` is present ONLY here, once. #[derive(Debug, Serialize)] pub struct CreatedKey { pub id: Uuid, pub machine_id: Uuid, /// The plaintext `cak_` key. Shown exactly once — store it now; the server /// keeps only its hash. pub key: String, pub created_at: DateTime, } /// Public metadata for an existing key. Never includes the hash or plaintext. #[derive(Debug, Serialize)] pub struct KeyMetadata { pub id: Uuid, pub machine_id: Uuid, pub created_at: DateTime, pub last_used_at: Option>, pub revoked_at: Option>, } /// Resolve the database handle or a 503 envelope. fn require_db(state: &AppState) -> ApiResult<&db::Database> { state.db.as_ref().ok_or_else(|| { ApiError::new( StatusCode::SERVICE_UNAVAILABLE, "DATABASE_UNAVAILABLE", "Database not available", ) }) } /// Resolve an `agent_id` path segment to the machine's UUID primary key (or a /// 404 envelope). The route param is `agent_id` (string) to stay consistent /// with the other `/api/machines/:agent_id` routes — matchit 0.7 panics if the /// same path position uses two different param names — while the key tables key /// on the machine UUID. async fn resolve_machine_id(db: &db::Database, agent_id: &str) -> ApiResult { let machine = db::machines::get_machine_by_agent_id(db.pool(), agent_id) .await .map_err(|e| { tracing::error!("DB error resolving machine: {}", e); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Internal server error", ) })? .ok_or_else(|| { ApiError::new( StatusCode::NOT_FOUND, "MACHINE_NOT_FOUND", "Machine not found", ) })?; Ok(machine.id) } /// POST /api/machines/:agent_id/keys — mint a new per-agent key for a machine. /// /// `:agent_id` is the machine's `agent_id`; it is resolved to the machine UUID /// (`connect_machines.id`) that the `connect_agent_keys.machine_id` FK targets. /// Returns the plaintext key once. pub async fn create_key( AdminUser(admin): AdminUser, State(state): State, Path(agent_id): Path, ) -> ApiResult<(StatusCode, Json)> { let db = require_db(&state)?; let machine_id = resolve_machine_id(db, &agent_id).await?; // Mint plaintext + hash. Only the hash is persisted. let plaintext = agent_keys::generate_agent_key(); let key_hash = agent_keys::hash_agent_key(&plaintext); let tenant_id = db::tenancy::current_tenant_id(); let key_id = db::agent_keys::insert_agent_key(db.pool(), machine_id, &key_hash, Some(tenant_id)) .await .map_err(|e| { tracing::error!("DB error inserting agent key: {}", e); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Failed to create agent key", ) })?; // Audit the issuance WITHOUT the key material. tracing::info!( "Admin {} issued a per-agent key {} for machine {}", admin.username, key_id, machine_id ); Ok(( StatusCode::CREATED, Json(CreatedKey { id: key_id, machine_id, key: plaintext, created_at: Utc::now(), }), )) } /// GET /api/machines/:agent_id/keys — list key metadata for a machine. /// /// Returns only non-secret metadata. Useful for an admin to see which keys /// exist, when they were last used, and which are revoked. pub async fn list_keys( AdminUser(_admin): AdminUser, State(state): State, Path(agent_id): Path, ) -> ApiResult>> { let db = require_db(&state)?; let machine_id = resolve_machine_id(db, &agent_id).await?; let keys = db::agent_keys::list_for_machine(db.pool(), machine_id) .await .map_err(|e| { tracing::error!("DB error listing agent keys: {}", e); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Failed to list agent keys", ) })?; let out = keys .into_iter() .map(|k| KeyMetadata { id: k.id, machine_id: k.machine_id, created_at: k.created_at, last_used_at: k.last_used_at, revoked_at: k.revoked_at, }) .collect(); Ok(Json(out)) } /// DELETE /api/machines/:agent_id/keys/:key_id — revoke a key. /// /// Sets `revoked_at`; the key is immediately rejected by agent auth thereafter. /// `:agent_id` (machine) scopes the revoke so a key id from another machine /// cannot be revoked via a mismatched URL. pub async fn revoke_key( AdminUser(admin): AdminUser, State(state): State, Path((agent_id, key_id)): Path<(String, Uuid)>, ) -> ApiResult { let db = require_db(&state)?; let machine_id = resolve_machine_id(db, &agent_id).await?; // Scope the revoke to the path machine so a key id from another machine // cannot be revoked via a mismatched URL. let owned = db::agent_keys::key_belongs_to_machine(db.pool(), key_id, machine_id) .await .map_err(|e| { tracing::error!("DB error verifying key ownership: {}", e); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Internal server error", ) })?; if !owned { return Err(ApiError::new( StatusCode::NOT_FOUND, "KEY_NOT_FOUND", "Key not found for this machine", )); } db::agent_keys::revoke_agent_key(db.pool(), key_id) .await .map_err(|e| { tracing::error!("DB error revoking agent key: {}", e); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Failed to revoke agent key", ) })?; tracing::info!( "Admin {} revoked per-agent key {} for machine {}", admin.username, key_id, machine_id ); Ok(StatusCode::NO_CONTENT) }