Files
guru-connect/server/src/api/machine_keys.rs
Mike Swanson 41691bfb2c
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m37s
Build and Test / Build Agent (Windows) (push) Successful in 6m37s
Build and Test / Security Audit (push) Successful in 4m10s
Build and Test / Build Summary (push) Has been skipped
feat(server): v2 secure-session-core Task 2 - auth rebuild
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>
2026-05-29 18:57:12 -07:00

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)
}