From eeedffb1f9bc3b39ae102b143c8b4ad6eb4b728a Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Wed, 2 Sep 2026 00:00:55 +0300 Subject: [PATCH 1/2] Update Rust crate rand_core to 0.10 --- communities/Cargo.toml | 2 +- omikron-connector/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/communities/Cargo.toml b/communities/Cargo.toml index 36ef42e..b2786ca 100644 --- a/communities/Cargo.toml +++ b/communities/Cargo.toml @@ -9,7 +9,7 @@ iota-util = { path = "../iota-util" } futures = "*" rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } +rand_core = { version = "0.10", features = ["getrandom", "std"] } sha2 = "0.11.0" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 5902891..03ae091 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -24,4 +24,4 @@ tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } base64 = "0.22.1" -rand_core = { version = "0.6", features = ["getrandom", "std"] } +rand_core = { version = "0.10", features = ["getrandom", "std"] } From b38b68ad96ada68497e600993b49591b8774d361 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:42:25 +0200 Subject: [PATCH 2/2] [Add] Proper User managment --- iota-cli/src/controls/header.rs | 97 +- iota-cli/src/controls/menu.rs | 119 ++ iota-cli/src/controls/mod.rs | 2 + iota-cli/src/controls/text_input.rs | 99 ++ iota-cli/src/ipc_client.rs | 30 +- iota-cli/src/screens/screens.rs | 14 +- iota-cli/src/screens/users.rs | 741 ---------- iota-cli/src/screens/users/action_menu.rs | 66 + iota-cli/src/screens/users/add_flow.rs | 126 ++ iota-cli/src/screens/users/confirmations.rs | 55 + iota-cli/src/screens/users/context.rs | 29 + .../src/screens/users/credential_export.rs | 85 ++ iota-cli/src/screens/users/invitations.rs | 38 + iota-cli/src/screens/users/list.rs | 7 + iota-cli/src/screens/users/mod.rs | 1281 +++++++++++++++++ iota-cli/src/screens/users/model.rs | 230 +++ iota-cli/src/ui.rs | 24 +- iota-daemon-lib/src/command_router.rs | 329 ++++- iota-daemon-lib/src/ipc_server.rs | 9 +- iota-daemon/src/main.rs | 20 +- iota-ipc/src/lib.rs | 15 +- iota-ipc/src/protocol.rs | 153 +- iota-ipc/src/text_commands.rs | 101 +- iota-storage/src/users/invitations.rs | 83 ++ iota-storage/src/users/mod.rs | 1 + iota-storage/src/users/pending_operations.rs | 33 +- iota-storage/src/users/user_manager.rs | 346 ++++- iota-storage/src/users/user_profile.rs | 19 +- iota-storage/src/util/config_util.rs | 3 + iota-storage/src/util/db.rs | 191 ++- iota-util/src/atomic_file.rs | 63 +- iota-util/tests/private_export.rs | 29 + iota/src/cli_args.rs | 225 ++- iota/src/main.rs | 221 ++- mtp-type-maps | 2 +- omikron-connector/src/client.rs | 4 +- omikron-connector/src/omikron_connection.rs | 274 +++- omikron-connector/src/user_ops.rs | 236 ++- 38 files changed, 4333 insertions(+), 1067 deletions(-) create mode 100644 iota-cli/src/controls/menu.rs create mode 100644 iota-cli/src/controls/text_input.rs delete mode 100644 iota-cli/src/screens/users.rs create mode 100644 iota-cli/src/screens/users/action_menu.rs create mode 100644 iota-cli/src/screens/users/add_flow.rs create mode 100644 iota-cli/src/screens/users/confirmations.rs create mode 100644 iota-cli/src/screens/users/context.rs create mode 100644 iota-cli/src/screens/users/credential_export.rs create mode 100644 iota-cli/src/screens/users/invitations.rs create mode 100644 iota-cli/src/screens/users/list.rs create mode 100644 iota-cli/src/screens/users/mod.rs create mode 100644 iota-cli/src/screens/users/model.rs create mode 100644 iota-storage/src/users/invitations.rs create mode 100644 iota-util/tests/private_export.rs diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index c38eda4..c898ac8 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -11,6 +11,36 @@ use ratatui::{ widgets::Paragraph, }; +#[derive(Debug, Clone, Copy)] +pub struct HeaderItem { + pub label: &'static str, + pub intent: ButtonIntent, + pub action: AppAction, +} + +pub const HEADER_ITEMS: &[HeaderItem] = &[ + HeaderItem { + label: "Overview", + intent: ButtonIntent::Primary, + action: AppAction::OpenOverview, + }, + HeaderItem { + label: "Users", + intent: ButtonIntent::Neutral, + action: AppAction::OpenUsers, + }, + HeaderItem { + label: "Settings", + intent: ButtonIntent::Neutral, + action: AppAction::OpenSettings, + }, + HeaderItem { + label: "Quit", + intent: ButtonIntent::Destructive, + action: AppAction::Quit, + }, +]; + fn connection_badge( state: &IpcConnectionState, theme: &ResolvedTheme, @@ -59,22 +89,14 @@ pub fn render_header( let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); - let cells = Layout::horizontal([ - Constraint::Min(28), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(8), - ]) - .split(rows[0]); - let cells2 = Layout::horizontal([ - Constraint::Min(28), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(8), - ]) - .split(rows[1]); + let constraints = std::iter::once(Constraint::Min(28)) + .chain( + HEADER_ITEMS + .iter() + .map(|item| Constraint::Length(if item.label == "Quit" { 8 } else { 12 })), + ) + .collect::>(); + let cells = Layout::horizontal(constraints.clone()).split(rows[0]); let (ipc_label, ipc_style) = connection_badge(connection, theme); let (omikron_text, omikron_style) = omikron_badge(daemon, theme); @@ -99,46 +121,15 @@ pub fn render_header( ); hits.register(brand_area, AppAction::OpenMain); - for (index, (top, _bottom, label, intent, action)) in [ - ( - cells[1], - cells2[1], - "Overview", - ButtonIntent::Primary, - AppAction::OpenOverview, - ), - ( - cells[2], - cells2[2], - "Users", - ButtonIntent::Neutral, - AppAction::OpenUsers, - ), - ( - cells[3], - cells2[3], - "Settings", - ButtonIntent::Neutral, - AppAction::OpenSettings, - ), - ( - cells[4], - cells2[4], - "Quit", - ButtonIntent::Destructive, - AppAction::Quit, - ), - ] - .into_iter() - .enumerate() - { + for (index, item) in HEADER_ITEMS.iter().enumerate() { + let top = cells[index + 1]; let button_area = Rect { x: top.x, y: top.y, width: top.width, height: area.height, }; - let style = match (intent, focused_action == Some(index)) { + let style = match (item.intent, focused_action == Some(index)) { (ButtonIntent::Primary, true) => theme.buttons.primary_focused, (ButtonIntent::Primary, false) => theme.buttons.primary, (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, @@ -148,9 +139,9 @@ pub fn render_header( (ButtonIntent::Destructive, _) => theme.buttons.destructive, }; let display_label = if focused_action == Some(index) { - format!("› {label}") + format!("› {}", item.label) } else { - label.to_owned() + item.label.to_owned() }; frame.render_widget( Paragraph::new(vec![ @@ -159,6 +150,6 @@ pub fn render_header( ]), button_area, ); - hits.register(button_area, action); + hits.register(button_area, item.action); } } diff --git a/iota-cli/src/controls/menu.rs b/iota-cli/src/controls/menu.rs new file mode 100644 index 0000000..2739040 --- /dev/null +++ b/iota-cli/src/controls/menu.rs @@ -0,0 +1,119 @@ +use crossterm::event::KeyCode; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MenuItem { + pub label: String, + pub description: Option, + pub value: T, + pub enabled: bool, + pub disabled_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MenuState { + items: Vec>, + selected: Option, +} + +impl MenuState { + pub fn new(items: Vec>) -> Self { + let selected = items.iter().position(|item| item.enabled); + Self { items, selected } + } + + pub fn items(&self) -> &[MenuItem] { + &self.items + } + pub fn selected_index(&self) -> Option { + self.selected + } + pub fn selected_item(&self) -> Option<&MenuItem> { + self.selected.and_then(|index| self.items.get(index)) + } + + pub fn move_next(&mut self) { + self.move_by(1); + } + pub fn move_previous(&mut self) { + self.move_by(-1); + } + pub fn first(&mut self) { + self.selected = self.items.iter().position(|item| item.enabled); + } + pub fn last(&mut self) { + self.selected = self.items.iter().rposition(|item| item.enabled); + } + + pub fn handle_key(&mut self, key: KeyCode) -> bool { + match key { + KeyCode::Up => self.move_previous(), + KeyCode::Down => self.move_next(), + KeyCode::Home => self.first(), + KeyCode::End => self.last(), + _ => return false, + } + true + } + + fn move_by(&mut self, delta: isize) { + let Some(current) = self.selected else { + return; + }; + for offset in 1..=self.items.len() { + let next = (current as isize + delta * offset as isize) + .rem_euclid(self.items.len() as isize) as usize; + if self.items[next].enabled { + self.selected = Some(next); + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_skips_disabled_items() { + let mut menu = MenuState::new(vec![ + MenuItem { + label: "A".into(), + description: None, + value: 1, + enabled: true, + disabled_reason: None, + }, + MenuItem { + label: "B".into(), + description: None, + value: 2, + enabled: false, + disabled_reason: Some("Unavailable".into()), + }, + MenuItem { + label: "C".into(), + description: None, + value: 3, + enabled: true, + disabled_reason: None, + }, + ]); + menu.move_next(); + assert_eq!(menu.selected_index(), Some(2)); + } + + #[test] + fn empty_enabled_set_is_safe() { + let mut menu = MenuState::new(vec![MenuItem { + label: "A".into(), + description: None, + value: (), + enabled: false, + disabled_reason: None, + }]); + menu.move_next(); + menu.last(); + assert_eq!(menu.selected_index(), None); + } +} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 02eaa84..6169155 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -4,7 +4,9 @@ pub mod checkbox_group; pub mod choice; pub mod dialog; pub mod header; +pub mod menu; pub mod navigation; pub mod panel; pub mod radio_group; pub mod scroll; +pub mod text_input; diff --git a/iota-cli/src/controls/text_input.rs b/iota-cli/src/controls/text_input.rs new file mode 100644 index 0000000..1ee8fb1 --- /dev/null +++ b/iota-cli/src/controls/text_input.rs @@ -0,0 +1,99 @@ +use crossterm::event::KeyCode; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TextInput { + value: String, + cursor: usize, + label: Option, + placeholder: Option, + validation: Option, + secret: bool, +} + +impl TextInput { + pub fn new(label: impl Into) -> Self { + Self { + label: Some(label.into()), + ..Self::default() + } + } + pub fn value(&self) -> &str { + &self.value + } + pub fn display_value(&self) -> String { + if self.secret { + "•".repeat(self.value.chars().count()) + } else { + self.value.clone() + } + } + pub fn set_placeholder(&mut self, value: impl Into) { + self.placeholder = Some(value.into()); + } + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } + pub fn validation(&self) -> Option<&str> { + self.validation.as_deref() + } + pub fn set_validation(&mut self, value: Option) { + self.validation = value; + } + pub fn set_secret(&mut self, secret: bool) { + self.secret = secret; + } + pub fn handle_key(&mut self, key: KeyCode) -> bool { + match key { + KeyCode::Backspace => { + if self.cursor > 0 { + let start = self.value[..self.cursor] + .char_indices() + .last() + .map(|(index, _)| index) + .unwrap_or(0); + self.value.drain(start..self.cursor); + self.cursor = start; + } + } + KeyCode::Delete => { + if self.cursor < self.value.len() { + let end = self.value[self.cursor..] + .char_indices() + .nth(1) + .map(|(index, _)| self.cursor + index) + .unwrap_or(self.value.len()); + self.value.drain(self.cursor..end); + } + } + KeyCode::Left => { + if self.cursor > 0 { + self.cursor = self.value[..self.cursor] + .char_indices() + .last() + .map(|(index, _)| index) + .unwrap_or(0); + } + } + KeyCode::Right => { + if self.cursor < self.value.len() { + self.cursor = self.value[self.cursor..] + .char_indices() + .nth(1) + .map(|(index, _)| self.cursor + index) + .unwrap_or(self.value.len()); + } + } + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.value.len(), + KeyCode::Char(character) => { + self.value.insert(self.cursor, character); + self.cursor += character.len_utf8(); + } + _ => return false, + } + true + } +} diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 45ca8fc..22b81e0 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -6,7 +6,7 @@ use iota_state::{ClientState, UiLogEntry}; use std::collections::HashMap; use std::io::Result; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::net::UnixStream; @@ -44,6 +44,7 @@ pub struct DaemonStatus { pub health: iota_ipc::HealthStatus, pub deployment_mode: Option, pub supervisor: Option, + pub capabilities: Vec, pub components: std::collections::BTreeMap, } @@ -71,6 +72,7 @@ pub struct IpcClient { writer: Mutex>, next_generation: AtomicU64, next_request_id: AtomicU64, + protocol_version: AtomicU16, pending: Mutex>, connection_state: watch::Sender, daemon_status: watch::Sender, @@ -103,6 +105,7 @@ impl IpcClient { health: negotiated.ack.health, deployment_mode: Some(negotiated.ack.deployment_mode), supervisor: Some(negotiated.ack.supervisor), + capabilities: negotiated.ack.capabilities.clone(), components: std::collections::BTreeMap::new(), }; let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected); @@ -115,6 +118,7 @@ impl IpcClient { })), next_generation: AtomicU64::new(2), next_request_id: AtomicU64::new(1), + protocol_version: AtomicU16::new(negotiated.ack.protocol_version), pending: Mutex::new(HashMap::new()), connection_state: conn_state_tx, daemon_status: daemon_status_tx, @@ -180,7 +184,7 @@ impl IpcClient { write_msg( &mut writer, &ClientMessage::Hello { - supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], + supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(), }, ), ) @@ -432,6 +436,8 @@ impl IpcClient { } fn update_hello_ack(&self, ack: HelloAck) { + self.protocol_version + .store(ack.protocol_version, Ordering::Release); self.daemon_status.send_modify(|status| { status.version = ack.daemon_version; status.instance_id = ack.instance_id; @@ -440,6 +446,7 @@ impl IpcClient { status.health = ack.health; status.deployment_mode = Some(ack.deployment_mode); status.supervisor = Some(ack.supervisor); + status.capabilities = ack.capabilities; }); } @@ -480,6 +487,23 @@ impl IpcClient { ResponsePayload::UserCreated { user_id, username } => { format!("Created user {} ({})", username, user_id) } + ResponsePayload::TuCredentialPreview(preview) => { + format!("Inspected {} ({})", preview.username, preview.user_id) + } + ResponsePayload::UserReconciled(result) => { + format!("Reconciled user {}: {:?}", result.user_id, result.action) + } + ResponsePayload::UserDiagnostics(diagnostics) => format!( + "User {}: state={:?}, data={}, credential={:?}, trusted_apps={}", + diagnostics.user_id, + diagnostics.local_state, + diagnostics.data_present, + diagnostics.credential_status, + diagnostics.trusted_app_count, + ), + ResponsePayload::UserCredentialExport { .. } => { + "Credential export payload withheld.".into() + } ResponsePayload::UserRemoved { user_id } => { format!("Removed user {}", user_id) } @@ -589,7 +613,7 @@ impl IpcClient { let envelope = RequestEnvelope { request_id, - protocol_version: PROTOCOL_VERSION, + protocol_version: self.protocol_version.load(Ordering::Acquire), request, }; if let Err(error) = self.send(ClientMessage::Request(envelope)).await { diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index 5299b3b..a5e658c 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -35,11 +35,9 @@ pub enum AppEvent { }, ThemeSaved(Result<(), String>), UsersLoaded(Result, String>), - UserCreated(Result), - UserRemoved { - user_id: i64, - result: Result<(), String>, - }, + TuInspected(Result), + UserOperationFinished(Result), + CredentialExportFinished(Result), RegenerateKeysRequested, KeysRegenerated(Result<(), String>), } @@ -52,14 +50,16 @@ pub enum AppAction { OpenMetrics, ToggleMetrics, AddUser, - RemoveUser, + OpenUserGroup(crate::screens::users::model::UserActionGroup), + SetUserAdminTab(crate::screens::users::model::UserAdminTab), + ActivateUserAction(crate::screens::users::model::UserAction), Back, Quit, FocusLogs, FocusConsole, FocusMetrics, OpenMain, - SelectUser(usize), + SelectUser(i64), ConfirmDialog, CancelDialog, RegenerateKeys, diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs deleted file mode 100644 index 1ff2689..0000000 --- a/iota-cli/src/screens/users.rs +++ /dev/null @@ -1,741 +0,0 @@ -use crate::{ - controls::{ - button::{ActionButton, ButtonIntent, render_button}, - choice::{ChoiceKind, render_choice_line}, - }, - interaction_result::InteractionResult, - ipc_client::IpcClient, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, -}; -use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicU8, AtomicUsize, Ordering}, - }, -}; - -#[derive(Clone, Debug)] -pub struct UserEntry { - pub user_id: i64, - pub username: String, - pub state: iota_ipc::LocalUserState, - pub data_present: bool, - pub credential_present: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - List, - AddButton, - RemoveButton, - Back, -} -#[derive(Clone, Debug)] -enum Dialog { - Add { username: String }, - Remove { user: UserEntry }, -} - -pub struct UsersScreen { - users: Vec, - focused_index: usize, - focus: Focus, - ipc: Arc, - message: Option, - dialog: Option, - pending_dialog: Option, - loading: bool, - pending: bool, - scroll_offset: usize, - viewport_height: AtomicUsize, - filter: String, - filtering: bool, - tick: AtomicU8, -} - -impl UsersScreen { - pub fn new(ipc: Arc, users: Vec) -> Self { - Self { - users, - focused_index: 0, - focus: Focus::List, - ipc, - message: None, - dialog: None, - pending_dialog: None, - loading: false, - pending: false, - scroll_offset: 0, - viewport_height: AtomicUsize::new(1), - filter: String::new(), - filtering: false, - tick: AtomicU8::new(0), - } - } - - pub fn loading(ipc: Arc) -> Self { - let mut screen = Self::new(ipc, Vec::new()); - screen.loading = true; - screen.message = Some("Loading users…".into()); - screen - } - - fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { - let visible_indices = self.filtered_indices(); - let title = if self.filter.is_empty() { - format!("Users ({})", self.users.len()) - } else { - format!( - "Users ({}/{}) filter: {}", - visible_indices.len(), - self.users.len(), - self.filter - ) - }; - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel( - f, - area, - &title, - self.focus == Focus::List, - context.theme, - ) - } else { - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .border_style(context.theme.borders.normal); - let inner = block.inner(area); - f.render_widget(block, area); - inner - }; - - if self.loading { - const SPINNERS: &[u8] = b"|/-\\"; - let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; - f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner); - return; - } - if visible_indices.is_empty() { - let par = Paragraph::new(if self.users.is_empty() { - "No users found." - } else { - "No users match the filter." - }); - f.render_widget(par, inner); - return; - } - - let mut lines = Vec::new(); - self.viewport_height - .store(inner.height as usize, Ordering::Relaxed); - let labels: Vec<(usize, String)> = visible_indices - .iter() - .skip(self.scroll_offset) - .take(inner.height as usize) - .map(|user_index| { - let user = &self.users[*user_index]; - ( - *user_index, - format!( - "{:>6} {} {}{}", - user.user_id, - user.username, - match user.state { - iota_ipc::LocalUserState::Managed => "managed", - iota_ipc::LocalUserState::Released => "released", - }, - if user.data_present { - "" - } else { - ", data purged" - } - ), - ) - }) - .collect(); - for (user_index, label) in &labels { - let visual = crate::controls::choice::ChoiceVisualState { - selected: false, - focused: self.focus == Focus::List && *user_index == self.focused_index, - enabled: !self.loading && !self.pending, - }; - lines.push(render_choice_line( - &label, - ChoiceKind::Radio, - visual, - context.theme, - )); - } - let par = Paragraph::new(lines); - f.render_widget(par, inner); - } - - fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { - let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area); - - if let Some(msg) = &self.message { - let par = Paragraph::new(Line::from(Span::styled( - msg.as_str(), - context.theme.text.muted, - ))); - f.render_widget(par, rows[0]); - } - - let buttons_area = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(rows[1]); - - render_button( - f, - buttons_area[0], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self.focus == Focus::Back, - enabled: true, - }, - context.theme, - ); - render_button( - f, - buttons_area[1], - ActionButton { - label: "Add", - intent: ButtonIntent::Primary, - focused: self.focus == Focus::AddButton, - enabled: true, - }, - context.theme, - ); - render_button( - f, - buttons_area[2], - ActionButton { - label: "Release", - intent: ButtonIntent::Destructive, - focused: self.focus == Focus::RemoveButton, - enabled: !self.loading && !self.pending && !self.users.is_empty(), - }, - context.theme, - ); - } - - fn activate(&mut self) -> InteractionResult { - if self.loading || self.pending { - return InteractionResult::Handled; - } - if let Some(dialog) = self.dialog.take() { - match dialog { - Dialog::Add { username } if !username.trim().is_empty() => { - let name = username.trim().to_owned(); - self.pending_dialog = Some(Dialog::Add { username }); - self.pending = true; - self.message = Some("Creating user…".into()); - let ipc = self.ipc.clone(); - return InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), - Err(error) => Err(format!("Cannot create user: {error}")), - }; - UiEvent::App(AppEvent::UserCreated(result)) - }), - }; - } - Dialog::Remove { user } => { - self.pending_dialog = Some(Dialog::Remove { user: user.clone() }); - let ipc = self.ipc.clone(); - let id = user.user_id; - self.pending = true; - self.message = Some(format!("Releasing {}…", user.username)); - return InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()), - Err(error) => Err(format!("Cannot release user: {error}")), - }; - UiEvent::App(AppEvent::UserRemoved { - user_id: id, - result, - }) - }), - }; - } - Dialog::Add { .. } => self.message = Some("A username is required.".into()), - } - return InteractionResult::Handled; - } - match self.focus { - Focus::Back => InteractionResult::CloseScreen, - Focus::AddButton => { - self.dialog = Some(Dialog::Add { - username: String::new(), - }); - InteractionResult::Handled - } - Focus::RemoveButton => { - if let Some(user) = self.users.get(self.focused_index) { - self.dialog = Some(Dialog::Remove { user: user.clone() }); - } - InteractionResult::Handled - } - Focus::List => InteractionResult::Handled, - } - } - - fn next_focus(&mut self) { - self.focus = match self.focus { - Focus::List => Focus::AddButton, - Focus::AddButton => Focus::RemoveButton, - Focus::RemoveButton => Focus::Back, - Focus::Back => Focus::List, - }; - } - - fn prev_focus(&mut self) { - self.focus = match self.focus { - Focus::List => Focus::Back, - Focus::Back => Focus::RemoveButton, - Focus::RemoveButton => Focus::AddButton, - Focus::AddButton => Focus::List, - }; - } - - fn keep_focused_user_visible(&mut self) { - let indices = self.filtered_indices(); - let Some(position) = indices - .iter() - .position(|index| *index == self.focused_index) - else { - self.scroll_offset = 0; - return; - }; - let height = self.viewport_height.load(Ordering::Relaxed).max(1); - if position < self.scroll_offset { - self.scroll_offset = position; - } else if position >= self.scroll_offset + height { - self.scroll_offset = position + 1 - height; - } - } - - fn move_user_focus(&mut self, index: usize) { - if !self.users.is_empty() { - self.focused_index = index.min(self.users.len() - 1); - self.keep_focused_user_visible(); - } - } - - fn filtered_indices(&self) -> Vec { - let needle = self.filter.to_ascii_lowercase(); - self.users - .iter() - .enumerate() - .filter(|(_, user)| { - needle.is_empty() - || user.username.to_ascii_lowercase().contains(&needle) - || user.user_id.to_string().contains(&needle) - }) - .map(|(index, _)| index) - .collect() - } - - fn move_visible(&mut self, delta: isize) { - let indices = self.filtered_indices(); - if indices.is_empty() { - return; - } - let current = indices - .iter() - .position(|index| *index == self.focused_index) - .unwrap_or(0); - let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize; - self.move_user_focus(indices[next]); - } - - fn reset_focus_to_filter(&mut self) { - self.scroll_offset = 0; - if let Some(index) = self.filtered_indices().first().copied() { - self.focused_index = index; - } - } -} - -impl Screen for UsersScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { - let outer_block = Block::default() - .title(" Users ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, rect, "Users", false, context.theme) - } else { - let inner = outer_block.inner(rect); - f.render_widget(outer_block, rect); - inner - }; - - let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner); - - self.render_user_list(f, chunks[0], context); - self.render_actions(f, chunks[1], context); - if let Some(dialog) = &self.dialog { - f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect); - let popup = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 42, - height: 7, - }, - ); - let text = match dialog { - Dialog::Add { username } => { - format!("Add user\nUsername: {username}") - } - Dialog::Remove { user } => format!( - "Remove user {} (ID {})?\nThis removes the local user record.", - user.username, user.user_id - ), - }; - let block = Block::default() - .title(" Confirm ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - let popup_inner = block.inner(popup); - f.render_widget(block, popup); - let dialog_rows = - Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); - f.render_widget( - Paragraph::new(text).style(context.theme.text.normal), - dialog_rows[0], - ); - let dialog_buttons = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(dialog_rows[1]); - render_button( - f, - dialog_buttons[0], - ActionButton { - label: "Cancel", - intent: ButtonIntent::Cancel, - focused: false, - enabled: true, - }, - context.theme, - ); - render_button( - f, - dialog_buttons[1], - ActionButton { - label: match dialog { - Dialog::Add { .. } => "Create", - Dialog::Remove { .. } => "Remove", - }, - intent: match dialog { - Dialog::Add { .. } => ButtonIntent::Primary, - Dialog::Remove { .. } => ButtonIntent::Destructive, - }, - focused: true, - enabled: true, - }, - context.theme, - ); - hits.register(dialog_buttons[0], AppAction::CancelDialog); - hits.register(dialog_buttons[1], AppAction::ConfirmDialog); - } - let buttons = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(chunks[1]); - if self.dialog.is_none() { - hits.register(buttons[0], AppAction::Back); - } - if self.dialog.is_none() && !self.loading && !self.pending { - hits.register(buttons[1], AppAction::AddUser); - } - if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() { - hits.register(buttons[2], AppAction::RemoveUser); - } - if self.dialog.is_none() { - let list_height = chunks[0].height.saturating_sub(2) as usize; - let filtered_indices = self.filtered_indices(); - for visible in 0..list_height { - let position = self.scroll_offset + visible; - let Some(index) = filtered_indices.get(position).copied() else { - break; - }; - hits.register( - Rect { - x: chunks[0].x.saturating_add(1), - y: chunks[0].y.saturating_add(1 + visible as u16), - width: chunks[0].width.saturating_sub(2), - height: 1, - }, - AppAction::SelectUser(index), - ); - } - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let event = match event { - UiEvent::App(AppEvent::UsersLoaded(result)) => { - self.loading = false; - match result { - Ok(users) => { - self.users = users; - self.message = None; - } - Err(error) => self.message = Some(error), - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::UserCreated(result)) => { - self.pending = false; - match result { - Ok(user) => { - self.pending_dialog = None; - self.focused_index = self.users.len(); - self.users.push(user.clone()); - self.message = Some(format!( - "Created user {} ({}).", - user.username, user.user_id - )); - } - Err(error) => { - self.dialog = self.pending_dialog.take(); - self.message = Some(error); - } - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::UserRemoved { user_id, result }) => { - self.pending = false; - match result { - Ok(()) => { - self.pending_dialog = None; - if let Some(user) = - self.users.iter_mut().find(|user| user.user_id == user_id) - { - user.state = iota_ipc::LocalUserState::Released; - user.credential_present = false; - } - self.message = - Some(format!("Released user {user_id}; hosted data retained.")); - } - Err(error) => { - self.dialog = self.pending_dialog.take(); - self.message = Some(error); - } - } - return InteractionResult::Handled; - } - UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => { - if let Some(Dialog::Add { username }) = self.dialog.as_mut() { - username.push_str(&text.replace(['\r', '\n'], " ")); - } - return InteractionResult::Handled; - } - UiEvent::Key(event) => event, - _ => return InteractionResult::Unhandled, - }; - if self.filtering && self.dialog.is_none() { - match event.code { - KeyCode::Esc => { - self.filtering = false; - self.filter.clear(); - self.reset_focus_to_filter(); - } - KeyCode::Enter => self.filtering = false, - KeyCode::Backspace => { - self.filter.pop(); - self.reset_focus_to_filter(); - } - KeyCode::Char(c) - if !event - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.filter.push(c); - self.reset_focus_to_filter(); - } - _ => {} - } - return InteractionResult::Handled; - } - if let Some(Dialog::Add { username }) = self.dialog.as_mut() { - match event.code { - KeyCode::Esc => { - self.dialog = None; - return InteractionResult::Handled; - } - KeyCode::Enter => return self.activate(), - KeyCode::Backspace => { - username.pop(); - return InteractionResult::Handled; - } - KeyCode::Char(c) - if !c.is_control() - && !event - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - username.push(c); - return InteractionResult::Handled; - } - _ => return InteractionResult::Handled, - } - } - if self.dialog.is_some() { - return match event.code { - KeyCode::Esc => { - self.dialog = None; - InteractionResult::Handled - } - KeyCode::Enter => self.activate(), - _ => InteractionResult::Handled, - }; - } - match event.code { - KeyCode::Esc => InteractionResult::CloseScreen, - KeyCode::Char('/') if self.focus == Focus::List => { - self.filtering = true; - self.filter.clear(); - self.reset_focus_to_filter(); - InteractionResult::Handled - } - KeyCode::Tab => { - self.next_focus(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_focus(); - InteractionResult::Handled - } - KeyCode::Down | KeyCode::Char('j') => { - if self.focus == Focus::List { - self.move_visible(1); - } - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - if self.focus == Focus::List { - self.move_visible(-1); - } - InteractionResult::Handled - } - KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.move_visible(page as isize); - InteractionResult::Handled - } - KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.move_visible(-(page as isize)); - InteractionResult::Handled - } - KeyCode::Home if self.focus == Focus::List => { - if let Some(index) = self.filtered_indices().first().copied() { - self.move_user_focus(index); - } - InteractionResult::Handled - } - KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => { - if let Some(index) = self.filtered_indices().last().copied() { - self.move_user_focus(index); - } - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::Back => InteractionResult::CloseScreen, - AppAction::AddUser => { - self.focus = Focus::AddButton; - self.activate() - } - AppAction::RemoveUser => { - self.focus = Focus::RemoveButton; - self.activate() - } - AppAction::SelectUser(index) if self.dialog.is_none() => { - self.focus = Focus::List; - self.move_user_focus(index); - InteractionResult::Handled - } - AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), - AppAction::CancelDialog if self.dialog.is_some() => { - self.dialog = None; - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn key_hints(&self) -> Vec { - if self.dialog.is_some() { - vec![ - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } else { - vec![ - KeyHint { - keys: "Up/Down", - action: "Select user", - }, - KeyHint { - keys: "PgUp/PgDn", - action: "Page", - }, - KeyHint { - keys: "/", - action: "Filter", - }, - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } - } -} diff --git a/iota-cli/src/screens/users/action_menu.rs b/iota-cli/src/screens/users/action_menu.rs new file mode 100644 index 0000000..3baf2d0 --- /dev/null +++ b/iota-cli/src/screens/users/action_menu.rs @@ -0,0 +1,66 @@ +use super::model::{UserAction, UserActionGroup}; +use crate::controls::menu::{MenuItem, MenuState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserActionMenu { + pub group: UserActionGroup, + pub menu: MenuState, +} +impl UserActionMenu { + pub fn new( + group: UserActionGroup, + actions: Vec, + credential_export_supported: bool, + ) -> Self { + Self { + group, + menu: MenuState::new( + actions + .into_iter() + .map(|action| { + let enabled = action != UserAction::ExportTu || credential_export_supported; + MenuItem { + label: action.label().into(), + description: None, + value: action, + enabled, + disabled_reason: (!enabled) + .then(|| "Not supported by connected daemon".into()), + } + }) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_is_disabled_without_daemon_capability() { + let menu = UserActionMenu::new( + UserActionGroup::Credential, + vec![UserAction::ExportTu], + false, + ); + + assert!(menu.menu.selected_item().is_none()); + assert!(!menu.menu.items()[0].enabled); + } + + #[test] + fn export_is_enabled_with_daemon_capability() { + let menu = UserActionMenu::new( + UserActionGroup::Credential, + vec![UserAction::ExportTu], + true, + ); + + assert_eq!( + menu.menu.selected_item().map(|item| item.value), + Some(UserAction::ExportTu) + ); + } +} diff --git a/iota-cli/src/screens/users/add_flow.rs b/iota-cli/src/screens/users/add_flow.rs new file mode 100644 index 0000000..27d1f1f --- /dev/null +++ b/iota-cli/src/screens/users/add_flow.rs @@ -0,0 +1,126 @@ +use crate::controls::{ + menu::{MenuItem, MenuState}, + text_input::TextInput, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddUserMethod { + Invitation, + CreateLocal, + ImportTu, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddUserPhase { + ChooseMethod, + ConfigureCreate, + ConfigureImport, + ConfigureInvitation, + InspectingTu, + ReviewImport, + Pending, + Result, +} + +#[derive(Debug, Clone)] +pub struct AddUserFlow { + pub phase: AddUserPhase, + pub methods: MenuState, + pub username: TextInput, + pub import_path: TextInput, + pub credential: Option, + pub preview: Option, + pub error: Option, +} + +impl AddUserFlow { + pub fn new(invitation_supported: bool, tu_inspection_supported: bool) -> Self { + Self { + phase: AddUserPhase::ChooseMethod, + methods: MenuState::new(vec![ + MenuItem { + label: "Share an invitation".into(), + description: Some("Not supported by the connected daemon".into()), + value: AddUserMethod::Invitation, + enabled: invitation_supported, + disabled_reason: Some("Not supported by the connected daemon".into()), + }, + MenuItem { + label: "Create user on this Iota".into(), + description: None, + value: AddUserMethod::CreateLocal, + enabled: true, + disabled_reason: None, + }, + MenuItem { + label: "Import existing user from TU".into(), + description: None, + value: AddUserMethod::ImportTu, + enabled: tu_inspection_supported, + disabled_reason: (!tu_inspection_supported) + .then(|| "TU inspection is not supported by the connected daemon".into()), + }, + ]), + username: TextInput::new("Username"), + import_path: TextInput::new("TU path"), + credential: None, + preview: None, + error: None, + } + } + pub fn choose(&mut self) { + self.phase = match self.methods.selected_item().map(|item| item.value) { + Some(AddUserMethod::CreateLocal) => AddUserPhase::ConfigureCreate, + Some(AddUserMethod::ImportTu) => AddUserPhase::ConfigureImport, + Some(AddUserMethod::Invitation) => AddUserPhase::ConfigureInvitation, + None => AddUserPhase::ChooseMethod, + }; + } + pub fn inspection_succeeded(&mut self, preview: iota_ipc::TuCredentialPreview) { + self.preview = Some(preview); + self.error = None; + self.phase = AddUserPhase::ReviewImport; + } + pub fn inspection_failed(&mut self, error: String) { + self.preview = None; + self.error = Some(error); + self.phase = AddUserPhase::ConfigureImport; + } + pub fn attachment_started(&mut self) { + self.phase = AddUserPhase::Pending; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn invitation_is_disabled_without_capability() { + let flow = AddUserFlow::new(false, false); + assert!(!flow.methods.items()[0].enabled); + } + + #[test] + fn create_and_import_have_distinct_configuration_phases() { + let mut flow = AddUserFlow::new(false, true); + flow.choose(); + assert_eq!(flow.phase, AddUserPhase::ConfigureCreate); + flow.phase = AddUserPhase::ChooseMethod; + flow.methods.move_next(); + flow.choose(); + assert_eq!(flow.phase, AddUserPhase::ConfigureImport); + } + + #[test] + fn inspection_result_controls_import_review() { + let mut flow = AddUserFlow::new(false, true); + flow.inspection_succeeded(iota_ipc::TuCredentialPreview { + user_id: 42, + username: "alice".into(), + assigned_iota_id: None, + }); + assert_eq!(flow.phase, AddUserPhase::ReviewImport); + flow.inspection_failed("invalid credential".into()); + assert_eq!(flow.phase, AddUserPhase::ConfigureImport); + assert_eq!(flow.error.as_deref(), Some("invalid credential")); + } +} diff --git a/iota-cli/src/screens/users/confirmations.rs b/iota-cli/src/screens/users/confirmations.rs new file mode 100644 index 0000000..409a905 --- /dev/null +++ b/iota-cli/src/screens/users/confirmations.rs @@ -0,0 +1,55 @@ +use super::{UserEntry, model::UserAction}; +#[derive(Debug, Clone)] +pub struct UserConfirmation { + pub user: UserEntry, + pub action: UserAction, +} +impl UserConfirmation { + pub fn title(&self) -> &'static str { + match self.action { + UserAction::Release => "Release user", + UserAction::PurgeData => "Purge hosted data", + UserAction::ExportTu => "Export TU", + UserAction::DeleteAccount => "Delete Tensamin account", + UserAction::Reconcile => "Reconcile with Omega", + UserAction::Diagnostics => "User diagnostics", + UserAction::ForceDetach => "Force local detach", + UserAction::ForgetResidency => "Forget residency record", + } + } + pub fn message(&self) -> String { + match self.action { + UserAction::Release => format!( + "Stop managing {} on this Iota?\nHosted data is retained. Local credentials are removed.", + self.user.username + ), + UserAction::PurgeData => format!( + "Delete hosted data for {}?\nThe account and Iota assignment are retained.", + self.user.username + ), + UserAction::ExportTu => format!("Export the local TU for {}?", self.user.username), + UserAction::DeleteAccount => format!( + "Delete the Tensamin account {}?\nHosted data erasure is requested.", + self.user.username + ), + UserAction::Reconcile => { + format!("Compare {} with its Omega assignment?", self.user.username) + } + UserAction::Diagnostics => { + format!("Load local diagnostics for {}?", self.user.username) + } + UserAction::ForceDetach => format!( + "Force local detach for {}?\nOmega assignment will not be changed. Hosted data is retained.", + self.user.username + ), + UserAction::ForgetResidency => format!( + "Forget the released residency record for {}?\nThis is allowed only when hosted data is empty.", + self.user.username + ), + } + } + + pub fn confirm_label(&self) -> &'static str { + self.action.confirmation_label() + } +} diff --git a/iota-cli/src/screens/users/context.rs b/iota-cli/src/screens/users/context.rs new file mode 100644 index 0000000..1dd2934 --- /dev/null +++ b/iota-cli/src/screens/users/context.rs @@ -0,0 +1,29 @@ +use super::{UserEntry, model::UserActionGroup}; +pub fn state_label(user: &UserEntry) -> &'static str { + match user.state { + iota_ipc::LocalUserState::Managed => "Managed", + iota_ipc::LocalUserState::Released => "Released", + } +} +pub fn group_label(group: UserActionGroup) -> &'static str { + group.label() +} + +pub fn credential_status_label(status: iota_ipc::CredentialStatus) -> &'static str { + match status { + iota_ipc::CredentialStatus::LocalPresent => "Local", + iota_ipc::CredentialStatus::External => "External", + iota_ipc::CredentialStatus::Missing => "Missing", + iota_ipc::CredentialStatus::None => "None", + } +} + +pub fn pending_operation_label(operation: &iota_ipc::UserOperationSummary) -> String { + let kind = match operation.operation { + iota_ipc::UserOperationKind::Create => "Create", + iota_ipc::UserOperationKind::Attach => "Attach", + iota_ipc::UserOperationKind::Release => "Release", + iota_ipc::UserOperationKind::Purge => "Purge", + }; + format!("{kind} ({})", operation.phase) +} diff --git a/iota-cli/src/screens/users/credential_export.rs b/iota-cli/src/screens/users/credential_export.rs new file mode 100644 index 0000000..390dbf4 --- /dev/null +++ b/iota-cli/src/screens/users/credential_export.rs @@ -0,0 +1,85 @@ +use super::UserEntry; +use crate::controls::text_input::TextInput; +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::Path, +}; + +#[derive(Clone, Debug)] +pub struct CredentialExportState { + pub user: UserEntry, + pub destination: TextInput, + pub error: Option, +} + +impl CredentialExportState { + pub fn new(user: UserEntry) -> Self { + Self { + user, + destination: TextInput::new("Destination"), + error: None, + } + } +} + +/* Export credentials with owner-only permissions and refuse to replace an + * existing destination so a typo cannot destroy another file. */ +pub fn write_private_export(path: &Path, contents: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "export path has no parent"))?; + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + let result = (|| { + file.write_all(contents)?; + file.sync_all() + })(); + if result.is_err() { + drop(file); + let _ = fs::remove_file(path); + } + result +} + +#[cfg(test)] +mod tests { + use super::write_private_export; + + #[test] + fn export_refuses_to_replace_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.tu"); + write_private_export(&path, b"first").unwrap(); + + assert_eq!( + write_private_export(&path, b"second").unwrap_err().kind(), + std::io::ErrorKind::AlreadyExists + ); + assert_eq!(std::fs::read(path).unwrap(), b"first"); + } + + #[cfg(unix)] + #[test] + fn export_uses_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.tu"); + write_private_export(&path, b"secret").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} diff --git a/iota-cli/src/screens/users/invitations.rs b/iota-cli/src/screens/users/invitations.rs new file mode 100644 index 0000000..9f8e29d --- /dev/null +++ b/iota-cli/src/screens/users/invitations.rs @@ -0,0 +1,38 @@ +/* Invitation state remains capability-gated until the daemon implements its protocol. */ +pub const UNSUPPORTED_MESSAGE: &str = + "Invitation management is not supported by the connected daemon."; + +pub fn empty_message(supported: bool) -> &'static str { + if supported { + "No invitations.\n\nInvitation protocol support is not implemented in this client build." + } else { + "No invitations.\n\nInvitation management is not supported by the connected daemon." + } +} + +pub fn render( + frame: &mut ratatui::Frame, + area: ratatui::layout::Rect, + context: &crate::render_context::RenderContext<'_>, + supported: bool, +) { + frame.render_widget( + ratatui::widgets::Paragraph::new(empty_message(supported)).style(context.theme.text.muted), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::{UNSUPPORTED_MESSAGE, empty_message}; + + #[test] + fn absent_capability_is_explained_in_the_empty_state() { + assert!(empty_message(false).contains(UNSUPPORTED_MESSAGE)); + } + + #[test] + fn supported_daemon_does_not_show_the_capability_error() { + assert!(!empty_message(true).contains(UNSUPPORTED_MESSAGE)); + } +} diff --git a/iota-cli/src/screens/users/list.rs b/iota-cli/src/screens/users/list.rs new file mode 100644 index 0000000..722f246 --- /dev/null +++ b/iota-cli/src/screens/users/list.rs @@ -0,0 +1,7 @@ +use super::UserEntry; +pub fn matches_filter(user: &UserEntry, filter: &str) -> bool { + let needle = filter.to_ascii_lowercase(); + needle.is_empty() + || user.username.to_ascii_lowercase().contains(&needle) + || user.user_id.to_string().contains(&needle) +} diff --git a/iota-cli/src/screens/users/mod.rs b/iota-cli/src/screens/users/mod.rs new file mode 100644 index 0000000..e8e29f9 --- /dev/null +++ b/iota-cli/src/screens/users/mod.rs @@ -0,0 +1,1281 @@ +pub mod action_menu; +pub mod add_flow; +pub mod confirmations; +pub mod context; +pub mod credential_export; +pub mod invitations; +pub mod list; +pub mod model; + +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + ipc_client::IpcClient, + render_context::RenderContext, + screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, +}; +use action_menu::UserActionMenu; +use add_flow::{AddUserFlow, AddUserPhase}; +use confirmations::UserConfirmation; +use credential_export::{CredentialExportState, write_private_export}; +use crossterm::event::{KeyCode, KeyModifiers}; +use model::{FocusZone, UserAction, UserAdminTab, available_action_groups}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use std::{ + any::Any, + sync::{ + Arc, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }, +}; + +#[derive(Clone, Debug)] +pub struct UserEntry { + pub user_id: i64, + pub username: String, + pub state: iota_ipc::LocalUserState, + pub data_present: bool, + pub credential_status: iota_ipc::CredentialStatus, + pub pending_operation: Option, +} + +#[derive(Clone, Debug)] +enum UsersOverlay { + Add(AddUserFlow), + UserMenu(UserActionMenu), + Confirm(UserConfirmation), + CredentialExport(CredentialExportState), +} + +pub struct UsersScreen { + ipc: Arc, + users: Vec, + selected_user_id: Option, + active_tab: UserAdminTab, + focus_zone: FocusZone, + toolbar_index: usize, + action_group_index: usize, + filter: String, + filtering: bool, + scroll_offset: usize, + viewport_height: AtomicUsize, + overlay: Option, + loading: bool, + pending: bool, + message: Option, + tick: AtomicU8, +} + +impl UsersScreen { + pub fn new(ipc: Arc, users: Vec) -> Self { + let selected_user_id = users.first().map(|user| user.user_id); + Self { + ipc, + users, + selected_user_id, + active_tab: UserAdminTab::Users, + focus_zone: FocusZone::UserList, + toolbar_index: 0, + action_group_index: 0, + filter: String::new(), + filtering: false, + scroll_offset: 0, + viewport_height: AtomicUsize::new(1), + overlay: None, + loading: false, + pending: false, + message: None, + tick: AtomicU8::new(0), + } + } + pub fn loading(ipc: Arc) -> Self { + let mut screen = Self::new(ipc, Vec::new()); + screen.loading = true; + screen.message = Some("Loading users…".into()); + screen + } + fn selected_user(&self) -> Option<&UserEntry> { + let id = self.selected_user_id?; + self.users.iter().find(|user| user.user_id == id) + } + fn visible_users(&self) -> Vec<&UserEntry> { + self.users + .iter() + .filter(|user| list::matches_filter(user, &self.filter)) + .collect() + } + fn restore_selection(&mut self) { + if self.selected_user().is_none() { + self.selected_user_id = self.visible_users().first().map(|user| user.user_id); + } + self.keep_selected_visible(); + } + fn keep_selected_visible(&mut self) { + let Some(id) = self.selected_user_id else { + self.scroll_offset = 0; + return; + }; + let visible = self.visible_users(); + let Some(position) = visible.iter().position(|user| user.user_id == id) else { + self.scroll_offset = 0; + return; + }; + let height = self.viewport_height.load(Ordering::Relaxed).max(1); + if position < self.scroll_offset { + self.scroll_offset = position; + } else if position >= self.scroll_offset + height { + self.scroll_offset = position + 1 - height; + } + } + fn move_selection(&mut self, delta: isize) { + let visible = self.visible_users(); + if visible.is_empty() { + return; + } + let current = self + .selected_user_id + .and_then(|id| visible.iter().position(|user| user.user_id == id)) + .unwrap_or(0); + let next = (current as isize + delta).clamp(0, visible.len() as isize - 1) as usize; + self.selected_user_id = Some(visible[next].user_id); + self.keep_selected_visible(); + } + fn groups(&self) -> Vec { + self.selected_user() + .map(available_action_groups) + .unwrap_or_default() + } + fn begin_action(&mut self, action: UserAction) -> InteractionResult { + if action == UserAction::ExportTu && !self.credential_export_supported() { + self.message = + Some("Credential export is not supported by the connected daemon.".into()); + return InteractionResult::Handled; + } + if let Some(user) = self.selected_user().cloned() { + if action == UserAction::ExportTu { + self.overlay = Some(UsersOverlay::CredentialExport(CredentialExportState::new( + user, + ))); + return InteractionResult::Handled; + } + if action.requires_confirmation() { + self.overlay = Some(UsersOverlay::Confirm(UserConfirmation { user, action })); + } else { + return self.start_operation(action, user.user_id); + } + } + InteractionResult::Handled + } + fn credential_export_supported(&self) -> bool { + self.ipc + .daemon_status() + .borrow() + .capabilities + .iter() + .any(|capability| capability == "credential_export_v1") + } + fn invitation_supported(&self) -> bool { + self.ipc + .daemon_status() + .borrow() + .capabilities + .iter() + .any(|capability| capability == "user_invitations_v1") + } + fn new_add_flow(&self) -> AddUserFlow { + let status = self.ipc.daemon_status(); + let capabilities = status.borrow().capabilities.clone(); + AddUserFlow::new( + capabilities + .iter() + .any(|value| value == "user_invitations_v1"), + capabilities.iter().any(|value| value == "tu_inspection_v1"), + ) + } + fn start_tu_inspection(&mut self, credential: iota_ipc::SecretString) -> InteractionResult { + let ipc = self.ipc.clone(); + if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() { + flow.phase = AddUserPhase::InspectingTu; + flow.credential = Some(credential.clone()); + flow.error = None; + } + InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc + .send_request(iota_ipc::LocalRequest::InspectTuCredential { credential }) + .await + { + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::TuCredentialPreview(preview), + )) => Ok(preview), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Credential inspection failed: {error}")) + } + Ok(_) => Err( + "Credential inspection failed: daemon returned an unexpected response." + .into(), + ), + Err(error) => Err(format!("Credential inspection failed: {error}")), + }; + UiEvent::App(AppEvent::TuInspected(result)) + }), + } + } + fn start_tu_attachment(&mut self, credential: iota_ipc::SecretString) -> InteractionResult { + self.pending = true; + self.overlay = None; + self.message = Some("Attaching existing user…".into()); + let ipc = self.ipc.clone(); + InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc + .send_request(iota_ipc::LocalRequest::AttachUserFromTu { credential }) + .await + { + Ok(iota_ipc::ResponseResult::Ok(_)) => Ok("Attached existing user.".into()), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Attach existing user failed: {error}")) + } + Err(error) => Err(format!("Attach existing user failed: {error}")), + }; + UiEvent::App(AppEvent::UserOperationFinished(result)) + }), + } + } + fn start_operation(&mut self, action: UserAction, user_id: i64) -> InteractionResult { + self.pending = true; + self.overlay = None; + self.message = Some(format!("{}…", action.label())); + let ipc = self.ipc.clone(); + InteractionResult::AppTask { + task: Box::pin(async move { + let response = match action { + UserAction::Release => { + ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id }) + .await + } + UserAction::PurgeData => { + ipc.send_request(iota_ipc::LocalRequest::PurgeUserData { user_id }) + .await + } + UserAction::ExportTu => { + return UiEvent::App(AppEvent::UserOperationFinished(Err( + "Credential export requires a destination path.".into(), + ))); + } + UserAction::DeleteAccount => { + ipc.send_request(iota_ipc::LocalRequest::CompleteDeleteUser { + user_id, + credential: None, + }) + .await + } + UserAction::Reconcile => { + ipc.send_request(iota_ipc::LocalRequest::ReconcileUser { user_id }) + .await + } + UserAction::Diagnostics => { + ipc.send_request(iota_ipc::LocalRequest::GetUserDiagnostics { user_id }) + .await + } + UserAction::ForceDetach => { + ipc.send_request(iota_ipc::LocalRequest::ForceDetachUser { user_id }) + .await + } + UserAction::ForgetResidency => { + ipc.send_request(iota_ipc::LocalRequest::ForgetReleasedUser { user_id }) + .await + } + }; + let result = match response { + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::UserDiagnostics(diagnostics), + )) => Ok(format!( + "Diagnostics: state={:?}, data={}, credential={}, trusted apps={}, pending={}", + diagnostics.local_state, + if diagnostics.data_present { + "present" + } else { + "empty" + }, + context::credential_status_label(diagnostics.credential_status), + diagnostics.trusted_app_count, + diagnostics + .pending_operation + .unwrap_or_else(|| "none".into()), + )), + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::UserReconciled(result), + )) => Ok(format!("Reconciliation result: {:?}.", result.action)), + Ok(iota_ipc::ResponseResult::Ok(_)) => { + Ok(format!("{} completed.", action.label())) + } + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("{} failed: {error}", action.label())) + } + Err(error) => Err(format!("{} failed: {error}", action.label())), + }; + UiEvent::App(AppEvent::UserOperationFinished(result)) + }), + } + } + fn start_credential_export( + &mut self, + user_id: i64, + destination: std::path::PathBuf, + ) -> InteractionResult { + self.pending = true; + self.overlay = None; + self.message = Some("Exporting credential…".into()); + let ipc = self.ipc.clone(); + InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc + .send_request(iota_ipc::LocalRequest::ExportUserCredential { user_id }) + .await + { + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::UserCredentialExport { + user_id: response_user_id, + username, + credential, + }, + )) if response_user_id == user_id => { + write_private_export(&destination, credential.0.as_bytes()) + .map(|()| { + format!( + "Exported credential for {username} to {}.", + destination.display() + ) + }) + .map_err(|error| { + format!( + "Credential export failed for {}: {error}", + destination.display() + ) + }) + } + Ok(iota_ipc::ResponseResult::Ok(_)) => Err( + "Credential export failed: daemon returned an unexpected response.".into(), + ), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Credential export failed: {error}")) + } + Err(error) => Err(format!("Credential export failed: {error}")), + }; + UiEvent::App(AppEvent::CredentialExportFinished(result)) + }), + } + } + fn start_create(&mut self, username: String) -> InteractionResult { + self.pending = true; + self.message = Some("Creating user…".into()); + self.overlay = None; + let ipc = self.ipc.clone(); + InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc + .send_request(iota_ipc::LocalRequest::CreateUser { username }) + .await + { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { + username, + user_id, + })) => Ok(format!("Created user {username} ({user_id}).")), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Create user failed: {error}")) + } + Ok(_) => { + Err("Create user failed: daemon returned an unexpected response.".into()) + } + Err(error) => Err(format!("Create user failed: {error}")), + }; + UiEvent::App(AppEvent::UserOperationFinished(result)) + }), + } + } + fn refresh(&self) -> InteractionResult { + let ipc = self.ipc.clone(); + InteractionResult::AppTask { + task: Box::pin(async move { + let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { + Ok(users + .into_iter() + .map(|user| UserEntry { + user_id: user.user_id, + username: user.username, + state: user.state, + data_present: user.data_present, + credential_status: user.credential_status, + pending_operation: user.pending_operation, + }) + .collect()) + } + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot reload users: {error}")) + } + Ok(_) => { + Err("Cannot reload users: daemon returned an unexpected response.".into()) + } + Err(error) => Err(format!("Cannot reload users: {error}")), + }; + UiEvent::App(AppEvent::UsersLoaded(result)) + }), + } + } + fn render_list( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { + let block = Block::default() + .title(" Users ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = block.inner(area); + frame.render_widget(block, area); + self.viewport_height + .store(inner.height as usize, Ordering::Relaxed); + if self.loading { + let spinner = b"|/-\\"[self.tick.fetch_add(1, Ordering::Relaxed) as usize % 4] as char; + frame.render_widget(Paragraph::new(format!("{spinner} Loading users…")), inner); + return; + } + let visible = self.visible_users(); + if visible.is_empty() { + let text = if self.users.is_empty() { + "No users are managed or retained on this Iota." + } else { + "No users match the current filter." + }; + frame.render_widget(Paragraph::new(text), inner); + return; + } + let mut lines = Vec::new(); + for (row, user) in visible + .iter() + .skip(self.scroll_offset) + .take(inner.height as usize) + .enumerate() + { + let label = format!( + "{:<18} {:<9} {:<8} {}", + user.username, + context::state_label(user), + if user.data_present { + "Present" + } else { + "Empty" + }, + context::credential_status_label(user.credential_status) + ); + let prefix = if self.focus_zone == FocusZone::UserList + && self.selected_user_id == Some(user.user_id) + { + "> " + } else { + " " + }; + lines.push(Line::from(format!("{prefix}{label}"))); + hits.register( + Rect { + x: inner.x, + y: inner.y + row as u16, + width: inner.width, + height: 1, + }, + AppAction::SelectUser(user.user_id), + ); + } + frame.render_widget(Paragraph::new(lines), inner); + } + fn render_context( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { + let block = Block::default() + .title(" Selected user ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = block.inner(area); + frame.render_widget(block, area); + let Some(user) = self.selected_user() else { + frame.render_widget( + Paragraph::new("Select a user to inspect local lifecycle state."), + inner, + ); + return; + }; + let groups = self.groups(); + let details = vec![ + Line::from(Span::styled( + user.username.as_str(), + context.theme.text.normal, + )), + Line::from(format!("State {}", context::state_label(user))), + Line::from(format!( + "Hosted data {}", + if user.data_present { + "Present" + } else { + "Empty" + } + )), + Line::from(format!( + "Credential {}", + context::credential_status_label(user.credential_status) + )), + Line::from(format!( + "Pending {}", + user.pending_operation + .as_ref() + .map(context::pending_operation_label) + .unwrap_or_else(|| "None".into()) + )), + ]; + let rows = Layout::vertical([Constraint::Min(5), Constraint::Length(3)]).split(inner); + frame.render_widget(Paragraph::new(details), rows[0]); + let widths = vec![Constraint::Ratio(1, groups.len().max(1) as u32); groups.len().max(1)]; + let buttons = Layout::horizontal(widths).split(rows[1]); + for (index, entry) in groups.iter().enumerate() { + let focused = + self.focus_zone == FocusZone::UserActions && self.action_group_index == index; + render_button( + frame, + buttons[index], + ActionButton { + label: entry.group.label(), + intent: ButtonIntent::Neutral, + focused, + enabled: !self.pending, + }, + context.theme, + ); + if !self.pending { + hits.register(buttons[index], AppAction::OpenUserGroup(entry.group)); + } + } + } + fn render_overlay( + &self, + frame: &mut Frame, + rect: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { + let Some(overlay) = &self.overlay else { + return; + }; + let area = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 56, + height: 12, + }, + ); + frame.render_widget(Clear, area); + let block = Block::default() + .title(match overlay { + UsersOverlay::Add(flow) => match flow.phase { + AddUserPhase::ChooseMethod => " Add User ", + AddUserPhase::ConfigureCreate => " Create User ", + _ => " Add User ", + }, + UsersOverlay::UserMenu(menu) => menu.group.label(), + UsersOverlay::Confirm(confirm) => confirm.title(), + UsersOverlay::CredentialExport(_) => " Export TU ", + }) + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + let inner = block.inner(area); + frame.render_widget(block, area); + match overlay { + UsersOverlay::Add(flow) => match flow.phase { + AddUserPhase::ChooseMethod => { + let lines = flow + .methods + .items() + .iter() + .enumerate() + .map(|(index, item)| { + Line::from(format!( + "{} {}{}", + if flow.methods.selected_index() == Some(index) { + ">" + } else { + " " + }, + item.label, + item.disabled_reason + .as_ref() + .map(|reason| format!(" ({reason})")) + .unwrap_or_default() + )) + }) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); + } + AddUserPhase::ConfigureCreate => { + let value = flow.username.display_value(); + let message = flow + .error + .as_deref() + .unwrap_or("Enter selects Create. Esc returns to methods."); + frame.render_widget( + Paragraph::new(format!("Username [{value}]\n\n{message}")), + inner, + ); + } + AddUserPhase::ConfigureImport => { + let value = flow.import_path.display_value(); + let message = flow + .error + .as_deref() + .unwrap_or("Enter inspects the credential. Esc returns to methods."); + frame.render_widget( + Paragraph::new(format!("TU path [{value}]\n\n{message}")), + inner, + ); + } + AddUserPhase::InspectingTu => { + frame.render_widget(Paragraph::new("Inspecting credential…"), inner); + } + AddUserPhase::ReviewImport => { + if let Some(preview) = &flow.preview { + let assignment = preview + .assigned_iota_id + .map(|value| value.to_string()) + .unwrap_or_else(|| "Unassigned".into()); + frame.render_widget( + Paragraph::new(format!( + "Username {}\nUser ID {}\nCurrent Iota {}\nDestination This Iota\n\nEnter attaches. Esc returns to the path.", + preview.username, preview.user_id, assignment + )), + inner, + ); + } + } + _ => frame.render_widget(Paragraph::new(invitations::UNSUPPORTED_MESSAGE), inner), + }, + UsersOverlay::UserMenu(menu) => { + let lines = menu + .menu + .items() + .iter() + .enumerate() + .map(|(index, item)| { + Line::from(format!( + "{} {}{}", + if menu.menu.selected_index() == Some(index) { + ">" + } else { + " " + }, + item.label, + item.disabled_reason + .as_ref() + .map(|reason| format!(" ({reason})")) + .unwrap_or_default() + )) + }) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); + } + UsersOverlay::Confirm(confirm) => { + let rows = + Layout::vertical([Constraint::Min(5), Constraint::Length(3)]).split(inner); + frame.render_widget(Paragraph::new(confirm.message()), rows[0]); + let buttons = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[1]); + render_button( + frame, + buttons[0], + ActionButton { + label: "Cancel", + intent: ButtonIntent::Cancel, + focused: false, + enabled: true, + }, + context.theme, + ); + render_button( + frame, + buttons[1], + ActionButton { + label: confirm.confirm_label(), + intent: ButtonIntent::Destructive, + focused: true, + enabled: !self.pending, + }, + context.theme, + ); + hits.register(buttons[0], AppAction::CancelDialog); + hits.register(buttons[1], AppAction::ConfirmDialog); + } + UsersOverlay::CredentialExport(export) => { + let value = export.destination.display_value(); + let message = export + .error + .as_deref() + .unwrap_or("Enter exports the TU. Esc cancels."); + frame.render_widget( + Paragraph::new(format!( + "User {}\nDestination [{value}]\n\n{message}", + export.user.username + )), + inner, + ); + } + } + } +} + +impl Screen for UsersScreen { + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn render( + &self, + frame: &mut Frame, + rect: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { + let outer = Block::default() + .title(" User management ") + .borders(Borders::ALL) + .border_style(context.theme.borders.normal); + let inner = outer.inner(rect); + frame.render_widget(outer, rect); + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(inner); + let header = Layout::horizontal([ + Constraint::Length(10), + Constraint::Length(14), + Constraint::Min(20), + Constraint::Length(18), + ]) + .split(rows[0]); + for (index, (label, tab)) in [ + ("Users", UserAdminTab::Users), + ("Invitations", UserAdminTab::Invitations), + ] + .into_iter() + .enumerate() + { + render_button( + frame, + header[index], + ActionButton { + label, + intent: ButtonIntent::Neutral, + focused: self.active_tab == tab, + enabled: !self.pending, + }, + context.theme, + ); + if !self.pending { + hits.register(header[index], AppAction::SetUserAdminTab(tab)); + } + } + frame.render_widget( + Paragraph::new(if self.active_tab == UserAdminTab::Users { + format!("Search [{}]", self.filter) + } else { + "Pending, redeemed, revoked, and expired invitations".into() + }) + .style(if self.filtering { + context.theme.text.normal + } else { + context.theme.text.muted + }), + header[2], + ); + let add_enabled = !self.pending + && (self.active_tab == UserAdminTab::Users || self.invitation_supported()); + render_button( + frame, + header[3], + ActionButton { + label: if self.active_tab == UserAdminTab::Users { + "+ Add User" + } else { + "+ New Invitation" + }, + intent: ButtonIntent::Primary, + focused: self.focus_zone == FocusZone::Toolbar && self.toolbar_index == 0, + enabled: add_enabled, + }, + context.theme, + ); + if add_enabled { + hits.register(header[3], AppAction::AddUser); + } + let panes = if rows[1].width >= 70 { + Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(rows[1]) + } else { + Layout::horizontal([Constraint::Percentage(100), Constraint::Length(0)]).split(rows[1]) + }; + if self.active_tab == UserAdminTab::Users { + self.render_list(frame, panes[0], context, hits); + if panes[1].width > 0 { + self.render_context(frame, panes[1], context, hits); + } + } else { + invitations::render(frame, rows[1], context, self.invitation_supported()); + } + if let Some(message) = &self.message { + frame.render_widget( + Paragraph::new(message.as_str()).style(context.theme.text.muted), + rows[2], + ); + } + self.render_overlay(frame, rect, context, hits); + } + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + match event { + UiEvent::App(AppEvent::UsersLoaded(result)) => { + self.loading = false; + match result { + Ok(users) => { + self.users = users; + self.restore_selection(); + } + Err(error) => self.message = Some(error), + } + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::UserOperationFinished(result)) => { + self.pending = false; + self.message = Some(match result { + Ok(message) => message, + Err(error) => error, + }); + return self.refresh(); + } + UiEvent::App(AppEvent::CredentialExportFinished(result)) => { + self.pending = false; + self.message = Some(match result { + Ok(message) => message, + Err(error) => error, + }); + return InteractionResult::Handled; + } + UiEvent::App(AppEvent::TuInspected(result)) => { + if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() { + match result { + Ok(preview) => { + flow.inspection_succeeded(preview); + } + Err(error) => { + flow.inspection_failed(error); + } + } + } + return InteractionResult::Handled; + } + UiEvent::Paste(text) => { + if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() { + let sanitized = text.replace(['\r', '\n'], " "); + let input = match flow.phase { + AddUserPhase::ConfigureCreate => Some(&mut flow.username), + AddUserPhase::ConfigureImport => Some(&mut flow.import_path), + _ => None, + }; + if let Some(input) = input { + for character in sanitized.chars() { + input.handle_key(KeyCode::Char(character)); + } + return InteractionResult::Handled; + } + } + if let Some(UsersOverlay::CredentialExport(export)) = self.overlay.as_mut() { + for character in text.replace(['\r', '\n'], " ").chars() { + export.destination.handle_key(KeyCode::Char(character)); + } + return InteractionResult::Handled; + } + if self.filtering { + self.filter.push_str(&text.replace(['\r', '\n'], " ")); + self.restore_selection(); + return InteractionResult::Handled; + } + return InteractionResult::Unhandled; + } + UiEvent::Key(key) => { + if let Some(overlay) = &mut self.overlay { + match overlay { + UsersOverlay::Add(flow) => match flow.phase { + AddUserPhase::ChooseMethod => { + if flow.methods.handle_key(key.code) { + return InteractionResult::Handled; + } + match key.code { + KeyCode::Enter => { + flow.choose(); + InteractionResult::Handled + } + KeyCode::Esc => { + self.overlay = None; + InteractionResult::Handled + } + _ => InteractionResult::Handled, + } + } + AddUserPhase::ConfigureCreate => match key.code { + KeyCode::Esc => { + flow.phase = AddUserPhase::ChooseMethod; + InteractionResult::Handled + } + KeyCode::Enter => { + let username = flow.username.value().trim().to_owned(); + if username.is_empty() { + flow.error = Some("A username is required.".into()); + InteractionResult::Handled + } else { + self.start_create(username) + } + } + _ => { + flow.username.handle_key(key.code); + InteractionResult::Handled + } + }, + AddUserPhase::ConfigureImport => match key.code { + KeyCode::Esc => { + flow.phase = AddUserPhase::ChooseMethod; + InteractionResult::Handled + } + KeyCode::Enter => { + let path = flow.import_path.value().trim(); + if path.is_empty() { + flow.error = Some("A TU path is required.".into()); + InteractionResult::Handled + } else { + match std::fs::read_to_string(path) { + Ok(contents) => self.start_tu_inspection( + iota_ipc::SecretString(contents), + ), + Err(error) => { + flow.error = Some(format!( + "Cannot read credential {path}: {error}" + )); + InteractionResult::Handled + } + } + } + } + _ => { + flow.import_path.handle_key(key.code); + InteractionResult::Handled + } + }, + AddUserPhase::ReviewImport => match key.code { + KeyCode::Esc => { + flow.phase = AddUserPhase::ConfigureImport; + InteractionResult::Handled + } + KeyCode::Enter => { + if let Some(credential) = flow.credential.clone() { + flow.attachment_started(); + self.start_tu_attachment(credential) + } else { + flow.error = + Some("Credential must be inspected again.".into()); + flow.phase = AddUserPhase::ConfigureImport; + InteractionResult::Handled + } + } + _ => InteractionResult::Handled, + }, + _ => { + self.overlay = None; + InteractionResult::Handled + } + }, + UsersOverlay::UserMenu(menu) => { + if menu.menu.handle_key(key.code) { + return InteractionResult::Handled; + } + match key.code { + KeyCode::Esc => { + self.overlay = None; + InteractionResult::Handled + } + KeyCode::Enter => { + let action = menu.menu.selected_item().map(|item| item.value); + action.map_or(InteractionResult::Handled, |action| { + self.begin_action(action) + }) + } + _ => InteractionResult::Handled, + } + } + UsersOverlay::Confirm(confirm) => match key.code { + KeyCode::Esc => { + self.overlay = None; + InteractionResult::Handled + } + KeyCode::Enter => { + let action = confirm.action; + let user_id = confirm.user.user_id; + self.start_operation(action, user_id) + } + _ => InteractionResult::Handled, + }, + UsersOverlay::CredentialExport(export) => match key.code { + KeyCode::Esc => { + self.overlay = None; + InteractionResult::Handled + } + KeyCode::Enter => { + let destination = export.destination.value().trim(); + if destination.is_empty() { + export.error = Some("A destination path is required.".into()); + InteractionResult::Handled + } else { + let user_id = export.user.user_id; + let destination = std::path::PathBuf::from(destination); + self.start_credential_export(user_id, destination) + } + } + _ => { + export.destination.handle_key(key.code); + InteractionResult::Handled + } + }, + } + } else if self.active_tab == UserAdminTab::Invitations { + match key.code { + KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Char('a') | KeyCode::Char('A') => { + if self.invitation_supported() { + self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); + } else { + self.message = Some(invitations::UNSUPPORTED_MESSAGE.into()); + } + InteractionResult::Handled + } + KeyCode::Char('u') | KeyCode::Char('U') => { + self.active_tab = UserAdminTab::Users; + self.focus_zone = FocusZone::UserList; + InteractionResult::Handled + } + KeyCode::Char('i') | KeyCode::Char('I') => InteractionResult::Handled, + _ => InteractionResult::Unhandled, + } + } else if self.filtering { + match key.code { + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + self.restore_selection(); + } + KeyCode::Enter => self.filtering = false, + KeyCode::Backspace => { + self.filter.pop(); + self.restore_selection(); + } + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.filter.push(character); + self.restore_selection(); + } + _ => {} + }; + InteractionResult::Handled + } else { + match key.code { + KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Char('/') => { + self.filtering = true; + InteractionResult::Handled + } + KeyCode::Char('a') | KeyCode::Char('A') => { + self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); + InteractionResult::Handled + } + KeyCode::Char('i') | KeyCode::Char('I') => { + self.active_tab = UserAdminTab::Invitations; + self.filtering = false; + InteractionResult::Handled + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_selection(1); + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_selection(-1); + InteractionResult::Handled + } + KeyCode::PageDown => { + self.move_selection( + self.viewport_height.load(Ordering::Relaxed) as isize + ); + InteractionResult::Handled + } + KeyCode::PageUp => { + self.move_selection( + -(self.viewport_height.load(Ordering::Relaxed) as isize), + ); + InteractionResult::Handled + } + KeyCode::Home => { + self.selected_user_id = + self.visible_users().first().map(|user| user.user_id); + self.keep_selected_visible(); + InteractionResult::Handled + } + KeyCode::End => { + self.selected_user_id = + self.visible_users().last().map(|user| user.user_id); + self.keep_selected_visible(); + InteractionResult::Handled + } + KeyCode::Tab => { + self.focus_zone = match self.focus_zone { + FocusZone::Toolbar => FocusZone::Search, + FocusZone::Search => FocusZone::UserList, + FocusZone::UserList => FocusZone::UserActions, + FocusZone::UserActions => FocusZone::Toolbar, + }; + InteractionResult::Handled + } + KeyCode::Left if self.focus_zone == FocusZone::UserActions => { + self.action_group_index = self.action_group_index.saturating_sub(1); + InteractionResult::Handled + } + KeyCode::Right if self.focus_zone == FocusZone::UserActions => { + let last = self.groups().len().saturating_sub(1); + self.action_group_index = (self.action_group_index + 1).min(last); + InteractionResult::Handled + } + KeyCode::Enter if self.focus_zone == FocusZone::Toolbar => { + self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); + InteractionResult::Handled + } + KeyCode::Enter if self.focus_zone == FocusZone::UserActions => { + if let Some(entry) = self.groups().get(self.action_group_index) { + self.overlay = Some(UsersOverlay::UserMenu(UserActionMenu::new( + entry.group, + entry.actions.clone(), + self.credential_export_supported(), + ))); + } + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + } + _ => InteractionResult::Unhandled, + } + } + fn handle_action(&mut self, action: AppAction) -> InteractionResult { + match action { + AppAction::AddUser => { + if self.active_tab == UserAdminTab::Invitations && !self.invitation_supported() { + self.message = Some(invitations::UNSUPPORTED_MESSAGE.into()); + } else { + self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); + } + InteractionResult::Handled + } + AppAction::SetUserAdminTab(tab) if self.overlay.is_none() => { + self.active_tab = tab; + self.filtering = false; + self.focus_zone = if tab == UserAdminTab::Users { + FocusZone::UserList + } else { + FocusZone::Toolbar + }; + InteractionResult::Handled + } + AppAction::SelectUser(id) if self.overlay.is_none() => { + self.selected_user_id = Some(id); + self.focus_zone = FocusZone::UserList; + self.keep_selected_visible(); + InteractionResult::Handled + } + AppAction::OpenUserGroup(group) if self.overlay.is_none() => { + if let Some(entry) = self.groups().into_iter().find(|entry| entry.group == group) { + self.overlay = Some(UsersOverlay::UserMenu(UserActionMenu::new( + group, + entry.actions, + self.credential_export_supported(), + ))); + } + InteractionResult::Handled + } + AppAction::ActivateUserAction(action) => self.begin_action(action), + AppAction::ConfirmDialog => match &self.overlay { + Some(UsersOverlay::Confirm(confirm)) => { + self.start_operation(confirm.action, confirm.user.user_id) + } + _ => InteractionResult::Unhandled, + }, + AppAction::CancelDialog => { + self.overlay = None; + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + fn key_hints(&self) -> Vec { + if self.overlay.is_some() { + vec![ + KeyHint { + keys: "Enter", + action: "Select", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } else { + vec![ + KeyHint { + keys: "↑/↓", + action: "Select user", + }, + KeyHint { + keys: "A", + action: "Add user", + }, + KeyHint { + keys: "/", + action: "Search", + }, + KeyHint { + keys: "F6", + action: "Header", + }, + ] + } + } +} diff --git a/iota-cli/src/screens/users/model.rs b/iota-cli/src/screens/users/model.rs new file mode 100644 index 0000000..9b3297e --- /dev/null +++ b/iota-cli/src/screens/users/model.rs @@ -0,0 +1,230 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusZone { + Toolbar, + Search, + UserList, + UserActions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserAdminTab { + Users, + Invitations, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserActionGroup { + Hosting, + Data, + Credential, + Account, + Repair, + Record, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserAction { + Release, + PurgeData, + ExportTu, + DeleteAccount, + Reconcile, + Diagnostics, + ForceDetach, + ForgetResidency, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionGroupEntry { + pub group: UserActionGroup, + pub actions: Vec, +} + +use super::UserEntry; + +/* Maps local daemon facts into the operator actions that are safe to present. */ +pub fn available_action_groups(user: &UserEntry) -> Vec { + let mut groups = Vec::new(); + if user.state == iota_ipc::LocalUserState::Managed { + groups.push(ActionGroupEntry { + group: UserActionGroup::Hosting, + actions: vec![UserAction::Release], + }); + } + groups.push(ActionGroupEntry { + group: UserActionGroup::Repair, + actions: if user.state == iota_ipc::LocalUserState::Managed { + vec![ + UserAction::Reconcile, + UserAction::Diagnostics, + UserAction::ForceDetach, + ] + } else { + vec![UserAction::Reconcile, UserAction::Diagnostics] + }, + }); + if user.state == iota_ipc::LocalUserState::Released && !user.data_present { + groups.push(ActionGroupEntry { + group: UserActionGroup::Record, + actions: vec![UserAction::ForgetResidency], + }); + } + if user.data_present { + groups.push(ActionGroupEntry { + group: UserActionGroup::Data, + actions: vec![UserAction::PurgeData], + }); + } + if user.state == iota_ipc::LocalUserState::Managed + && user.credential_status == iota_ipc::CredentialStatus::LocalPresent + { + groups.push(ActionGroupEntry { + group: UserActionGroup::Credential, + actions: vec![UserAction::ExportTu], + }); + } + if user.state == iota_ipc::LocalUserState::Managed { + groups.push(ActionGroupEntry { + group: UserActionGroup::Account, + actions: vec![UserAction::DeleteAccount], + }); + } + groups +} + +impl UserActionGroup { + pub fn label(self) -> &'static str { + match self { + Self::Hosting => "Hosting", + Self::Data => "Data", + Self::Credential => "Credential", + Self::Account => "Account", + Self::Repair => "Repair", + Self::Record => "Record", + } + } +} +impl UserAction { + pub fn label(self) -> &'static str { + match self { + Self::Release => "Release from this Iota", + Self::PurgeData => "Purge hosted data", + Self::ExportTu => "Export TU", + Self::DeleteAccount => "Delete Tensamin account", + Self::Reconcile => "Reconcile with Omega", + Self::Diagnostics => "Diagnostics", + Self::ForceDetach => "Force local detach", + Self::ForgetResidency => "Forget residency record", + } + } + + pub fn requires_confirmation(self) -> bool { + matches!( + self, + Self::Release + | Self::PurgeData + | Self::DeleteAccount + | Self::ForceDetach + | Self::ForgetResidency + ) + } + + pub fn confirmation_label(self) -> &'static str { + match self { + Self::Release => "Release user", + Self::PurgeData => "Purge data", + Self::DeleteAccount => "Delete account", + Self::ForceDetach => "Force detach", + Self::ForgetResidency => "Forget record", + Self::ExportTu => "Export TU", + Self::Reconcile => "Reconcile", + Self::Diagnostics => "Load diagnostics", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn user(state: iota_ipc::LocalUserState, data_present: bool) -> UserEntry { + UserEntry { + user_id: 1, + username: "alice".into(), + state, + data_present, + credential_status: iota_ipc::CredentialStatus::LocalPresent, + pending_operation: None, + } + } + #[test] + fn released_users_cannot_be_released() { + assert!( + !available_action_groups(&user(iota_ipc::LocalUserState::Released, true)) + .iter() + .any(|entry| entry.actions.contains(&UserAction::Release)) + ); + } + #[test] + fn empty_data_does_not_offer_purge() { + assert!( + !available_action_groups(&user(iota_ipc::LocalUserState::Managed, false)) + .iter() + .any(|entry| entry.actions.contains(&UserAction::PurgeData)) + ); + } + + #[test] + fn released_empty_user_can_forget_but_cannot_force_detach() { + let actions = available_action_groups(&user(iota_ipc::LocalUserState::Released, false)); + assert!( + actions + .iter() + .any(|entry| entry.actions.contains(&UserAction::ForgetResidency)) + ); + assert!( + !actions + .iter() + .any(|entry| entry.actions.contains(&UserAction::ForceDetach)) + ); + } + + #[test] + fn external_credential_does_not_offer_export() { + let mut external = user(iota_ipc::LocalUserState::Managed, true); + external.credential_status = iota_ipc::CredentialStatus::External; + + assert!( + !available_action_groups(&external) + .iter() + .any(|entry| entry.actions.contains(&UserAction::ExportTu)) + ); + } + + #[test] + fn local_credential_offers_export_only_in_credential_group() { + let groups = available_action_groups(&user(iota_ipc::LocalUserState::Managed, true)); + + assert!(groups.iter().any(|entry| { + entry.group == UserActionGroup::Credential + && entry.actions.contains(&UserAction::ExportTu) + })); + assert!( + groups + .iter() + .filter(|entry| entry.group != UserActionGroup::Credential) + .all(|entry| !entry.actions.contains(&UserAction::ExportTu)) + ); + } + + #[test] + fn only_lifecycle_mutations_require_confirmation() { + assert!(UserAction::Release.requires_confirmation()); + assert!(UserAction::PurgeData.requires_confirmation()); + assert!(UserAction::DeleteAccount.requires_confirmation()); + assert!(UserAction::ForceDetach.requires_confirmation()); + assert!(UserAction::ForgetResidency.requires_confirmation()); + assert!(!UserAction::Reconcile.requires_confirmation()); + assert!(!UserAction::Diagnostics.requires_confirmation()); + assert!(!UserAction::ExportTu.requires_confirmation()); + } +} diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index ece8697..41608d9 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,5 +1,5 @@ use crate::{ - controls::header::render_header, + controls::header::{HEADER_ITEMS, render_header}, help_overlay::HelpOverlay, input_handler::setup_input_handler, interaction_result::InteractionResult, @@ -432,19 +432,16 @@ impl UI { if header_is_focused { let mut action = None; if let Ok(mut focus) = self.header_focus.lock() { - let index = focus.unwrap_or(0); + let index = focus.unwrap_or(0).min(HEADER_ITEMS.len().saturating_sub(1)); match key.code { - KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), - KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), + KeyCode::Left | KeyCode::BackTab => { + *focus = Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len()) + } + KeyCode::Right | KeyCode::Tab => { + *focus = Some((index + 1) % HEADER_ITEMS.len()) + } KeyCode::Enter | KeyCode::Char(' ') => { - action = Some( - [ - AppAction::OpenOverview, - AppAction::OpenUsers, - AppAction::OpenSettings, - AppAction::Quit, - ][index], - ); + action = HEADER_ITEMS.get(index).map(|item| item.action); *focus = None; } KeyCode::Esc => *focus = None, @@ -607,7 +604,8 @@ impl UI { username: u.username, state: u.state, data_present: u.data_present, - credential_present: u.credential_present, + credential_status: u.credential_status, + pending_operation: u.pending_operation, }) .collect()) } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 59a87f9..5047d39 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -3,8 +3,9 @@ use crate::{DaemonRuntime, DaemonServices}; use iota_ipc::{ CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent, IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, - OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, - TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, + OmikronStatusResponse, ReconcileAction, ResponseEnvelope, ResponsePayload, ResponseResult, + StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, + UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary, }; use iota_logger::{log, log_command}; use iota_storage::users::pending_operations::{ @@ -29,6 +30,30 @@ pub struct PeerContext { const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512; +fn credential_status( + residency: &user_manager::UserResidency, + profile: Option<&iota_storage::users::user_profile::UserProfile>, +) -> iota_ipc::CredentialStatus { + match (residency.state, residency.credential_origin) { + (user_manager::LocalUserState::Released, _) => iota_ipc::CredentialStatus::None, + (_, user_manager::CredentialOrigin::External) => iota_ipc::CredentialStatus::External, + (_, user_manager::CredentialOrigin::Local) + if profile.is_some_and(|profile| { + iota_util::file_util::read_user_credential_with_legacy( + residency.user_id, + &profile.username, + ) + .ok() + .flatten() + .is_some() + }) => + { + iota_ipc::CredentialStatus::LocalPresent + } + _ => iota_ipc::CredentialStatus::Missing, + } +} + fn now_millis() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -119,7 +144,9 @@ impl CommandRouter { let needs_omikron = matches!( request, LocalRequest::CreateUser { .. } + | LocalRequest::InspectTuCredential { .. } | LocalRequest::AttachUserFromTu { .. } + | LocalRequest::ReconcileUser { .. } | LocalRequest::ReleaseUser { .. } | LocalRequest::CompleteDeleteUser { .. } ); @@ -162,22 +189,35 @@ impl CommandRouter { ResponseResult::Ok(ResponsePayload::Tasks(tasks)) } LocalRequest::ListUsers => { + let pending = match pending_operations::get_all() { + Ok(operations) => operations + .into_iter() + .map(|operation| { + let kind = match operation.operation { + PendingUserOperationKind::Create => UserOperationKind::Create, + PendingUserOperationKind::Attach => UserOperationKind::Attach, + PendingUserOperationKind::Release => UserOperationKind::Release, + PendingUserOperationKind::Purge => UserOperationKind::Purge, + }; + ( + operation.user_id, + UserOperationSummary { + operation: kind, + phase: operation.phase.as_str().into(), + }, + ) + }) + .collect::>(), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; let users = user_manager::get_residency() .into_iter() .map(|user| { + let user_id = user.user_id; let profile = user_manager::get_user(user.user_id)?; Ok(UserSummary { - credential_present: user.state == user_manager::LocalUserState::Managed - && profile.is_some_and(|profile| { - iota_util::file_util::read_user_credential_with_legacy( - user.user_id, - &profile.username, - ) - .ok() - .flatten() - .is_some() - }), - user_id: user.user_id, + credential_status: credential_status(&user, profile.as_ref()), + user_id, username: user.username, state: match user.state { user_manager::LocalUserState::Managed => { @@ -188,6 +228,7 @@ impl CommandRouter { } }, data_present: user.data_present, + pending_operation: pending.get(&user_id).cloned(), }) }) .collect::, iota_storage::storage_error::StorageError>>(); @@ -265,6 +306,163 @@ impl CommandRouter { } } } + LocalRequest::InspectTuCredential { credential } => { + match omikron_connector::user_ops::inspect_tu_credential( + self.services.omikron.as_ref(), + &credential.0, + ) + .await + { + Ok(preview) => ResponseResult::Ok(ResponsePayload::TuCredentialPreview( + iota_ipc::TuCredentialPreview { + user_id: preview.user_id, + username: preview.username, + assigned_iota_id: preview.assigned_iota_id, + }, + )), + Err(error) => { + log!("Credential inspection failed: {error:?}"); + ResponseResult::Error(IpcErrorCode::Unauthorized) + } + } + } + LocalRequest::ReconcileUser { user_id } => { + let residency = match user_manager::get_residency_by_id(user_id) { + Ok(Some(residency)) => residency, + Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + let omega_iota_id = match omikron_connector::user_ops::get_remote_user_assignment( + self.services.omikron.as_ref(), + user_id, + ) + .await + { + Ok(assignment) => assignment, + Err(error) => { + log!("User reconciliation failed for {user_id}: {error:?}"); + return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); + } + }; + let local_iota_id = config_util::CONFIG + .load() + .iota_id + .and_then(|id| i64::try_from(id).ok()); + let action = if residency.state == user_manager::LocalUserState::Managed + && omega_iota_id != local_iota_id + { + match user_manager::finalize_local_release(user_id, Some(&residency.username)) { + Ok(()) => ReconcileAction::ReleasedLocally, + Err(error) => { + log!("User reconciliation cleanup failed for {user_id}: {error}"); + return ResponseResult::Error(IpcErrorCode::StorageFailure); + } + } + } else { + ReconcileAction::None + }; + ResponseResult::Ok(ResponsePayload::UserReconciled(UserReconcileResult { + user_id, + local_state: match residency.state { + user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, + user_manager::LocalUserState::Released => { + iota_ipc::LocalUserState::Released + } + }, + omega_iota_id, + action, + })) + } + LocalRequest::ForceDetachUser { user_id } => { + match user_manager::force_local_detach(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!( + "Force-detached user {user_id} locally; Omega was not changed" + ), + }), + Err(error) => { + log!("Force local detach failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + } + } + LocalRequest::ForgetReleasedUser { user_id } => { + match user_manager::forget_released_user(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Forgot released user residency {user_id}"), + }), + Err(error) => { + log!("Forget residency failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::Conflict) + } + } + } + LocalRequest::GetUserDiagnostics { user_id } => { + let residency = match user_manager::get_residency_by_id(user_id) { + Ok(Some(residency)) => residency, + Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + let profile = match user_manager::get_user(user_id) { + Ok(profile) => profile, + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + let pending_operation = match pending_operations::get_all() { + Ok(operations) => operations + .into_iter() + .find(|operation| operation.user_id == user_id) + .map(|operation| { + format!( + "{}/{}", + operation.operation.as_str(), + operation.phase.as_str() + ) + }), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + let credential_status = credential_status(&residency, profile.as_ref()); + ResponseResult::Ok(ResponsePayload::UserDiagnostics(UserDiagnostics { + user_id, + username: residency.username, + local_state: match residency.state { + user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, + user_manager::LocalUserState::Released => { + iota_ipc::LocalUserState::Released + } + }, + data_present: residency.data_present, + credential_status, + trusted_app_count: profile + .as_ref() + .map_or(0, |profile| profile.trusted_apps.len()), + pending_operation, + })) + } + LocalRequest::RevokeTrustedApp { user_id, app_id } => { + match user_manager::revoke_trusted_app(user_id, &app_id) { + Ok(true) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Revoked trusted application {app_id} for user {user_id}"), + }), + Ok(false) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(error) => { + log!("Trusted application revocation failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + } + } + LocalRequest::RevokeAllTrustedApps { user_id } => { + match user_manager::revoke_all_trusted_apps(user_id) { + Ok(removed) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!( + "Revoked {removed} trusted applications for user {user_id}" + ), + }), + Err(error) => { + log!("Trusted application revocation failed for {user_id}: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + } + } LocalRequest::CompleteDeleteUser { user_id, credential, @@ -457,33 +655,71 @@ impl CommandRouter { .collect(); ResponseResult::Ok(ResponsePayload::Components(components)) } - LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - Ok(Some(user)) => { - let credential_present = - iota_util::file_util::read_user_credential_with_legacy( - user_id, - &user.username, - ) - .ok() - .flatten() - .is_some(); - ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { - user_id: user.user_id, - username: user.username, - display_name: user.display_name, - created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), - state: iota_ipc::LocalUserState::Managed, - data_present: user_manager::get_residency() - .iter() - .find(|entry| entry.user_id == user_id) - .is_none_or(|entry| entry.data_present), - credential_present, - })) + LocalRequest::GetUser { user_id } => { + let residency = match user_manager::get_residency_by_id(user_id) { + Ok(Some(residency)) => residency, + Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + match user_manager::get_user(user_id) { + Ok(Some(user)) => { + let credential_status = credential_status(&residency, Some(&user)); + ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + created_at: user.created_at, + trusted_apps: user.trusted_apps.keys().cloned().collect(), + state: iota_ipc::LocalUserState::Managed, + data_present: residency.data_present, + credential_status, + })) + } + Ok(None) if residency.state == user_manager::LocalUserState::Released => { + ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { + user_id: residency.user_id, + username: residency.username, + display_name: None, + created_at: 0, + trusted_apps: Vec::new(), + state: iota_ipc::LocalUserState::Released, + data_present: residency.data_present, + credential_status: iota_ipc::CredentialStatus::None, + })) + } + Ok(None) => ResponseResult::Error(IpcErrorCode::StorageFailure), + Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure), } - Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), - Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure), - }, + } + LocalRequest::ExportUserCredential { user_id } => { + let residency = match user_manager::get_residency_by_id(user_id) { + Ok(Some(residency)) => residency, + Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + if residency.state != user_manager::LocalUserState::Managed + || residency.credential_origin != user_manager::CredentialOrigin::Local + { + return ResponseResult::Error(IpcErrorCode::Conflict); + } + let credential = match iota_util::file_util::read_user_credential_with_legacy( + user_id, + &residency.username, + ) { + Ok(Some(credential)) => credential, + Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + let parsed = match iota_util::tu::TuCredential::parse(&credential) { + Ok(parsed) if parsed.user_id == user_id => parsed, + Ok(_) | Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), + }; + ResponseResult::Ok(ResponsePayload::UserCredentialExport { + user_id, + username: residency.username, + credential: iota_ipc::SecretString(parsed.to_canonical_string()), + }) + } LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::GetLogs { limit } => { let entries = if let Ok(buf) = self.log_buffer.lock() { @@ -538,6 +774,19 @@ mod tests { LocalRequest::AttachUserFromTu { credential: SecretString("credential".into()), }, + LocalRequest::InspectTuCredential { + credential: SecretString("credential".into()), + }, + LocalRequest::ReconcileUser { user_id: 1 }, + LocalRequest::ForceDetachUser { user_id: 1 }, + LocalRequest::ForgetReleasedUser { user_id: 1 }, + LocalRequest::GetUserDiagnostics { user_id: 1 }, + LocalRequest::RevokeTrustedApp { + user_id: 1, + app_id: "desktop".into(), + }, + LocalRequest::RevokeAllTrustedApps { user_id: 1 }, + LocalRequest::ExportUserCredential { user_id: 1 }, LocalRequest::PurgeUserData { user_id: 1 }, LocalRequest::ReleaseUser { user_id: 1 }, LocalRequest::CompleteDeleteUser { @@ -570,7 +819,7 @@ mod tests { LocalRequest::ListCommunities, ]; - assert_eq!(requests.len(), 25); + assert_eq!(requests.len(), 33); for request in requests { let required = request.required_role(); assert!(IpcRole::Admin.allows(required)); diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index a472348..980c739 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -401,7 +401,14 @@ async fn handle_client( daemon_version: env!("CARGO_PKG_VERSION").to_string(), instance_id: instance_id.clone(), startup_phase: runtime.current_startup_phase().into(), - capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], + capabilities: vec![ + "commands".into(), + "metrics".into(), + "logs".into(), + "user_management_v2".into(), + "tu_inspection_v1".into(), + "credential_export_v1".into(), + ], lifecycle: *runtime.lifecycle.borrow(), health: runtime.overall_health(), deployment_mode: from_environment().mode, diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 4ebe682..af5d269 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -149,8 +149,7 @@ async fn main() -> ExitCode { Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { runtime.set_component_failed( iota_ipc::ComponentId::Omikron, - "Omikron authentication failed; regenerate the Iota identity to register again" - .into(), + "Omikron authentication failed; inspect the authenticated relay and Omega status before rotating the Iota identity".into(), ); // Keep IPC alive: identity rotation is the supported recovery // action and must remain available after authentication fails. @@ -221,6 +220,23 @@ async fn main() -> ExitCode { .spawn_tracked("user-lifecycle-reconciliation", async move { let mut states = omikron_reconcile.connection_state(); loop { + match iota_storage::users::pending_operations::get_all() { + Ok(operations) => { + for operation in operations.into_iter().filter(|operation| { + operation.operation + == iota_storage::users::pending_operations::PendingUserOperationKind::Purge + }) { + if let Err(error) = user_manager::purge_user_data(operation.user_id) { + log!( + "Pending purge reconciliation failed for {}: {}", + operation.user_id, + error + ); + } + } + } + Err(error) => log!("Pending purge reconciliation could not read storage: {error}"), + } if matches!( *states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. } diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index acf72b1..64603e6 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -4,16 +4,17 @@ pub mod transport; pub use protocol::{ ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, - ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, - ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase, - LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, - OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, - SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, - UpdateStatusResponse, UserDetailResponse, UserSummary, + ConfigResponse, ConnectionStatus, CredentialStatus, DaemonMessage, DaemonStatusResponse, + DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, + LifecyclePhase, LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, + OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload, + ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, + TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, + UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary, }; pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; /// Current IPC protocol version. -pub const PROTOCOL_VERSION: u16 = 2; +pub const PROTOCOL_VERSION: u16 = 4; /// Minimum protocol version this daemon understands. pub const MIN_PROTOCOL_VERSION: u16 = 2; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 4006583..f5051c6 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -48,9 +48,34 @@ pub enum LocalRequest { CreateUser { username: String, }, + InspectTuCredential { + credential: SecretString, + }, AttachUserFromTu { credential: SecretString, }, + ReconcileUser { + user_id: i64, + }, + ForceDetachUser { + user_id: i64, + }, + ForgetReleasedUser { + user_id: i64, + }, + GetUserDiagnostics { + user_id: i64, + }, + RevokeTrustedApp { + user_id: i64, + app_id: String, + }, + RevokeAllTrustedApps { + user_id: i64, + }, + ExportUserCredential { + user_id: i64, + }, PurgeUserData { user_id: i64, }, @@ -127,6 +152,7 @@ impl LocalRequest { | Self::GetOmikronStatus | Self::ListComponents | Self::GetUser { .. } + | Self::GetUserDiagnostics { .. } | Self::GetLogs { .. } | Self::CheckUpdate | Self::ListCommunities => IpcRole::Read, @@ -134,7 +160,14 @@ impl LocalRequest { Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate, Self::CreateUser { .. } + | Self::InspectTuCredential { .. } | Self::AttachUserFromTu { .. } + | Self::ReconcileUser { .. } + | Self::ForceDetachUser { .. } + | Self::ForgetReleasedUser { .. } + | Self::RevokeTrustedApp { .. } + | Self::RevokeAllTrustedApps { .. } + | Self::ExportUserCredential { .. } | Self::PurgeUserData { .. } | Self::ReleaseUser { .. } | Self::CompleteDeleteUser { .. } @@ -220,6 +253,14 @@ pub enum ResponsePayload { user_id: i64, username: String, }, + TuCredentialPreview(TuCredentialPreview), + UserReconciled(UserReconcileResult), + UserDiagnostics(UserDiagnostics), + UserCredentialExport { + user_id: i64, + username: String, + credential: SecretString, + }, /// Retained only for wire compatibility. New lifecycle code never emits it. UserRemoved { user_id: i64, @@ -267,7 +308,7 @@ pub struct UserDetailResponse { pub trusted_apps: Vec, pub state: LocalUserState, pub data_present: bool, - pub credential_present: bool, + pub credential_status: CredentialStatus, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -304,7 +345,66 @@ pub struct UserSummary { pub username: String, pub state: LocalUserState, pub data_present: bool, - pub credential_present: bool, + pub credential_status: CredentialStatus, + #[serde(default)] + pub pending_operation: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UserOperationKind { + Create, + Attach, + Release, + Purge, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct UserOperationSummary { + pub operation: UserOperationKind, + pub phase: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CredentialStatus { + LocalPresent, + External, + Missing, + None, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct TuCredentialPreview { + pub user_id: i64, + pub username: String, + pub assigned_iota_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct UserReconcileResult { + pub user_id: i64, + pub local_state: LocalUserState, + pub omega_iota_id: Option, + pub action: ReconcileAction, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReconcileAction { + None, + ReleasedLocally, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct UserDiagnostics { + pub user_id: i64, + pub username: String, + pub local_state: LocalUserState, + pub data_present: bool, + pub credential_status: CredentialStatus, + pub trusted_app_count: usize, + pub pending_operation: Option, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -359,6 +459,11 @@ impl std::fmt::Display for IpcErrorCode { #[cfg(test)] mod error_tests { + use super::{ + CredentialStatus, LocalUserState, ResponsePayload, SecretString, UserOperationKind, + UserOperationSummary, UserSummary, + }; + use super::IpcErrorCode; #[test] @@ -378,6 +483,50 @@ mod error_tests { .contains("required role") ); } + + #[test] + fn credential_export_debug_output_is_redacted() { + let payload = ResponsePayload::UserCredentialExport { + user_id: 42, + username: "alice".into(), + credential: SecretString("private-tu-contents".into()), + }; + let output = format!("{payload:?}"); + + assert!(!output.contains("private-tu-contents")); + assert!(output.contains("")); + } + + #[test] + fn user_summary_serializes_pending_operation_without_lifecycle_secrets() { + let summary = UserSummary { + user_id: 42, + username: "alice".into(), + state: LocalUserState::Managed, + data_present: true, + credential_status: CredentialStatus::LocalPresent, + pending_operation: Some(UserOperationSummary { + operation: UserOperationKind::Purge, + phase: "database_purged".into(), + }), + }; + let encoded = serde_json::to_string(&summary).unwrap(); + + assert!(encoded.contains("purge")); + assert!(encoded.contains("database_purged")); + assert!(!encoded.contains("private_key")); + assert!(!encoded.contains("reset_token")); + } + + #[test] + fn user_summary_accepts_older_payload_without_pending_operation() { + let summary: UserSummary = serde_json::from_str( + r#"{"user_id":42,"username":"alice","state":"managed","data_present":true,"credential_status":"local_present"}"#, + ) + .unwrap(); + + assert!(summary.pending_operation.is_none()); + } } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs index c6daa2b..1eccdf7 100644 --- a/iota-ipc/src/text_commands.rs +++ b/iota-ipc/src/text_commands.rs @@ -5,9 +5,16 @@ pub const COMMANDS: &[&str] = &[ "tasks", "users list", "users show ", - "users add ", - "users remove ", - "users import ", + "users add create ", + "users hosting release ", + "users data purge ", + "users account delete ", + "users repair reconcile ", + "users repair diagnostics ", + "users repair force-detach ", + "users forget ", + "users apps revoke ", + "users apps revoke-all ", "omikron status", "reconnect", "identity rotate", @@ -59,16 +66,47 @@ pub fn parse(line: &str) -> Option { let user_id = id_str.parse::().ok()?; Some(LocalRequest::GetUser { user_id }) } - ["user" | "users", "add", username] => Some(LocalRequest::CreateUser { + ["user" | "users", "add", "create", username] => Some(LocalRequest::CreateUser { username: username.to_string(), }), - ["user" | "users", "remove", id_str] => { + ["user" | "users", "hosting", "release", id_str] => { let user_id = id_str.parse::().ok()?; - Some(LocalRequest::RemoveUser { user_id }) + Some(LocalRequest::ReleaseUser { user_id }) } - ["user" | "users", "import", username] => Some(LocalRequest::ImportUser { - username: username.to_string(), + ["user" | "users", "data", "purge", id_str] => Some(LocalRequest::PurgeUserData { + user_id: id_str.parse::().ok()?, }), + ["user" | "users", "account", "delete", id_str] => Some(LocalRequest::CompleteDeleteUser { + user_id: id_str.parse::().ok()?, + credential: None, + }), + ["user" | "users", "repair", "reconcile", id_str] => Some(LocalRequest::ReconcileUser { + user_id: id_str.parse::().ok()?, + }), + ["user" | "users", "repair", "diagnostics", id_str] => { + Some(LocalRequest::GetUserDiagnostics { + user_id: id_str.parse::().ok()?, + }) + } + ["user" | "users", "repair", "force-detach", id_str] => { + Some(LocalRequest::ForceDetachUser { + user_id: id_str.parse::().ok()?, + }) + } + ["user" | "users", "forget", id_str] => Some(LocalRequest::ForgetReleasedUser { + user_id: id_str.parse::().ok()?, + }), + ["user" | "users", "apps", "revoke", id_str, app_id] => { + Some(LocalRequest::RevokeTrustedApp { + user_id: id_str.parse::().ok()?, + app_id: app_id.to_string(), + }) + } + ["user" | "users", "apps", "revoke-all", id_str] => { + Some(LocalRequest::RevokeAllTrustedApps { + user_id: id_str.parse::().ok()?, + }) + } ["reconnect"] => Some(LocalRequest::ReconnectOmikron), ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { @@ -116,7 +154,7 @@ mod tests { #[test] fn parses_user_add() { - let req = parse("user add alice").unwrap(); + let req = parse("user add create alice").unwrap(); match req { LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), _ => panic!("expected CreateUser"), @@ -127,12 +165,20 @@ mod tests { fn accepts_the_headless_cli_user_vocabulary() { assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); assert!(matches!( - parse("users add alice"), + parse("users add create alice"), Some(LocalRequest::CreateUser { .. }) )); assert!(matches!( - parse("users remove 42"), - Some(LocalRequest::RemoveUser { user_id: 42 }) + parse("users hosting release 42"), + Some(LocalRequest::ReleaseUser { user_id: 42 }) + )); + assert!(matches!( + parse("users apps revoke 42 desktop"), + Some(LocalRequest::RevokeTrustedApp { user_id: 42, .. }) + )); + assert!(matches!( + parse("users apps revoke-all 42"), + Some(LocalRequest::RevokeAllTrustedApps { user_id: 42 }) )); assert!(matches!( parse("identity rotate"), @@ -141,17 +187,18 @@ mod tests { } #[test] - fn parses_user_remove_by_id() { - let req = parse("user remove 42").unwrap(); + fn parses_user_release_by_id() { + let req = parse("user hosting release 42").unwrap(); match req { - LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), - _ => panic!("expected RemoveUser"), + LocalRequest::ReleaseUser { user_id } => assert_eq!(user_id, 42), + _ => panic!("expected ReleaseUser"), } } #[test] - fn user_remove_requires_numeric_id() { - assert!(parse("user remove alice").is_none()); + fn generic_remove_is_not_routed() { + assert!(parse("user remove 42").is_none()); + assert!(parse("users remove alice").is_none()); } #[test] @@ -295,12 +342,8 @@ mod tests { } #[test] - fn parses_users_import() { - let req = parse("users import alice").unwrap(); - match req { - LocalRequest::ImportUser { username } => assert_eq!(username, "alice"), - _ => panic!("expected ImportUser"), - } + fn deprecated_import_is_not_routed() { + assert!(parse("users import alice").is_none()); } #[test] @@ -317,7 +360,15 @@ mod tests { #[test] fn completion_is_prefix_based_and_deterministic() { assert_eq!(completions("identity r"), vec!["identity rotate"]); - assert_eq!(completions("/users a"), vec!["users add "]); + assert_eq!( + completions("/users a"), + vec![ + "users add create ", + "users account delete ", + "users apps revoke ", + "users apps revoke-all ", + ] + ); assert!(completions("definitely-unknown").is_empty()); } diff --git a/iota-storage/src/users/invitations.rs b/iota-storage/src/users/invitations.rs new file mode 100644 index 0000000..48f8b1d --- /dev/null +++ b/iota-storage/src/users/invitations.rs @@ -0,0 +1,83 @@ +use crate::{storage_error::StorageError, util::db}; +use rusqlite::params; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvitationState { + Pending, + Redeemed, + Revoked, + Expired, +} + +impl InvitationState { + fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Redeemed => "redeemed", + Self::Revoked => "revoked", + Self::Expired => "expired", + } + } + + fn parse(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "redeemed" => Ok(Self::Redeemed), + "revoked" => Ok(Self::Revoked), + "expired" => Ok(Self::Expired), + _ => Err(StorageError::Other("unknown invitation state".into())), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InvitationSummary { + pub invitation_id: String, + pub created_at: i64, + pub expires_at: Option, + pub state: InvitationState, + pub redeemed_user_id: Option, + pub redeemed_at: Option, + pub revoked_at: Option, +} + +pub fn insert(summary: &InvitationSummary, token_hash: &[u8]) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + tx.execute( + "INSERT INTO user_invitations (invitation_id, token_hash, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![summary.invitation_id, token_hash, summary.created_at, summary.expires_at, summary.state.as_str(), summary.redeemed_user_id, summary.redeemed_at, summary.revoked_at], + )?; + Ok(()) + }) +} + +pub fn list() -> Result, StorageError> { + db::with_db(|conn| { + let mut statement = conn.prepare("SELECT invitation_id, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at FROM user_invitations ORDER BY created_at DESC")?; + Ok(statement + .query_map([], |row| { + Ok(InvitationSummary { + invitation_id: row.get(0)?, + created_at: row.get(1)?, + expires_at: row.get(2)?, + state: InvitationState::parse(&row.get::<_, String>(3)?).map_err(|error| { + rusqlite::Error::ToSqlConversionFailure(Box::new(error)) + })?, + redeemed_user_id: row.get(4)?, + redeemed_at: row.get(5)?, + revoked_at: row.get(6)?, + }) + })? + .collect::, _>>()?) + }) +} + +pub fn revoke(invitation_id: &str, revoked_at: i64) -> Result { + db::with_immediate_transaction(|tx| { + let changed = tx.execute( + "UPDATE user_invitations SET state = 'revoked', revoked_at = ?2 WHERE invitation_id = ?1 AND state = 'pending'", + params![invitation_id, revoked_at], + )?; + Ok(changed == 1) + }) +} diff --git a/iota-storage/src/users/mod.rs b/iota-storage/src/users/mod.rs index dad90f9..ab1268f 100644 --- a/iota-storage/src/users/mod.rs +++ b/iota-storage/src/users/mod.rs @@ -1,4 +1,5 @@ pub mod contact; +pub mod invitations; pub mod pending_operations; pub mod user_manager; pub mod user_profile; diff --git a/iota-storage/src/users/pending_operations.rs b/iota-storage/src/users/pending_operations.rs index 4993f17..c54865b 100644 --- a/iota-storage/src/users/pending_operations.rs +++ b/iota-storage/src/users/pending_operations.rs @@ -7,6 +7,7 @@ pub enum PendingUserOperationKind { Create, Attach, Release, + Purge, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -15,15 +16,21 @@ pub enum PendingUserOperationPhase { CredentialWritten, RemoteCommitted, LocalCommitted, + DatabasePurged, + E2eePurged, + FilesystemPurged, } impl PendingUserOperationPhase { - fn as_str(self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { Self::Prepared => "prepared", Self::CredentialWritten => "credential_written", Self::RemoteCommitted => "remote_committed", Self::LocalCommitted => "local_committed", + Self::DatabasePurged => "database_purged", + Self::E2eePurged => "e2ee_purged", + Self::FilesystemPurged => "filesystem_purged", } } @@ -33,6 +40,9 @@ impl PendingUserOperationPhase { "credential_written" => Ok(Self::CredentialWritten), "remote_committed" => Ok(Self::RemoteCommitted), "local_committed" => Ok(Self::LocalCommitted), + "database_purged" => Ok(Self::DatabasePurged), + "e2ee_purged" => Ok(Self::E2eePurged), + "filesystem_purged" => Ok(Self::FilesystemPurged), _ => Err(StorageError::Other( "unknown pending user operation phase".into(), )), @@ -41,11 +51,12 @@ impl PendingUserOperationPhase { } impl PendingUserOperationKind { - fn as_str(self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { Self::Create => "create", Self::Attach => "attach", Self::Release => "release", + Self::Purge => "purge", } } @@ -54,6 +65,7 @@ impl PendingUserOperationKind { "create" => Ok(Self::Create), "attach" => Ok(Self::Attach), "release" => Ok(Self::Release), + "purge" => Ok(Self::Purge), _ => Err(StorageError::Other("unknown pending user operation".into())), } } @@ -171,3 +183,20 @@ pub fn remove(user_id: i64) -> Result<(), StorageError> { Ok(()) }) } + +pub fn complete_purge(user_id: i64, updated_at: i64) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + let changed = tx.execute( + "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", + params![user_id, updated_at], + )?; + if changed == 0 { + return Err(StorageError::Other("user residency was not found".into())); + } + tx.execute( + "DELETE FROM pending_user_operations WHERE user_id = ?1 AND operation = 'purge'", + [user_id], + )?; + Ok(()) + }) +} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 43b2ed4..548bbc3 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,3 +1,4 @@ +use crate::users::pending_operations; use crate::users::user_profile::UserProfile; use crate::util::db; use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; @@ -10,12 +11,19 @@ pub enum LocalUserState { Released, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CredentialOrigin { + Local, + External, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct UserResidency { pub user_id: i64, pub username: String, pub state: LocalUserState, pub data_present: bool, + pub credential_origin: CredentialOrigin, } fn now_millis() -> i64 { @@ -32,6 +40,13 @@ pub fn add_user(user: UserProfile) { } pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { + try_add_user_with_credential_origin(user, CredentialOrigin::Local) +} + +pub fn try_add_user_with_credential_origin( + user: UserProfile, + credential_origin: CredentialOrigin, +) -> Result<(), crate::storage_error::StorageError> { db::with_immediate_transaction(|tx| { tx.execute( r#" @@ -65,10 +80,10 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora )?; } tx.execute( - r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) - VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) - ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#, - params![user.user_id, user.username, now_millis()], + r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at) + VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3, ?4) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', credential_origin = excluded.credential_origin, updated_at = excluded.updated_at"#, + params![user.user_id, user.username, match credential_origin { CredentialOrigin::Local => "local", CredentialOrigin::External => "external" }, now_millis()], )?; Ok(()) }) @@ -155,8 +170,8 @@ pub fn get_users() -> Vec { let user_id: i64 = r.get(0)?; let username: String = r.get(1)?; let public_key: String = r.get(2)?; - let private_key_hash: String = r.get(3)?; - let reset_token: String = r.get(4)?; + let private_key_hash: Option = r.get(3)?; + let reset_token: Option = r.get(4)?; let created_at: i64 = r.get(5)?; let display_name: Option = r.get(6)?; @@ -211,6 +226,29 @@ fn load_trusted_apps( }) } +pub fn revoke_trusted_app( + user_id: i64, + app_id: &str, +) -> Result { + db::with_immediate_transaction(|tx| { + let removed = tx.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1 AND app_id = ?2", + params![user_id, app_id], + )?; + Ok(removed > 0) + }) +} + +pub fn revoke_all_trusted_apps(user_id: i64) -> Result { + db::with_immediate_transaction(|tx| { + let removed = tx.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; + Ok(removed) + }) +} + pub fn remove_user(user_id: i64) { if let Err(e) = db::with_db(|conn| { conn.execute( @@ -232,6 +270,31 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr .ok_or_else(|| { crate::storage_error::StorageError::Other("managed user was not found".into()) })?; + finalize_local_release(user_id, Some(&username)) +} + +/* + * Finish local release after Omega has changed assignment. Every step accepts + * an earlier attempt completing it, so reconciliation can safely retry it. + */ +pub fn finalize_local_release( + user_id: i64, + username_hint: Option<&str>, +) -> Result<(), crate::storage_error::StorageError> { + let username = get_user(user_id)? + .map(|user| user.username) + .or_else(|| { + get_residency_by_id(user_id) + .ok() + .flatten() + .map(|residency| residency.username) + }) + .or_else(|| username_hint.map(str::to_owned)); + let Some(username) = username else { + return Err(crate::storage_error::StorageError::Other( + "user residency was not found".into(), + )); + }; db::with_db(|conn| { let tx = conn.unchecked_transaction()?; tx.execute( @@ -252,12 +315,84 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } -/// Authoritative hosted-data erasure used by local purge and future Omega -/// erasure delivery. Management metadata and credentials are left intact. -pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> { - if crate::util::relay_queue::has_unclassified_relays()? { - return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown); +pub fn force_local_detach(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + finalize_local_release(user_id, None) +} + +pub fn forget_released_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + let residency = get_residency_by_id(user_id)?.ok_or_else(|| { + crate::storage_error::StorageError::Other("user residency was not found".into()) + })?; + if residency.state != LocalUserState::Released || residency.data_present { + return Err(crate::storage_error::StorageError::Other( + "only released users with empty hosted data can be forgotten".into(), + )); } + if pending_operations::get_all()? + .iter() + .any(|operation| operation.user_id == user_id) + { + return Err(crate::storage_error::StorageError::Other( + "user has a pending lifecycle operation".into(), + )); + } + db::with_db(|conn| { + conn.execute( + "DELETE FROM user_residency WHERE user_id = ?1", + params![user_id], + )?; + Ok(()) + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PurgeStage { + Database, + E2ee, + Filesystem, + Complete, +} + +fn remaining_purge_stages( + phase: pending_operations::PendingUserOperationPhase, +) -> Result, crate::storage_error::StorageError> { + use pending_operations::PendingUserOperationPhase; + let stages = match phase { + PendingUserOperationPhase::Prepared => vec![ + PurgeStage::Database, + PurgeStage::E2ee, + PurgeStage::Filesystem, + PurgeStage::Complete, + ], + PendingUserOperationPhase::DatabasePurged => vec![ + PurgeStage::E2ee, + PurgeStage::Filesystem, + PurgeStage::Complete, + ], + PendingUserOperationPhase::E2eePurged => { + vec![PurgeStage::Filesystem, PurgeStage::Complete] + } + PendingUserOperationPhase::FilesystemPurged => vec![PurgeStage::Complete], + _ => { + return Err(crate::storage_error::StorageError::Other( + "pending purge has an invalid phase".into(), + )); + } + }; + Ok(stages) +} + +fn run_purge_stages( + phase: pending_operations::PendingUserOperationPhase, + mut run: impl FnMut(PurgeStage) -> Result<(), crate::storage_error::StorageError>, +) -> Result<(), crate::storage_error::StorageError> { + for stage in remaining_purge_stages(phase)? { + run(stage)?; + } + Ok(()) +} + +fn purge_database_rows(user_id: i64) -> Result<(), crate::storage_error::StorageError> { db::with_db(|conn| { let tx = conn.unchecked_transaction()?; tx.execute( @@ -315,10 +450,6 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage "DELETE FROM client_message_deliveries WHERE user_id = ?1", params![user_id], )?; - tx.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", - params![user_id], - )?; tx.execute( "DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1", params![user_id], @@ -331,17 +462,84 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage "DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1", params![user_id], )?; - tx.execute( - "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", - params![user_id, now_millis()], - )?; tx.commit()?; Ok(()) + }) +} + +/* Keep the residency authoritative until every hosted-data store has been + * erased. Each completed phase is durable, and every cleanup stage is safe to + * repeat after a crash between cleanup and phase persistence. */ +pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> { + use pending_operations::{ + PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, + }; + + let residency = get_residency_by_id(user_id)?.ok_or_else(|| { + crate::storage_error::StorageError::Other("user residency was not found".into()) })?; - crate::util::e2ee_storage::purge_user(user_id) - .map_err(crate::storage_error::StorageError::Other)?; - delete_user_directory(user_id) - .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + let existing = pending_operations::get_all()? + .into_iter() + .find(|operation| operation.user_id == user_id); + let phase = match existing { + Some(operation) if operation.operation == PendingUserOperationKind::Purge => { + operation.phase + } + Some(_) => { + return Err(crate::storage_error::StorageError::Other( + "user has another pending lifecycle operation".into(), + )); + } + None if !residency.data_present => return Ok(()), + None => { + let operation = PendingUserOperation { + user_id, + operation: PendingUserOperationKind::Purge, + username: residency.username, + public_key: None, + private_key_hash: None, + reset_token: None, + registration_token: None, + phase: PendingUserOperationPhase::Prepared, + created_at: now_millis(), + }; + pending_operations::upsert(&operation)?; + PendingUserOperationPhase::Prepared + } + }; + + run_purge_stages(phase, |stage| { + match stage { + PurgeStage::Database => { + if crate::util::relay_queue::has_unclassified_relays()? { + return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown); + } + purge_database_rows(user_id)?; + pending_operations::update_phase( + user_id, + PendingUserOperationPhase::DatabasePurged, + )?; + } + PurgeStage::E2ee => { + crate::util::e2ee_storage::purge_user(user_id) + .map_err(crate::storage_error::StorageError::Other)?; + pending_operations::update_phase(user_id, PendingUserOperationPhase::E2eePurged)?; + } + PurgeStage::Filesystem => { + delete_user_directory(user_id).map_err(|error| { + crate::storage_error::StorageError::Other(error.to_string()) + })?; + pending_operations::update_phase( + user_id, + PendingUserOperationPhase::FilesystemPurged, + )?; + } + PurgeStage::Complete => { + pending_operations::complete_purge(user_id, now_millis())?; + } + } + Ok(()) + }) } /// Complete local erasure is idempotent and is the target for a durable @@ -372,19 +570,50 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor pub fn get_residency() -> Vec { db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?; + let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state, credential_origin FROM user_residency ORDER BY username")?; let rows = stmt.query_map([], |row| { let lifecycle: String = row.get(2)?; Ok(UserResidency { user_id: row.get(0)?, username: row.get(1)?, state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released }, data_present: row.get::<_, String>(3)? == "present", + credential_origin: if row.get::<_, String>(4)? == "external" { CredentialOrigin::External } else { CredentialOrigin::Local }, }) })?; rows.collect::, _>>().map_err(Into::into) }).unwrap_or_default() } +pub fn get_residency_by_id( + user_id: i64, +) -> Result, crate::storage_error::StorageError> { + db::with_db(|conn| { + let mut statement = conn.prepare( + "SELECT user_id, username, lifecycle_state, data_state, credential_origin FROM user_residency WHERE user_id = ?1", + )?; + let mut rows = statement.query(params![user_id])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let lifecycle: String = row.get(2)?; + Ok(Some(UserResidency { + user_id: row.get(0)?, + username: row.get(1)?, + state: if lifecycle == "managed" { + LocalUserState::Managed + } else { + LocalUserState::Released + }, + data_present: row.get::<_, String>(3)? == "present", + credential_origin: if row.get::<_, String>(4)? == "external" { + CredentialOrigin::External + } else { + CredentialOrigin::Local + }, + })) + }) +} + pub fn clear() { if let Err(e) = db::with_db(|conn| { conn.execute_batch( @@ -424,6 +653,75 @@ pub fn load_users_sync() -> std::io::Result<()> { Ok(()) } +#[cfg(test)] +mod purge_tests { + use super::{PurgeStage, remaining_purge_stages, run_purge_stages}; + use crate::users::pending_operations::PendingUserOperationPhase; + + #[test] + fn prepared_purge_runs_every_stage_before_completion() { + assert_eq!( + remaining_purge_stages(PendingUserOperationPhase::Prepared).unwrap(), + vec![ + PurgeStage::Database, + PurgeStage::E2ee, + PurgeStage::Filesystem, + PurgeStage::Complete, + ] + ); + } + + #[test] + fn purge_resumes_after_each_persisted_phase() { + assert_eq!( + remaining_purge_stages(PendingUserOperationPhase::DatabasePurged).unwrap(), + vec![ + PurgeStage::E2ee, + PurgeStage::Filesystem, + PurgeStage::Complete, + ] + ); + assert_eq!( + remaining_purge_stages(PendingUserOperationPhase::E2eePurged).unwrap(), + vec![PurgeStage::Filesystem, PurgeStage::Complete] + ); + assert_eq!( + remaining_purge_stages(PendingUserOperationPhase::FilesystemPurged).unwrap(), + vec![PurgeStage::Complete] + ); + } + + #[test] + fn lifecycle_phases_are_rejected_for_pending_purge() { + assert!(remaining_purge_stages(PendingUserOperationPhase::RemoteCommitted).is_err()); + } + + #[test] + fn purge_does_not_complete_after_an_interrupted_cleanup_stage() { + for failed_stage in [ + PurgeStage::Database, + PurgeStage::E2ee, + PurgeStage::Filesystem, + ] { + let mut data_empty = false; + let result = run_purge_stages(PendingUserOperationPhase::Prepared, |stage| { + if stage == failed_stage { + return Err(crate::storage_error::StorageError::Other( + "interrupted".into(), + )); + } + if stage == PurgeStage::Complete { + data_empty = true; + } + Ok(()) + }); + + assert!(result.is_err()); + assert!(!data_empty); + } + } +} + pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { let path = format!("users/{}/apps", user_id); let name = format!("{}.json", app_identifier); diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index f1aa2a3..f097ae5 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -12,8 +12,8 @@ pub struct UserProfile { pub user_id: i64, pub username: String, pub public_key: String, - pub private_key_hash: String, - pub reset_token: String, + pub private_key_hash: Option, + pub reset_token: Option, pub created_at: i64, pub display_name: Option, pub trusted_apps: std::collections::HashMap, @@ -25,8 +25,8 @@ impl UserProfile { username: String, display_name: Option, public_key: String, - private_key_hash: String, - reset_token: String, + private_key_hash: Option, + reset_token: Option, ) -> Self { Self::new_with_created_at( user_id, @@ -47,8 +47,8 @@ impl UserProfile { username: String, display_name: Option, public_key: String, - private_key_hash: String, - reset_token: String, + private_key_hash: Option, + reset_token: Option, created_at: i64, ) -> Self { Self { @@ -68,7 +68,6 @@ impl UserProfile { "uuid" => self.user_id, "username" => self.username.clone(), "public_key" => self.public_key.clone(), - "private_key_hash" => self.private_key_hash.clone(), "created_at" => self.created_at, "storage" => used_dir_space(&format!("users/{}", self.user_id.to_string())), }; @@ -88,8 +87,8 @@ impl UserProfile { let user_id = j["uuid"].as_i64()?; let username = j["username"].as_str()?.to_string(); let public_key = j["public_key"].as_str()?.to_string(); - let private_key_hash = j["private_key_hash"].as_str()?.to_string(); - let reset_token = j["reset_token"].as_str()?.to_string(); + let private_key_hash = j["private_key_hash"].as_str().map(str::to_owned); + let reset_token = j["reset_token"].as_str().map(str::to_owned); let created_at = j["created_at"].as_i64()?; let display_name = j["display_name"].as_str().map(|s| s.to_string()); @@ -127,7 +126,7 @@ impl UserProfile { let mut bytes = [0u8; 192]; OsRng.fill(bytes.as_mut()); let new_token = general_purpose::STANDARD.encode(&bytes); - self.reset_token = new_token.clone(); + self.reset_token = Some(new_token.clone()); new_token } diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index b3dfe08..ef3e827 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -21,6 +21,8 @@ pub struct IotaConfig { pub omikron_host: Option, #[serde(skip_serializing_if = "Option::is_none")] pub omikron_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub omikron_id: Option, #[serde(skip_serializing)] pub keyring: Option, #[serde(skip_serializing)] @@ -101,6 +103,7 @@ impl Default for IotaConfig { web: WebSettings::default(), omikron_host: None, omikron_port: None, + omikron_id: None, keyring: None, public_key: None, private_key: None, diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index c983324..c63e481 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -710,6 +710,110 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 21 { + let users_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')", + [], + |row| row.get(0), + )?; + if users_exist { + conn.execute_batch( + r#" + ALTER TABLE users RENAME TO users_before_credential_origin; + CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + private_key_hash TEXT, + reset_token TEXT, + created_at INTEGER NOT NULL, + display_name TEXT + ); + INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) + SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name + FROM users_before_credential_origin; + DROP TABLE users_before_credential_origin; + "#, + )?; + } + let residency_exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_residency')", + [], + |row| row.get(0), + )?; + if residency_exists { + add_table_column_if_missing( + conn, + "user_residency", + "credential_origin", + "credential_origin TEXT NOT NULL DEFAULT 'local' CHECK (credential_origin IN ('local', 'external'))", + )?; + } + conn.pragma_update(None, "user_version", 21)?; + } + + if current_version < 22 { + let pending_exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_user_operations')", + [], + |row| row.get(0), + )?; + if pending_exists { + conn.execute_batch( + r#" + ALTER TABLE pending_user_operations RENAME TO pending_user_operations_before_purge; + CREATE TABLE pending_user_operations ( + user_id INTEGER PRIMARY KEY, + operation TEXT NOT NULL + CHECK (operation IN ('create', 'attach', 'release', 'purge')), + username TEXT NOT NULL, + public_key TEXT, + private_key_hash TEXT, + reset_token TEXT, + registration_token TEXT, + phase TEXT NOT NULL DEFAULT 'prepared' + CHECK (phase IN ( + 'prepared', 'credential_written', 'remote_committed', 'local_committed', + 'database_purged', 'e2ee_purged', 'filesystem_purged' + )), + created_at INTEGER NOT NULL + ); + INSERT INTO pending_user_operations ( + user_id, operation, username, public_key, private_key_hash, + reset_token, registration_token, phase, created_at + ) + SELECT user_id, operation, username, public_key, private_key_hash, + reset_token, registration_token, phase, created_at + FROM pending_user_operations_before_purge; + DROP TABLE pending_user_operations_before_purge; + CREATE INDEX idx_pending_user_operations_operation + ON pending_user_operations (operation, created_at); + "#, + )?; + } + conn.pragma_update(None, "user_version", 22)?; + } + + if current_version < 23 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS user_invitations ( + invitation_id TEXT PRIMARY KEY, + token_hash BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + state TEXT NOT NULL CHECK (state IN ('pending', 'redeemed', 'revoked', 'expired')), + redeemed_user_id INTEGER, + redeemed_at INTEGER, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_user_invitations_state_created + ON user_invitations (state, created_at); + PRAGMA user_version = 23; + "#, + )?; + } + Ok(()) } @@ -779,7 +883,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 20); + assert_eq!(version, 23); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -798,7 +902,7 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 20); + assert_eq!(version, 23); for table in [ "sync_heads", "sync_events", @@ -813,6 +917,7 @@ mod tests { "blocked_users", "user_receipt_policy", "user_message_storage_policy", + "user_invitations", ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -837,7 +942,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 20); + assert_eq!(version, 23); for column in [ "id", "user_id", @@ -855,4 +960,84 @@ mod tests { Ok(()) } + + #[test] + fn credential_origin_migration_allows_external_profiles() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch( + r#" + CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + private_key_hash TEXT NOT NULL, + reset_token TEXT NOT NULL, + created_at INTEGER NOT NULL, + display_name TEXT + ); + CREATE TABLE user_residency ( + user_id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + data_state TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + PRAGMA user_version = 20; + "#, + )?; + run_migrations_on_connection(&conn)?; + conn.execute( + "INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at) VALUES (1, 'alice', 'key', NULL, NULL, 1)", + [], + )?; + let origin_exists: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('user_residency') WHERE name = 'credential_origin'", + [], + |row| row.get(0), + )?; + assert_eq!(origin_exists, 1); + Ok(()) + } + + #[test] + fn pending_purge_migration_preserves_lifecycle_operations() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch( + r#" + CREATE TABLE pending_user_operations ( + user_id INTEGER PRIMARY KEY, + operation TEXT NOT NULL + CHECK (operation IN ('create', 'attach', 'release')), + username TEXT NOT NULL, + public_key TEXT, + private_key_hash TEXT, + reset_token TEXT, + registration_token TEXT, + phase TEXT NOT NULL DEFAULT 'prepared' + CHECK (phase IN ('prepared', 'credential_written', 'remote_committed', 'local_committed')), + created_at INTEGER NOT NULL + ); + INSERT INTO pending_user_operations ( + user_id, operation, username, phase, created_at + ) VALUES (1, 'release', 'alice', 'remote_committed', 1); + PRAGMA user_version = 21; + "#, + )?; + + run_migrations_on_connection(&conn)?; + conn.execute( + "INSERT INTO pending_user_operations (user_id, operation, username, phase, created_at) VALUES (2, 'purge', 'bob', 'filesystem_purged', 2)", + [], + )?; + + let preserved: String = conn.query_row( + "SELECT phase FROM pending_user_operations WHERE user_id = 1", + [], + |row| row.get(0), + )?; + let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + assert_eq!(preserved, "remote_committed"); + assert_eq!(version, 23); + Ok(()) + } } diff --git a/iota-util/src/atomic_file.rs b/iota-util/src/atomic_file.rs index 9800a91..7ac5607 100644 --- a/iota-util/src/atomic_file.rs +++ b/iota-util/src/atomic_file.rs @@ -16,6 +16,39 @@ pub fn replace_private(path: &Path, contents: &[u8], backup_limit: usize) -> io: replace_with_mode(path, contents, backup_limit, true) } +/* Create exported secret material without replacing an existing destination. + * Operators must choose another path explicitly if a file already exists. */ +pub fn create_private(path: &Path, contents: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "export file has no parent directory", + ) + })?; + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + if let Err(error) = set_private_permissions(path, true) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + let write_result = (|| { + file.write_all(contents)?; + file.sync_all()?; + if !parent.as_os_str().is_empty() { + sync_directory(parent)?; + } + Ok(()) + })(); + if write_result.is_err() { + drop(file); + let _ = fs::remove_file(path); + } + write_result +} + fn replace_with_mode( path: &Path, contents: &[u8], @@ -133,7 +166,7 @@ fn sync_directory(_path: &Path) -> io::Result<()> { #[cfg(test)] mod tests { - use super::replace; + use super::{create_private, replace}; #[test] fn replace_preserves_a_previous_version_as_a_backup() { @@ -155,4 +188,32 @@ mod tests { .count(); assert_eq!(backups, 1); } + + #[test] + fn create_private_refuses_to_replace_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.tu"); + create_private(&path, b"first").unwrap(); + + assert_eq!( + create_private(&path, b"second").unwrap_err().kind(), + std::io::ErrorKind::AlreadyExists + ); + assert_eq!(std::fs::read(path).unwrap(), b"first"); + } + + #[cfg(unix)] + #[test] + fn create_private_uses_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.tu"); + create_private(&path, b"secret").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } } diff --git a/iota-util/tests/private_export.rs b/iota-util/tests/private_export.rs new file mode 100644 index 0000000..06b3efc --- /dev/null +++ b/iota-util/tests/private_export.rs @@ -0,0 +1,29 @@ +use iota_util::atomic_file::create_private; + +#[test] +fn private_export_refuses_to_replace_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.tu"); + create_private(&path, b"first").unwrap(); + + assert_eq!( + create_private(&path, b"second").unwrap_err().kind(), + std::io::ErrorKind::AlreadyExists + ); + assert_eq!(std::fs::read(path).unwrap(), b"first"); +} + +#[cfg(unix)] +#[test] +fn private_export_uses_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.tu"); + create_private(&path, b"secret").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index ca914f8..57f60d6 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -117,20 +117,55 @@ enum UsersAction { user_id: i64, }, Add { - username: Option, - #[arg(long, value_name = "PATH")] - tu: Option, + #[command(subcommand)] + action: UserAddAction, }, - Release { - user_id: i64, - #[arg(long)] - yes: bool, + Hosting { + #[command(subcommand)] + action: UserHostingAction, }, Data { #[command(subcommand)] action: UserDataAction, }, - CompleteDelete { + Account { + #[command(subcommand)] + action: UserAccountAction, + }, + Repair { + #[command(subcommand)] + action: UserRepairAction, + }, + Forget { + user_id: i64, + #[arg(long)] + yes: bool, + }, + Apps { + #[command(subcommand)] + action: UserAppsAction, + }, + Credential { + #[command(subcommand)] + action: UserCredentialAction, + }, +} +#[derive(Subcommand, Debug)] +enum UserAddAction { + Create { username: String }, + Tu { path: PathBuf }, +} +#[derive(Subcommand, Debug)] +enum UserHostingAction { + Release { + user_id: i64, + #[arg(long)] + yes: bool, + }, +} +#[derive(Subcommand, Debug)] +enum UserAccountAction { + Delete { user_id: i64, #[arg(long, value_name = "PATH")] tu: Option, @@ -139,6 +174,42 @@ enum UsersAction { }, } #[derive(Subcommand, Debug)] +enum UserRepairAction { + Reconcile { + user_id: i64, + }, + Diagnostics { + user_id: i64, + }, + ForceDetach { + user_id: i64, + #[arg(long)] + yes: bool, + }, +} +#[derive(Subcommand, Debug)] +enum UserAppsAction { + Revoke { + user_id: i64, + app_id: String, + #[arg(long)] + yes: bool, + }, + RevokeAll { + user_id: i64, + #[arg(long)] + yes: bool, + }, +} +#[derive(Subcommand, Debug)] +enum UserCredentialAction { + Export { + user_id: i64, + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, +} +#[derive(Subcommand, Debug)] enum UserDataAction { Purge { user_id: i64, @@ -310,6 +381,33 @@ pub enum Command { tu: Option, confirmed: bool, }, + UsersReconcile { + user_id: i64, + }, + UsersDiagnostics { + user_id: i64, + }, + UsersForceDetach { + user_id: i64, + confirmed: bool, + }, + UsersForget { + user_id: i64, + confirmed: bool, + }, + UsersRevokeApp { + user_id: i64, + app_id: String, + confirmed: bool, + }, + UsersRevokeAllApps { + user_id: i64, + confirmed: bool, + }, + UsersExportCredential { + user_id: i64, + output: PathBuf, + }, OmikronReconnect, IdentityRotate { confirmed: bool, @@ -406,15 +504,21 @@ impl CliInvocation { Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, - UsersAction::Add { username, tu } => { - if username.is_some() == tu.is_some() { - return Err( - "users add requires exactly one of or --tu ".into(), - ); - } - Command::UsersAdd { username, tu } - } - UsersAction::Release { user_id, yes } => Command::UsersRelease { + UsersAction::Add { + action: UserAddAction::Create { username }, + } => Command::UsersAdd { + username: Some(username), + tu: None, + }, + UsersAction::Add { + action: UserAddAction::Tu { path }, + } => Command::UsersAdd { + username: None, + tu: Some(path), + }, + UsersAction::Hosting { + action: UserHostingAction::Release { user_id, yes }, + } => Command::UsersRelease { user_id, confirmed: resolve_confirmed(yes), }, @@ -424,11 +528,50 @@ impl CliInvocation { user_id, confirmed: resolve_confirmed(yes), }, - UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete { + UsersAction::Account { + action: UserAccountAction::Delete { user_id, tu, yes }, + } => Command::UsersCompleteDelete { user_id, tu, confirmed: resolve_confirmed(yes), }, + UsersAction::Repair { + action: UserRepairAction::Reconcile { user_id }, + } => Command::UsersReconcile { user_id }, + UsersAction::Repair { + action: UserRepairAction::Diagnostics { user_id }, + } => Command::UsersDiagnostics { user_id }, + UsersAction::Repair { + action: UserRepairAction::ForceDetach { user_id, yes }, + } => Command::UsersForceDetach { + user_id, + confirmed: resolve_confirmed(yes), + }, + UsersAction::Forget { user_id, yes } => Command::UsersForget { + user_id, + confirmed: resolve_confirmed(yes), + }, + UsersAction::Apps { + action: + UserAppsAction::Revoke { + user_id, + app_id, + yes, + }, + } => Command::UsersRevokeApp { + user_id, + app_id, + confirmed: resolve_confirmed(yes), + }, + UsersAction::Apps { + action: UserAppsAction::RevokeAll { user_id, yes }, + } => Command::UsersRevokeAllApps { + user_id, + confirmed: resolve_confirmed(yes), + }, + UsersAction::Credential { + action: UserCredentialAction::Export { user_id, output }, + } => Command::UsersExportCredential { user_id, output }, }, Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, @@ -594,7 +737,8 @@ mod tests { #[test] fn command_schema_drives_help_and_completion_paths() { let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users release".to_owned())); + assert!(paths.contains(&"users hosting release".to_owned())); + assert!(paths.contains(&"users add create".to_owned())); assert!(paths.contains(&"daemon install".to_owned())); let help = CliInvocation::help_text(); assert!(help.contains("users")); @@ -649,8 +793,13 @@ mod tests { #[test] fn parses_users_add() { - let invocation = - CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); + let invocation = CliInvocation::parse([ + "users".into(), + "add".into(), + "create".into(), + "alice".into(), + ]) + .unwrap(); assert_eq!( invocation.command, Command::UsersAdd { @@ -689,6 +838,7 @@ mod tests { fn parses_users_release_with_confirmation() { let invocation = CliInvocation::parse([ "users".into(), + "hosting".into(), "release".into(), "42".into(), "--yes".into(), @@ -703,6 +853,39 @@ mod tests { ); } + #[test] + fn parses_credential_export_with_required_destination() { + let invocation = CliInvocation::parse([ + "users".into(), + "credential".into(), + "export".into(), + "42".into(), + "--output".into(), + "alice.tu".into(), + ]) + .unwrap(); + assert_eq!( + invocation.command, + Command::UsersExportCredential { + user_id: 42, + output: PathBuf::from("alice.tu"), + } + ); + } + + #[test] + fn credential_export_rejects_a_missing_destination() { + assert!( + CliInvocation::parse([ + "users".into(), + "credential".into(), + "export".into(), + "42".into(), + ]) + .is_err() + ); + } + #[test] fn parses_omikron_reconnect() { let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap(); diff --git a/iota/src/main.rs b/iota/src/main.rs index 3746707..94beca5 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -400,10 +400,18 @@ fn print_help() { println!(" tasks List active tasks"); println!(" users list List all users"); println!(" users show Show user details"); - println!(" users add Create a new user"); - println!(" users add --tu Add an existing account credential"); + println!(" users add create Create a new user"); + println!(" users add tu Attach an existing account credential"); println!(" users data purge Purge hosted data (requires --yes)"); - println!(" users release Release this Iota (requires --yes)"); + println!(" users hosting release Release from this Iota (requires --yes)"); + println!(" users account delete Delete the Tensamin account (requires --yes)"); + println!(" users repair reconcile Compare local state with Omega"); + println!(" users repair diagnostics Show local lifecycle diagnostics"); + println!(" users repair force-detach Remove local management (requires --yes)"); + println!(" users forget Forget an empty released residency (requires --yes)"); + println!(" users apps revoke Revoke a trusted app (requires --yes)"); + println!(" users apps revoke-all Revoke all trusted apps (requires --yes)"); + println!(" users credential export --output Export the local TU"); println!(" omikron status Show Omikron connection status"); println!(" omikron reconnect Reconnect to Omikron"); println!(" identity rotate Rotate identity keys (requires --yes)"); @@ -549,6 +557,7 @@ async fn run_command( output: OutputFormat, ) -> Result<(), StartupError> { let color = ColorConfig::new(); + let mut credential_output = None; let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, @@ -622,6 +631,40 @@ async fn run_command( credential, } } + Command::UsersReconcile { user_id } => LocalRequest::ReconcileUser { user_id }, + Command::UsersDiagnostics { user_id } => LocalRequest::GetUserDiagnostics { user_id }, + Command::UsersForceDetach { + user_id, + confirmed: true, + } => LocalRequest::ForceDetachUser { user_id }, + Command::UsersForget { + user_id, + confirmed: true, + } => LocalRequest::ForgetReleasedUser { user_id }, + Command::UsersRevokeApp { + user_id, + app_id, + confirmed: true, + } => LocalRequest::RevokeTrustedApp { user_id, app_id }, + Command::UsersRevokeAllApps { + user_id, + confirmed: true, + } => LocalRequest::RevokeAllTrustedApps { user_id }, + Command::UsersExportCredential { user_id, output } => { + let daemon_status = ipc.daemon_status(); + if !daemon_status + .borrow() + .capabilities + .iter() + .any(|capability| capability == "credential_export_v1") + { + return Err(StartupError::InvalidCommand( + "The connected daemon does not support credential export.".into(), + )); + } + credential_output = Some(output); + LocalRequest::ExportUserCredential { user_id } + } Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -650,6 +693,18 @@ async fn run_command( | Command::UsersCompleteDelete { confirmed: false, .. } + | Command::UsersForceDetach { + confirmed: false, .. + } + | Command::UsersForget { + confirmed: false, .. + } + | Command::UsersRevokeApp { + confirmed: false, .. + } + | Command::UsersRevokeAllApps { + confirmed: false, .. + } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -685,6 +740,38 @@ async fn run_command( .map_err(|e| StartupError::Other(e.to_string()))? { ResponseResult::Ok(payload) => { + if let ResponsePayload::UserCredentialExport { + user_id, + username, + credential, + } = &payload + { + let path = credential_output.as_deref().ok_or_else(|| { + StartupError::Other( + "Refusing to render a credential without an export destination.".into(), + ) + })?; + iota_util::atomic_file::create_private(path, credential.0.as_bytes()).map_err( + |error| { + StartupError::Other(format!( + "Cannot export credential to {}: {error}", + path.display() + )) + }, + )?; + println!( + "Exported credential for {} ({}) to {}.", + username, + user_id, + path.display() + ); + return Ok(()); + } + if credential_output.is_some() { + return Err(StartupError::Other( + "The daemon returned an unexpected credential export response.".into(), + )); + } if !matches!(output, OutputFormat::Text) { return render_structured(&payload, output); } @@ -725,11 +812,34 @@ async fn run_command( if users.is_empty() { println!("{}", cli_color::muted(&color, "No users.")); } else { + println!( + "{:<12} {:<18} {:<10} {:<9} {:<12} PENDING", + "ID", "USERNAME", "STATE", "DATA", "CREDENTIAL" + ); for user in &users { println!( - "{} ({})", + "{:<12} {:<18} {:<10} {:<9} {:<12} {}", + user.user_id, cli_color::heading(&color, &user.username), - user.user_id + match user.state { + iota_ipc::LocalUserState::Managed => "managed", + iota_ipc::LocalUserState::Released => "released", + }, + if user.data_present { + "present" + } else { + "empty" + }, + match user.credential_status { + iota_ipc::CredentialStatus::LocalPresent => "local", + iota_ipc::CredentialStatus::External => "external", + iota_ipc::CredentialStatus::Missing => "missing", + iota_ipc::CredentialStatus::None => "none", + }, + user.pending_operation + .as_ref() + .map(format_pending_operation) + .unwrap_or_else(|| "-".into()) ); } } @@ -815,6 +925,34 @@ async fn run_command( println!("Trusted Apps: {}", user.trusted_apps.join(", ")); } } + ResponsePayload::TuCredentialPreview(preview) => { + println!("User: {} ({})", preview.username, preview.user_id); + println!( + "Current Iota: {}", + preview + .assigned_iota_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "Unassigned".into()) + ); + } + ResponsePayload::UserReconciled(result) => { + println!("Reconciled user {}: {:?}", result.user_id, result.action); + } + ResponsePayload::UserDiagnostics(diagnostics) => { + println!("User: {} ({})", diagnostics.username, diagnostics.user_id); + println!("Local state: {:?}", diagnostics.local_state); + println!("Hosted data: {}", diagnostics.data_present); + println!("Credential: {:?}", diagnostics.credential_status); + println!("Trusted applications: {}", diagnostics.trusted_app_count); + if let Some(operation) = &diagnostics.pending_operation { + println!("Pending operation: {operation}"); + } + } + ResponsePayload::UserCredentialExport { .. } => { + return Err(StartupError::Other( + "Refusing to display credential material.".into(), + )); + } ResponsePayload::LogEntries(logs) => { for entry in &logs.entries { let ts = entry.timestamp_ms; @@ -860,6 +998,11 @@ async fn run_command( /// Text remains an operator-oriented presentation; JSON and YAML must never /// require consumers to parse it. fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { + if matches!(payload, ResponsePayload::UserCredentialExport { .. }) { + return Err(StartupError::Other( + "Refusing to encode credential material as ordinary output.".into(), + )); + } match output { OutputFormat::Json => println!( "{}", @@ -886,10 +1029,35 @@ fn render_table(payload: &ResponsePayload) { println!("No users."); return; } - println!("{:<8} {}", "ID", "USERNAME"); - println!("{:<8} {}", "--------", "--------"); + println!( + "{:<12} {:<18} {:<10} {:<9} {:<12} PENDING", + "ID", "USERNAME", "STATE", "DATA", "CREDENTIAL" + ); for user in users { - println!("{:<8} {}", user.user_id, user.username); + println!( + "{:<12} {:<18} {:<10} {:<9} {:<12} {}", + user.user_id, + user.username, + match user.state { + iota_ipc::LocalUserState::Managed => "managed", + iota_ipc::LocalUserState::Released => "released", + }, + if user.data_present { + "present" + } else { + "empty" + }, + match user.credential_status { + iota_ipc::CredentialStatus::LocalPresent => "local", + iota_ipc::CredentialStatus::External => "external", + iota_ipc::CredentialStatus::Missing => "missing", + iota_ipc::CredentialStatus::None => "none", + }, + user.pending_operation + .as_ref() + .map(format_pending_operation) + .unwrap_or_else(|| "-".into()) + ); } } ResponsePayload::Tasks(tasks) => { @@ -990,6 +1158,33 @@ fn render_table(payload: &ResponsePayload) { ResponsePayload::UserCreated { user_id, username } => { println!("Created user {} ({})", username, user_id); } + ResponsePayload::TuCredentialPreview(preview) => { + println!("{:<15} {}", "Username", preview.username); + println!("{:<15} {}", "User ID", preview.user_id); + println!( + "{:<15} {}", + "Current Iota", + preview + .assigned_iota_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "Unassigned".into()) + ); + } + ResponsePayload::UserReconciled(result) => { + println!("{:<15} {}", "User ID", result.user_id); + println!("{:<15} {:?}", "Action", result.action); + } + ResponsePayload::UserDiagnostics(diagnostics) => { + println!("{:<15} {}", "Username", diagnostics.username); + println!("{:<15} {}", "User ID", diagnostics.user_id); + println!("{:<15} {:?}", "Local state", diagnostics.local_state); + println!("{:<15} {}", "Hosted data", diagnostics.data_present); + println!("{:<15} {:?}", "Credential", diagnostics.credential_status); + println!("{:<15} {}", "Trusted apps", diagnostics.trusted_app_count); + } + ResponsePayload::UserCredentialExport { .. } => { + println!("Credential export payload withheld."); + } ResponsePayload::UserRemoved { user_id } => { println!("Removed user {}", user_id); } @@ -1014,3 +1209,13 @@ fn render_table(payload: &ResponsePayload) { } } } + +fn format_pending_operation(operation: &iota_ipc::UserOperationSummary) -> String { + let kind = match operation.operation { + iota_ipc::UserOperationKind::Create => "create", + iota_ipc::UserOperationKind::Attach => "attach", + iota_ipc::UserOperationKind::Release => "release", + iota_ipc::UserOperationKind::Purge => "purge", + }; + format!("{kind}/{}", operation.phase) +} diff --git a/mtp-type-maps b/mtp-type-maps index 4b82f4f..909b397 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 4b82f4f8139ed9aa74fa86f73ba8f0d565703c86 +Subproject commit 909b3977cb6a233e74a925eb467412fb384844bc diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs index 0561212..03cb832 100644 --- a/omikron-connector/src/client.rs +++ b/omikron-connector/src/client.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use mtp::codec::CommunicationValue; +use mtp::codec::{CommunicationType, CommunicationValue}; use std::time::Duration; #[derive(Clone, Debug, PartialEq, Eq)] @@ -7,6 +7,7 @@ pub enum OmikronError { Disconnected(String), Timeout(String), Authentication(String), + Rejected(CommunicationType, String), Internal(String), } @@ -16,6 +17,7 @@ impl std::fmt::Display for OmikronError { Self::Disconnected(v) | Self::Timeout(v) | Self::Authentication(v) + | Self::Rejected(_, v) | Self::Internal(v) => f.write_str(v), } } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 263910f..2aafa5a 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -38,7 +38,7 @@ use iota_util::route_target::RouteTarget; const IOTA_KEYRING_PATH: &str = "iota.mk"; static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); -static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); +static OMIKRON_TRUST_DIRECTORY: std::sync::OnceLock = std::sync::OnceLock::new(); fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) { let Ok(storage_owner) = i64::try_from(signer_id) else { @@ -60,10 +60,10 @@ fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failur * working directory, so restarts use the same trusted material. */ pub fn configure_identity_path(path: PathBuf) { - let key_path = path.parent().map(|parent| parent.join("omikron.mpkb")); + let trust_directory = path.parent().map(|parent| parent.join("omikrons")); let _ = IDENTITY_PATH.set(path); - if let Some(key_path) = key_path { - let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path); + if let Some(trust_directory) = trust_directory { + let _ = OMIKRON_TRUST_DIRECTORY.set(trust_directory); } } fn identity_path() -> &'static Path { @@ -72,11 +72,22 @@ fn identity_path() -> &'static Path { .map(PathBuf::as_path) .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) } -fn omikron_public_key_path() -> &'static Path { - OMIKRON_PUBLIC_KEY_PATH +fn omikron_trust_directory() -> &'static Path { + OMIKRON_TRUST_DIRECTORY .get() .map(PathBuf::as_path) - .unwrap_or_else(|| Path::new("omikron.mpkb")) + .unwrap_or_else(|| Path::new("omikrons")) +} + +fn omikron_public_key_path(id: i64) -> PathBuf { + omikron_trust_directory().join(format!("{id}.mpkb")) +} + +fn legacy_omikron_public_key_path() -> PathBuf { + identity_path() + .parent() + .map(|parent| parent.join("omikron.mpkb")) + .unwrap_or_else(|| PathBuf::from("omikron.mpkb")) } fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> { @@ -107,13 +118,20 @@ fn serialization_path(path: &Path) -> Result { const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(45); const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); const MAX_CONCURRENT_HANDLERS: usize = 20; const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; +struct ResolvedOmikronEndpoint { + id: Option, + host: String, + port: u16, + public_key: PublicKeyBundle, +} + #[derive(Debug)] pub enum IdentityError { Storage(mtp::files::FileError), @@ -159,6 +177,18 @@ fn jittered_reconnect_delay(delay: Duration) -> Duration { Duration::from_millis(u64::try_from(jittered).expect("bounded jitter must be non-negative")) } +fn map_await_response_error(error: String) -> OmikronError { + if error.contains("timed out") { + OmikronError::Timeout(error) + } else if error.contains("kind=not_found") { + OmikronError::Rejected(CommunicationType::ErrorNotFound, error) + } else if error.starts_with("Request rejected") { + OmikronError::Rejected(CommunicationType::Error, error) + } else { + OmikronError::Disconnected(error) + } +} + fn wire_user_id(user_id: i64) -> u64 { u64::try_from(user_id).expect("validated user ID is non-negative") } @@ -443,10 +473,9 @@ impl OmikronConnection { let existing_iota_id = CONFIG.load().iota_id; - let (host, port, omikron_public_key) = - self.resolve_omikron_endpoint(existing_iota_id).await?; + let endpoint = self.resolve_omikron_endpoint(existing_iota_id).await?; - let addr_str = format!("https://{}:{}", host, port); + let addr_str = format!("https://{}:{}", endpoint.host, endpoint.port); log!("Connecting to Omikron at {}", addr_str); @@ -470,26 +499,24 @@ impl OmikronConnection { client_config, existing_iota_id, &keyring, - &omikron_public_key, + &endpoint.public_key, ) .await { Ok(connection) => connection, Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => { - let reason = format!( - "Authentication failed: {}. Your Iota keys may be invalid or the private key has changed on the server.", - reason - ); - *self.reconnect_on_close.write().await = false; - *self.auth_failure.write().await = Some(reason.clone()); - self.set_state(ConnectionState::Disconnected).await; - return Err(reason); + /* MTP currently combines invalid proofs with timeouts and backend + * failures in this variant. Retrying is safe; stopping here can + * strand an Iota during a temporary Omega outage. */ + return Err(format!("Authentication attempt failed: {reason}")); } Err(e) => return Err(format!("Connection failed: {}", e)), }; log_t!("omikron_connection_success"); + self.persist_authenticated_omikron(&endpoint)?; + if existing_iota_id.is_none() { modify_config(|cfg| cfg.iota_id = Some(connection.client_id)); log!("Registered with Iota-ID: {}", connection.client_id); @@ -564,22 +591,20 @@ impl OmikronConnection { * override for local dev/testing against a hand-run Omikron without a * live Omega. * - * The fetched Omikron public key is pinned to `omikron.mpkb` (trust on - * first use): if a cached key exists and a fresh discovery response - * disagrees with it, the mismatch is logged loudly and the cached key is - * kept rather than silently trusting whatever Omega's HTTP API returned - * this time - the same trust boundary the previous manual-file-drop - * model had, just automated for the common case. + * Keys are pinned per Omikron ID. A different relay can therefore be used + * after failover, while an unexpected key change for one relay remains a + * security error. Discovery data becomes durable only after MTP + * authentication has completed. */ async fn resolve_omikron_endpoint( &self, existing_iota_id: Option, - ) -> Result<(String, u16, PublicKeyBundle), String> { + ) -> Result { if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) { let port: u16 = port_str .parse() .map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?; - let key_path = omikron_public_key_path(); + let key_path = Path::new("omikron.mpkb"); let public_key = mtp::files::load_public_key_bundle(key_path) .map_err(|e| { format!( @@ -587,21 +612,41 @@ impl OmikronConnection { key_path.display(), e, key_path.display() ) })?; - return Ok((host, port, public_key)); + return Ok(ResolvedOmikronEndpoint { + id: None, + host, + port, + public_key, + }); } - let key_path = omikron_public_key_path(); - let cached_key = mtp::files::load_public_key_bundle(key_path).ok(); - let cached_host_port = { + let cached_endpoint = { let conf = CONFIG.load(); - match (&conf.omikron_host, conf.omikron_port) { - (Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { - Some((host.clone(), port)) + match (&conf.omikron_id, &conf.omikron_host, conf.omikron_port) { + (Some(id), Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { + mtp::files::load_public_key_bundle(&omikron_public_key_path(*id)) + .ok() + .map(|public_key| ResolvedOmikronEndpoint { + id: Some(*id), + host: host.clone(), + port, + public_key, + }) } - (Some(_), Some(_)) => { + (Some(_), Some(_), Some(_)) => { log!("Ignoring invalid cached Omikron endpoint in Iota configuration"); None } + (None, Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { + mtp::files::load_public_key_bundle(legacy_omikron_public_key_path()) + .ok() + .map(|public_key| ResolvedOmikronEndpoint { + id: None, + host: host.clone(), + port, + public_key, + }) + } _ => None, } }; @@ -620,8 +665,9 @@ impl OmikronConnection { None => omega_discovery::discover_random().await.ok(), }; - let (host, port, public_key) = if let Some(endpoint) = discovered { - match &cached_key { + let endpoint = if let Some(endpoint) = discovered { + let key_path = omikron_public_key_path(endpoint.id); + match mtp::files::load_public_key_bundle(&key_path).ok() { Some(cached) => { let keys_match = match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) { @@ -632,49 +678,63 @@ impl OmikronConnection { }; if !keys_match { log!( - "Fetched Omikron public key differs from the cached {} - keeping the \ - cached key. Delete {} manually if this is an expected key rotation.", + "Fetched Omikron public key differs from the trusted {}. \ + Omikron key rotation requires an explicit trust refresh.", key_path.display(), - key_path.display() ); - if let Some((cached_host, cached_port)) = &cached_host_port { - (cached_host.clone(), *cached_port, cached.clone()) - } else { - return Err(format!( - "Omega returned an Omikron key that differs from {} and no validated cached endpoint is available", - key_path.display() - )); - } + return Err(format!( + "Omega returned a changed public key for Omikron {}", + endpoint.id + )); } else { - (endpoint.host, endpoint.port, cached.clone()) + ResolvedOmikronEndpoint { + id: Some(endpoint.id), + host: endpoint.host, + port: endpoint.port, + public_key: cached, + } } } - None => { - if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) { - log!("Failed to cache Omikron public key: {}", e); - } - (endpoint.host, endpoint.port, endpoint.public_key) - } + None => ResolvedOmikronEndpoint { + id: Some(endpoint.id), + host: endpoint.host, + port: endpoint.port, + public_key: endpoint.public_key, + }, } - } else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) { + } else if let Some(cached) = cached_endpoint { log!( "Omega discovery unreachable, falling back to last-known Omikron {}:{}", - host, - port + cached.host, + cached.port ); - (host.clone(), *port, cached.clone()) + cached } else { return Err( "Omega discovery failed and no cached Omikron address/key is available".to_string(), ); }; - modify_config(|cfg| { - cfg.omikron_host = Some(host.clone()); - cfg.omikron_port = Some(port); - }); + Ok(endpoint) + } - Ok((host, port, public_key)) + fn persist_authenticated_omikron( + &self, + endpoint: &ResolvedOmikronEndpoint, + ) -> Result<(), String> { + let Some(id) = endpoint.id else { + return Ok(()); + }; + let key_path = omikron_public_key_path(id); + std::fs::create_dir_all(omikron_trust_directory()) + .map_err(|error| format!("create Omikron trust directory: {error}"))?; + save_omikron_public_key(&endpoint.public_key, &key_path)?; + modify_config(|cfg| { + cfg.omikron_id = Some(id); + cfg.omikron_host = Some(endpoint.host.clone()); + cfg.omikron_port = Some(endpoint.port); + }); + Ok(()) } // ------------------------------------------------------------------------- @@ -1946,6 +2006,7 @@ impl OmikronConnection { dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set); dispatch!(UserBlockCheck, handle_user_block_check); dispatch!(EraseHostedUserData, handle_erase_hosted_user_data); + dispatch!(ProvisionIotaUser, handle_iota_user_provisioning); } // ------------------------------------------------------------------------- @@ -1980,6 +2041,54 @@ impl OmikronConnection { let _ = self.send_message(&acknowledgement).await; } + /* Omega sends only public account metadata here. The locally created + * profile records an external credential origin and never receives a TU. */ + async fn handle_iota_user_provisioning(self: Arc, cv: &CommunicationValue) { + let user_id = cv + .get_data(DataType::UserId) + .as_signed_number() + .and_then(|id| i64::try_from(id).ok()) + .filter(|id| *id > 0); + let username = cv.get_data(DataType::Username).as_str().map(str::to_owned); + let public_key = cv.get_data(DataType::PublicKey).as_str().map(str::to_owned); + let invitation_id = cv + .get_data(DataType::InvitationId) + .as_str() + .filter(|value| uuid::Uuid::parse_str(value).is_ok()) + .map(str::to_owned); + let Some((user_id, username, public_key, invitation_id)) = user_id + .zip(username) + .zip(public_key) + .zip(invitation_id) + .map(|(((id, username), key), invitation_id)| (id, username, key, invitation_id)) + else { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + .await; + return; + }; + let profile = iota_storage::users::user_profile::UserProfile::new( + user_id, username, None, public_key, None, None, + ); + if iota_storage::users::user_manager::try_add_user_with_credential_origin( + profile, + iota_storage::users::user_manager::CredentialOrigin::External, + ) + .is_err() + { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + let acknowledgement = + CommunicationValue::new(CommunicationType::AcknowledgeIotaUserProvision) + .with_request_id(cv) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) + .add_typed_default(DataType::InvitationId, DataValue::Str(invitation_id)); + let _ = self.send_message(&acknowledgement).await; + } + async fn handle_get_chat_secret(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_get_chat_secret(cv)) @@ -2781,9 +2890,13 @@ impl OmikronConnection { .or_else(|| response_cv.get_data(DataType::ErrorType).as_str()) .unwrap_or("connection error") .to_string(); + let rejection_kind = if response_cv.is_type(CommunicationType::ErrorNotFound) { + "not_found" + } else { + "other" + }; Err(format!( - "Request rejected (msg_id={}, reason={})", - msg_id, reason + "Request rejected (kind={rejection_kind}, msg_id={msg_id}, reason={reason})" )) } else { Ok(response_cv) @@ -3029,15 +3142,7 @@ impl OmikronClient for OmikronConnection { ) -> Result { Self::await_response(self, value, Some(timeout)) .await - .map_err(|error| { - if error.contains("timed out") { - OmikronError::Timeout(error) - } else if error.starts_with("Request rejected") { - OmikronError::Internal(error) - } else { - OmikronError::Disconnected(error) - } - }) + .map_err(map_await_response_error) } async fn reconnect(&self) -> Result<(), OmikronError> { @@ -3165,4 +3270,21 @@ mod tests { assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY); } + + #[test] + fn per_omikron_trust_paths_do_not_collide() { + assert_ne!(omikron_public_key_path(1), omikron_public_key_path(2)); + } + + #[test] + fn not_found_response_is_preserved_for_callers() { + let error = map_await_response_error( + "Request rejected (kind=not_found, msg_id=1, reason=connection error)".into(), + ); + + assert!(matches!( + error, + OmikronError::Rejected(CommunicationType::ErrorNotFound, _) + )); + } } diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 5c33416..d81b5ef 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -37,6 +37,13 @@ pub enum LifecycleUserError { LocalPersistence(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectedTuCredential { + pub user_id: i64, + pub username: String, + pub assigned_iota_id: Option, +} + impl From for LifecycleUserError { fn from(value: crate::OmikronError) -> Self { Self::Transport(value) @@ -120,6 +127,58 @@ async fn inspect_credential_account( Ok((username, public_key, created_at)) } +/* + * Inspection verifies the credential against Omega but deliberately stops + * before proof, assignment, pending-operation, credential, or profile writes. + */ +pub async fn inspect_tu_credential( + connection: &dyn OmikronClient, + contents: &str, +) -> Result { + let credential = TuCredential::parse(contents) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let (username, _, _) = inspect_credential_account(connection, &credential).await?; + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ); + let response = connection + .await_response(&request, Duration::from_secs(20)) + .await?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(LifecycleUserError::RemoteRejected); + } + let assigned_iota_id = response + .get_data(DataType::IotaId) + .as_signed_number() + .and_then(|value| i64::try_from(value).ok()) + .filter(|value| *value > 0); + Ok(InspectedTuCredential { + user_id: credential.user_id, + username, + assigned_iota_id, + }) +} + +pub async fn get_remote_user_assignment( + connection: &dyn OmikronClient, + user_id: i64, +) -> Result, LifecycleUserError> { + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); + let response = connection + .await_response(&request, Duration::from_secs(20)) + .await?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(LifecycleUserError::RemoteRejected); + } + Ok(response + .get_data(DataType::IotaId) + .as_signed_number() + .and_then(|value| i64::try_from(value).ok()) + .filter(|value| *value > 0)) +} + async fn credential_proof( connection: &dyn OmikronClient, credential: &TuCredential, @@ -181,8 +240,8 @@ pub async fn attach_user_from_tu( username, None, public_key, - hex_hash(contents), - String::new(), + Some(hex_hash(contents)), + Some(String::new()), created_at, ); pending_operations::upsert(&PendingUserOperation { @@ -190,8 +249,8 @@ pub async fn attach_user_from_tu( operation: PendingUserOperationKind::Attach, username: profile.username.clone(), public_key: Some(profile.public_key.clone()), - private_key_hash: Some(profile.private_key_hash.clone()), - reset_token: Some(profile.reset_token.clone()), + private_key_hash: profile.private_key_hash.clone(), + reset_token: profile.reset_token.clone(), registration_token: None, phase: PendingUserOperationPhase::Prepared, created_at: now_millis(), @@ -254,16 +313,17 @@ pub async fn complete_delete_user_with_tu( /// Repair local management state after a release or migration committed in /// Omega but local cleanup was interrupted. Hosted data is retained. pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { - let Ok(local_iota_id) = configured_iota_id() else { - return; - }; - let pending = match pending_operations::get_all() { + let mut pending = match pending_operations::get_all() { Ok(pending) => pending, Err(error) => { log!("Pending user operation reconciliation could not read storage: {error}"); return; } }; + pending.retain(|operation| operation.operation != PendingUserOperationKind::Purge); + let Ok(local_iota_id) = configured_iota_id() else { + return; + }; for operation in pending { let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( DataType::UserId, @@ -319,15 +379,20 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { operation.username, None, public_key, - operation.private_key_hash.unwrap_or_default(), - operation.reset_token.unwrap_or_default(), + operation.private_key_hash, + operation.reset_token, ); if try_add_user(profile).is_ok() { let _ = pending_operations::remove(operation.user_id); } } PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => { - if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() { + if iota_storage::users::user_manager::finalize_local_release( + operation.user_id, + Some(&operation.username), + ) + .is_ok() + { let _ = pending_operations::remove(operation.user_id); } } @@ -339,19 +404,47 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { DataType::UserId, DataValue::SignedNumber(user.user_id.into()), ); - let Ok(response) = connection + let response = connection .await_response(&request, Duration::from_secs(10)) - .await - else { - continue; + .await; + let should_release = match should_release_reconciled_user(response, local_iota_id) { + Ok(should_release) => should_release, + Err(error) => { + log!( + "Could not reconcile local user {} with Omega: {}", + user.user_id, + error + ); + continue; + } }; - let remote_iota_id = response + if should_release { + if let Err(error) = iota_storage::users::user_manager::finalize_local_release( + user.user_id, + Some(&user.username), + ) { + log!( + "Could not release local user {} after Omega reconciliation: {}", + user.user_id, + error + ); + } + } + } +} + +fn should_release_reconciled_user( + response: Result, + local_iota_id: i64, +) -> Result { + match response { + Ok(response) => Ok(response .get_data(DataType::IotaId) .as_signed_number() - .and_then(|value| i64::try_from(value).ok()); - if remote_iota_id != Some(local_iota_id) { - let _ = iota_storage::users::user_manager::release_user(user.user_id); - } + .and_then(|value| i64::try_from(value).ok()) + != Some(local_iota_id)), + Err(crate::OmikronError::Rejected(CommunicationType::ErrorNotFound, _)) => Ok(true), + Err(error) => Err(error), } } @@ -505,8 +598,8 @@ pub async fn create_user( username.to_string(), None, public_key_bundle_to_base64(&pub_key_bundle), - private_key_hash, - reset_token.clone(), + Some(private_key_hash), + Some(reset_token.clone()), ); let credential = format!( "{}@{}::{}", @@ -519,8 +612,8 @@ pub async fn create_user( operation: PendingUserOperationKind::Create, username: user_profile.username.clone(), public_key: Some(user_profile.public_key.clone()), - private_key_hash: Some(user_profile.private_key_hash.clone()), - reset_token: Some(user_profile.reset_token.clone()), + private_key_hash: user_profile.private_key_hash.clone(), + reset_token: user_profile.reset_token.clone(), registration_token: Some(registration_token.clone()), phase: PendingUserOperationPhase::Prepared, created_at: now_millis(), @@ -563,7 +656,9 @@ pub async fn create_user( } else { log_t!("User creation: {}", error.to_string()); return Err(match error { - crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, + crate::OmikronError::Rejected(_, _) | crate::OmikronError::Internal(_) => { + CreateUserError::RemoteRejected + } error => CreateUserError::Transport(error), }); } @@ -583,10 +678,15 @@ pub async fn create_user( #[cfg(test)] mod tests { - use super::{CreateUserError, request_user_id, valid_username}; + use super::{ + CreateUserError, LifecycleUserError, inspect_tu_credential, request_user_id, + should_release_reconciled_user, valid_username, + }; use crate::{OmikronClient, OmikronError}; use async_trait::async_trait; use iota_connection::message_common::CommunicationResponseExt; + use iota_util::crypto_helper::{generate_keyring, public_key_bundle_to_base64}; + use iota_util::tu::TuCredential; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::time::Duration; @@ -594,6 +694,34 @@ mod tests { response: CommunicationValue, } + struct InspectionClient { + response: CommunicationValue, + } + + #[async_trait] + impl OmikronClient for InspectionClient { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { + unreachable!() + } + async fn await_response( + &self, + request: &CommunicationValue, + _: Duration, + ) -> Result { + assert!(request.is_type(CommunicationType::GetUserData)); + Ok(self.response.clone().with_request_id(request)) + } + async fn reconnect(&self) -> Result<(), OmikronError> { + unreachable!() + } + async fn rotate_identity(&self) -> Result<(), OmikronError> { + unreachable!() + } + async fn is_connected(&self) -> bool { + true + } + } + #[async_trait] impl OmikronClient for RegistrationClient { async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { @@ -634,6 +762,42 @@ mod tests { assert!(!valid_username("line\nbreak")); } + #[tokio::test] + async fn inspection_returns_verified_identity_without_lifecycle_requests() { + let credential = TuCredential { + user_id: 42, + omega_host: crate::omega_discovery::omega_host(), + keyring: generate_keyring(), + }; + let client = InspectionClient { + response: CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::Username, DataValue::Str("alice".into())) + .add_typed_default( + DataType::PublicKey, + DataValue::Str(public_key_bundle_to_base64(&credential.public_key_bundle())), + ) + .add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(1)) + .add_typed_default(DataType::IotaId, DataValue::SignedNumber(7)), + }; + let preview = inspect_tu_credential(&client, &credential.to_canonical_string()) + .await + .unwrap(); + assert_eq!(preview.user_id, 42); + assert_eq!(preview.username, "alice"); + assert_eq!(preview.assigned_iota_id, Some(7)); + } + + #[tokio::test] + async fn inspection_rejects_malformed_credentials_before_remote_access() { + let client = InspectionClient { + response: CommunicationValue::new(CommunicationType::GetUserData), + }; + assert!(matches!( + inspect_tu_credential(&client, "malformed").await, + Err(LifecycleUserError::InvalidCredential(_)) + )); + } + #[tokio::test] async fn uses_user_id_allocated_by_omega() { let client = RegistrationClient { @@ -663,4 +827,24 @@ mod tests { Err(CreateUserError::InvalidResponse) )); } + + #[test] + fn releases_a_user_that_omega_no_longer_has() { + let response = Err(OmikronError::Rejected( + CommunicationType::ErrorNotFound, + "user does not exist".into(), + )); + + assert_eq!(should_release_reconciled_user(response, 7), Ok(true)); + } + + #[test] + fn retains_a_user_when_omega_cannot_be_reached() { + let response = Err(OmikronError::Timeout("timed out".into())); + + assert!(matches!( + should_release_reconciled_user(response, 7), + Err(OmikronError::Timeout(_)) + )); + } }