style(enroll): cargo fmt --all (satisfy CI fmt gate)
All checks were successful
Build and Test / Build Agent (Windows) (pull_request) Successful in 16m35s
Build and Test / Build Server (Linux) (pull_request) Successful in 19m7s
Build and Test / Security Audit (pull_request) Successful in 5m27s
Build and Test / Build Summary (pull_request) Successful in 26s
All checks were successful
Build and Test / Build Agent (Windows) (pull_request) Successful in 16m35s
Build and Test / Build Server (Linux) (pull_request) Successful in 19m7s
Build and Test / Security Audit (pull_request) Successful in 5m27s
Build and Test / Build Summary (pull_request) Successful in 26s
The Phase A work passed cargo check + clippy + tests locally but missed `cargo fmt --all -- --check` (the first step of the Linux CI job): module ordering in db/mod.rs and two trailing-comment alignments in rate_limit.rs. No logic change. Agent build failure on the prior run was transient infra (verified: agent crate compiles clean locally). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -218,11 +218,7 @@ fn is_collision(existing: &db::machines::Machine, incoming_hostname: &str) -> bo
|
|||||||
|
|
||||||
/// Mint a `cak_`, store its hash bound to `machine_id` + tenant, and return the
|
/// Mint a `cak_`, store its hash bound to `machine_id` + tenant, and return the
|
||||||
/// plaintext. Shared by the new/reuse/move active paths.
|
/// plaintext. Shared by the new/reuse/move active paths.
|
||||||
async fn mint_cak(
|
async fn mint_cak(db: &db::Database, machine_id: Uuid, tenant_id: Uuid) -> ApiResult<String> {
|
||||||
db: &db::Database,
|
|
||||||
machine_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
) -> ApiResult<String> {
|
|
||||||
let plaintext = agent_keys::generate_agent_key();
|
let plaintext = agent_keys::generate_agent_key();
|
||||||
let key_hash = agent_keys::hash_agent_key(&plaintext);
|
let key_hash = agent_keys::hash_agent_key(&plaintext);
|
||||||
db::agent_keys::insert_agent_key(db.pool(), machine_id, &key_hash, Some(tenant_id))
|
db::agent_keys::insert_agent_key(db.pool(), machine_id, &key_hash, Some(tenant_id))
|
||||||
@@ -287,11 +283,16 @@ pub async fn enroll(
|
|||||||
// site_code is not distinguishable by timing (enumeration oracle).
|
// site_code is not distinguishable by timing (enumeration oracle).
|
||||||
equalize_reject_timing(&req.enrollment_key);
|
equalize_reject_timing(&req.enrollment_key);
|
||||||
state.rate_limits.enroll.record_failure(site_code, ip);
|
state.rate_limits.enroll.record_failure(site_code, ip);
|
||||||
audit(db, db::events::EventTypes::ENROLL_REJECTED, ip, json!({
|
audit(
|
||||||
"reason": "unknown_site_code",
|
db,
|
||||||
"site_code": site_code,
|
db::events::EventTypes::ENROLL_REJECTED,
|
||||||
"machine_uid": machine_uid,
|
ip,
|
||||||
}))
|
json!({
|
||||||
|
"reason": "unknown_site_code",
|
||||||
|
"site_code": site_code,
|
||||||
|
"machine_uid": machine_uid,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
tracing::warn!("[ENROLL] unknown site_code={} from {}", site_code, ip);
|
tracing::warn!("[ENROLL] unknown site_code={} from {}", site_code, ip);
|
||||||
// Same opaque rejection shape AND the same KDF cost as a bad key — do not
|
// Same opaque rejection shape AND the same KDF cost as a bad key — do not
|
||||||
@@ -322,13 +323,21 @@ pub async fn enroll(
|
|||||||
// has no active key" is not distinguishable by timing from a bad key.
|
// has no active key" is not distinguishable by timing from a bad key.
|
||||||
equalize_reject_timing(&req.enrollment_key);
|
equalize_reject_timing(&req.enrollment_key);
|
||||||
state.rate_limits.enroll.record_failure(site_code, ip);
|
state.rate_limits.enroll.record_failure(site_code, ip);
|
||||||
audit(db, db::events::EventTypes::ENROLL_REJECTED, ip, json!({
|
audit(
|
||||||
"reason": "no_active_key",
|
db,
|
||||||
"site_code": site_code,
|
db::events::EventTypes::ENROLL_REJECTED,
|
||||||
"machine_uid": machine_uid,
|
ip,
|
||||||
}))
|
json!({
|
||||||
|
"reason": "no_active_key",
|
||||||
|
"site_code": site_code,
|
||||||
|
"machine_uid": machine_uid,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
tracing::warn!("[ENROLL] no active enrollment key for site_code={}", site_code);
|
tracing::warn!(
|
||||||
|
"[ENROLL] no active enrollment key for site_code={}",
|
||||||
|
site_code
|
||||||
|
);
|
||||||
return Err(ApiError::new(
|
return Err(ApiError::new(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"ENROLL_REJECTED",
|
"ENROLL_REJECTED",
|
||||||
@@ -347,13 +356,22 @@ pub async fn enroll(
|
|||||||
|
|
||||||
if !enrollment_keys::verify_enrollment_key(&req.enrollment_key, &active_key.key_hash) {
|
if !enrollment_keys::verify_enrollment_key(&req.enrollment_key, &active_key.key_hash) {
|
||||||
state.rate_limits.enroll.record_failure(site_code, ip);
|
state.rate_limits.enroll.record_failure(site_code, ip);
|
||||||
audit(db, db::events::EventTypes::ENROLL_REJECTED, ip, json!({
|
audit(
|
||||||
"reason": "bad_enrollment_key",
|
db,
|
||||||
"site_code": site_code,
|
db::events::EventTypes::ENROLL_REJECTED,
|
||||||
"machine_uid": machine_uid,
|
ip,
|
||||||
}))
|
json!({
|
||||||
|
"reason": "bad_enrollment_key",
|
||||||
|
"site_code": site_code,
|
||||||
|
"machine_uid": machine_uid,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
tracing::warn!("[ENROLL] bad enrollment key for site_code={} from {}", site_code, ip);
|
tracing::warn!(
|
||||||
|
"[ENROLL] bad enrollment key for site_code={} from {}",
|
||||||
|
site_code,
|
||||||
|
ip
|
||||||
|
);
|
||||||
return Err(ApiError::new(
|
return Err(ApiError::new(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"ENROLL_REJECTED",
|
"ENROLL_REJECTED",
|
||||||
@@ -366,7 +384,12 @@ pub async fn enroll(
|
|||||||
|
|
||||||
// Build the label/identity params shared by the create/update paths.
|
// Build the label/identity params shared by the create/update paths.
|
||||||
let tags = effective_tags(&req.labels);
|
let tags = effective_tags(&req.labels);
|
||||||
let company = req.labels.company.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
let company = req
|
||||||
|
.labels
|
||||||
|
.company
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
let site_label = req
|
let site_label = req
|
||||||
.labels
|
.labels
|
||||||
.site
|
.site
|
||||||
@@ -386,25 +409,137 @@ pub async fn enroll(
|
|||||||
let mut attempts = 0u8;
|
let mut attempts = 0u8;
|
||||||
loop {
|
loop {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
let existing =
|
let existing = match db::machines::get_machine_by_tenant_uid(
|
||||||
match db::machines::get_machine_by_tenant_uid(db.pool(), tenant_id, machine_uid).await {
|
db.pool(),
|
||||||
Ok(e) => e,
|
tenant_id,
|
||||||
Err(e) => {
|
machine_uid,
|
||||||
tracing::error!("[ENROLL] DB error on dedup lookup: {}", e);
|
)
|
||||||
return Err(ApiError::new(
|
.await
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
{
|
||||||
"INTERNAL_ERROR",
|
Ok(e) => e,
|
||||||
"Internal server error",
|
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 {
|
match existing {
|
||||||
// -- Reuse / collision path -------------------------------------------
|
// -- Reuse / collision path -------------------------------------------
|
||||||
Some(existing) => {
|
Some(existing) => {
|
||||||
// Collision gate: a seemingly-different live endpoint resolving to an
|
// Collision gate: a seemingly-different live endpoint resolving to an
|
||||||
// existing uid -> pending, alert, NO usable cak_ minted.
|
// existing uid -> pending, alert, NO usable cak_ minted.
|
||||||
if is_collision(&existing, hostname) {
|
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(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-site guard (SPEC-016 Phase A, HIGH): if this machine_uid is
|
||||||
|
// already bound to a DIFFERENT site, REFUSE — do not move it and do not
|
||||||
|
// mint a key. `machine_uid` is a raw, spoofable client string in Phase A,
|
||||||
|
// so a silent move here is a hijack vector: a holder of site B's key could
|
||||||
|
// claim a site-A machine merely by presenting its uid, repointing the row
|
||||||
|
// and minting a fresh `cak_` on site B. Deliberate moves are out of scope
|
||||||
|
// for Phase A and arrive with the Phase-B `--reassign` flow + dashboard
|
||||||
|
// (SPEC-016 §"Explicitly out of scope"); until then this is the explicit
|
||||||
|
// accidental-move / hijack guard the spec calls for.
|
||||||
|
//
|
||||||
|
// Only `Some(other_site)` is a conflict. `None` (a legacy / connect-path /
|
||||||
|
// support-code row that never had a relational site binding) is treated as
|
||||||
|
// a first-time bind to the enrolling site, NOT a cross-site move — there is
|
||||||
|
// no prior owning site to hijack from.
|
||||||
|
if let Some(existing_site) = existing.site_id {
|
||||||
|
if existing_site != site.id {
|
||||||
|
audit(
|
||||||
|
db,
|
||||||
|
db::events::EventTypes::ENROLL_SITE_CONFLICT,
|
||||||
|
ip,
|
||||||
|
json!({
|
||||||
|
"machine_id": existing.id,
|
||||||
|
"machine_uid": machine_uid,
|
||||||
|
// Record the bound site id for the operator audit trail; the
|
||||||
|
// response body below leaks nothing about the other site.
|
||||||
|
"bound_site_id": existing_site,
|
||||||
|
"attempted_site_code": site_code,
|
||||||
|
"attempted_site_id": site.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// TODO(SPEC-016): wire to #dev-alerts — cross-site enroll refused
|
||||||
|
// (possible accidental move or a cross-site claim attempt).
|
||||||
|
tracing::warn!(
|
||||||
|
"[ENROLL] cross-site conflict REFUSED: machine_id={} already bound to \
|
||||||
|
a different site; attempted site_code={} from {}",
|
||||||
|
existing.id,
|
||||||
|
site_code,
|
||||||
|
ip
|
||||||
|
);
|
||||||
|
// Opaque-enough: states the machine is already enrolled elsewhere
|
||||||
|
// and how to move it deliberately, without naming the other site.
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"ENROLL_SITE_CONFLICT",
|
||||||
|
"This machine is already enrolled at another site. A deliberate \
|
||||||
|
move requires the operator-initiated reassignment flow.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-site reuse (re-image / re-install) or first relational bind of a
|
||||||
|
// legacy/connect-path row (existing.site_id was NULL). No site move occurs
|
||||||
|
// on the unauthenticated path in Phase A.
|
||||||
let params = enroll_params(
|
let params = enroll_params(
|
||||||
&existing.agent_id,
|
&existing.agent_id,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -414,195 +549,123 @@ pub async fn enroll(
|
|||||||
company,
|
company,
|
||||||
site_label,
|
site_label,
|
||||||
&tags,
|
&tags,
|
||||||
"pending",
|
"active",
|
||||||
);
|
);
|
||||||
let machine = db::machines::update_enrolled_machine(db.pool(), existing.id, ¶ms)
|
let machine =
|
||||||
.await
|
db::machines::update_enrolled_machine(db.pool(), existing.id, ¶ms)
|
||||||
.map_err(map_update_err)?;
|
.await
|
||||||
|
.map_err(map_update_err)?;
|
||||||
|
|
||||||
audit(db, db::events::EventTypes::ENROLL_COLLISION_PENDING, ip, json!({
|
let cak = mint_cak(db, machine.id, tenant_id).await?;
|
||||||
"machine_id": machine.id,
|
|
||||||
"machine_uid": machine_uid,
|
audit(
|
||||||
"site_code": site_code,
|
db,
|
||||||
"existing_hostname": existing.hostname,
|
db::events::EventTypes::ENROLL_REUSE,
|
||||||
"incoming_hostname": hostname,
|
ip,
|
||||||
"heuristic": "online_existing_with_different_hostname (PROVISIONAL)",
|
json!({
|
||||||
}))
|
"machine_id": machine.id,
|
||||||
|
"machine_uid": machine_uid,
|
||||||
|
"site_code": site_code,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
// TODO(SPEC-016): wire to #dev-alerts — collision requires operator
|
tracing::info!(
|
||||||
// confirmation in the dashboard before this endpoint may activate.
|
"[ENROLL] reuse: machine_id={} re-enrolled at site_code={} from {}",
|
||||||
tracing::warn!(
|
machine.id,
|
||||||
"[ENROLL] machine_uid collision -> PENDING: machine_id={} site_code={} \
|
site_code,
|
||||||
existing_host={} incoming_host={} from {}",
|
ip
|
||||||
machine.id, site_code, existing.hostname, hostname, ip
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::OK,
|
||||||
Json(EnrollResponse {
|
Json(EnrollResponse {
|
||||||
machine_id: machine.id,
|
machine_id: machine.id,
|
||||||
key: None,
|
key: Some(cak),
|
||||||
enrollment_state: "pending".to_string(),
|
enrollment_state: "active".to_string(),
|
||||||
disposition: "collision_pending".to_string(),
|
disposition: "reuse".to_string(),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-site guard (SPEC-016 Phase A, HIGH): if this machine_uid is
|
// -- New enrollment ----------------------------------------------------
|
||||||
// already bound to a DIFFERENT site, REFUSE — do not move it and do not
|
None => {
|
||||||
// mint a key. `machine_uid` is a raw, spoofable client string in Phase A,
|
// Fresh opaque agent_id for the new row's `agent_id UNIQUE` column. The
|
||||||
// so a silent move here is a hijack vector: a holder of site B's key could
|
// agent's own config-UUID story is Phase B; the server only needs a
|
||||||
// claim a site-A machine merely by presenting its uid, repointing the row
|
// unique non-null value here, and the authoritative identity is the
|
||||||
// and minting a fresh `cak_` on site B. Deliberate moves are out of scope
|
// minted cak_ -> machine binding.
|
||||||
// for Phase A and arrive with the Phase-B `--reassign` flow + dashboard
|
let agent_id = format!("enroll-{}", Uuid::new_v4());
|
||||||
// (SPEC-016 §"Explicitly out of scope"); until then this is the explicit
|
let params = enroll_params(
|
||||||
// accidental-move / hijack guard the spec calls for.
|
&agent_id,
|
||||||
//
|
hostname,
|
||||||
// Only `Some(other_site)` is a conflict. `None` (a legacy / connect-path /
|
machine_uid,
|
||||||
// support-code row that never had a relational site binding) is treated as
|
tenant_id,
|
||||||
// a first-time bind to the enrolling site, NOT a cross-site move — there is
|
site.id,
|
||||||
// no prior owning site to hijack from.
|
company,
|
||||||
if let Some(existing_site) = existing.site_id {
|
site_label,
|
||||||
if existing_site != site.id {
|
&tags,
|
||||||
audit(db, db::events::EventTypes::ENROLL_SITE_CONFLICT, ip, json!({
|
"active",
|
||||||
"machine_id": existing.id,
|
);
|
||||||
"machine_uid": machine_uid,
|
let machine = match db::machines::insert_enrolled_machine(db.pool(), ¶ms).await
|
||||||
// Record the bound site id for the operator audit trail; the
|
{
|
||||||
// response body below leaks nothing about the other site.
|
Ok(m) => m,
|
||||||
"bound_site_id": existing_site,
|
// TOCTOU loser: a concurrent first-enroll of the same machine_uid won
|
||||||
"attempted_site_code": site_code,
|
// the race and committed its row between our lookup and this INSERT, so
|
||||||
"attempted_site_id": site.id,
|
// the partial unique index on `machine_uid` rejects ours. Loop back: the
|
||||||
}))
|
// re-lookup now finds the winner's row and we converge to reuse instead
|
||||||
.await;
|
// of 500ing. Capped so a non-uid unique violation (or any persistent
|
||||||
// TODO(SPEC-016): wire to #dev-alerts — cross-site enroll refused
|
// conflict) surfaces as a 500 rather than spinning.
|
||||||
// (possible accidental move or a cross-site claim attempt).
|
Err(e) if is_machine_uid_conflict(&e) && attempts < 2 => {
|
||||||
tracing::warn!(
|
tracing::info!(
|
||||||
"[ENROLL] cross-site conflict REFUSED: machine_id={} already bound to \
|
"[ENROLL] concurrent first-enroll race on machine_uid; \
|
||||||
a different site; attempted site_code={} from {}",
|
|
||||||
existing.id, site_code, ip
|
|
||||||
);
|
|
||||||
// Opaque-enough: states the machine is already enrolled elsewhere
|
|
||||||
// and how to move it deliberately, without naming the other site.
|
|
||||||
return Err(ApiError::new(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"ENROLL_SITE_CONFLICT",
|
|
||||||
"This machine is already enrolled at another site. A deliberate \
|
|
||||||
move requires the operator-initiated reassignment flow.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same-site reuse (re-image / re-install) or first relational bind of a
|
|
||||||
// legacy/connect-path row (existing.site_id was NULL). No site move occurs
|
|
||||||
// on the unauthenticated path in Phase A.
|
|
||||||
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?;
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(EnrollResponse {
|
|
||||||
machine_id: machine.id,
|
|
||||||
key: Some(cak),
|
|
||||||
enrollment_state: "active".to_string(),
|
|
||||||
disposition: "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 = match db::machines::insert_enrolled_machine(db.pool(), ¶ms).await {
|
|
||||||
Ok(m) => m,
|
|
||||||
// TOCTOU loser: a concurrent first-enroll of the same machine_uid won
|
|
||||||
// the race and committed its row between our lookup and this INSERT, so
|
|
||||||
// the partial unique index on `machine_uid` rejects ours. Loop back: the
|
|
||||||
// re-lookup now finds the winner's row and we converge to reuse instead
|
|
||||||
// of 500ing. Capped so a non-uid unique violation (or any persistent
|
|
||||||
// conflict) surfaces as a 500 rather than spinning.
|
|
||||||
Err(e) if is_machine_uid_conflict(&e) && attempts < 2 => {
|
|
||||||
tracing::info!(
|
|
||||||
"[ENROLL] concurrent first-enroll race on machine_uid; \
|
|
||||||
retrying as reuse (site_code={} from {})",
|
retrying as reuse (site_code={} from {})",
|
||||||
site_code, ip
|
site_code,
|
||||||
);
|
ip
|
||||||
continue;
|
);
|
||||||
}
|
continue;
|
||||||
Err(e) => {
|
}
|
||||||
tracing::error!("[ENROLL] DB error inserting enrolled machine: {}", e);
|
Err(e) => {
|
||||||
return Err(ApiError::new(
|
tracing::error!("[ENROLL] DB error inserting enrolled machine: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
return Err(ApiError::new(
|
||||||
"INTERNAL_ERROR",
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to register machine",
|
"INTERNAL_ERROR",
|
||||||
));
|
"Failed to register machine",
|
||||||
}
|
));
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let cak = mint_cak(db, machine.id, tenant_id).await?;
|
let cak = mint_cak(db, machine.id, tenant_id).await?;
|
||||||
|
|
||||||
audit(db, db::events::EventTypes::ENROLL_NEW, ip, json!({
|
audit(
|
||||||
"machine_id": machine.id,
|
db,
|
||||||
"machine_uid": machine_uid,
|
db::events::EventTypes::ENROLL_NEW,
|
||||||
"site_code": site_code,
|
ip,
|
||||||
"hostname": hostname,
|
json!({
|
||||||
}))
|
"machine_id": machine.id,
|
||||||
.await;
|
"machine_uid": machine_uid,
|
||||||
// TODO(SPEC-016): wire to #dev-alerts — new-enrollment tripwire.
|
"site_code": site_code,
|
||||||
tracing::info!(
|
"hostname": hostname,
|
||||||
"[ENROLL] new: machine_id={} hostname={} site_code={} from {}",
|
}),
|
||||||
machine.id, hostname, site_code, ip
|
)
|
||||||
);
|
.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
|
||||||
|
);
|
||||||
|
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(EnrollResponse {
|
Json(EnrollResponse {
|
||||||
machine_id: machine.id,
|
machine_id: machine.id,
|
||||||
key: Some(cak),
|
key: Some(cak),
|
||||||
enrollment_state: "active".to_string(),
|
enrollment_state: "active".to_string(),
|
||||||
disposition: "new".to_string(),
|
disposition: "new".to_string(),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,7 +699,12 @@ fn effective_tags(labels: &EnrollLabels) -> Vec<String> {
|
|||||||
.map(|t| t.trim().to_string())
|
.map(|t| t.trim().to_string())
|
||||||
.filter(|t| !t.is_empty())
|
.filter(|t| !t.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if let Some(d) = labels.department.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
if let Some(d) = labels
|
||||||
|
.department
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
tags.push(format!("department:{}", d));
|
tags.push(format!("department:{}", d));
|
||||||
}
|
}
|
||||||
if let Some(d) = labels
|
if let Some(d) = labels
|
||||||
@@ -708,7 +776,8 @@ mod tests {
|
|||||||
fn timing_equalizer_phc_is_a_valid_parseable_hash() {
|
fn timing_equalizer_phc_is_a_valid_parseable_hash() {
|
||||||
// A wrong password must verify to `false` WITHOUT erroring — proving the PHC
|
// A wrong password must verify to `false` WITHOUT erroring — proving the PHC
|
||||||
// parsed and the KDF actually ran (a parse failure would surface as `Err`).
|
// parsed and the KDF actually ran (a parse failure would surface as `Err`).
|
||||||
let res = crate::auth::password::verify_password("cek_anything_at_all", TIMING_EQUALIZER_PHC);
|
let res =
|
||||||
|
crate::auth::password::verify_password("cek_anything_at_all", TIMING_EQUALIZER_PHC);
|
||||||
assert!(
|
assert!(
|
||||||
res.is_ok(),
|
res.is_ok(),
|
||||||
"TIMING_EQUALIZER_PHC must be a valid PHC string so the KDF runs; got Err: {:?}",
|
"TIMING_EQUALIZER_PHC must be a valid PHC string so the KDF runs; got Err: {:?}",
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fingerprint_differs_per_key() {
|
fn fingerprint_differs_per_key() {
|
||||||
assert_ne!(compute_fingerprint("cek_aaa"), compute_fingerprint("cek_bbb"));
|
assert_ne!(
|
||||||
|
compute_fingerprint("cek_aaa"),
|
||||||
|
compute_fingerprint("cek_bbb")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -102,12 +102,11 @@ pub async fn rotate_key(
|
|||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
// Highest existing version for this site (NULL -> 0 so the first key is v1).
|
// Highest existing version for this site (NULL -> 0 so the first key is v1).
|
||||||
let current_max: Option<i32> = sqlx::query_scalar(
|
let current_max: Option<i32> =
|
||||||
"SELECT MAX(version) FROM site_enrollment_keys WHERE site_id = $1",
|
sqlx::query_scalar("SELECT MAX(version) FROM site_enrollment_keys WHERE site_id = $1")
|
||||||
)
|
.bind(site_id)
|
||||||
.bind(site_id)
|
.fetch_one(&mut *tx)
|
||||||
.fetch_one(&mut *tx)
|
.await?;
|
||||||
.await?;
|
|
||||||
let next_version = current_max.unwrap_or(0) + 1;
|
let next_version = current_max.unwrap_or(0) + 1;
|
||||||
|
|
||||||
// Deactivate the current active key (if any), stamping rotated_at.
|
// Deactivate the current active key (if any), stamping rotated_at.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ pub mod agent_keys;
|
|||||||
pub mod enrollment_keys;
|
pub mod enrollment_keys;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod machines;
|
pub mod machines;
|
||||||
pub mod sites;
|
|
||||||
pub mod releases;
|
pub mod releases;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
pub mod sites;
|
||||||
pub mod support_codes;
|
pub mod support_codes;
|
||||||
pub mod tenancy;
|
pub mod tenancy;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ mod tests {
|
|||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||||
assert!(!lim.check_at("SITE-A", ip(1), t0)); // same key over cap
|
assert!(!lim.check_at("SITE-A", ip(1), t0)); // same key over cap
|
||||||
// Different site, same IP -> independent bucket.
|
// Different site, same IP -> independent bucket.
|
||||||
assert!(lim.check_at("SITE-B", ip(1), t0));
|
assert!(lim.check_at("SITE-B", ip(1), t0));
|
||||||
// Same site, different IP -> independent bucket.
|
// Same site, different IP -> independent bucket.
|
||||||
assert!(lim.check_at("SITE-A", ip(2), t0));
|
assert!(lim.check_at("SITE-A", ip(2), t0));
|
||||||
@@ -723,7 +723,7 @@ mod tests {
|
|||||||
// Not yet tripped: a check still admits.
|
// Not yet tripped: a check still admits.
|
||||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||||
lim.record_failure_at("SITE-A", ip(1), t0); // 3rd -> trips
|
lim.record_failure_at("SITE-A", ip(1), t0); // 3rd -> trips
|
||||||
// Now locked out: check denies even though under the request cap.
|
// Now locked out: check denies even though under the request cap.
|
||||||
assert!(!lim.check_at("SITE-A", ip(1), t0));
|
assert!(!lim.check_at("SITE-A", ip(1), t0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user