feat(enroll): SPEC-016 Phase A — enrollment backend + migration
Server-side zero-touch per-site enrollment (Phase A: backend + DB only;
agent-side machine_uid derivation is Phase B, server treats it as opaque).
Migration 010_spec016_enrollment.sql:
- connect_sites: relational site anchor (site_code natural key, per-tenant
unique). The spec assumed a sites table existed; it did not (site/company
were free-text columns on connect_machines), so this creates a minimal one.
- site_enrollment_keys: rotatable, Argon2id-hashed cek_ secret + monotonic
version + hex fingerprint + active flag; one-active-per-site partial unique.
- connect_machines: + site_id (FK), + enrollment_state ('active'|'pending')
collision gate, + per-tenant (tenant_id, machine_uid) unique index added
ALONGSIDE the 008 global index (the connect-path upsert_machine ON CONFLICT
arbiter binds to 008 — dropping it would break live reconnect).
- connect_sites.enrollment_policy: reserved (default auto-approve), not enforced.
auth/enrollment_keys.rs: cek_ mint (256-bit, OS CSPRNG), Argon2id hash/verify
(reuses auth::password), and hex fingerprint vN (XXXX) per resolved-decision #3.
db/sites.rs + db/enrollment_keys.rs: runtime sqlx persistence; rotate_key
deactivates+inserts in one tx to hold the one-active-key invariant.
POST /api/enroll (public, api/enroll.rs): site_code+cek_ verify against active
key -> dedup on (tenant, machine_uid) -> new / reuse / site-move / collision.
Collision gate (PROVISIONAL heuristic: online existing row + different hostname)
-> pending, no usable cak_, alert. Mints cak_ via existing agent_keys path in the
exact form relay::validate_agent_api_key expects. Per-(site_code,IP) rate-limit +
lockout (EnrollLimiter). Audit events + [ENROLL] alert markers with
TODO(SPEC-016) #dev-alerts notes.
Admin (JWT) api/sites.rs: POST /api/sites/:id/enrollment-key/rotate (plaintext +
fingerprint once) and GET .../enrollment-key (fingerprint/version, no secret).
Routes wired in main.rs (enroll public, rotation admin). 13 new unit tests;
full server suite 99 passing. cargo check + clippy clean on the host (Windows)
target — Linux cross-target not installed here; server crate is platform-neutral
Rust. No sqlx offline cache needed (codebase uses runtime queries, no query!).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
562
server/src/api/enroll.rs
Normal file
562
server/src/api/enroll.rs
Normal file
@@ -0,0 +1,562 @@
|
||||
//! Zero-touch per-site self-registration endpoint (SPEC-016, Phase A).
|
||||
//!
|
||||
//! `POST /api/enroll` is the PUBLIC (no-JWT) door a managed agent walks through on
|
||||
//! first run: it presents its site's `site_code` + the long per-site enrollment
|
||||
//! key (`cek_`) and its machine-derived `machine_uid`, and the server — if the key
|
||||
//! verifies — dedups on `(tenant, machine_uid)`, creates or reuses the machine row,
|
||||
//! and mints the per-machine `cak_` operating credential, returning the plaintext
|
||||
//! `cak_` exactly once.
|
||||
//!
|
||||
//! Two-tier credential model (SPEC-016 §Security): the enrollment key is the
|
||||
//! low-sensitivity, rotatable, per-site GATE ("may register"); the minted `cak_` is
|
||||
//! the high-sensitivity, per-machine, independently-revocable OPERATING credential
|
||||
//! the relay (`relay::validate_agent_api_key`) already accepts. This handler only
|
||||
//! MINTS a `cak_` in the exact stored form `verify_agent_key` expects (SHA-256 hash
|
||||
//! in `connect_agent_keys`) — it does not touch the relay auth path.
|
||||
//!
|
||||
//! AUTH POSTURE: auto-approve (ScreenConnect parity) — a clean enroll is live and
|
||||
//! controllable immediately, with the new-enrollment alert as the tripwire. The one
|
||||
//! exception is a detected `machine_uid` collision, which gates the machine to
|
||||
//! `enrollment_state = 'pending'` and withholds a usable `cak_` until an operator
|
||||
//! confirms it in the dashboard.
|
||||
//!
|
||||
//! SECURITY: never log the enrollment key, the minted `cak_`, or any hash. The
|
||||
//! plaintext `cak_` appears only in the success response body, once.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{agent_keys, enrollment_keys};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
/// Standard error envelope (see `.claude/standards/api/response-format.md`),
|
||||
/// matching `api::machine_keys::ApiError`.
|
||||
#[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>)>;
|
||||
|
||||
/// Labels an installer carries for the machines it enrolls (SPEC-016 §3).
|
||||
///
|
||||
/// All optional: a thin installer may carry only company/site. `company` ->
|
||||
/// `connect_machines.organization`; `site` -> `connect_machines.site` (the
|
||||
/// free-text label, distinct from the relational site binding resolved from
|
||||
/// `site_code`). `department` / `device_type` are reserved label fields (SPEC-007
|
||||
/// AgentStatus parity) — accepted and folded into `tags` for now (no dedicated
|
||||
/// columns yet), so they are not silently dropped.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct EnrollLabels {
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// `POST /api/enroll` request body (SPEC-016 §3).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnrollRequest {
|
||||
pub site_code: String,
|
||||
/// The per-site enrollment secret (`cek_`). Verified against the site's active
|
||||
/// hashed key; never logged.
|
||||
pub enrollment_key: String,
|
||||
/// Opaque caller-supplied stable machine identity. Phase A treats this as an
|
||||
/// opaque string; the hardware-salted derivation is Phase B (agent-side).
|
||||
pub machine_uid: String,
|
||||
pub hostname: String,
|
||||
#[serde(default)]
|
||||
pub labels: EnrollLabels,
|
||||
}
|
||||
|
||||
/// `POST /api/enroll` success response.
|
||||
///
|
||||
/// On a clean (active) enroll, `key` carries the plaintext `cak_` ONCE. On a
|
||||
/// collision-gated `pending` enroll, `key` is `None` and `enrollment_state` is
|
||||
/// `"pending"` — no usable operating credential is issued until an operator
|
||||
/// confirms the endpoint in the dashboard.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnrollResponse {
|
||||
/// `connect_machines.id` for the enrolled machine.
|
||||
pub machine_id: Uuid,
|
||||
/// The minted plaintext `cak_`, present ONLY for an active enroll, ONLY here.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// `"active"` (live, controllable, key issued) or `"pending"` (collision-gated;
|
||||
/// awaiting operator confirmation; no key issued).
|
||||
pub enrollment_state: String,
|
||||
/// Disposition: `"new"` | `"reuse"` | `"site_move"` | `"collision_pending"`.
|
||||
pub disposition: String,
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Collision-gate heuristic (PROVISIONAL — SPEC-016 §item-1/6).
|
||||
///
|
||||
/// The residual collision case is template-cloned VMs that share a hardware UUID
|
||||
/// (some hypervisors clone the SMBIOS UUID), so a *different* physical endpoint
|
||||
/// resolves to an existing `machine_uid`. We cannot distinguish that from a benign
|
||||
/// re-image purely from a client-asserted uid, so the gate is intentionally
|
||||
/// CONSERVATIVE and the heuristic is provisional, to be tightened in planning
|
||||
/// (which durable hardware signals feed the uid, and the hypervisor behavior
|
||||
/// matrix — see SPEC-016 §Remaining-for-planning).
|
||||
///
|
||||
/// PROVISIONAL heuristic chosen for Phase A: treat it as a collision when the
|
||||
/// matched existing machine
|
||||
/// (a) is currently considered ONLINE (status == "online"), AND
|
||||
/// (b) reports a DIFFERENT hostname than the incoming request,
|
||||
/// i.e. an apparently-live box already owns this uid yet a second box with a
|
||||
/// different name is enrolling against it concurrently — the clone signature. A
|
||||
/// case-insensitive hostname compare avoids false positives from case drift. An
|
||||
/// OFFLINE matched row (the common re-image / re-install case) is NOT treated as a
|
||||
/// collision — that is the legitimate reuse path. A same-hostname match is reuse,
|
||||
/// never a collision.
|
||||
///
|
||||
/// Rationale for conservatism: a false POSITIVE merely sends a real machine to
|
||||
/// `pending` (an operator clicks confirm — annoying, recoverable); a false NEGATIVE
|
||||
/// would auto-activate a cloned endpoint (worse). When the salt set is finalized in
|
||||
/// planning this should become "uid is stable hardware, so a genuine clone is
|
||||
/// expected to be rare; gate on the salt's distinguishing component instead."
|
||||
fn is_collision(existing: &db::machines::Machine, incoming_hostname: &str) -> bool {
|
||||
let online = existing.status.eq_ignore_ascii_case("online");
|
||||
let different_host = !existing
|
||||
.hostname
|
||||
.eq_ignore_ascii_case(incoming_hostname.trim());
|
||||
online && different_host
|
||||
}
|
||||
|
||||
/// Mint a `cak_`, store its hash bound to `machine_id` + tenant, and return the
|
||||
/// plaintext. Shared by the new/reuse/move active paths.
|
||||
async fn mint_cak(
|
||||
db: &db::Database,
|
||||
machine_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> ApiResult<String> {
|
||||
let plaintext = agent_keys::generate_agent_key();
|
||||
let key_hash = agent_keys::hash_agent_key(&plaintext);
|
||||
db::agent_keys::insert_agent_key(db.pool(), machine_id, &key_hash, Some(tenant_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[ENROLL] DB error minting agent key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to mint machine credential",
|
||||
)
|
||||
})?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// `POST /api/enroll` — public self-registration (SPEC-016 §3).
|
||||
pub async fn enroll(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<EnrollRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<EnrollResponse>)> {
|
||||
let db = require_db(&state)?;
|
||||
let tenant_id = db::tenancy::current_tenant_id();
|
||||
|
||||
// Real client IP via the shared trusted-proxy-aware extractor (same source the
|
||||
// relay / rate limiter / audit log use, so the buckets never drift).
|
||||
let ip: IpAddr = crate::utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies);
|
||||
|
||||
// Basic input hygiene before any DB/KDF work.
|
||||
let site_code = req.site_code.trim();
|
||||
let hostname = req.hostname.trim();
|
||||
let machine_uid = req.machine_uid.trim();
|
||||
if site_code.is_empty() || hostname.is_empty() || machine_uid.is_empty() {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID_REQUEST",
|
||||
"site_code, hostname, and machine_uid are required",
|
||||
));
|
||||
}
|
||||
|
||||
// Defense-in-depth rate limit / lockout per (site_code, IP). The 256-bit
|
||||
// enrollment key is the load-bearing gate; this throttles brute-force/abuse.
|
||||
if !state.rate_limits.enroll.check(site_code, ip) {
|
||||
tracing::warn!(
|
||||
"[ENROLL] rate-limited/locked-out enroll for site_code={} from {}",
|
||||
site_code,
|
||||
ip
|
||||
);
|
||||
return Err(ApiError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"RATE_LIMITED",
|
||||
"Too many enrollment attempts. Please try again later.",
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve the site by code (per-tenant).
|
||||
let site = match db::sites::get_site_by_code(db.pool(), site_code, tenant_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
state.rate_limits.enroll.record_failure(site_code, ip);
|
||||
audit(db, db::events::EventTypes::ENROLL_REJECTED, ip, json!({
|
||||
"reason": "unknown_site_code",
|
||||
"site_code": site_code,
|
||||
"machine_uid": machine_uid,
|
||||
}))
|
||||
.await;
|
||||
tracing::warn!("[ENROLL] unknown site_code={} from {}", site_code, ip);
|
||||
// Same opaque rejection shape as a bad key — do not reveal which of the
|
||||
// two failed (avoids a site_code enumeration oracle).
|
||||
return Err(ApiError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"ENROLL_REJECTED",
|
||||
"Invalid site code or enrollment key",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[ENROLL] DB error resolving site: {}", e);
|
||||
return Err(ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the enrollment key against the site's ACTIVE key. A rotated-out (old)
|
||||
// installer's key is inactive and rejected here — old installers cannot enroll
|
||||
// NEW machines after rotation (SPEC-016 success-criterion #3).
|
||||
let active_key = match db::enrollment_keys::get_active_for_site(db.pool(), site.id).await {
|
||||
Ok(Some(k)) => k,
|
||||
Ok(None) => {
|
||||
state.rate_limits.enroll.record_failure(site_code, ip);
|
||||
audit(db, db::events::EventTypes::ENROLL_REJECTED, ip, json!({
|
||||
"reason": "no_active_key",
|
||||
"site_code": site_code,
|
||||
"machine_uid": machine_uid,
|
||||
}))
|
||||
.await;
|
||||
tracing::warn!("[ENROLL] no active enrollment key for site_code={}", site_code);
|
||||
return Err(ApiError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"ENROLL_REJECTED",
|
||||
"Invalid site code or enrollment key",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[ENROLL] DB error loading active enrollment key: {}", e);
|
||||
return Err(ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !enrollment_keys::verify_enrollment_key(&req.enrollment_key, &active_key.key_hash) {
|
||||
state.rate_limits.enroll.record_failure(site_code, ip);
|
||||
audit(db, db::events::EventTypes::ENROLL_REJECTED, ip, json!({
|
||||
"reason": "bad_enrollment_key",
|
||||
"site_code": site_code,
|
||||
"machine_uid": machine_uid,
|
||||
}))
|
||||
.await;
|
||||
tracing::warn!("[ENROLL] bad enrollment key for site_code={} from {}", site_code, ip);
|
||||
return Err(ApiError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"ENROLL_REJECTED",
|
||||
"Invalid site code or enrollment key",
|
||||
));
|
||||
}
|
||||
|
||||
// Key verified — this is a legitimate attempt; reset the failure streak.
|
||||
state.rate_limits.enroll.record_success(site_code, ip);
|
||||
|
||||
// Build the label/identity params shared by the create/update paths.
|
||||
let tags = effective_tags(&req.labels);
|
||||
let company = req.labels.company.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let site_label = req
|
||||
.labels
|
||||
.site
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Dedup on (tenant, machine_uid).
|
||||
let existing =
|
||||
match db::machines::get_machine_by_tenant_uid(db.pool(), tenant_id, machine_uid).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!("[ENROLL] DB error on dedup lookup: {}", e);
|
||||
return Err(ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match existing {
|
||||
// -- Reuse / site-move / collision path -------------------------------
|
||||
Some(existing) => {
|
||||
// Collision gate: a seemingly-different live endpoint resolving to an
|
||||
// existing uid -> pending, alert, NO usable cak_ minted.
|
||||
if is_collision(&existing, hostname) {
|
||||
let params = enroll_params(
|
||||
&existing.agent_id,
|
||||
hostname,
|
||||
machine_uid,
|
||||
tenant_id,
|
||||
site.id,
|
||||
company,
|
||||
site_label,
|
||||
&tags,
|
||||
"pending",
|
||||
);
|
||||
let machine = db::machines::update_enrolled_machine(db.pool(), existing.id, ¶ms)
|
||||
.await
|
||||
.map_err(map_update_err)?;
|
||||
|
||||
audit(db, db::events::EventTypes::ENROLL_COLLISION_PENDING, ip, json!({
|
||||
"machine_id": machine.id,
|
||||
"machine_uid": machine_uid,
|
||||
"site_code": site_code,
|
||||
"existing_hostname": existing.hostname,
|
||||
"incoming_hostname": hostname,
|
||||
"heuristic": "online_existing_with_different_hostname (PROVISIONAL)",
|
||||
}))
|
||||
.await;
|
||||
// TODO(SPEC-016): wire to #dev-alerts — collision requires operator
|
||||
// confirmation in the dashboard before this endpoint may activate.
|
||||
tracing::warn!(
|
||||
"[ENROLL] machine_uid collision -> PENDING: machine_id={} site_code={} \
|
||||
existing_host={} incoming_host={} from {}",
|
||||
machine.id, site_code, existing.hostname, hostname, ip
|
||||
);
|
||||
|
||||
return Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(EnrollResponse {
|
||||
machine_id: machine.id,
|
||||
key: None,
|
||||
enrollment_state: "pending".to_string(),
|
||||
disposition: "collision_pending".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Legitimate reuse (re-image / re-install) or a site move.
|
||||
let is_move = existing.site_id.map(|s| s != site.id).unwrap_or(true);
|
||||
let params = enroll_params(
|
||||
&existing.agent_id,
|
||||
hostname,
|
||||
machine_uid,
|
||||
tenant_id,
|
||||
site.id,
|
||||
company,
|
||||
site_label,
|
||||
&tags,
|
||||
"active",
|
||||
);
|
||||
let machine = db::machines::update_enrolled_machine(db.pool(), existing.id, ¶ms)
|
||||
.await
|
||||
.map_err(map_update_err)?;
|
||||
|
||||
let cak = mint_cak(db, machine.id, tenant_id).await?;
|
||||
|
||||
if is_move {
|
||||
audit(db, db::events::EventTypes::ENROLL_SITE_MOVE, ip, json!({
|
||||
"machine_id": machine.id,
|
||||
"machine_uid": machine_uid,
|
||||
"from_site_id": existing.site_id,
|
||||
"to_site_code": site_code,
|
||||
"to_site_id": site.id,
|
||||
}))
|
||||
.await;
|
||||
// TODO(SPEC-016): wire to #dev-alerts — a machine moved between sites.
|
||||
tracing::info!(
|
||||
"[ENROLL] site-move: machine_id={} machine_uid present, to site_code={} from {}",
|
||||
machine.id, site_code, ip
|
||||
);
|
||||
} else {
|
||||
audit(db, db::events::EventTypes::ENROLL_REUSE, ip, json!({
|
||||
"machine_id": machine.id,
|
||||
"machine_uid": machine_uid,
|
||||
"site_code": site_code,
|
||||
}))
|
||||
.await;
|
||||
tracing::info!(
|
||||
"[ENROLL] reuse: machine_id={} re-enrolled at site_code={} from {}",
|
||||
machine.id, site_code, ip
|
||||
);
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(EnrollResponse {
|
||||
machine_id: machine.id,
|
||||
key: Some(cak),
|
||||
enrollment_state: "active".to_string(),
|
||||
disposition: if is_move { "site_move" } else { "reuse" }.to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// -- New enrollment ----------------------------------------------------
|
||||
None => {
|
||||
// Fresh opaque agent_id for the new row's `agent_id UNIQUE` column. The
|
||||
// agent's own config-UUID story is Phase B; the server only needs a
|
||||
// unique non-null value here, and the authoritative identity is the
|
||||
// minted cak_ -> machine binding.
|
||||
let agent_id = format!("enroll-{}", Uuid::new_v4());
|
||||
let params = enroll_params(
|
||||
&agent_id,
|
||||
hostname,
|
||||
machine_uid,
|
||||
tenant_id,
|
||||
site.id,
|
||||
company,
|
||||
site_label,
|
||||
&tags,
|
||||
"active",
|
||||
);
|
||||
let machine = db::machines::insert_enrolled_machine(db.pool(), ¶ms)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[ENROLL] DB error inserting enrolled machine: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to register machine",
|
||||
)
|
||||
})?;
|
||||
|
||||
let cak = mint_cak(db, machine.id, tenant_id).await?;
|
||||
|
||||
audit(db, db::events::EventTypes::ENROLL_NEW, ip, json!({
|
||||
"machine_id": machine.id,
|
||||
"machine_uid": machine_uid,
|
||||
"site_code": site_code,
|
||||
"hostname": hostname,
|
||||
}))
|
||||
.await;
|
||||
// TODO(SPEC-016): wire to #dev-alerts — new-enrollment tripwire.
|
||||
tracing::info!(
|
||||
"[ENROLL] new: machine_id={} hostname={} site_code={} from {}",
|
||||
machine.id, hostname, site_code, ip
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(EnrollResponse {
|
||||
machine_id: machine.id,
|
||||
key: Some(cak),
|
||||
enrollment_state: "active".to_string(),
|
||||
disposition: "new".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold `department` / `device_type` (no dedicated columns yet — SPEC-007) into the
|
||||
/// tag set as `department:<x>` / `device_type:<x>` so they are preserved rather than
|
||||
/// dropped, alongside any explicit tags. Empty/whitespace values are skipped.
|
||||
fn effective_tags(labels: &EnrollLabels) -> Vec<String> {
|
||||
let mut tags: Vec<String> = labels
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if let Some(d) = labels.department.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
tags.push(format!("department:{}", d));
|
||||
}
|
||||
if let Some(d) = labels
|
||||
.device_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
tags.push(format!("device_type:{}", d));
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
/// Assemble [`db::machines::EnrollMachineParams`] from the resolved pieces.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn enroll_params<'a>(
|
||||
agent_id: &'a str,
|
||||
hostname: &'a str,
|
||||
machine_uid: &'a str,
|
||||
tenant_id: Uuid,
|
||||
site_id: Uuid,
|
||||
company: Option<&'a str>,
|
||||
site_label: Option<&'a str>,
|
||||
tags: &'a [String],
|
||||
enrollment_state: &'a str,
|
||||
) -> db::machines::EnrollMachineParams<'a> {
|
||||
db::machines::EnrollMachineParams {
|
||||
agent_id,
|
||||
hostname,
|
||||
machine_uid,
|
||||
tenant_id,
|
||||
site_id,
|
||||
company,
|
||||
site_label,
|
||||
tags,
|
||||
enrollment_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort enrollment audit write — a failure here never fails the enroll.
|
||||
async fn audit(db: &db::Database, event_type: &str, ip: IpAddr, details: serde_json::Value) {
|
||||
if let Err(e) = db::events::log_enrollment_event(db.pool(), event_type, details, Some(ip)).await
|
||||
{
|
||||
tracing::warn!("[ENROLL] failed to write {} audit event: {}", event_type, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a DB error from the existing-row update to the standard 500 envelope.
|
||||
fn map_update_err(e: sqlx::Error) -> (StatusCode, Json<ApiError>) {
|
||||
tracing::error!("[ENROLL] DB error updating enrolled machine: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to update machine",
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,12 @@ pub mod auth;
|
||||
pub mod auth_logout;
|
||||
pub mod changelog;
|
||||
pub mod downloads;
|
||||
pub mod enroll;
|
||||
pub mod machine_keys;
|
||||
pub mod releases;
|
||||
pub mod removal;
|
||||
pub mod sessions;
|
||||
pub mod sites;
|
||||
pub mod users;
|
||||
|
||||
use axum::{
|
||||
|
||||
217
server/src/api/sites.rs
Normal file
217
server/src/api/sites.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Site enrollment-key administration (SPEC-016, admin plane).
|
||||
//!
|
||||
//! Admin (dashboard JWT + admin role) endpoints for the per-site enrollment key
|
||||
//! the dashboard surfaces and rotates:
|
||||
//!
|
||||
//! - `POST /api/sites/:id/enrollment-key/rotate` — regenerate the `cek_` secret,
|
||||
//! bump the monotonic version, derive a new fingerprint, deactivate the prior
|
||||
//! active key, and return the plaintext + fingerprint ONCE. Old installers can no
|
||||
//! longer enroll NEW machines after this; already-enrolled agents (holding their
|
||||
//! own `cak_`) are unaffected (SPEC-016 success-criterion #3). Doubles as
|
||||
//! first-issue when a site has no key yet.
|
||||
//! - `GET /api/sites/:id/enrollment-key` — read the CURRENT non-secret fingerprint
|
||||
//! + version (never the secret). 404 if the site has no active key yet.
|
||||
//!
|
||||
//! Auth mirrors `api::machine_keys`: the [`crate::auth::AdminUser`] extractor gates
|
||||
//! both routes, and they are mounted behind the JWT `auth_layer`.
|
||||
//!
|
||||
//! SECURITY: the plaintext `cek_` is returned exactly once (rotate response),
|
||||
//! never persisted in plaintext and never logged. Read responses expose only the
|
||||
//! version + fingerprint.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{enrollment_keys, AdminUser};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
/// Standard error envelope (matches `api::machine_keys::ApiError`).
|
||||
#[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 rotated/issued enrollment key. `key` is present ONLY
|
||||
/// here, once.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RotatedEnrollmentKey {
|
||||
pub site_id: Uuid,
|
||||
/// The plaintext `cek_` enrollment key. Shown exactly once — bake it into the
|
||||
/// site installer now; the server keeps only its hash.
|
||||
pub key: String,
|
||||
/// Monotonic rotation version.
|
||||
pub version: i32,
|
||||
/// The non-secret short hex code (the `XXXX` in `vN (XXXX)`).
|
||||
pub fingerprint: String,
|
||||
/// Fully rendered operator-facing fingerprint, e.g. `v3 (7F2A)`.
|
||||
pub fingerprint_label: String,
|
||||
}
|
||||
|
||||
/// Non-secret current-key view for the GET endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnrollmentKeyView {
|
||||
pub site_id: Uuid,
|
||||
pub version: i32,
|
||||
pub fingerprint: String,
|
||||
pub fingerprint_label: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
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 a site by its UUID path segment, or a 404 envelope.
|
||||
async fn resolve_site(db: &db::Database, site_id: Uuid) -> ApiResult<db::sites::Site> {
|
||||
db::sites::get_site_by_id(db.pool(), site_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error resolving site: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "SITE_NOT_FOUND", "Site not found"))
|
||||
}
|
||||
|
||||
/// POST /api/sites/:id/enrollment-key/rotate — rotate (or first-issue) a site's
|
||||
/// enrollment key. Returns the plaintext `cek_` + fingerprint once.
|
||||
pub async fn rotate_enrollment_key(
|
||||
AdminUser(admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(site_id): Path<Uuid>,
|
||||
) -> ApiResult<(StatusCode, Json<RotatedEnrollmentKey>)> {
|
||||
let db = require_db(&state)?;
|
||||
let site = resolve_site(db, site_id).await?;
|
||||
|
||||
// Mint plaintext + Argon2id hash + fingerprint. Only the hash + fingerprint
|
||||
// are persisted; the plaintext is surfaced once below.
|
||||
let plaintext = enrollment_keys::generate_enrollment_key();
|
||||
let key_hash = enrollment_keys::hash_enrollment_key(&plaintext).map_err(|e| {
|
||||
tracing::error!("Failed to hash enrollment key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to hash enrollment key",
|
||||
)
|
||||
})?;
|
||||
let fingerprint = enrollment_keys::compute_fingerprint(&plaintext);
|
||||
|
||||
let new_key = db::enrollment_keys::rotate_key(db.pool(), site.id, &key_hash, &fingerprint)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error rotating enrollment key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to rotate enrollment key",
|
||||
)
|
||||
})?;
|
||||
|
||||
let fingerprint_label =
|
||||
enrollment_keys::render_fingerprint(new_key.version, &new_key.fingerprint);
|
||||
|
||||
// Audit WITHOUT key material (no plaintext, no hash).
|
||||
if let Err(e) = db::events::log_enrollment_event(
|
||||
db.pool(),
|
||||
db::events::EventTypes::ENROLLMENT_KEY_ROTATED,
|
||||
serde_json::json!({
|
||||
"site_id": site.id,
|
||||
"site_code": site.site_code,
|
||||
"version": new_key.version,
|
||||
"fingerprint": new_key.fingerprint,
|
||||
"rotated_by": admin.username,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[ENROLL] failed to write key-rotate audit event: {}", e);
|
||||
}
|
||||
tracing::info!(
|
||||
"Admin {} rotated enrollment key for site {} to {}",
|
||||
admin.username,
|
||||
site.site_code,
|
||||
fingerprint_label
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(RotatedEnrollmentKey {
|
||||
site_id: site.id,
|
||||
key: plaintext,
|
||||
version: new_key.version,
|
||||
fingerprint: new_key.fingerprint,
|
||||
fingerprint_label,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/sites/:id/enrollment-key — current non-secret fingerprint + version.
|
||||
pub async fn get_enrollment_key(
|
||||
AdminUser(_admin): AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(site_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<EnrollmentKeyView>> {
|
||||
let db = require_db(&state)?;
|
||||
let site = resolve_site(db, site_id).await?;
|
||||
|
||||
let key = db::enrollment_keys::get_active_for_site(db.pool(), site.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error loading enrollment key: {}", e);
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error",
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"NO_ENROLLMENT_KEY",
|
||||
"Site has no active enrollment key",
|
||||
)
|
||||
})?;
|
||||
|
||||
let fingerprint_label = enrollment_keys::render_fingerprint(key.version, &key.fingerprint);
|
||||
|
||||
Ok(Json(EnrollmentKeyView {
|
||||
site_id: site.id,
|
||||
version: key.version,
|
||||
fingerprint: key.fingerprint,
|
||||
fingerprint_label,
|
||||
active: key.active,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user