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}