Skip to main content

backbone_pos/application/service/
pos_manager_pin.rs

1//! The manager-PIN credential path (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. A manager
4//! PIN is the register's Tier-B credential (PSX-4): a short numeric code whose ARGON2 HASH lives on
5//! `pos.pos_manager_pins`, one live PIN per manager (per org unit under composition — the composing
6//! service's decorator re-declares the live-credential unique). The hash NEVER leaves the server —
7//! not into a response, not into an event, not into a log line.
8//!
9//! **Privileged verbs verify on every call.** There is no session, no token, no "manager mode" a
10//! client can assert: each privileged mutation carries a [`ManagerAuth`] (employee + PIN) and the
11//! service re-verifies it against the hash before writing anything. The privileged surface of this
12//! module:
13//!
14//! - [`Self::set_pin`] / [`Self::verify_pin`] — the credential verbs themselves.
15//! - [`super::pos_drawer::PosWriteService::close_session`] — closing the till books a GL variance.
16//! - [`super::pos_sync::PosWriteService::sync_from_ui`] — only when the replay is a refund
17//!   (`refund_of_client_uuid` set); a plain sale replay is a cashier action.
18//!
19//! Anti-abuse is two-layered and entirely CONFIG-DRIVEN ([`PinPolicy`] — env overrides, short
20//! defaults): a per-manager consecutive-failure counter with lockout (persisted, so a restart does
21//! not reset an attack), and a per-source-address throttle ring in front of it (process-local).
22//!
23//! Per the module's 4-layer rule this file holds no SQL — the credential statements live on
24//! `PosManagerPinRepository`.
25//!
26//! Tenancy is composition-installed (ADR-0029): every credential read and write rides the caller's
27//! ambient org scope — under another unit's scope a manager's credential reads as plain absence
28//! (`PinNotFound`), so a PIN cannot be probed across units.
29
30use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
31use argon2::Argon2;
32use uuid::Uuid;
33
34use super::pos_write_service::{relay_ambient_scope, ManagerAuth, PosError, PosWriteService};
35
36/// A `set_pin` request: the manager getting a credential + the new PIN + the proof of authority.
37#[derive(Debug, Clone)]
38pub struct SetPin {
39    pub employee_party_id: Uuid,
40    /// Digits only; length must fall inside the policy window.
41    pub new_pin: String,
42    /// Proof of authority to set: the SAME manager's current PIN (self-change), ANOTHER manager's
43    /// verified PIN (supervised change), or `None` — allowed only while the caller's scope holds no
44    /// live PIN at all (the bootstrap: the very first credential cannot demand a credential).
45    pub current: Option<ManagerAuth>,
46    pub source_ip: Option<String>,
47}
48
49impl PosWriteService {
50    /// Set (or replace) a manager's PIN. Hashes with argon2 server-side and persists ONLY the hash;
51    /// setting a new PIN clears any lockout and failure history (the administrative unlock).
52    pub async fn set_pin(&self, s: SetPin) -> Result<(), PosError> {
53        // Strength first — a weak PIN never reaches the hash (and never burns the authority proof).
54        validate_pin_strength(&s.new_pin, &self.pin_policy)?;
55        // One scope-relayed transaction (ADR-0029): the bootstrap read, the authority proof, and the
56        // credential write all see through the same fence.
57        let mut tx = self.rpool().begin().await?;
58        relay_ambient_scope(&mut tx).await?;
59        // Authority: once ANY credential exists in the caller's scope, changing one requires proof —
60        // the manager's own current PIN, or another live manager's.
61        let bootstrapping = !self.pins.any_live_pin_exists_on(&mut *tx).await?;
62        if !bootstrapping {
63            let proof = s.current.as_ref().ok_or(PosError::ManagerAuthRequired)?;
64            self.verify_manager_internal(proof, s.source_ip.as_deref()).await?;
65        }
66        let salt = SaltString::generate(&mut OsRng);
67        let hash = Argon2::default()
68            .hash_password(s.new_pin.as_bytes(), &salt)
69            .map_err(|e| PosError::Db(sqlx::Error::Protocol(format!("pin hash: {e}"))))?;
70        self.pins
71            .upsert_hash_on(&mut tx, s.employee_party_id, &hash.to_string(), chrono::Utc::now(), s.source_ip.as_deref())
72            .await?;
73        tx.commit().await?;
74        Ok(())
75    }
76
77    /// Verify a manager's PIN — the gate every privileged mutation calls. Fails CLOSED on every
78    /// abuse signal: unknown manager (`PinNotFound`), wrong PIN (`PinInvalid`), locked
79    /// (`PinLocked`), source over its throttle budget (`PinThrottled`). Succeeds by clearing the
80    /// failure counter. State changes ride the persisted counters, so a process restart never
81    /// resets an in-progress attack.
82    pub async fn verify_pin(
83        &self,
84        employee_party_id: Uuid,
85        pin: &str,
86        source_ip: Option<&str>,
87    ) -> Result<(), PosError> {
88        self.verify_manager_internal(
89            &ManagerAuth { employee_party_id, pin: pin.to_string() },
90            source_ip,
91        )
92        .await
93    }
94
95    /// The shared verify path (also what privileged verbs call through [`ManagerAuth`]). Rides the
96    /// ambient org scope (ADR-0029).
97    pub(super) async fn verify_manager_internal(
98        &self,
99        auth: &ManagerAuth,
100        source_ip: Option<&str>,
101    ) -> Result<(), PosError> {
102        // Per-source throttle FIRST — a throttled source does not get to touch the manager's
103        // counter at all (the counter is per-manager; the ring is per-address, so one address
104        // hammering different managers still gets fenced).
105        if let Some(ip) = source_ip {
106            if !self.take_ip_budget(ip) {
107                return Err(PosError::PinThrottled);
108            }
109        }
110        let cred = self.pins
111            .fetch_credential(&self.rpool(), auth.employee_party_id)
112            .await?
113            .ok_or(PosError::PinNotFound)?;
114        let now = chrono::Utc::now();
115        if let Some(until) = cred.locked_until {
116            if until > now {
117                return Err(PosError::PinLocked { until });
118            }
119        }
120        let parsed = match PasswordHash::new(&cred.pin_hash) {
121            Ok(h) => h,
122            // An unreadable hash — a corrupt row, or a credential-blind CRUD create that landed on
123            // the non-verifying placeholder — is not a server fault: it is a credential that cannot
124            // authenticate anyone. Fail closed as a wrong-PIN (403), never a 500.
125            Err(_) => return Err(PosError::PinInvalid),
126        };
127        if Argon2::default().verify_password(auth.pin.as_bytes(), &parsed).is_ok() {
128            self.pins.record_success(&self.rpool(), cred.id, now, source_ip).await?;
129            Ok(())
130        } else {
131            let lockout_until = now + chrono::Duration::seconds(self.pin_policy.lockout_secs);
132            self.pins
133                .record_failure(&self.rpool(), cred.id, self.pin_policy.max_attempts, lockout_until, now, source_ip)
134                .await?;
135            // Report the lock when THIS failure crossed the threshold — the manager sees the
136            // unlock instant instead of a bare "wrong PIN" they cannot recover from.
137            if cred.failed_attempts + 1 >= self.pin_policy.max_attempts as i32 {
138                Err(PosError::PinLocked { until: lockout_until })
139            } else {
140                Err(PosError::PinInvalid)
141            }
142        }
143    }
144
145    /// Consume one verification attempt from a source address's rolling budget. `false` = over
146    /// budget inside the window. Pure sync (Mutex never held across an `.await`).
147    fn take_ip_budget(&self, ip: &str) -> bool {
148        let key = format!("pin:{ip}");
149        let now = chrono::Utc::now().timestamp();
150        // A poisoned ring means some thread panicked mid-update; the counts are still coherent
151        // (poisoning marks ownership doubt, not corrupt data), so recover the inner map rather
152        // than panicking this request too. The persisted per-manager counters remain the real
153        // credential fence either way — this ring is a process-local pre-filter.
154        let mut ring = self.ip_attempts.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
155        // Read the window state first (release the shared borrow), then decide.
156        let in_window = match ring.get(&key) {
157            Some((window_start, count)) if now - *window_start < self.pin_policy.ip_window_secs => Some(*count),
158            _ => None,
159        };
160        match in_window {
161            // Over budget inside the window: fenced.
162            Some(count) if count >= self.pin_policy.ip_max_attempts => false,
163            // Inside the window with budget left: consume one. The `if let` is belt-and-braces:
164            // the entry was just read, and a missed increment fails SAFE (this attempt is simply
165            // not counted — the persisted counters still fence the credential).
166            Some(_) => {
167                if let Some((_, count)) = ring.get_mut(&key) {
168                    *count += 1;
169                }
170                true
171            }
172            // No entry, or the previous window expired: start a fresh window with this attempt.
173            None => {
174                ring.insert(key, (now, 1));
175                true
176            }
177        }
178    }
179}
180
181/// Digits-only + policy length window. The reason string is static so the error wire stays stable.
182fn validate_pin_strength(pin: &str, policy: &super::pos_write_service::PinPolicy) -> Result<(), PosError> {
183    if pin.is_empty() || !pin.bytes().all(|b| b.is_ascii_digit()) {
184        return Err(PosError::WeakPin("pin must be digits only"));
185    }
186    let n = pin.len();
187    if n < policy.min_digits {
188        return Err(PosError::WeakPin("pin is too short"));
189    }
190    if n > policy.max_digits {
191        return Err(PosError::WeakPin("pin is too long"));
192    }
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::application::service::pos_write_service::PinPolicy;
200
201    fn policy() -> PinPolicy {
202        PinPolicy { max_attempts: 3, lockout_secs: 5, min_digits: 4, max_digits: 6, ip_window_secs: 10, ip_max_attempts: 4 }
203    }
204
205    #[test]
206    fn strength_rejects_short_long_and_nondigits() {
207        let p = policy();
208        assert!(matches!(validate_pin_strength("123", &p), Err(PosError::WeakPin(_))));
209        assert!(matches!(validate_pin_strength("1234567", &p), Err(PosError::WeakPin(_))));
210        assert!(matches!(validate_pin_strength("12ab", &p), Err(PosError::WeakPin(_))));
211        assert!(validate_pin_strength("4321", &p).is_ok());
212    }
213
214    #[test]
215    fn env_policy_falls_back_to_defaults_on_garbage() {
216        // Parsing is forgiving by design: a bad override must fall back, never panic or zero a window.
217        std::env::set_var("POS_PIN_TEST_SENTINEL", "not-a-number");
218        let d = PinPolicy::default();
219        let p = PinPolicy::from_env();
220        assert_eq!(p.max_attempts, d.max_attempts);
221        assert_eq!(p.lockout_secs, d.lockout_secs);
222    }
223}