oxedyne/fe2o3/fe2o3_steel/src/srv/admin/auth.rs
5.7 KiB, 50 runs
created by r1870400018:10322, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Dashboard login flow. |
| 2 | //! |
| 3 | //! Verifies a passphrase against the loaded wallet and produces an |
| 4 | //! [`AdminPrincipal`] on success. The wallet is already resident in |
| 5 | //! memory -- it was loaded at Steel start-up by the TUI unlock |
| 6 | //! prompt -- so login reuses the same `Wallet::unlock` path the CLI |
| 7 | //! uses. |
| 8 | //! |
| 9 | //! Unlike the CLI unlock, which is a one-time event at start-up, |
| 10 | //! dashboard login also gates on *scope*: the passphrase must not |
| 11 | //! only unwrap an admin entry, the matched entry must also hold |
| 12 | //! one of the dashboard scopes. An admin whose scope list gates |
| 13 | //! CLI-only verbs is therefore still recognised by the wallet but |
| 14 | //! refused by the dashboard. |
| 15 | //! |
| 16 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 17 | //! Anthropic Claude |
| 18 | |
| 19 | use crate::srv::{ |
| 20 | admin::{ |
| 21 | AdminPrincipal, |
| 22 | session::{ |
| 23 | DEFAULT_SESSION_TTL_SECS, |
| 24 | now_secs, |
| 25 | }, |
| 26 | state::AdminState, |
| 27 | }, |
| 28 | alert::AlertEvent, |
| 29 | }; |
| 30 | |
| 31 | use oxedyne_fe2o3_core::prelude::*; |
| 32 | |
| 33 | use std::net::SocketAddr; |
| 34 | |
| 35 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 36 | // │ LOGIN OUTCOME │ |
| 37 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 38 | |
| 39 | /// Distinct from a plain `Outcome` so the handler can distinguish |
| 40 | /// "wrong password" (re-prompt with a generic error) from "correct |
| 41 | /// password but no dashboard scope" (explicit refusal with operator |
| 42 | /// guidance). |
| 43 | #[derive(Debug)] |
| 44 | pub enum LoginOutcome { |
| 45 | Ok(AdminPrincipal), |
| 46 | // No admin entry unwrapped with the supplied passphrase. The message stays |
| 47 | // generic so the response cannot leak whether any admin exists. |
| 48 | BadCredentials, |
| 49 | // An admin entry unwrapped, but it holds neither `dashboard.view` nor |
| 50 | // `dashboard.admin`: the wallet accepts the passphrase, the dashboard refuses |
| 51 | // the session. The name is for the audit log and must not be echoed to the |
| 52 | // client. |
| 53 | NoDashboardScope { name: String }, |
| 54 | } |
| 55 | |
| 56 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 57 | // │ LOGIN │ |
| 58 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 59 | |
| 60 | /// Verifies a passphrase against the wallet and, on success, builds an |
| 61 | /// [`AdminPrincipal`] with a fresh sliding-TTL expiry. |
| 62 | /// |
| 63 | /// Structural errors (poisoned lock, wallet corruption) propagate as `Err(_)`; |
| 64 | /// user-visible refusals arrive as [`LoginOutcome`] variants so the caller can |
| 65 | /// respond to and audit-log them differently. |
| 66 | pub fn verify_passphrase( |
| 67 | state: &AdminState, |
| 68 | passphrase: &[u8], |
| 69 | peer: SocketAddr, |
| 70 | ) |
| 71 | -> Outcome<LoginOutcome> |
| 72 | { |
| 73 | // `unseal` performs the wallet unlock, and installs the recovered |
| 74 | // master key if the process is still sealed. A dashboard login is |
| 75 | // therefore the same act as an unseal: the passphrase that proves |
| 76 | // who you are is the passphrase that unwraps the key. That is what |
| 77 | // lets an admin bring a cold-started Steel's databases up from a |
| 78 | // browser, with no terminal and no database behind the login form. |
| 79 | // |
| 80 | // A wrong passphrase fails the unwrap, so it can neither log in |
| 81 | // nor unseal. There is no separate credential to get out of step. |
| 82 | // |
| 83 | // Note the unseal is not gated on dashboard scope, while the |
| 84 | // session below is. This is not an escalation: every admin in the |
| 85 | // wallet holds their own wrap of the master key, so any of them |
| 86 | // can already recover it by definition. Scope governs what the |
| 87 | // dashboard will *show* them, not whether they are trusted with |
| 88 | // the key they already have. |
| 89 | let unsealed = match state.unseal(passphrase) { |
| 90 | Ok(u) => u, |
| 91 | Err(_) => { |
| 92 | // A wrong passphrase. Count it: this form unwraps the wallet |
| 93 | // master key, so it is worth guessing at, and a burst of |
| 94 | // guesses is something the operator should hear about. |
| 95 | if let Some(alerter) = state.alerter() { |
| 96 | alerter.note_failed_unseal(peer); |
| 97 | } |
| 98 | return Ok(LoginOutcome::BadCredentials); |
| 99 | } |
| 100 | }; |
| 101 | |
| 102 | // Alert only when this login is what actually lifted the seal. Every |
| 103 | // subsequent sign-in is a routine login, and alerting on those would |
| 104 | // bury the one message that mattered. |
| 105 | if unsealed.lifted { |
| 106 | if let Some(alerter) = state.alerter() { |
| 107 | alerter.raise(AlertEvent::Unsealed { |
| 108 | admin: unsealed.name.clone(), |
| 109 | peer, |
| 110 | }); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | let name = unsealed.name; |
| 115 | let scopes = unsealed.scopes; |
| 116 | |
| 117 | // Reuse the principal-side scope check so the dashboard |
| 118 | // access rule lives in exactly one place. |
| 119 | let probe = AdminPrincipal { |
| 120 | name: name.clone(), |
| 121 | scopes: scopes.clone(), |
| 122 | expires_at: 0, |
| 123 | }; |
| 124 | if !probe.can_view_dashboard() { |
| 125 | return Ok(LoginOutcome::NoDashboardScope { name }); |
| 126 | } |
| 127 | |
| 128 | Ok(LoginOutcome::Ok(AdminPrincipal { |
| 129 | name, |
| 130 | scopes, |
| 131 | expires_at: now_secs().saturating_add(DEFAULT_SESSION_TTL_SECS), |
| 132 | })) |
| 133 | } |