SPEC-002 Phase 1 Task 2 (specs/v2-secure-session-core), code-reviewed APPROVED. - DELETE the JWT-as-agent-key branch in relay validate_agent_api_key (audit CRITICAL): agent auth now = per-agent cak_ key (SHA-256 -> connect_agent_keys, revoked filtered) OR support code OR deprecated shared AGENT_API_KEY (warned). A user JWT can no longer authenticate an agent. - auth/agent_keys.rs: cak_ gen (OsRng 256-bit) + SHA-256 hash + verify. - auth/jwt.rs: ViewerClaims + create/validate_viewer_token (5-min TTL, purpose=viewer, session_id+tenant_id claims; non-interchangeable with login). - Admin key issuance: POST/GET/DELETE /api/machines/:agent_id/keys. - POST /api/sessions/:id/viewer-token mints a session-bound short-lived token. - Migration 005: organization/site/tags on connect_machines (fixes the silent update_machine_metadata write, coord todo faf39fe0). NOTE: viewer-token minting is gated by AuthenticatedUser only; the AUTHORIZATION check (admin/permission gate) that closes audit CRITICAL #1 lands in Task 3 (the viewer WS verification). The viewer WS path (relay/mod.rs:285) is untouched here. Not cargo-check-verified (no toolchain on the authoring host) - self-reviewed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
8.0 KiB
Rust
254 lines
8.0 KiB
Rust
//! 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<ApiError>) {
|
|
(
|
|
status,
|
|
Json(ApiError {
|
|
detail: detail.to_string(),
|
|
error_code: code.to_string(),
|
|
status_code: status.as_u16(),
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
|
|
|
/// 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<Utc>,
|
|
}
|
|
|
|
/// 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<Utc>,
|
|
pub last_used_at: Option<DateTime<Utc>>,
|
|
pub revoked_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// 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<Uuid> {
|
|
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<AppState>,
|
|
Path(agent_id): Path<String>,
|
|
) -> ApiResult<(StatusCode, Json<CreatedKey>)> {
|
|
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<AppState>,
|
|
Path(agent_id): Path<String>,
|
|
) -> ApiResult<Json<Vec<KeyMetadata>>> {
|
|
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<AppState>,
|
|
Path((agent_id, key_id)): Path<(String, Uuid)>,
|
|
) -> ApiResult<StatusCode> {
|
|
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)
|
|
}
|