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