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:
@@ -64,6 +64,16 @@ pub struct Machine {
|
||||
/// history) is retained. NULL = live. Nullable, so it is read NULL-tolerantly
|
||||
/// in the manual `FromRow` below.
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
/// Relational site binding for a machine enrolled via `/api/enroll` (SPEC-016,
|
||||
/// migration 010). NULL for legacy / support-code / connect-path machines that
|
||||
/// never enrolled through the zero-touch flow. A change of this on re-enroll is
|
||||
/// the "site move" the enroll path audits.
|
||||
pub site_id: Option<Uuid>,
|
||||
/// Collision-gate state (SPEC-016, migration 010): `'active'` (live, auto-approve)
|
||||
/// or `'pending'` (a machine_uid collision was detected at enroll; awaiting
|
||||
/// operator confirmation before the endpoint may be controlled). Non-null with a
|
||||
/// default of `'active'`; read NULL-tolerantly below for defense in depth.
|
||||
pub enrollment_state: String,
|
||||
}
|
||||
|
||||
impl<'r> FromRow<'r, PgRow> for Machine {
|
||||
@@ -83,6 +93,13 @@ impl<'r> FromRow<'r, PgRow> for Machine {
|
||||
machine_uid: row.try_get("machine_uid")?,
|
||||
// Schema-nullable (migration 009); decode directly as Option.
|
||||
deleted_at: row.try_get("deleted_at")?,
|
||||
// Schema-nullable (migration 010); decode directly as Option.
|
||||
site_id: row.try_get("site_id")?,
|
||||
// Non-null with default 'active' (migration 010); read NULL-tolerantly
|
||||
// (older snapshots / partial rows) and fall back to 'active'.
|
||||
enrollment_state: row
|
||||
.try_get::<Option<String>, _>("enrollment_state")?
|
||||
.unwrap_or_else(|| "active".to_string()),
|
||||
// Nullable-with-default columns mapped to non-`Option` Rust types: read as
|
||||
// `Option<T>` and fall back to the type default so a NULL cell never errors.
|
||||
is_elevated: row
|
||||
@@ -207,6 +224,131 @@ pub async fn upsert_machine(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a machine by the SPEC-016 per-tenant dedup key `(tenant_id, machine_uid)`.
|
||||
///
|
||||
/// This is the enroll-time dedup lookup: the same hardware re-enrolling (re-image /
|
||||
/// re-install) resolves to its existing row within the tenant, while the same
|
||||
/// hardware in a DIFFERENT tenant is a distinct row (resolved-decision #4). Tenant
|
||||
/// scoping uses the same default-tenant fold as the unique index so the lookup
|
||||
/// matches the uniqueness guarantee.
|
||||
///
|
||||
/// Unlike `get_machine_by_agent_id`, this deliberately does NOT filter
|
||||
/// `deleted_at IS NULL`: a previously operator-purged machine that legitimately
|
||||
/// re-enrolls must be found so the enroll path can revive it (clearing
|
||||
/// `deleted_at`), mirroring the connect-path revive in `upsert_machine`.
|
||||
pub async fn get_machine_by_tenant_uid(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
machine_uid: &str,
|
||||
) -> Result<Option<Machine>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
SELECT * FROM connect_machines
|
||||
WHERE machine_uid = $1
|
||||
AND COALESCE(tenant_id, '00000000-0000-0000-0000-000000000001'::uuid) = $2
|
||||
"#,
|
||||
)
|
||||
.bind(machine_uid)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Parameters for an enroll-time machine create/update (SPEC-016 `/api/enroll`).
|
||||
///
|
||||
/// `agent_id` is a freshly minted opaque id for a NEW enrollment (the agent's
|
||||
/// config UUID story is Phase B; the server only needs a unique non-null value for
|
||||
/// the `agent_id UNIQUE` column). On REUSE/MOVE the existing row's `agent_id` is
|
||||
/// preserved (the FK target of any already-minted `cak_`), so the update path does
|
||||
/// not touch it.
|
||||
pub struct EnrollMachineParams<'a> {
|
||||
pub agent_id: &'a str,
|
||||
pub hostname: &'a str,
|
||||
pub machine_uid: &'a str,
|
||||
pub tenant_id: Uuid,
|
||||
pub site_id: Uuid,
|
||||
/// Company label (-> connect_machines.organization).
|
||||
pub company: Option<&'a str>,
|
||||
/// Site label (-> connect_machines.site) — the free-text label, distinct from
|
||||
/// the relational site_id binding.
|
||||
pub site_label: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
/// 'active' (auto-approve) or 'pending' (collision-gated).
|
||||
pub enrollment_state: &'a str,
|
||||
}
|
||||
|
||||
/// Insert a NEW machine row for a first-time enrollment (SPEC-016).
|
||||
///
|
||||
/// Carries the labels, the relational `site_id`, the per-tenant `machine_uid`, and
|
||||
/// the collision-gate `enrollment_state`. Persistent + online. Returns the created
|
||||
/// row (its `id` is the FK target for the `cak_` the caller mints next).
|
||||
pub async fn insert_enrolled_machine(
|
||||
pool: &PgPool,
|
||||
p: &EnrollMachineParams<'_>,
|
||||
) -> Result<Machine, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
INSERT INTO connect_machines
|
||||
(agent_id, hostname, is_persistent, status, last_seen, machine_uid,
|
||||
tenant_id, site_id, organization, site, tags, enrollment_state)
|
||||
VALUES ($1, $2, true, 'online', NOW(), $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(p.agent_id)
|
||||
.bind(p.hostname)
|
||||
.bind(p.machine_uid)
|
||||
.bind(p.tenant_id)
|
||||
.bind(p.site_id)
|
||||
.bind(p.company)
|
||||
.bind(p.site_label)
|
||||
.bind(p.tags)
|
||||
.bind(p.enrollment_state)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update an EXISTING machine row on re-enroll / reuse / site-move (SPEC-016).
|
||||
///
|
||||
/// Refreshes hostname, site binding (`site_id`), labels, and `enrollment_state`,
|
||||
/// and revives a soft-deleted row (`deleted_at = NULL`) — a re-enroll of a purged
|
||||
/// host means it is live again, mirroring `upsert_machine`'s revive. Deliberately
|
||||
/// does NOT change `agent_id`: the existing id is the FK target of any prior `cak_`.
|
||||
/// Labels are COALESCE-merged so an enroll that omits a label does not wipe an
|
||||
/// existing value; `tags` is overwritten only when a non-empty set is supplied
|
||||
/// (matching `update_machine_metadata`'s convention).
|
||||
pub async fn update_enrolled_machine(
|
||||
pool: &PgPool,
|
||||
machine_id: Uuid,
|
||||
p: &EnrollMachineParams<'_>,
|
||||
) -> Result<Machine, sqlx::Error> {
|
||||
sqlx::query_as::<_, Machine>(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
hostname = $2,
|
||||
site_id = $3,
|
||||
organization = COALESCE($4, organization),
|
||||
site = COALESCE($5, site),
|
||||
tags = CASE WHEN $6::text[] = '{}' THEN tags ELSE $6 END,
|
||||
enrollment_state = $7,
|
||||
status = 'online',
|
||||
last_seen = NOW(),
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(machine_id)
|
||||
.bind(p.hostname)
|
||||
.bind(p.site_id)
|
||||
.bind(p.company)
|
||||
.bind(p.site_label)
|
||||
.bind(p.tags)
|
||||
.bind(p.enrollment_state)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update machine status and info
|
||||
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
||||
pub async fn update_machine_status(
|
||||
|
||||
Reference in New Issue
Block a user