Oregami
Repositories/oxedyne/fe2o3

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
19use 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
31use oxedyne_fe2o3_core::prelude::*;
32
33use 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)]
44pub 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.
66pub 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}