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}