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:
@@ -77,6 +77,19 @@ pub const CODE_VALIDATE_MAX_FAILURES: u32 = 10;
|
||||
/// Support-code validate: how long an IP stays locked out once tripped.
|
||||
pub const CODE_VALIDATE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Enroll (`POST /api/enroll`, SPEC-016): window length.
|
||||
pub const ENROLL_WINDOW: Duration = Duration::from_secs(60);
|
||||
/// Enroll: max requests per window per `(site_code, IP)`. A zero-touch site push
|
||||
/// drives N machines through enroll near-simultaneously, so this is generous
|
||||
/// (mass-deploy friendly) while still capping a runaway loop. Defense-in-depth: the
|
||||
/// 256-bit enrollment key is the load-bearing gate, not this cap.
|
||||
pub const ENROLL_MAX_PER_WINDOW: u32 = 60;
|
||||
/// Enroll: consecutive FAILED enroll attempts (bad/inactive key, unknown site) from
|
||||
/// one `(site_code, IP)` that trip the lockout.
|
||||
pub const ENROLL_MAX_FAILURES: u32 = 20;
|
||||
/// Enroll: how long a `(site_code, IP)` stays locked out once tripped.
|
||||
pub const ENROLL_LOCKOUT: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Hard cap on the number of distinct IPs tracked by any single limiter map.
|
||||
/// Prevents an IP-rotating attacker from growing memory without bound. When the
|
||||
/// cap is hit, the oldest-windowed entries are pruned. Generous for a real MSP
|
||||
@@ -260,6 +273,150 @@ impl FailureLockout {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Composite-key limiter for enrollment (keyed by (site_code, IP)) — SPEC-016
|
||||
// ============================================================================
|
||||
//
|
||||
// The login / change-password / code-validate limiters above key purely on IP.
|
||||
// SPEC-016 §3 wants the enroll defense keyed on `(site_code, source-IP)` so a noisy
|
||||
// site push from one office IP cannot lock out a different site enrolling from the
|
||||
// same egress IP. Rather than overload the IP-only maps, this is a small dedicated
|
||||
// composite-key limiter + lockout. It is invoked from the enroll HANDLER (not a
|
||||
// `from_fn` layer) because the `site_code` lives in the JSON body, which a
|
||||
// pre-handler middleware cannot read without consuming it. Documented as
|
||||
// defense-in-depth: the 256-bit enrollment key is the real gate.
|
||||
|
||||
/// Composite limiter key: the site_code and the real client IP.
|
||||
type EnrollKey = (String, IpAddr);
|
||||
|
||||
/// Per-`(site_code, IP)` fixed-window limiter + consecutive-failure lockout.
|
||||
///
|
||||
/// Combines both protections behind one lock-guarded map so the enroll handler
|
||||
/// makes a single allow/deny decision and reports success/failure into the same
|
||||
/// structure. Self-pruning and size-capped, like the IP-only limiters.
|
||||
#[derive(Clone)]
|
||||
pub struct EnrollLimiter {
|
||||
inner: std::sync::Arc<Mutex<HashMap<EnrollKey, EnrollEntry>>>,
|
||||
max_per_window: u32,
|
||||
window: Duration,
|
||||
max_failures: u32,
|
||||
cooldown: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct EnrollEntry {
|
||||
window_started: Instant,
|
||||
count: u32,
|
||||
failures: u32,
|
||||
locked_until: Option<Instant>,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
impl EnrollLimiter {
|
||||
pub fn new(
|
||||
max_per_window: u32,
|
||||
window: Duration,
|
||||
max_failures: u32,
|
||||
cooldown: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
max_per_window,
|
||||
window,
|
||||
max_failures,
|
||||
cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_now() -> EnrollEntry {
|
||||
let now = Instant::now();
|
||||
EnrollEntry {
|
||||
window_started: now,
|
||||
count: 0,
|
||||
failures: 0,
|
||||
locked_until: None,
|
||||
last_seen: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one enroll attempt for `(site_code, ip)`. Returns `true` if allowed
|
||||
/// (and counts it). Returns `false` if the key is currently locked out OR over
|
||||
/// the per-window request cap. Clock injected for tests.
|
||||
fn check_at(&self, site_code: &str, ip: IpAddr, now: Instant) -> bool {
|
||||
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
if map.len() >= MAX_TRACKED_IPS {
|
||||
let window = self.window;
|
||||
let cooldown = self.cooldown;
|
||||
map.retain(|_, e| {
|
||||
e.locked_until.map(|u| now < u).unwrap_or(false)
|
||||
|| now.duration_since(e.window_started) < window
|
||||
|| now.duration_since(e.last_seen) < cooldown
|
||||
});
|
||||
}
|
||||
|
||||
let key = (site_code.to_string(), ip);
|
||||
let e = map.entry(key).or_insert_with(Self::entry_now);
|
||||
e.last_seen = now;
|
||||
|
||||
// Lockout takes precedence.
|
||||
if let Some(until) = e.locked_until {
|
||||
if now < until {
|
||||
return false;
|
||||
}
|
||||
// Cooldown elapsed — clear it for a fresh start.
|
||||
e.locked_until = None;
|
||||
e.failures = 0;
|
||||
}
|
||||
|
||||
// Roll the fixed window forward if elapsed.
|
||||
if now.duration_since(e.window_started) >= self.window {
|
||||
e.window_started = now;
|
||||
e.count = 0;
|
||||
}
|
||||
|
||||
if e.count >= self.max_per_window {
|
||||
false
|
||||
} else {
|
||||
e.count += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one enroll attempt (real clock).
|
||||
pub fn check(&self, site_code: &str, ip: IpAddr) -> bool {
|
||||
self.check_at(site_code, ip, Instant::now())
|
||||
}
|
||||
|
||||
fn record_failure_at(&self, site_code: &str, ip: IpAddr, now: Instant) {
|
||||
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (site_code.to_string(), ip);
|
||||
let e = map.entry(key).or_insert_with(Self::entry_now);
|
||||
e.last_seen = now;
|
||||
e.failures = e.failures.saturating_add(1);
|
||||
if e.failures >= self.max_failures {
|
||||
e.locked_until = Some(now + self.cooldown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a FAILED enroll attempt (bad key / unknown site) for the key,
|
||||
/// tripping the lockout once the streak reaches `max_failures`.
|
||||
pub fn record_failure(&self, site_code: &str, ip: IpAddr) {
|
||||
self.record_failure_at(site_code, ip, Instant::now());
|
||||
}
|
||||
|
||||
/// Record a SUCCESSFUL enroll for the key, resetting its failure streak.
|
||||
pub fn record_success(&self, site_code: &str, ip: IpAddr) {
|
||||
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (site_code.to_string(), ip);
|
||||
if let Some(e) = map.get_mut(&key) {
|
||||
e.failures = 0;
|
||||
e.locked_until = None;
|
||||
e.last_seen = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared rate-limit state (lives in AppState)
|
||||
// ============================================================================
|
||||
@@ -275,6 +432,9 @@ pub struct RateLimitState {
|
||||
pub code_validate: RateLimiter,
|
||||
/// Per-IP lockout on repeated failed code validations (brute-force defense).
|
||||
pub code_validate_lockout: FailureLockout,
|
||||
/// `POST /api/enroll` (SPEC-016): per-`(site_code, IP)` request cap +
|
||||
/// consecutive-failure lockout. Invoked from the enroll handler.
|
||||
pub enroll: EnrollLimiter,
|
||||
}
|
||||
|
||||
impl RateLimitState {
|
||||
@@ -290,6 +450,12 @@ impl RateLimitState {
|
||||
CODE_VALIDATE_MAX_FAILURES,
|
||||
CODE_VALIDATE_LOCKOUT,
|
||||
),
|
||||
enroll: EnrollLimiter::new(
|
||||
ENROLL_MAX_PER_WINDOW,
|
||||
ENROLL_WINDOW,
|
||||
ENROLL_MAX_FAILURES,
|
||||
ENROLL_LOCKOUT,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,4 +690,51 @@ mod tests {
|
||||
assert!(lockout.is_locked_at(ip(8), t0));
|
||||
assert!(!lockout.is_locked_at(ip(9), t0)); // ip9 unaffected
|
||||
}
|
||||
|
||||
// -- EnrollLimiter (composite (site_code, IP) key) --------------------------
|
||||
|
||||
#[test]
|
||||
fn enroll_window_allows_up_to_cap_then_blocks() {
|
||||
let lim = EnrollLimiter::new(2, Duration::from_secs(60), 100, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0)); // 1
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0)); // 2
|
||||
assert!(!lim.check_at("SITE-A", ip(1), t0)); // over cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_is_keyed_by_site_and_ip() {
|
||||
let lim = EnrollLimiter::new(1, Duration::from_secs(60), 100, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||
assert!(!lim.check_at("SITE-A", ip(1), t0)); // same key over cap
|
||||
// Different site, same IP -> independent bucket.
|
||||
assert!(lim.check_at("SITE-B", ip(1), t0));
|
||||
// Same site, different IP -> independent bucket.
|
||||
assert!(lim.check_at("SITE-A", ip(2), t0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_lockout_trips_after_failures_and_blocks_check() {
|
||||
let lim = EnrollLimiter::new(100, Duration::from_secs(60), 3, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
// Not yet tripped: a check still admits.
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||
lim.record_failure_at("SITE-A", ip(1), t0); // 3rd -> trips
|
||||
// Now locked out: check denies even though under the request cap.
|
||||
assert!(!lim.check_at("SITE-A", ip(1), t0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enroll_success_resets_failure_streak() {
|
||||
let lim = EnrollLimiter::new(100, Duration::from_secs(60), 2, Duration::from_secs(600));
|
||||
let t0 = Instant::now();
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
lim.record_success("SITE-A", ip(1)); // reset
|
||||
lim.record_failure_at("SITE-A", ip(1), t0);
|
||||
// Only one failure since reset -> not locked.
|
||||
assert!(lim.check_at("SITE-A", ip(1), t0));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user