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>
This commit is contained in:
253
server/src/api/machine_keys.rs
Normal file
253
server/src/api/machine_keys.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -4,7 +4,9 @@ pub mod auth;
|
||||
pub mod auth_logout;
|
||||
pub mod changelog;
|
||||
pub mod downloads;
|
||||
pub mod machine_keys;
|
||||
pub mod releases;
|
||||
pub mod sessions;
|
||||
pub mod users;
|
||||
|
||||
use axum::{
|
||||
|
||||
106
server/src/api/sessions.rs
Normal file
106
server/src/api/sessions.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! Session API endpoints — viewer-token minting.
|
||||
//!
|
||||
//! `POST /api/sessions/:id/viewer-token` mints a short-lived, session-scoped
|
||||
//! viewer JWT for an authenticated dashboard user. This replaces the v1 model
|
||||
//! where any dashboard JWT could join any session at the viewer WebSocket:
|
||||
//! a viewer token is now bound to one `session_id`, and the WS layer (Task 3)
|
||||
//! verifies signature + expiry + blacklist + that the token's `session_id`
|
||||
//! matches the requested session.
|
||||
//!
|
||||
//! Authorization (Phase 1): the requester must present a valid dashboard JWT
|
||||
//! (the [`AuthenticatedUser`] extractor) AND the target session must exist in
|
||||
//! the live session manager. Per-tenant / per-machine ACL narrowing is a
|
||||
//! Phase-4 concern; the tenancy claim is already carried so the WS and future
|
||||
//! phases can enforce it. Minting is itself the authorization gate — only an
|
||||
//! authenticated user can obtain a token, and only for a real session.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
use super::machine_keys::ApiError;
|
||||
|
||||
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
||||
|
||||
/// Response carrying a freshly minted viewer token.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ViewerTokenResponse {
|
||||
/// The short-lived viewer JWT. Use as the `token` query param on
|
||||
/// `/ws/viewer` for this session only.
|
||||
pub token: String,
|
||||
/// The session the token authorizes (echoed for client convenience).
|
||||
pub session_id: String,
|
||||
/// Token lifetime in seconds (for client-side refresh scheduling).
|
||||
pub expires_in_secs: i64,
|
||||
}
|
||||
|
||||
/// Build a standard error envelope (mirrors `machine_keys`).
|
||||
fn err(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(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/sessions/:id/viewer-token
|
||||
///
|
||||
/// Mints a ~5-minute, session-scoped viewer token for the authenticated user.
|
||||
pub async fn mint_viewer_token(
|
||||
user: AuthenticatedUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<ViewerTokenResponse>> {
|
||||
let session_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| err(StatusCode::BAD_REQUEST, "INVALID_SESSION_ID", "Invalid session ID"))?;
|
||||
|
||||
// Authorization gate: the session must exist (live session manager is the
|
||||
// source of truth for joinable sessions, matching GET /api/sessions/:id).
|
||||
let session = state.sessions.get_session(session_id).await.ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::NOT_FOUND,
|
||||
"SESSION_NOT_FOUND",
|
||||
"Session not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Resolve tenancy (Phase-1: always the default tenant). Carried in the
|
||||
// claim so the WS and Phase-4 isolation can enforce it.
|
||||
let tenant_id = db::tenancy::current_tenant_id();
|
||||
|
||||
let token = state
|
||||
.jwt_config
|
||||
.create_viewer_token(&user.user_id, session_id, tenant_id)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to mint viewer token: {}", e);
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to mint viewer token",
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"User {} minted a viewer token for session {} (agent {})",
|
||||
user.username,
|
||||
session_id,
|
||||
session.agent_id
|
||||
);
|
||||
|
||||
Ok(Json(ViewerTokenResponse {
|
||||
token,
|
||||
session_id: session_id.to_string(),
|
||||
expires_in_secs: crate::auth::jwt::VIEWER_TOKEN_TTL_SECS,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user