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/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index c898ac8..c38eda4 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -11,36 +11,6 @@ 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, @@ -89,14 +59,22 @@ pub fn render_header( let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); - 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 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 (ipc_label, ipc_style) = connection_badge(connection, theme); let (omikron_text, omikron_style) = omikron_badge(daemon, theme); @@ -121,15 +99,46 @@ pub fn render_header( ); hits.register(brand_area, AppAction::OpenMain); - for (index, item) in HEADER_ITEMS.iter().enumerate() { - let top = cells[index + 1]; + 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() + { let button_area = Rect { x: top.x, y: top.y, width: top.width, height: area.height, }; - let style = match (item.intent, focused_action == Some(index)) { + let style = match (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, @@ -139,9 +148,9 @@ pub fn render_header( (ButtonIntent::Destructive, _) => theme.buttons.destructive, }; let display_label = if focused_action == Some(index) { - format!("› {}", item.label) + format!("› {label}") } else { - item.label.to_owned() + label.to_owned() }; frame.render_widget( Paragraph::new(vec![ @@ -150,6 +159,6 @@ pub fn render_header( ]), button_area, ); - hits.register(button_area, item.action); + hits.register(button_area, action); } } diff --git a/iota-cli/src/controls/menu.rs b/iota-cli/src/controls/menu.rs deleted file mode 100644 index 2739040..0000000 --- a/iota-cli/src/controls/menu.rs +++ /dev/null @@ -1,119 +0,0 @@ -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 6169155..02eaa84 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -4,9 +4,7 @@ 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 deleted file mode 100644 index 1ee8fb1..0000000 --- a/iota-cli/src/controls/text_input.rs +++ /dev/null @@ -1,99 +0,0 @@ -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 22b81e0..45ca8fc 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, AtomicU16, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::net::UnixStream; @@ -44,7 +44,6 @@ pub struct DaemonStatus { pub health: iota_ipc::HealthStatus, pub deployment_mode: Option, pub supervisor: Option, - pub capabilities: Vec, pub components: std::collections::BTreeMap, } @@ -72,7 +71,6 @@ 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, @@ -105,7 +103,6 @@ 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); @@ -118,7 +115,6 @@ 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, @@ -184,7 +180,7 @@ impl IpcClient { write_msg( &mut writer, &ClientMessage::Hello { - supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(), + supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], }, ), ) @@ -436,8 +432,6 @@ 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; @@ -446,7 +440,6 @@ impl IpcClient { status.health = ack.health; status.deployment_mode = Some(ack.deployment_mode); status.supervisor = Some(ack.supervisor); - status.capabilities = ack.capabilities; }); } @@ -487,23 +480,6 @@ 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) } @@ -613,7 +589,7 @@ impl IpcClient { let envelope = RequestEnvelope { request_id, - protocol_version: self.protocol_version.load(Ordering::Acquire), + protocol_version: PROTOCOL_VERSION, 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 a5e658c..5299b3b 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -35,9 +35,11 @@ pub enum AppEvent { }, ThemeSaved(Result<(), String>), UsersLoaded(Result, String>), - TuInspected(Result), - UserOperationFinished(Result), - CredentialExportFinished(Result), + UserCreated(Result), + UserRemoved { + user_id: i64, + result: Result<(), String>, + }, RegenerateKeysRequested, KeysRegenerated(Result<(), String>), } @@ -50,16 +52,14 @@ pub enum AppAction { OpenMetrics, ToggleMetrics, AddUser, - OpenUserGroup(crate::screens::users::model::UserActionGroup), - SetUserAdminTab(crate::screens::users::model::UserAdminTab), - ActivateUserAction(crate::screens::users::model::UserAction), + RemoveUser, Back, Quit, FocusLogs, FocusConsole, FocusMetrics, OpenMain, - SelectUser(i64), + SelectUser(usize), ConfirmDialog, CancelDialog, RegenerateKeys, diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs new file mode 100644 index 0000000..1ff2689 --- /dev/null +++ b/iota-cli/src/screens/users.rs @@ -0,0 +1,741 @@ +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 deleted file mode 100644 index 3baf2d0..0000000 --- a/iota-cli/src/screens/users/action_menu.rs +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 27d1f1f..0000000 --- a/iota-cli/src/screens/users/add_flow.rs +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 409a905..0000000 --- a/iota-cli/src/screens/users/confirmations.rs +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 1dd2934..0000000 --- a/iota-cli/src/screens/users/context.rs +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 390dbf4..0000000 --- a/iota-cli/src/screens/users/credential_export.rs +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 9f8e29d..0000000 --- a/iota-cli/src/screens/users/invitations.rs +++ /dev/null @@ -1,38 +0,0 @@ -/* 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 deleted file mode 100644 index 722f246..0000000 --- a/iota-cli/src/screens/users/list.rs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index e8e29f9..0000000 --- a/iota-cli/src/screens/users/mod.rs +++ /dev/null @@ -1,1281 +0,0 @@ -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 deleted file mode 100644 index 9b3297e..0000000 --- a/iota-cli/src/screens/users/model.rs +++ /dev/null @@ -1,230 +0,0 @@ -#[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 41608d9..ece8697 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,5 +1,5 @@ use crate::{ - controls::header::{HEADER_ITEMS, render_header}, + controls::header::render_header, help_overlay::HelpOverlay, input_handler::setup_input_handler, interaction_result::InteractionResult, @@ -432,16 +432,19 @@ 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).min(HEADER_ITEMS.len().saturating_sub(1)); + let index = focus.unwrap_or(0); match key.code { - 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::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), + KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), KeyCode::Enter | KeyCode::Char(' ') => { - action = HEADER_ITEMS.get(index).map(|item| item.action); + action = Some( + [ + AppAction::OpenOverview, + AppAction::OpenUsers, + AppAction::OpenSettings, + AppAction::Quit, + ][index], + ); *focus = None; } KeyCode::Esc => *focus = None, @@ -604,8 +607,7 @@ impl UI { username: u.username, state: u.state, data_present: u.data_present, - credential_status: u.credential_status, - pending_operation: u.pending_operation, + credential_present: u.credential_present, }) .collect()) } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 5047d39..59a87f9 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -3,9 +3,8 @@ use crate::{DaemonRuntime, DaemonServices}; use iota_ipc::{ CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent, IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, - OmikronStatusResponse, ReconcileAction, ResponseEnvelope, ResponsePayload, ResponseResult, - StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, - UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary, + OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, + TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; use iota_logger::{log, log_command}; use iota_storage::users::pending_operations::{ @@ -30,30 +29,6 @@ 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) @@ -144,9 +119,7 @@ impl CommandRouter { let needs_omikron = matches!( request, LocalRequest::CreateUser { .. } - | LocalRequest::InspectTuCredential { .. } | LocalRequest::AttachUserFromTu { .. } - | LocalRequest::ReconcileUser { .. } | LocalRequest::ReleaseUser { .. } | LocalRequest::CompleteDeleteUser { .. } ); @@ -189,35 +162,22 @@ 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_status: credential_status(&user, profile.as_ref()), - user_id, + 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, username: user.username, state: match user.state { user_manager::LocalUserState::Managed => { @@ -228,7 +188,6 @@ impl CommandRouter { } }, data_present: user.data_present, - pending_operation: pending.get(&user_id).cloned(), }) }) .collect::, iota_storage::storage_error::StorageError>>(); @@ -306,163 +265,6 @@ 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, @@ -655,71 +457,33 @@ impl CommandRouter { .collect(); ResponseResult::Ok(ResponsePayload::Components(components)) } - 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), + 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::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()), - }) - } + Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure), + }, LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::GetLogs { limit } => { let entries = if let Ok(buf) = self.log_buffer.lock() { @@ -774,19 +538,6 @@ 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 { @@ -819,7 +570,7 @@ mod tests { LocalRequest::ListCommunities, ]; - assert_eq!(requests.len(), 33); + assert_eq!(requests.len(), 25); 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 980c739..a472348 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -401,14 +401,7 @@ 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(), - "user_management_v2".into(), - "tu_inspection_v1".into(), - "credential_export_v1".into(), - ], + capabilities: vec!["commands".into(), "metrics".into(), "logs".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 af5d269..4ebe682 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -149,7 +149,8 @@ async fn main() -> ExitCode { Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { runtime.set_component_failed( iota_ipc::ComponentId::Omikron, - "Omikron authentication failed; inspect the authenticated relay and Omega status before rotating the Iota identity".into(), + "Omikron authentication failed; regenerate the Iota identity to register again" + .into(), ); // Keep IPC alive: identity rotation is the supported recovery // action and must remain available after authentication fails. @@ -220,23 +221,6 @@ 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 64603e6..acf72b1 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -4,17 +4,16 @@ pub mod transport; pub use protocol::{ ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, - 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, + 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, }; pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; /// Current IPC protocol version. -pub const PROTOCOL_VERSION: u16 = 4; +pub const PROTOCOL_VERSION: u16 = 2; /// 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 f5051c6..4006583 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -48,34 +48,9 @@ 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, }, @@ -152,7 +127,6 @@ impl LocalRequest { | Self::GetOmikronStatus | Self::ListComponents | Self::GetUser { .. } - | Self::GetUserDiagnostics { .. } | Self::GetLogs { .. } | Self::CheckUpdate | Self::ListCommunities => IpcRole::Read, @@ -160,14 +134,7 @@ 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 { .. } @@ -253,14 +220,6 @@ 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, @@ -308,7 +267,7 @@ pub struct UserDetailResponse { pub trusted_apps: Vec, pub state: LocalUserState, pub data_present: bool, - pub credential_status: CredentialStatus, + pub credential_present: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -345,66 +304,7 @@ pub struct UserSummary { pub username: String, pub state: LocalUserState, pub data_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, + pub credential_present: bool, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -459,11 +359,6 @@ impl std::fmt::Display for IpcErrorCode { #[cfg(test)] mod error_tests { - use super::{ - CredentialStatus, LocalUserState, ResponsePayload, SecretString, UserOperationKind, - UserOperationSummary, UserSummary, - }; - use super::IpcErrorCode; #[test] @@ -483,50 +378,6 @@ 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 1eccdf7..c6daa2b 100644 --- a/iota-ipc/src/text_commands.rs +++ b/iota-ipc/src/text_commands.rs @@ -5,16 +5,9 @@ pub const COMMANDS: &[&str] = &[ "tasks", "users list", "users show ", - "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 ", + "users add ", + "users remove ", + "users import ", "omikron status", "reconnect", "identity rotate", @@ -66,47 +59,16 @@ pub fn parse(line: &str) -> Option { let user_id = id_str.parse::().ok()?; Some(LocalRequest::GetUser { user_id }) } - ["user" | "users", "add", "create", username] => Some(LocalRequest::CreateUser { + ["user" | "users", "add", username] => Some(LocalRequest::CreateUser { username: username.to_string(), }), - ["user" | "users", "hosting", "release", id_str] => { + ["user" | "users", "remove", id_str] => { let user_id = id_str.parse::().ok()?; - Some(LocalRequest::ReleaseUser { user_id }) + Some(LocalRequest::RemoveUser { user_id }) } - ["user" | "users", "data", "purge", id_str] => Some(LocalRequest::PurgeUserData { - user_id: id_str.parse::().ok()?, + ["user" | "users", "import", username] => Some(LocalRequest::ImportUser { + username: username.to_string(), }), - ["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 { @@ -154,7 +116,7 @@ mod tests { #[test] fn parses_user_add() { - let req = parse("user add create alice").unwrap(); + let req = parse("user add alice").unwrap(); match req { LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), _ => panic!("expected CreateUser"), @@ -165,20 +127,12 @@ mod tests { fn accepts_the_headless_cli_user_vocabulary() { assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); assert!(matches!( - parse("users add create alice"), + parse("users add alice"), Some(LocalRequest::CreateUser { .. }) )); assert!(matches!( - 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 }) + parse("users remove 42"), + Some(LocalRequest::RemoveUser { user_id: 42 }) )); assert!(matches!( parse("identity rotate"), @@ -187,18 +141,17 @@ mod tests { } #[test] - fn parses_user_release_by_id() { - let req = parse("user hosting release 42").unwrap(); + fn parses_user_remove_by_id() { + let req = parse("user remove 42").unwrap(); match req { - LocalRequest::ReleaseUser { user_id } => assert_eq!(user_id, 42), - _ => panic!("expected ReleaseUser"), + LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), + _ => panic!("expected RemoveUser"), } } #[test] - fn generic_remove_is_not_routed() { - assert!(parse("user remove 42").is_none()); - assert!(parse("users remove alice").is_none()); + fn user_remove_requires_numeric_id() { + assert!(parse("user remove alice").is_none()); } #[test] @@ -342,8 +295,12 @@ mod tests { } #[test] - fn deprecated_import_is_not_routed() { - assert!(parse("users import alice").is_none()); + fn parses_users_import() { + let req = parse("users import alice").unwrap(); + match req { + LocalRequest::ImportUser { username } => assert_eq!(username, "alice"), + _ => panic!("expected ImportUser"), + } } #[test] @@ -360,15 +317,7 @@ 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 create ", - "users account delete ", - "users apps revoke ", - "users apps revoke-all ", - ] - ); + assert_eq!(completions("/users a"), vec!["users add "]); assert!(completions("definitely-unknown").is_empty()); } diff --git a/iota-storage/src/users/invitations.rs b/iota-storage/src/users/invitations.rs deleted file mode 100644 index 48f8b1d..0000000 --- a/iota-storage/src/users/invitations.rs +++ /dev/null @@ -1,83 +0,0 @@ -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 ab1268f..dad90f9 100644 --- a/iota-storage/src/users/mod.rs +++ b/iota-storage/src/users/mod.rs @@ -1,5 +1,4 @@ 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 c54865b..4993f17 100644 --- a/iota-storage/src/users/pending_operations.rs +++ b/iota-storage/src/users/pending_operations.rs @@ -7,7 +7,6 @@ pub enum PendingUserOperationKind { Create, Attach, Release, - Purge, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -16,21 +15,15 @@ pub enum PendingUserOperationPhase { CredentialWritten, RemoteCommitted, LocalCommitted, - DatabasePurged, - E2eePurged, - FilesystemPurged, } impl PendingUserOperationPhase { - pub fn as_str(self) -> &'static str { + 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", } } @@ -40,9 +33,6 @@ 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(), )), @@ -51,12 +41,11 @@ impl PendingUserOperationPhase { } impl PendingUserOperationKind { - pub fn as_str(self) -> &'static str { + fn as_str(self) -> &'static str { match self { Self::Create => "create", Self::Attach => "attach", Self::Release => "release", - Self::Purge => "purge", } } @@ -65,7 +54,6 @@ 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())), } } @@ -183,20 +171,3 @@ 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 548bbc3..43b2ed4 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,4 +1,3 @@ -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}; @@ -11,19 +10,12 @@ 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 { @@ -40,13 +32,6 @@ 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#" @@ -80,10 +65,10 @@ pub fn try_add_user_with_credential_origin( )?; } tx.execute( - 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()], + 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()], )?; Ok(()) }) @@ -170,8 +155,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: Option = r.get(3)?; - let reset_token: Option = r.get(4)?; + let private_key_hash: String = r.get(3)?; + let reset_token: String = r.get(4)?; let created_at: i64 = r.get(5)?; let display_name: Option = r.get(6)?; @@ -226,29 +211,6 @@ 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( @@ -270,31 +232,6 @@ 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( @@ -315,84 +252,12 @@ pub fn finalize_local_release( .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } -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(), - )); +/// 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); } - 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( @@ -450,6 +315,10 @@ fn purge_database_rows(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], @@ -462,84 +331,17 @@ fn purge_database_rows(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()) })?; - 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(()) - }) + 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())) } /// Complete local erasure is idempotent and is the target for a durable @@ -570,50 +372,19 @@ 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, credential_origin FROM user_residency ORDER BY username")?; + let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state 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( @@ -653,75 +424,6 @@ 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 f097ae5..f1aa2a3 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: Option, - pub reset_token: Option, + pub private_key_hash: String, + pub reset_token: String, 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: Option, - reset_token: Option, + private_key_hash: String, + reset_token: String, ) -> 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: Option, - reset_token: Option, + private_key_hash: String, + reset_token: String, created_at: i64, ) -> Self { Self { @@ -68,6 +68,7 @@ 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())), }; @@ -87,8 +88,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().map(str::to_owned); - let reset_token = j["reset_token"].as_str().map(str::to_owned); + let private_key_hash = j["private_key_hash"].as_str()?.to_string(); + let reset_token = j["reset_token"].as_str()?.to_string(); let created_at = j["created_at"].as_i64()?; let display_name = j["display_name"].as_str().map(|s| s.to_string()); @@ -126,7 +127,7 @@ impl UserProfile { let mut bytes = [0u8; 192]; OsRng.fill(bytes.as_mut()); let new_token = general_purpose::STANDARD.encode(&bytes); - self.reset_token = Some(new_token.clone()); + self.reset_token = new_token.clone(); new_token } diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index ef3e827..b3dfe08 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -21,8 +21,6 @@ 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)] @@ -103,7 +101,6 @@ 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 c63e481..c983324 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -710,110 +710,6 @@ 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(()) } @@ -883,7 +779,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, 23); + assert_eq!(version, 20); 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")?; @@ -902,7 +798,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, 23); + assert_eq!(version, 20); for table in [ "sync_heads", "sync_events", @@ -917,7 +813,6 @@ 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", @@ -942,7 +837,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, 23); + assert_eq!(version, 20); for column in [ "id", "user_id", @@ -960,84 +855,4 @@ 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 7ac5607..9800a91 100644 --- a/iota-util/src/atomic_file.rs +++ b/iota-util/src/atomic_file.rs @@ -16,39 +16,6 @@ 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], @@ -166,7 +133,7 @@ fn sync_directory(_path: &Path) -> io::Result<()> { #[cfg(test)] mod tests { - use super::{create_private, replace}; + use super::replace; #[test] fn replace_preserves_a_previous_version_as_a_backup() { @@ -188,32 +155,4 @@ 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 deleted file mode 100644 index 06b3efc..0000000 --- a/iota-util/tests/private_export.rs +++ /dev/null @@ -1,29 +0,0 @@ -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 57f60d6..ca914f8 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -117,55 +117,20 @@ enum UsersAction { user_id: i64, }, Add { - #[command(subcommand)] - action: UserAddAction, + username: Option, + #[arg(long, value_name = "PATH")] + tu: Option, }, - Hosting { - #[command(subcommand)] - action: UserHostingAction, - }, - Data { - #[command(subcommand)] - action: UserDataAction, - }, - 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 { + Data { + #[command(subcommand)] + action: UserDataAction, + }, + CompleteDelete { user_id: i64, #[arg(long, value_name = "PATH")] tu: Option, @@ -174,42 +139,6 @@ enum UserAccountAction { }, } #[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, @@ -381,33 +310,6 @@ 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, @@ -504,21 +406,15 @@ impl CliInvocation { Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, - 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 { + 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 { user_id, confirmed: resolve_confirmed(yes), }, @@ -528,50 +424,11 @@ impl CliInvocation { user_id, confirmed: resolve_confirmed(yes), }, - UsersAction::Account { - action: UserAccountAction::Delete { user_id, tu, yes }, - } => Command::UsersCompleteDelete { + UsersAction::CompleteDelete { 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, @@ -737,8 +594,7 @@ mod tests { #[test] fn command_schema_drives_help_and_completion_paths() { let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users hosting release".to_owned())); - assert!(paths.contains(&"users add create".to_owned())); + assert!(paths.contains(&"users release".to_owned())); assert!(paths.contains(&"daemon install".to_owned())); let help = CliInvocation::help_text(); assert!(help.contains("users")); @@ -793,13 +649,8 @@ mod tests { #[test] fn parses_users_add() { - let invocation = CliInvocation::parse([ - "users".into(), - "add".into(), - "create".into(), - "alice".into(), - ]) - .unwrap(); + let invocation = + CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); assert_eq!( invocation.command, Command::UsersAdd { @@ -838,7 +689,6 @@ mod tests { fn parses_users_release_with_confirmation() { let invocation = CliInvocation::parse([ "users".into(), - "hosting".into(), "release".into(), "42".into(), "--yes".into(), @@ -853,39 +703,6 @@ 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 94beca5..3746707 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -400,18 +400,10 @@ fn print_help() { println!(" tasks List active tasks"); println!(" users list List all users"); println!(" users show Show user details"); - println!(" users add create Create a new user"); - println!(" users add tu Attach an existing account credential"); + println!(" users add Create a new user"); + println!(" users add --tu Add an existing account credential"); println!(" users data purge Purge hosted data (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!(" users release Release this Iota (requires --yes)"); println!(" omikron status Show Omikron connection status"); println!(" omikron reconnect Reconnect to Omikron"); println!(" identity rotate Rotate identity keys (requires --yes)"); @@ -557,7 +549,6 @@ 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, @@ -631,40 +622,6 @@ 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, @@ -693,18 +650,6 @@ 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 } @@ -740,38 +685,6 @@ 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); } @@ -812,34 +725,11 @@ 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), - 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()) + user.user_id ); } } @@ -925,34 +815,6 @@ 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; @@ -998,11 +860,6 @@ 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!( "{}", @@ -1029,35 +886,10 @@ fn render_table(payload: &ResponsePayload) { println!("No users."); return; } - println!( - "{:<12} {:<18} {:<10} {:<9} {:<12} PENDING", - "ID", "USERNAME", "STATE", "DATA", "CREDENTIAL" - ); + println!("{:<8} {}", "ID", "USERNAME"); + println!("{:<8} {}", "--------", "--------"); for user in users { - 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()) - ); + println!("{:<8} {}", user.user_id, user.username); } } ResponsePayload::Tasks(tasks) => { @@ -1158,33 +990,6 @@ 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); } @@ -1209,13 +1014,3 @@ 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 909b397..4b82f4f 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 909b3977cb6a233e74a925eb467412fb384844bc +Subproject commit 4b82f4f8139ed9aa74fa86f73ba8f0d565703c86 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"] } diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs index 03cb832..0561212 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::{CommunicationType, CommunicationValue}; +use mtp::codec::CommunicationValue; use std::time::Duration; #[derive(Clone, Debug, PartialEq, Eq)] @@ -7,7 +7,6 @@ pub enum OmikronError { Disconnected(String), Timeout(String), Authentication(String), - Rejected(CommunicationType, String), Internal(String), } @@ -17,7 +16,6 @@ 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 2aafa5a..263910f 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_TRUST_DIRECTORY: std::sync::OnceLock = std::sync::OnceLock::new(); +static OMIKRON_PUBLIC_KEY_PATH: 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 trust_directory = path.parent().map(|parent| parent.join("omikrons")); + let key_path = path.parent().map(|parent| parent.join("omikron.mpkb")); let _ = IDENTITY_PATH.set(path); - if let Some(trust_directory) = trust_directory { - let _ = OMIKRON_TRUST_DIRECTORY.set(trust_directory); + if let Some(key_path) = key_path { + let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path); } } fn identity_path() -> &'static Path { @@ -72,22 +72,11 @@ fn identity_path() -> &'static Path { .map(PathBuf::as_path) .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) } -fn omikron_trust_directory() -> &'static Path { - OMIKRON_TRUST_DIRECTORY +fn omikron_public_key_path() -> &'static Path { + OMIKRON_PUBLIC_KEY_PATH .get() .map(PathBuf::as_path) - .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")) + .unwrap_or_else(|| Path::new("omikron.mpkb")) } fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> { @@ -118,20 +107,13 @@ 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(45); +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); 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), @@ -177,18 +159,6 @@ 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") } @@ -473,9 +443,10 @@ impl OmikronConnection { let existing_iota_id = CONFIG.load().iota_id; - let endpoint = self.resolve_omikron_endpoint(existing_iota_id).await?; + let (host, port, omikron_public_key) = + self.resolve_omikron_endpoint(existing_iota_id).await?; - let addr_str = format!("https://{}:{}", endpoint.host, endpoint.port); + let addr_str = format!("https://{}:{}", host, port); log!("Connecting to Omikron at {}", addr_str); @@ -499,24 +470,26 @@ impl OmikronConnection { client_config, existing_iota_id, &keyring, - &endpoint.public_key, + &omikron_public_key, ) .await { Ok(connection) => connection, Err(mtp::common::CommunicationError::AuthenticationFailed(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}")); + 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); } 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); @@ -591,20 +564,22 @@ impl OmikronConnection { * override for local dev/testing against a hand-run Omikron without a * live Omega. * - * 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. + * 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. */ async fn resolve_omikron_endpoint( &self, existing_iota_id: Option, - ) -> Result { + ) -> Result<(String, u16, PublicKeyBundle), String> { 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 = Path::new("omikron.mpkb"); + let key_path = omikron_public_key_path(); let public_key = mtp::files::load_public_key_bundle(key_path) .map_err(|e| { format!( @@ -612,41 +587,21 @@ impl OmikronConnection { key_path.display(), e, key_path.display() ) })?; - return Ok(ResolvedOmikronEndpoint { - id: None, - host, - port, - public_key, - }); + return Ok((host, port, public_key)); } - let cached_endpoint = { + let key_path = omikron_public_key_path(); + let cached_key = mtp::files::load_public_key_bundle(key_path).ok(); + let cached_host_port = { let conf = CONFIG.load(); - 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, - }) + match (&conf.omikron_host, conf.omikron_port) { + (Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { + Some((host.clone(), port)) } - (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, } }; @@ -665,9 +620,8 @@ impl OmikronConnection { None => omega_discovery::discover_random().await.ok(), }; - 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() { + let (host, port, public_key) = if let Some(endpoint) = discovered { + match &cached_key { Some(cached) => { let keys_match = match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) { @@ -678,63 +632,49 @@ impl OmikronConnection { }; if !keys_match { log!( - "Fetched Omikron public key differs from the trusted {}. \ - Omikron key rotation requires an explicit trust refresh.", + "Fetched Omikron public key differs from the cached {} - keeping the \ + cached key. Delete {} manually if this is an expected key rotation.", key_path.display(), + key_path.display() ); - return Err(format!( - "Omega returned a changed public key for Omikron {}", - endpoint.id - )); - } else { - ResolvedOmikronEndpoint { - id: Some(endpoint.id), - host: endpoint.host, - port: endpoint.port, - public_key: cached, + 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() + )); } + } else { + (endpoint.host, endpoint.port, cached.clone()) } } - None => ResolvedOmikronEndpoint { - id: Some(endpoint.id), - host: endpoint.host, - port: endpoint.port, - public_key: endpoint.public_key, - }, + 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) + } } - } else if let Some(cached) = cached_endpoint { + } else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) { log!( "Omega discovery unreachable, falling back to last-known Omikron {}:{}", - cached.host, - cached.port + host, + port ); - cached + (host.clone(), *port, cached.clone()) } else { return Err( "Omega discovery failed and no cached Omikron address/key is available".to_string(), ); }; - Ok(endpoint) - } - - 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); + cfg.omikron_host = Some(host.clone()); + cfg.omikron_port = Some(port); }); - Ok(()) + + Ok((host, port, public_key)) } // ------------------------------------------------------------------------- @@ -2006,7 +1946,6 @@ 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); } // ------------------------------------------------------------------------- @@ -2041,54 +1980,6 @@ 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)) @@ -2890,13 +2781,9 @@ 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 (kind={rejection_kind}, msg_id={msg_id}, reason={reason})" + "Request rejected (msg_id={}, reason={})", + msg_id, reason )) } else { Ok(response_cv) @@ -3142,7 +3029,15 @@ impl OmikronClient for OmikronConnection { ) -> Result { Self::await_response(self, value, Some(timeout)) .await - .map_err(map_await_response_error) + .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) + } + }) } async fn reconnect(&self) -> Result<(), OmikronError> { @@ -3270,21 +3165,4 @@ 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 d81b5ef..5c33416 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -37,13 +37,6 @@ 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) @@ -127,58 +120,6 @@ 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, @@ -240,8 +181,8 @@ pub async fn attach_user_from_tu( username, None, public_key, - Some(hex_hash(contents)), - Some(String::new()), + hex_hash(contents), + String::new(), created_at, ); pending_operations::upsert(&PendingUserOperation { @@ -249,8 +190,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: profile.private_key_hash.clone(), - reset_token: profile.reset_token.clone(), + private_key_hash: Some(profile.private_key_hash.clone()), + reset_token: Some(profile.reset_token.clone()), registration_token: None, phase: PendingUserOperationPhase::Prepared, created_at: now_millis(), @@ -313,17 +254,16 @@ 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 mut pending = match pending_operations::get_all() { + let Ok(local_iota_id) = configured_iota_id() else { + return; + }; + let 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, @@ -379,20 +319,15 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { operation.username, None, public_key, - operation.private_key_hash, - operation.reset_token, + operation.private_key_hash.unwrap_or_default(), + operation.reset_token.unwrap_or_default(), ); 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::finalize_local_release( - operation.user_id, - Some(&operation.username), - ) - .is_ok() - { + if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() { let _ = pending_operations::remove(operation.user_id); } } @@ -404,47 +339,19 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { DataType::UserId, DataValue::SignedNumber(user.user_id.into()), ); - let response = connection + let Ok(response) = connection .await_response(&request, Duration::from_secs(10)) - .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; - } + .await + else { + continue; }; - 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 + let remote_iota_id = response .get_data(DataType::IotaId) .as_signed_number() - .and_then(|value| i64::try_from(value).ok()) - != Some(local_iota_id)), - Err(crate::OmikronError::Rejected(CommunicationType::ErrorNotFound, _)) => Ok(true), - Err(error) => Err(error), + .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); + } } } @@ -598,8 +505,8 @@ pub async fn create_user( username.to_string(), None, public_key_bundle_to_base64(&pub_key_bundle), - Some(private_key_hash), - Some(reset_token.clone()), + private_key_hash, + reset_token.clone(), ); let credential = format!( "{}@{}::{}", @@ -612,8 +519,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: user_profile.private_key_hash.clone(), - reset_token: user_profile.reset_token.clone(), + private_key_hash: Some(user_profile.private_key_hash.clone()), + reset_token: Some(user_profile.reset_token.clone()), registration_token: Some(registration_token.clone()), phase: PendingUserOperationPhase::Prepared, created_at: now_millis(), @@ -656,9 +563,7 @@ pub async fn create_user( } else { log_t!("User creation: {}", error.to_string()); return Err(match error { - crate::OmikronError::Rejected(_, _) | crate::OmikronError::Internal(_) => { - CreateUserError::RemoteRejected - } + crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, error => CreateUserError::Transport(error), }); } @@ -678,15 +583,10 @@ pub async fn create_user( #[cfg(test)] mod tests { - use super::{ - CreateUserError, LifecycleUserError, inspect_tu_credential, request_user_id, - should_release_reconciled_user, valid_username, - }; + use super::{CreateUserError, request_user_id, 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; @@ -694,34 +594,6 @@ 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> { @@ -762,42 +634,6 @@ 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 { @@ -827,24 +663,4 @@ 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(_)) - )); - } }