[Add] Proper User managment

This commit is contained in:
Alex Emmet 2026-09-02 22:42:25 +02:00
commit b38b68ad96
No known key found for this signature in database
38 changed files with 4331 additions and 1065 deletions

View file

@ -11,6 +11,36 @@ use ratatui::{
widgets::Paragraph, 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( fn connection_badge(
state: &IpcConnectionState, state: &IpcConnectionState,
theme: &ResolvedTheme, theme: &ResolvedTheme,
@ -59,22 +89,14 @@ pub fn render_header(
let rows = let rows =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let cells = Layout::horizontal([ let constraints = std::iter::once(Constraint::Min(28))
Constraint::Min(28), .chain(
Constraint::Length(12), HEADER_ITEMS
Constraint::Length(12), .iter()
Constraint::Length(12), .map(|item| Constraint::Length(if item.label == "Quit" { 8 } else { 12 })),
Constraint::Length(8), )
]) .collect::<Vec<_>>();
.split(rows[0]); let cells = Layout::horizontal(constraints.clone()).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 (ipc_label, ipc_style) = connection_badge(connection, theme);
let (omikron_text, omikron_style) = omikron_badge(daemon, theme); let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
@ -99,46 +121,15 @@ pub fn render_header(
); );
hits.register(brand_area, AppAction::OpenMain); hits.register(brand_area, AppAction::OpenMain);
for (index, (top, _bottom, label, intent, action)) in [ for (index, item) in HEADER_ITEMS.iter().enumerate() {
( let top = cells[index + 1];
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 { let button_area = Rect {
x: top.x, x: top.x,
y: top.y, y: top.y,
width: top.width, width: top.width,
height: area.height, height: area.height,
}; };
let style = match (intent, focused_action == Some(index)) { let style = match (item.intent, focused_action == Some(index)) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused, (ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary, (ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
@ -148,9 +139,9 @@ pub fn render_header(
(ButtonIntent::Destructive, _) => theme.buttons.destructive, (ButtonIntent::Destructive, _) => theme.buttons.destructive,
}; };
let display_label = if focused_action == Some(index) { let display_label = if focused_action == Some(index) {
format!(" {label}") format!(" {}", item.label)
} else { } else {
label.to_owned() item.label.to_owned()
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(vec![ Paragraph::new(vec![
@ -159,6 +150,6 @@ pub fn render_header(
]), ]),
button_area, button_area,
); );
hits.register(button_area, action); hits.register(button_area, item.action);
} }
} }

View file

@ -0,0 +1,119 @@
use crossterm::event::KeyCode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem<T> {
pub label: String,
pub description: Option<String>,
pub value: T,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuState<T> {
items: Vec<MenuItem<T>>,
selected: Option<usize>,
}
impl<T> MenuState<T> {
pub fn new(items: Vec<MenuItem<T>>) -> Self {
let selected = items.iter().position(|item| item.enabled);
Self { items, selected }
}
pub fn items(&self) -> &[MenuItem<T>] {
&self.items
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected_item(&self) -> Option<&MenuItem<T>> {
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);
}
}

View file

@ -4,7 +4,9 @@ pub mod checkbox_group;
pub mod choice; pub mod choice;
pub mod dialog; pub mod dialog;
pub mod header; pub mod header;
pub mod menu;
pub mod navigation; pub mod navigation;
pub mod panel; pub mod panel;
pub mod radio_group; pub mod radio_group;
pub mod scroll; pub mod scroll;
pub mod text_input;

View file

@ -0,0 +1,99 @@
use crossterm::event::KeyCode;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TextInput {
value: String,
cursor: usize,
label: Option<String>,
placeholder: Option<String>,
validation: Option<String>,
secret: bool,
}
impl TextInput {
pub fn new(label: impl Into<String>) -> 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<String>) {
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<String>) {
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
}
}

View file

@ -6,7 +6,7 @@ use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Result; use std::io::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration; use std::time::Duration;
use tokio::net::UnixStream; use tokio::net::UnixStream;
@ -44,6 +44,7 @@ pub struct DaemonStatus {
pub health: iota_ipc::HealthStatus, pub health: iota_ipc::HealthStatus,
pub deployment_mode: Option<iota_ipc::DeploymentMode>, pub deployment_mode: Option<iota_ipc::DeploymentMode>,
pub supervisor: Option<iota_ipc::SupervisorKind>, pub supervisor: Option<iota_ipc::SupervisorKind>,
pub capabilities: Vec<String>,
pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>, pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>,
} }
@ -71,6 +72,7 @@ pub struct IpcClient {
writer: Mutex<Option<ActiveWriter>>, writer: Mutex<Option<ActiveWriter>>,
next_generation: AtomicU64, next_generation: AtomicU64,
next_request_id: AtomicU64, next_request_id: AtomicU64,
protocol_version: AtomicU16,
pending: Mutex<HashMap<u64, PendingRequest>>, pending: Mutex<HashMap<u64, PendingRequest>>,
connection_state: watch::Sender<IpcConnectionState>, connection_state: watch::Sender<IpcConnectionState>,
daemon_status: watch::Sender<DaemonStatus>, daemon_status: watch::Sender<DaemonStatus>,
@ -103,6 +105,7 @@ impl IpcClient {
health: negotiated.ack.health, health: negotiated.ack.health,
deployment_mode: Some(negotiated.ack.deployment_mode), deployment_mode: Some(negotiated.ack.deployment_mode),
supervisor: Some(negotiated.ack.supervisor), supervisor: Some(negotiated.ack.supervisor),
capabilities: negotiated.ack.capabilities.clone(),
components: std::collections::BTreeMap::new(), components: std::collections::BTreeMap::new(),
}; };
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected); let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
@ -115,6 +118,7 @@ impl IpcClient {
})), })),
next_generation: AtomicU64::new(2), next_generation: AtomicU64::new(2),
next_request_id: AtomicU64::new(1), next_request_id: AtomicU64::new(1),
protocol_version: AtomicU16::new(negotiated.ack.protocol_version),
pending: Mutex::new(HashMap::new()), pending: Mutex::new(HashMap::new()),
connection_state: conn_state_tx, connection_state: conn_state_tx,
daemon_status: daemon_status_tx, daemon_status: daemon_status_tx,
@ -180,7 +184,7 @@ impl IpcClient {
write_msg( write_msg(
&mut writer, &mut writer,
&ClientMessage::Hello { &ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(),
}, },
), ),
) )
@ -432,6 +436,8 @@ impl IpcClient {
} }
fn update_hello_ack(&self, ack: HelloAck) { fn update_hello_ack(&self, ack: HelloAck) {
self.protocol_version
.store(ack.protocol_version, Ordering::Release);
self.daemon_status.send_modify(|status| { self.daemon_status.send_modify(|status| {
status.version = ack.daemon_version; status.version = ack.daemon_version;
status.instance_id = ack.instance_id; status.instance_id = ack.instance_id;
@ -440,6 +446,7 @@ impl IpcClient {
status.health = ack.health; status.health = ack.health;
status.deployment_mode = Some(ack.deployment_mode); status.deployment_mode = Some(ack.deployment_mode);
status.supervisor = Some(ack.supervisor); status.supervisor = Some(ack.supervisor);
status.capabilities = ack.capabilities;
}); });
} }
@ -480,6 +487,23 @@ impl IpcClient {
ResponsePayload::UserCreated { user_id, username } => { ResponsePayload::UserCreated { user_id, username } => {
format!("Created user {} ({})", username, user_id) 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 } => { ResponsePayload::UserRemoved { user_id } => {
format!("Removed user {}", user_id) format!("Removed user {}", user_id)
} }
@ -589,7 +613,7 @@ impl IpcClient {
let envelope = RequestEnvelope { let envelope = RequestEnvelope {
request_id, request_id,
protocol_version: PROTOCOL_VERSION, protocol_version: self.protocol_version.load(Ordering::Acquire),
request, request,
}; };
if let Err(error) = self.send(ClientMessage::Request(envelope)).await { if let Err(error) = self.send(ClientMessage::Request(envelope)).await {

View file

@ -35,11 +35,9 @@ pub enum AppEvent {
}, },
ThemeSaved(Result<(), String>), ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>), UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
UserCreated(Result<crate::screens::users::UserEntry, String>), TuInspected(Result<iota_ipc::TuCredentialPreview, String>),
UserRemoved { UserOperationFinished(Result<String, String>),
user_id: i64, CredentialExportFinished(Result<String, String>),
result: Result<(), String>,
},
RegenerateKeysRequested, RegenerateKeysRequested,
KeysRegenerated(Result<(), String>), KeysRegenerated(Result<(), String>),
} }
@ -52,14 +50,16 @@ pub enum AppAction {
OpenMetrics, OpenMetrics,
ToggleMetrics, ToggleMetrics,
AddUser, AddUser,
RemoveUser, OpenUserGroup(crate::screens::users::model::UserActionGroup),
SetUserAdminTab(crate::screens::users::model::UserAdminTab),
ActivateUserAction(crate::screens::users::model::UserAction),
Back, Back,
Quit, Quit,
FocusLogs, FocusLogs,
FocusConsole, FocusConsole,
FocusMetrics, FocusMetrics,
OpenMain, OpenMain,
SelectUser(usize), SelectUser(i64),
ConfirmDialog, ConfirmDialog,
CancelDialog, CancelDialog,
RegenerateKeys, RegenerateKeys,

View file

@ -1,741 +0,0 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU8, AtomicUsize, Ordering},
},
};
#[derive(Clone, Debug)]
pub struct UserEntry {
pub user_id: i64,
pub username: String,
pub state: iota_ipc::LocalUserState,
pub data_present: bool,
pub credential_present: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
List,
AddButton,
RemoveButton,
Back,
}
#[derive(Clone, Debug)]
enum Dialog {
Add { username: String },
Remove { user: UserEntry },
}
pub struct UsersScreen {
users: Vec<UserEntry>,
focused_index: usize,
focus: Focus,
ipc: Arc<IpcClient>,
message: Option<String>,
dialog: Option<Dialog>,
pending_dialog: Option<Dialog>,
loading: bool,
pending: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
filter: String,
filtering: bool,
tick: AtomicU8,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> 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<IpcClient>) -> 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<usize> {
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<KeyHint> {
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",
},
]
}
}
}

View file

@ -0,0 +1,66 @@
use super::model::{UserAction, UserActionGroup};
use crate::controls::menu::{MenuItem, MenuState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserActionMenu {
pub group: UserActionGroup,
pub menu: MenuState<UserAction>,
}
impl UserActionMenu {
pub fn new(
group: UserActionGroup,
actions: Vec<UserAction>,
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)
);
}
}

View file

@ -0,0 +1,126 @@
use crate::controls::{
menu::{MenuItem, MenuState},
text_input::TextInput,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddUserMethod {
Invitation,
CreateLocal,
ImportTu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddUserPhase {
ChooseMethod,
ConfigureCreate,
ConfigureImport,
ConfigureInvitation,
InspectingTu,
ReviewImport,
Pending,
Result,
}
#[derive(Debug, Clone)]
pub struct AddUserFlow {
pub phase: AddUserPhase,
pub methods: MenuState<AddUserMethod>,
pub username: TextInput,
pub import_path: TextInput,
pub credential: Option<iota_ipc::SecretString>,
pub preview: Option<iota_ipc::TuCredentialPreview>,
pub error: Option<String>,
}
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"));
}
}

View file

@ -0,0 +1,55 @@
use super::{UserEntry, model::UserAction};
#[derive(Debug, Clone)]
pub struct UserConfirmation {
pub user: UserEntry,
pub action: UserAction,
}
impl UserConfirmation {
pub fn title(&self) -> &'static str {
match self.action {
UserAction::Release => "Release user",
UserAction::PurgeData => "Purge hosted data",
UserAction::ExportTu => "Export TU",
UserAction::DeleteAccount => "Delete Tensamin account",
UserAction::Reconcile => "Reconcile with Omega",
UserAction::Diagnostics => "User diagnostics",
UserAction::ForceDetach => "Force local detach",
UserAction::ForgetResidency => "Forget residency record",
}
}
pub fn message(&self) -> String {
match self.action {
UserAction::Release => format!(
"Stop managing {} on this Iota?\nHosted data is retained. Local credentials are removed.",
self.user.username
),
UserAction::PurgeData => format!(
"Delete hosted data for {}?\nThe account and Iota assignment are retained.",
self.user.username
),
UserAction::ExportTu => format!("Export the local TU for {}?", self.user.username),
UserAction::DeleteAccount => format!(
"Delete the Tensamin account {}?\nHosted data erasure is requested.",
self.user.username
),
UserAction::Reconcile => {
format!("Compare {} with its Omega assignment?", self.user.username)
}
UserAction::Diagnostics => {
format!("Load local diagnostics for {}?", self.user.username)
}
UserAction::ForceDetach => format!(
"Force local detach for {}?\nOmega assignment will not be changed. Hosted data is retained.",
self.user.username
),
UserAction::ForgetResidency => format!(
"Forget the released residency record for {}?\nThis is allowed only when hosted data is empty.",
self.user.username
),
}
}
pub fn confirm_label(&self) -> &'static str {
self.action.confirmation_label()
}
}

View file

@ -0,0 +1,29 @@
use super::{UserEntry, model::UserActionGroup};
pub fn state_label(user: &UserEntry) -> &'static str {
match user.state {
iota_ipc::LocalUserState::Managed => "Managed",
iota_ipc::LocalUserState::Released => "Released",
}
}
pub fn group_label(group: UserActionGroup) -> &'static str {
group.label()
}
pub fn credential_status_label(status: iota_ipc::CredentialStatus) -> &'static str {
match status {
iota_ipc::CredentialStatus::LocalPresent => "Local",
iota_ipc::CredentialStatus::External => "External",
iota_ipc::CredentialStatus::Missing => "Missing",
iota_ipc::CredentialStatus::None => "None",
}
}
pub fn pending_operation_label(operation: &iota_ipc::UserOperationSummary) -> String {
let kind = match operation.operation {
iota_ipc::UserOperationKind::Create => "Create",
iota_ipc::UserOperationKind::Attach => "Attach",
iota_ipc::UserOperationKind::Release => "Release",
iota_ipc::UserOperationKind::Purge => "Purge",
};
format!("{kind} ({})", operation.phase)
}

View file

@ -0,0 +1,85 @@
use super::UserEntry;
use crate::controls::text_input::TextInput;
use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::Path,
};
#[derive(Clone, Debug)]
pub struct CredentialExportState {
pub user: UserEntry,
pub destination: TextInput,
pub error: Option<String>,
}
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
);
}
}

View file

@ -0,0 +1,38 @@
/* Invitation state remains capability-gated until the daemon implements its protocol. */
pub const UNSUPPORTED_MESSAGE: &str =
"Invitation management is not supported by the connected daemon.";
pub fn empty_message(supported: bool) -> &'static str {
if supported {
"No invitations.\n\nInvitation protocol support is not implemented in this client build."
} else {
"No invitations.\n\nInvitation management is not supported by the connected daemon."
}
}
pub fn render(
frame: &mut ratatui::Frame,
area: ratatui::layout::Rect,
context: &crate::render_context::RenderContext<'_>,
supported: bool,
) {
frame.render_widget(
ratatui::widgets::Paragraph::new(empty_message(supported)).style(context.theme.text.muted),
area,
);
}
#[cfg(test)]
mod tests {
use super::{UNSUPPORTED_MESSAGE, empty_message};
#[test]
fn absent_capability_is_explained_in_the_empty_state() {
assert!(empty_message(false).contains(UNSUPPORTED_MESSAGE));
}
#[test]
fn supported_daemon_does_not_show_the_capability_error() {
assert!(!empty_message(true).contains(UNSUPPORTED_MESSAGE));
}
}

View file

@ -0,0 +1,7 @@
use super::UserEntry;
pub fn matches_filter(user: &UserEntry, filter: &str) -> bool {
let needle = filter.to_ascii_lowercase();
needle.is_empty()
|| user.username.to_ascii_lowercase().contains(&needle)
|| user.user_id.to_string().contains(&needle)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusZone {
Toolbar,
Search,
UserList,
UserActions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserAdminTab {
Users,
Invitations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserActionGroup {
Hosting,
Data,
Credential,
Account,
Repair,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserAction {
Release,
PurgeData,
ExportTu,
DeleteAccount,
Reconcile,
Diagnostics,
ForceDetach,
ForgetResidency,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionGroupEntry {
pub group: UserActionGroup,
pub actions: Vec<UserAction>,
}
use super::UserEntry;
/* Maps local daemon facts into the operator actions that are safe to present. */
pub fn available_action_groups(user: &UserEntry) -> Vec<ActionGroupEntry> {
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());
}
}

View file

@ -1,5 +1,5 @@
use crate::{ use crate::{
controls::header::render_header, controls::header::{HEADER_ITEMS, render_header},
help_overlay::HelpOverlay, help_overlay::HelpOverlay,
input_handler::setup_input_handler, input_handler::setup_input_handler,
interaction_result::InteractionResult, interaction_result::InteractionResult,
@ -432,19 +432,16 @@ impl UI {
if header_is_focused { if header_is_focused {
let mut action = None; let mut action = None;
if let Ok(mut focus) = self.header_focus.lock() { if let Ok(mut focus) = self.header_focus.lock() {
let index = focus.unwrap_or(0); let index = focus.unwrap_or(0).min(HEADER_ITEMS.len().saturating_sub(1));
match key.code { match key.code {
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), KeyCode::Left | KeyCode::BackTab => {
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), *focus = Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len())
}
KeyCode::Right | KeyCode::Tab => {
*focus = Some((index + 1) % HEADER_ITEMS.len())
}
KeyCode::Enter | KeyCode::Char(' ') => { KeyCode::Enter | KeyCode::Char(' ') => {
action = Some( action = HEADER_ITEMS.get(index).map(|item| item.action);
[
AppAction::OpenOverview,
AppAction::OpenUsers,
AppAction::OpenSettings,
AppAction::Quit,
][index],
);
*focus = None; *focus = None;
} }
KeyCode::Esc => *focus = None, KeyCode::Esc => *focus = None,
@ -607,7 +604,8 @@ impl UI {
username: u.username, username: u.username,
state: u.state, state: u.state,
data_present: u.data_present, data_present: u.data_present,
credential_present: u.credential_present, credential_status: u.credential_status,
pending_operation: u.pending_operation,
}) })
.collect()) .collect())
} }

View file

@ -3,8 +3,9 @@ use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{ use iota_ipc::{
CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent, CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent,
IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE,
OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, OmikronStatusResponse, ReconcileAction, ResponseEnvelope, ResponsePayload, ResponseResult,
TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserDiagnostics,
UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary,
}; };
use iota_logger::{log, log_command}; use iota_logger::{log, log_command};
use iota_storage::users::pending_operations::{ use iota_storage::users::pending_operations::{
@ -29,6 +30,30 @@ pub struct PeerContext {
const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512; 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 { fn now_millis() -> i64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -119,7 +144,9 @@ impl CommandRouter {
let needs_omikron = matches!( let needs_omikron = matches!(
request, request,
LocalRequest::CreateUser { .. } LocalRequest::CreateUser { .. }
| LocalRequest::InspectTuCredential { .. }
| LocalRequest::AttachUserFromTu { .. } | LocalRequest::AttachUserFromTu { .. }
| LocalRequest::ReconcileUser { .. }
| LocalRequest::ReleaseUser { .. } | LocalRequest::ReleaseUser { .. }
| LocalRequest::CompleteDeleteUser { .. } | LocalRequest::CompleteDeleteUser { .. }
); );
@ -162,22 +189,35 @@ impl CommandRouter {
ResponseResult::Ok(ResponsePayload::Tasks(tasks)) ResponseResult::Ok(ResponsePayload::Tasks(tasks))
} }
LocalRequest::ListUsers => { 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::<std::collections::HashMap<_, _>>(),
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let users = user_manager::get_residency() let users = user_manager::get_residency()
.into_iter() .into_iter()
.map(|user| { .map(|user| {
let user_id = user.user_id;
let profile = user_manager::get_user(user.user_id)?; let profile = user_manager::get_user(user.user_id)?;
Ok(UserSummary { Ok(UserSummary {
credential_present: user.state == user_manager::LocalUserState::Managed credential_status: credential_status(&user, profile.as_ref()),
&& profile.is_some_and(|profile| { user_id,
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, username: user.username,
state: match user.state { state: match user.state {
user_manager::LocalUserState::Managed => { user_manager::LocalUserState::Managed => {
@ -188,6 +228,7 @@ impl CommandRouter {
} }
}, },
data_present: user.data_present, data_present: user.data_present,
pending_operation: pending.get(&user_id).cloned(),
}) })
}) })
.collect::<Result<Vec<_>, iota_storage::storage_error::StorageError>>(); .collect::<Result<Vec<_>, iota_storage::storage_error::StorageError>>();
@ -265,6 +306,163 @@ impl CommandRouter {
} }
} }
} }
LocalRequest::InspectTuCredential { credential } => {
match omikron_connector::user_ops::inspect_tu_credential(
self.services.omikron.as_ref(),
&credential.0,
)
.await
{
Ok(preview) => ResponseResult::Ok(ResponsePayload::TuCredentialPreview(
iota_ipc::TuCredentialPreview {
user_id: preview.user_id,
username: preview.username,
assigned_iota_id: preview.assigned_iota_id,
},
)),
Err(error) => {
log!("Credential inspection failed: {error:?}");
ResponseResult::Error(IpcErrorCode::Unauthorized)
}
}
}
LocalRequest::ReconcileUser { user_id } => {
let residency = match user_manager::get_residency_by_id(user_id) {
Ok(Some(residency)) => residency,
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let omega_iota_id = match omikron_connector::user_ops::get_remote_user_assignment(
self.services.omikron.as_ref(),
user_id,
)
.await
{
Ok(assignment) => assignment,
Err(error) => {
log!("User reconciliation failed for {user_id}: {error:?}");
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
}
};
let local_iota_id = config_util::CONFIG
.load()
.iota_id
.and_then(|id| i64::try_from(id).ok());
let action = if residency.state == user_manager::LocalUserState::Managed
&& omega_iota_id != local_iota_id
{
match user_manager::finalize_local_release(user_id, Some(&residency.username)) {
Ok(()) => ReconcileAction::ReleasedLocally,
Err(error) => {
log!("User reconciliation cleanup failed for {user_id}: {error}");
return ResponseResult::Error(IpcErrorCode::StorageFailure);
}
}
} else {
ReconcileAction::None
};
ResponseResult::Ok(ResponsePayload::UserReconciled(UserReconcileResult {
user_id,
local_state: match residency.state {
user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed,
user_manager::LocalUserState::Released => {
iota_ipc::LocalUserState::Released
}
},
omega_iota_id,
action,
}))
}
LocalRequest::ForceDetachUser { user_id } => {
match user_manager::force_local_detach(user_id) {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!(
"Force-detached user {user_id} locally; Omega was not changed"
),
}),
Err(error) => {
log!("Force local detach failed for {user_id}: {error}");
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
}
}
LocalRequest::ForgetReleasedUser { user_id } => {
match user_manager::forget_released_user(user_id) {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Forgot released user residency {user_id}"),
}),
Err(error) => {
log!("Forget residency failed for {user_id}: {error}");
ResponseResult::Error(IpcErrorCode::Conflict)
}
}
}
LocalRequest::GetUserDiagnostics { user_id } => {
let residency = match user_manager::get_residency_by_id(user_id) {
Ok(Some(residency)) => residency,
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let profile = match user_manager::get_user(user_id) {
Ok(profile) => profile,
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let pending_operation = match pending_operations::get_all() {
Ok(operations) => operations
.into_iter()
.find(|operation| operation.user_id == user_id)
.map(|operation| {
format!(
"{}/{}",
operation.operation.as_str(),
operation.phase.as_str()
)
}),
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let credential_status = credential_status(&residency, profile.as_ref());
ResponseResult::Ok(ResponsePayload::UserDiagnostics(UserDiagnostics {
user_id,
username: residency.username,
local_state: match residency.state {
user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed,
user_manager::LocalUserState::Released => {
iota_ipc::LocalUserState::Released
}
},
data_present: residency.data_present,
credential_status,
trusted_app_count: profile
.as_ref()
.map_or(0, |profile| profile.trusted_apps.len()),
pending_operation,
}))
}
LocalRequest::RevokeTrustedApp { user_id, app_id } => {
match user_manager::revoke_trusted_app(user_id, &app_id) {
Ok(true) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Revoked trusted application {app_id} for user {user_id}"),
}),
Ok(false) => ResponseResult::Error(IpcErrorCode::NotFound),
Err(error) => {
log!("Trusted application revocation failed for {user_id}: {error}");
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
}
}
LocalRequest::RevokeAllTrustedApps { user_id } => {
match user_manager::revoke_all_trusted_apps(user_id) {
Ok(removed) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!(
"Revoked {removed} trusted applications for user {user_id}"
),
}),
Err(error) => {
log!("Trusted application revocation failed for {user_id}: {error}");
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
}
}
LocalRequest::CompleteDeleteUser { LocalRequest::CompleteDeleteUser {
user_id, user_id,
credential, credential,
@ -457,16 +655,15 @@ impl CommandRouter {
.collect(); .collect();
ResponseResult::Ok(ResponsePayload::Components(components)) ResponseResult::Ok(ResponsePayload::Components(components))
} }
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { 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)) => { Ok(Some(user)) => {
let credential_present = let credential_status = credential_status(&residency, Some(&user));
iota_util::file_util::read_user_credential_with_legacy(
user_id,
&user.username,
)
.ok()
.flatten()
.is_some();
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
user_id: user.user_id, user_id: user.user_id,
username: user.username, username: user.username,
@ -474,16 +671,55 @@ impl CommandRouter {
created_at: user.created_at, created_at: user.created_at,
trusted_apps: user.trusted_apps.keys().cloned().collect(), trusted_apps: user.trusted_apps.keys().cloned().collect(),
state: iota_ipc::LocalUserState::Managed, state: iota_ipc::LocalUserState::Managed,
data_present: user_manager::get_residency() data_present: residency.data_present,
.iter() credential_status,
.find(|entry| entry.user_id == user_id)
.is_none_or(|entry| entry.data_present),
credential_present,
})) }))
} }
Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), 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), Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure),
}, }
}
LocalRequest::ExportUserCredential { user_id } => {
let residency = match user_manager::get_residency_by_id(user_id) {
Ok(Some(residency)) => residency,
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
if residency.state != user_manager::LocalUserState::Managed
|| residency.credential_origin != user_manager::CredentialOrigin::Local
{
return ResponseResult::Error(IpcErrorCode::Conflict);
}
let credential = match iota_util::file_util::read_user_credential_with_legacy(
user_id,
&residency.username,
) {
Ok(Some(credential)) => credential,
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let parsed = match iota_util::tu::TuCredential::parse(&credential) {
Ok(parsed) if parsed.user_id == user_id => parsed,
Ok(_) | Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
ResponseResult::Ok(ResponsePayload::UserCredentialExport {
user_id,
username: residency.username,
credential: iota_ipc::SecretString(parsed.to_canonical_string()),
})
}
LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
LocalRequest::GetLogs { limit } => { LocalRequest::GetLogs { limit } => {
let entries = if let Ok(buf) = self.log_buffer.lock() { let entries = if let Ok(buf) = self.log_buffer.lock() {
@ -538,6 +774,19 @@ mod tests {
LocalRequest::AttachUserFromTu { LocalRequest::AttachUserFromTu {
credential: SecretString("credential".into()), 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::PurgeUserData { user_id: 1 },
LocalRequest::ReleaseUser { user_id: 1 }, LocalRequest::ReleaseUser { user_id: 1 },
LocalRequest::CompleteDeleteUser { LocalRequest::CompleteDeleteUser {
@ -570,7 +819,7 @@ mod tests {
LocalRequest::ListCommunities, LocalRequest::ListCommunities,
]; ];
assert_eq!(requests.len(), 25); assert_eq!(requests.len(), 33);
for request in requests { for request in requests {
let required = request.required_role(); let required = request.required_role();
assert!(IpcRole::Admin.allows(required)); assert!(IpcRole::Admin.allows(required));

View file

@ -401,7 +401,14 @@ async fn handle_client(
daemon_version: env!("CARGO_PKG_VERSION").to_string(), daemon_version: env!("CARGO_PKG_VERSION").to_string(),
instance_id: instance_id.clone(), instance_id: instance_id.clone(),
startup_phase: runtime.current_startup_phase().into(), startup_phase: runtime.current_startup_phase().into(),
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], capabilities: vec![
"commands".into(),
"metrics".into(),
"logs".into(),
"user_management_v2".into(),
"tu_inspection_v1".into(),
"credential_export_v1".into(),
],
lifecycle: *runtime.lifecycle.borrow(), lifecycle: *runtime.lifecycle.borrow(),
health: runtime.overall_health(), health: runtime.overall_health(),
deployment_mode: from_environment().mode, deployment_mode: from_environment().mode,

View file

@ -149,8 +149,7 @@ async fn main() -> ExitCode {
Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { Err(omikron_connector::OmikronStartupError::Authentication { connection }) => {
runtime.set_component_failed( runtime.set_component_failed(
iota_ipc::ComponentId::Omikron, iota_ipc::ComponentId::Omikron,
"Omikron authentication failed; regenerate the Iota identity to register again" "Omikron authentication failed; inspect the authenticated relay and Omega status before rotating the Iota identity".into(),
.into(),
); );
// Keep IPC alive: identity rotation is the supported recovery // Keep IPC alive: identity rotation is the supported recovery
// action and must remain available after authentication fails. // action and must remain available after authentication fails.
@ -221,6 +220,23 @@ async fn main() -> ExitCode {
.spawn_tracked("user-lifecycle-reconciliation", async move { .spawn_tracked("user-lifecycle-reconciliation", async move {
let mut states = omikron_reconcile.connection_state(); let mut states = omikron_reconcile.connection_state();
loop { 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!( if matches!(
*states.borrow(), *states.borrow(),
omikron_connector::omikron_connection::ConnectionState::Connected { .. } omikron_connector::omikron_connection::ConnectionState::Connected { .. }

View file

@ -4,16 +4,17 @@ pub mod transport;
pub use protocol::{ pub use protocol::{
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ConfigResponse, ConnectionStatus, CredentialStatus, DaemonMessage, DaemonStatusResponse,
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase, DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent,
LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, LifecyclePhase, LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload,
SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind,
UpdateStatusResponse, UserDetailResponse, UserSummary, TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics,
UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary,
}; };
pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg};
/// Current IPC protocol version. /// Current IPC protocol version.
pub const PROTOCOL_VERSION: u16 = 2; pub const PROTOCOL_VERSION: u16 = 4;
/// Minimum protocol version this daemon understands. /// Minimum protocol version this daemon understands.
pub const MIN_PROTOCOL_VERSION: u16 = 2; pub const MIN_PROTOCOL_VERSION: u16 = 2;

View file

@ -48,9 +48,34 @@ pub enum LocalRequest {
CreateUser { CreateUser {
username: String, username: String,
}, },
InspectTuCredential {
credential: SecretString,
},
AttachUserFromTu { AttachUserFromTu {
credential: SecretString, 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 { PurgeUserData {
user_id: i64, user_id: i64,
}, },
@ -127,6 +152,7 @@ impl LocalRequest {
| Self::GetOmikronStatus | Self::GetOmikronStatus
| Self::ListComponents | Self::ListComponents
| Self::GetUser { .. } | Self::GetUser { .. }
| Self::GetUserDiagnostics { .. }
| Self::GetLogs { .. } | Self::GetLogs { .. }
| Self::CheckUpdate | Self::CheckUpdate
| Self::ListCommunities => IpcRole::Read, | Self::ListCommunities => IpcRole::Read,
@ -134,7 +160,14 @@ impl LocalRequest {
Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate, Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate,
Self::CreateUser { .. } Self::CreateUser { .. }
| Self::InspectTuCredential { .. }
| Self::AttachUserFromTu { .. } | Self::AttachUserFromTu { .. }
| Self::ReconcileUser { .. }
| Self::ForceDetachUser { .. }
| Self::ForgetReleasedUser { .. }
| Self::RevokeTrustedApp { .. }
| Self::RevokeAllTrustedApps { .. }
| Self::ExportUserCredential { .. }
| Self::PurgeUserData { .. } | Self::PurgeUserData { .. }
| Self::ReleaseUser { .. } | Self::ReleaseUser { .. }
| Self::CompleteDeleteUser { .. } | Self::CompleteDeleteUser { .. }
@ -220,6 +253,14 @@ pub enum ResponsePayload {
user_id: i64, user_id: i64,
username: String, 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. /// Retained only for wire compatibility. New lifecycle code never emits it.
UserRemoved { UserRemoved {
user_id: i64, user_id: i64,
@ -267,7 +308,7 @@ pub struct UserDetailResponse {
pub trusted_apps: Vec<String>, pub trusted_apps: Vec<String>,
pub state: LocalUserState, pub state: LocalUserState,
pub data_present: bool, pub data_present: bool,
pub credential_present: bool, pub credential_status: CredentialStatus,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@ -304,7 +345,66 @@ pub struct UserSummary {
pub username: String, pub username: String,
pub state: LocalUserState, pub state: LocalUserState,
pub data_present: bool, pub data_present: bool,
pub credential_present: bool, pub credential_status: CredentialStatus,
#[serde(default)]
pub pending_operation: Option<UserOperationSummary>,
}
#[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<i64>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct UserReconcileResult {
pub user_id: i64,
pub local_state: LocalUserState,
pub omega_iota_id: Option<i64>,
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<String>,
} }
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
@ -359,6 +459,11 @@ impl std::fmt::Display for IpcErrorCode {
#[cfg(test)] #[cfg(test)]
mod error_tests { mod error_tests {
use super::{
CredentialStatus, LocalUserState, ResponsePayload, SecretString, UserOperationKind,
UserOperationSummary, UserSummary,
};
use super::IpcErrorCode; use super::IpcErrorCode;
#[test] #[test]
@ -378,6 +483,50 @@ mod error_tests {
.contains("required role") .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("<redacted>"));
}
#[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)] #[derive(Clone, Debug, Deserialize, Serialize)]

View file

@ -5,9 +5,16 @@ pub const COMMANDS: &[&str] = &[
"tasks", "tasks",
"users list", "users list",
"users show ", "users show ",
"users add ", "users add create ",
"users remove ", "users hosting release ",
"users import ", "users data purge ",
"users account delete ",
"users repair reconcile ",
"users repair diagnostics ",
"users repair force-detach ",
"users forget ",
"users apps revoke ",
"users apps revoke-all ",
"omikron status", "omikron status",
"reconnect", "reconnect",
"identity rotate", "identity rotate",
@ -59,16 +66,47 @@ pub fn parse(line: &str) -> Option<LocalRequest> {
let user_id = id_str.parse::<i64>().ok()?; let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::GetUser { user_id }) Some(LocalRequest::GetUser { user_id })
} }
["user" | "users", "add", username] => Some(LocalRequest::CreateUser { ["user" | "users", "add", "create", username] => Some(LocalRequest::CreateUser {
username: username.to_string(), username: username.to_string(),
}), }),
["user" | "users", "remove", id_str] => { ["user" | "users", "hosting", "release", id_str] => {
let user_id = id_str.parse::<i64>().ok()?; let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::RemoveUser { user_id }) Some(LocalRequest::ReleaseUser { user_id })
} }
["user" | "users", "import", username] => Some(LocalRequest::ImportUser { ["user" | "users", "data", "purge", id_str] => Some(LocalRequest::PurgeUserData {
username: username.to_string(), user_id: id_str.parse::<i64>().ok()?,
}), }),
["user" | "users", "account", "delete", id_str] => Some(LocalRequest::CompleteDeleteUser {
user_id: id_str.parse::<i64>().ok()?,
credential: None,
}),
["user" | "users", "repair", "reconcile", id_str] => Some(LocalRequest::ReconcileUser {
user_id: id_str.parse::<i64>().ok()?,
}),
["user" | "users", "repair", "diagnostics", id_str] => {
Some(LocalRequest::GetUserDiagnostics {
user_id: id_str.parse::<i64>().ok()?,
})
}
["user" | "users", "repair", "force-detach", id_str] => {
Some(LocalRequest::ForceDetachUser {
user_id: id_str.parse::<i64>().ok()?,
})
}
["user" | "users", "forget", id_str] => Some(LocalRequest::ForgetReleasedUser {
user_id: id_str.parse::<i64>().ok()?,
}),
["user" | "users", "apps", "revoke", id_str, app_id] => {
Some(LocalRequest::RevokeTrustedApp {
user_id: id_str.parse::<i64>().ok()?,
app_id: app_id.to_string(),
})
}
["user" | "users", "apps", "revoke-all", id_str] => {
Some(LocalRequest::RevokeAllTrustedApps {
user_id: id_str.parse::<i64>().ok()?,
})
}
["reconnect"] => Some(LocalRequest::ReconnectOmikron), ["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
@ -116,7 +154,7 @@ mod tests {
#[test] #[test]
fn parses_user_add() { fn parses_user_add() {
let req = parse("user add alice").unwrap(); let req = parse("user add create alice").unwrap();
match req { match req {
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected CreateUser"), _ => panic!("expected CreateUser"),
@ -127,12 +165,20 @@ mod tests {
fn accepts_the_headless_cli_user_vocabulary() { fn accepts_the_headless_cli_user_vocabulary() {
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
assert!(matches!( assert!(matches!(
parse("users add alice"), parse("users add create alice"),
Some(LocalRequest::CreateUser { .. }) Some(LocalRequest::CreateUser { .. })
)); ));
assert!(matches!( assert!(matches!(
parse("users remove 42"), parse("users hosting release 42"),
Some(LocalRequest::RemoveUser { user_id: 42 }) Some(LocalRequest::ReleaseUser { user_id: 42 })
));
assert!(matches!(
parse("users apps revoke 42 desktop"),
Some(LocalRequest::RevokeTrustedApp { user_id: 42, .. })
));
assert!(matches!(
parse("users apps revoke-all 42"),
Some(LocalRequest::RevokeAllTrustedApps { user_id: 42 })
)); ));
assert!(matches!( assert!(matches!(
parse("identity rotate"), parse("identity rotate"),
@ -141,17 +187,18 @@ mod tests {
} }
#[test] #[test]
fn parses_user_remove_by_id() { fn parses_user_release_by_id() {
let req = parse("user remove 42").unwrap(); let req = parse("user hosting release 42").unwrap();
match req { match req {
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), LocalRequest::ReleaseUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected RemoveUser"), _ => panic!("expected ReleaseUser"),
} }
} }
#[test] #[test]
fn user_remove_requires_numeric_id() { fn generic_remove_is_not_routed() {
assert!(parse("user remove alice").is_none()); assert!(parse("user remove 42").is_none());
assert!(parse("users remove alice").is_none());
} }
#[test] #[test]
@ -295,12 +342,8 @@ mod tests {
} }
#[test] #[test]
fn parses_users_import() { fn deprecated_import_is_not_routed() {
let req = parse("users import alice").unwrap(); assert!(parse("users import alice").is_none());
match req {
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected ImportUser"),
}
} }
#[test] #[test]
@ -317,7 +360,15 @@ mod tests {
#[test] #[test]
fn completion_is_prefix_based_and_deterministic() { fn completion_is_prefix_based_and_deterministic() {
assert_eq!(completions("identity r"), vec!["identity rotate"]); assert_eq!(completions("identity r"), vec!["identity rotate"]);
assert_eq!(completions("/users a"), vec!["users add "]); assert_eq!(
completions("/users a"),
vec![
"users add create ",
"users account delete ",
"users apps revoke ",
"users apps revoke-all ",
]
);
assert!(completions("definitely-unknown").is_empty()); assert!(completions("definitely-unknown").is_empty());
} }

View file

@ -0,0 +1,83 @@
use crate::{storage_error::StorageError, util::db};
use rusqlite::params;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InvitationState {
Pending,
Redeemed,
Revoked,
Expired,
}
impl InvitationState {
fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Redeemed => "redeemed",
Self::Revoked => "revoked",
Self::Expired => "expired",
}
}
fn parse(value: &str) -> Result<Self, StorageError> {
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<i64>,
pub state: InvitationState,
pub redeemed_user_id: Option<i64>,
pub redeemed_at: Option<i64>,
pub revoked_at: Option<i64>,
}
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<Vec<InvitationSummary>, 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::<Result<Vec<_>, _>>()?)
})
}
pub fn revoke(invitation_id: &str, revoked_at: i64) -> Result<bool, StorageError> {
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)
})
}

View file

@ -1,4 +1,5 @@
pub mod contact; pub mod contact;
pub mod invitations;
pub mod pending_operations; pub mod pending_operations;
pub mod user_manager; pub mod user_manager;
pub mod user_profile; pub mod user_profile;

View file

@ -7,6 +7,7 @@ pub enum PendingUserOperationKind {
Create, Create,
Attach, Attach,
Release, Release,
Purge,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -15,15 +16,21 @@ pub enum PendingUserOperationPhase {
CredentialWritten, CredentialWritten,
RemoteCommitted, RemoteCommitted,
LocalCommitted, LocalCommitted,
DatabasePurged,
E2eePurged,
FilesystemPurged,
} }
impl PendingUserOperationPhase { impl PendingUserOperationPhase {
fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Prepared => "prepared", Self::Prepared => "prepared",
Self::CredentialWritten => "credential_written", Self::CredentialWritten => "credential_written",
Self::RemoteCommitted => "remote_committed", Self::RemoteCommitted => "remote_committed",
Self::LocalCommitted => "local_committed", Self::LocalCommitted => "local_committed",
Self::DatabasePurged => "database_purged",
Self::E2eePurged => "e2ee_purged",
Self::FilesystemPurged => "filesystem_purged",
} }
} }
@ -33,6 +40,9 @@ impl PendingUserOperationPhase {
"credential_written" => Ok(Self::CredentialWritten), "credential_written" => Ok(Self::CredentialWritten),
"remote_committed" => Ok(Self::RemoteCommitted), "remote_committed" => Ok(Self::RemoteCommitted),
"local_committed" => Ok(Self::LocalCommitted), "local_committed" => Ok(Self::LocalCommitted),
"database_purged" => Ok(Self::DatabasePurged),
"e2ee_purged" => Ok(Self::E2eePurged),
"filesystem_purged" => Ok(Self::FilesystemPurged),
_ => Err(StorageError::Other( _ => Err(StorageError::Other(
"unknown pending user operation phase".into(), "unknown pending user operation phase".into(),
)), )),
@ -41,11 +51,12 @@ impl PendingUserOperationPhase {
} }
impl PendingUserOperationKind { impl PendingUserOperationKind {
fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Create => "create", Self::Create => "create",
Self::Attach => "attach", Self::Attach => "attach",
Self::Release => "release", Self::Release => "release",
Self::Purge => "purge",
} }
} }
@ -54,6 +65,7 @@ impl PendingUserOperationKind {
"create" => Ok(Self::Create), "create" => Ok(Self::Create),
"attach" => Ok(Self::Attach), "attach" => Ok(Self::Attach),
"release" => Ok(Self::Release), "release" => Ok(Self::Release),
"purge" => Ok(Self::Purge),
_ => Err(StorageError::Other("unknown pending user operation".into())), _ => Err(StorageError::Other("unknown pending user operation".into())),
} }
} }
@ -171,3 +183,20 @@ pub fn remove(user_id: i64) -> Result<(), StorageError> {
Ok(()) 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(())
})
}

View file

@ -1,3 +1,4 @@
use crate::users::pending_operations;
use crate::users::user_profile::UserProfile; use crate::users::user_profile::UserProfile;
use crate::util::db; use crate::util::db;
use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file};
@ -10,12 +11,19 @@ pub enum LocalUserState {
Released, Released,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CredentialOrigin {
Local,
External,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserResidency { pub struct UserResidency {
pub user_id: i64, pub user_id: i64,
pub username: String, pub username: String,
pub state: LocalUserState, pub state: LocalUserState,
pub data_present: bool, pub data_present: bool,
pub credential_origin: CredentialOrigin,
} }
fn now_millis() -> i64 { fn now_millis() -> i64 {
@ -32,6 +40,13 @@ pub fn add_user(user: UserProfile) {
} }
pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { 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| { db::with_immediate_transaction(|tx| {
tx.execute( tx.execute(
r#" r#"
@ -65,10 +80,10 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora
)?; )?;
} }
tx.execute( tx.execute(
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) 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) 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', updated_at = excluded.updated_at"#, 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, now_millis()], params![user.user_id, user.username, match credential_origin { CredentialOrigin::Local => "local", CredentialOrigin::External => "external" }, now_millis()],
)?; )?;
Ok(()) Ok(())
}) })
@ -155,8 +170,8 @@ pub fn get_users() -> Vec<UserProfile> {
let user_id: i64 = r.get(0)?; let user_id: i64 = r.get(0)?;
let username: String = r.get(1)?; let username: String = r.get(1)?;
let public_key: String = r.get(2)?; let public_key: String = r.get(2)?;
let private_key_hash: String = r.get(3)?; let private_key_hash: Option<String> = r.get(3)?;
let reset_token: String = r.get(4)?; let reset_token: Option<String> = r.get(4)?;
let created_at: i64 = r.get(5)?; let created_at: i64 = r.get(5)?;
let display_name: Option<String> = r.get(6)?; let display_name: Option<String> = r.get(6)?;
@ -211,6 +226,29 @@ fn load_trusted_apps(
}) })
} }
pub fn revoke_trusted_app(
user_id: i64,
app_id: &str,
) -> Result<bool, crate::storage_error::StorageError> {
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<usize, crate::storage_error::StorageError> {
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) { pub fn remove_user(user_id: i64) {
if let Err(e) = db::with_db(|conn| { if let Err(e) = db::with_db(|conn| {
conn.execute( conn.execute(
@ -232,6 +270,31 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
.ok_or_else(|| { .ok_or_else(|| {
crate::storage_error::StorageError::Other("managed user was not found".into()) 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| { db::with_db(|conn| {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
tx.execute( tx.execute(
@ -252,12 +315,84 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) .map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
} }
/// Authoritative hosted-data erasure used by local purge and future Omega pub fn force_local_detach(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
/// erasure delivery. Management metadata and credentials are left intact. finalize_local_release(user_id, None)
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> { }
if crate::util::relay_queue::has_unclassified_relays()? {
return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown); pub fn forget_released_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
let residency = get_residency_by_id(user_id)?.ok_or_else(|| {
crate::storage_error::StorageError::Other("user residency was not found".into())
})?;
if residency.state != LocalUserState::Released || residency.data_present {
return Err(crate::storage_error::StorageError::Other(
"only released users with empty hosted data can be forgotten".into(),
));
} }
if pending_operations::get_all()?
.iter()
.any(|operation| operation.user_id == user_id)
{
return Err(crate::storage_error::StorageError::Other(
"user has a pending lifecycle operation".into(),
));
}
db::with_db(|conn| {
conn.execute(
"DELETE FROM user_residency WHERE user_id = ?1",
params![user_id],
)?;
Ok(())
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PurgeStage {
Database,
E2ee,
Filesystem,
Complete,
}
fn remaining_purge_stages(
phase: pending_operations::PendingUserOperationPhase,
) -> Result<Vec<PurgeStage>, 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| { db::with_db(|conn| {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
tx.execute( tx.execute(
@ -315,10 +450,6 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
"DELETE FROM client_message_deliveries WHERE user_id = ?1", "DELETE FROM client_message_deliveries WHERE user_id = ?1",
params![user_id], params![user_id],
)?; )?;
tx.execute(
"DELETE FROM trusted_apps WHERE user_id = ?1",
params![user_id],
)?;
tx.execute( tx.execute(
"DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1", "DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1",
params![user_id], params![user_id],
@ -331,17 +462,84 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
"DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1", "DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1",
params![user_id], 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()?; tx.commit()?;
Ok(()) 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) crate::util::e2ee_storage::purge_user(user_id)
.map_err(crate::storage_error::StorageError::Other)?; .map_err(crate::storage_error::StorageError::Other)?;
delete_user_directory(user_id) pending_operations::update_phase(user_id, PendingUserOperationPhase::E2eePurged)?;
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) }
PurgeStage::Filesystem => {
delete_user_directory(user_id).map_err(|error| {
crate::storage_error::StorageError::Other(error.to_string())
})?;
pending_operations::update_phase(
user_id,
PendingUserOperationPhase::FilesystemPurged,
)?;
}
PurgeStage::Complete => {
pending_operations::complete_purge(user_id, now_millis())?;
}
}
Ok(())
})
} }
/// Complete local erasure is idempotent and is the target for a durable /// Complete local erasure is idempotent and is the target for a durable
@ -372,19 +570,50 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor
pub fn get_residency() -> Vec<UserResidency> { pub fn get_residency() -> Vec<UserResidency> {
db::with_db(|conn| { db::with_db(|conn| {
let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?; let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state, credential_origin FROM user_residency ORDER BY username")?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
let lifecycle: String = row.get(2)?; let lifecycle: String = row.get(2)?;
Ok(UserResidency { Ok(UserResidency {
user_id: row.get(0)?, username: row.get(1)?, user_id: row.get(0)?, username: row.get(1)?,
state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released }, state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released },
data_present: row.get::<_, String>(3)? == "present", data_present: row.get::<_, String>(3)? == "present",
credential_origin: if row.get::<_, String>(4)? == "external" { CredentialOrigin::External } else { CredentialOrigin::Local },
}) })
})?; })?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into) rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}).unwrap_or_default() }).unwrap_or_default()
} }
pub fn get_residency_by_id(
user_id: i64,
) -> Result<Option<UserResidency>, 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() { pub fn clear() {
if let Err(e) = db::with_db(|conn| { if let Err(e) = db::with_db(|conn| {
conn.execute_batch( conn.execute_batch(
@ -424,6 +653,75 @@ pub fn load_users_sync() -> std::io::Result<()> {
Ok(()) 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) { pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) {
let path = format!("users/{}/apps", user_id); let path = format!("users/{}/apps", user_id);
let name = format!("{}.json", app_identifier); let name = format!("{}.json", app_identifier);

View file

@ -12,8 +12,8 @@ pub struct UserProfile {
pub user_id: i64, pub user_id: i64,
pub username: String, pub username: String,
pub public_key: String, pub public_key: String,
pub private_key_hash: String, pub private_key_hash: Option<String>,
pub reset_token: String, pub reset_token: Option<String>,
pub created_at: i64, pub created_at: i64,
pub display_name: Option<String>, pub display_name: Option<String>,
pub trusted_apps: std::collections::HashMap<String, String>, pub trusted_apps: std::collections::HashMap<String, String>,
@ -25,8 +25,8 @@ impl UserProfile {
username: String, username: String,
display_name: Option<String>, display_name: Option<String>,
public_key: String, public_key: String,
private_key_hash: String, private_key_hash: Option<String>,
reset_token: String, reset_token: Option<String>,
) -> Self { ) -> Self {
Self::new_with_created_at( Self::new_with_created_at(
user_id, user_id,
@ -47,8 +47,8 @@ impl UserProfile {
username: String, username: String,
display_name: Option<String>, display_name: Option<String>,
public_key: String, public_key: String,
private_key_hash: String, private_key_hash: Option<String>,
reset_token: String, reset_token: Option<String>,
created_at: i64, created_at: i64,
) -> Self { ) -> Self {
Self { Self {
@ -68,7 +68,6 @@ impl UserProfile {
"uuid" => self.user_id, "uuid" => self.user_id,
"username" => self.username.clone(), "username" => self.username.clone(),
"public_key" => self.public_key.clone(), "public_key" => self.public_key.clone(),
"private_key_hash" => self.private_key_hash.clone(),
"created_at" => self.created_at, "created_at" => self.created_at,
"storage" => used_dir_space(&format!("users/{}", self.user_id.to_string())), "storage" => used_dir_space(&format!("users/{}", self.user_id.to_string())),
}; };
@ -88,8 +87,8 @@ impl UserProfile {
let user_id = j["uuid"].as_i64()?; let user_id = j["uuid"].as_i64()?;
let username = j["username"].as_str()?.to_string(); let username = j["username"].as_str()?.to_string();
let public_key = j["public_key"].as_str()?.to_string(); let public_key = j["public_key"].as_str()?.to_string();
let private_key_hash = j["private_key_hash"].as_str()?.to_string(); let private_key_hash = j["private_key_hash"].as_str().map(str::to_owned);
let reset_token = j["reset_token"].as_str()?.to_string(); let reset_token = j["reset_token"].as_str().map(str::to_owned);
let created_at = j["created_at"].as_i64()?; let created_at = j["created_at"].as_i64()?;
let display_name = j["display_name"].as_str().map(|s| s.to_string()); let display_name = j["display_name"].as_str().map(|s| s.to_string());
@ -127,7 +126,7 @@ impl UserProfile {
let mut bytes = [0u8; 192]; let mut bytes = [0u8; 192];
OsRng.fill(bytes.as_mut()); OsRng.fill(bytes.as_mut());
let new_token = general_purpose::STANDARD.encode(&bytes); let new_token = general_purpose::STANDARD.encode(&bytes);
self.reset_token = new_token.clone(); self.reset_token = Some(new_token.clone());
new_token new_token
} }

View file

@ -21,6 +21,8 @@ pub struct IotaConfig {
pub omikron_host: Option<String>, pub omikron_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub omikron_port: Option<u16>, pub omikron_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub omikron_id: Option<i64>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub keyring: Option<String>, pub keyring: Option<String>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
@ -101,6 +103,7 @@ impl Default for IotaConfig {
web: WebSettings::default(), web: WebSettings::default(),
omikron_host: None, omikron_host: None,
omikron_port: None, omikron_port: None,
omikron_id: None,
keyring: None, keyring: None,
public_key: None, public_key: None,
private_key: None, private_key: None,

View file

@ -710,6 +710,110 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
)?; )?;
} }
if current_version < 21 {
let users_exist: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')",
[],
|row| row.get(0),
)?;
if users_exist {
conn.execute_batch(
r#"
ALTER TABLE users RENAME TO users_before_credential_origin;
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
private_key_hash TEXT,
reset_token TEXT,
created_at INTEGER NOT NULL,
display_name TEXT
);
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name)
SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name
FROM users_before_credential_origin;
DROP TABLE users_before_credential_origin;
"#,
)?;
}
let residency_exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_residency')",
[],
|row| row.get(0),
)?;
if residency_exists {
add_table_column_if_missing(
conn,
"user_residency",
"credential_origin",
"credential_origin TEXT NOT NULL DEFAULT 'local' CHECK (credential_origin IN ('local', 'external'))",
)?;
}
conn.pragma_update(None, "user_version", 21)?;
}
if current_version < 22 {
let pending_exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_user_operations')",
[],
|row| row.get(0),
)?;
if pending_exists {
conn.execute_batch(
r#"
ALTER TABLE pending_user_operations RENAME TO pending_user_operations_before_purge;
CREATE TABLE pending_user_operations (
user_id INTEGER PRIMARY KEY,
operation TEXT NOT NULL
CHECK (operation IN ('create', 'attach', 'release', 'purge')),
username TEXT NOT NULL,
public_key TEXT,
private_key_hash TEXT,
reset_token TEXT,
registration_token TEXT,
phase TEXT NOT NULL DEFAULT 'prepared'
CHECK (phase IN (
'prepared', 'credential_written', 'remote_committed', 'local_committed',
'database_purged', 'e2ee_purged', 'filesystem_purged'
)),
created_at INTEGER NOT NULL
);
INSERT INTO pending_user_operations (
user_id, operation, username, public_key, private_key_hash,
reset_token, registration_token, phase, created_at
)
SELECT user_id, operation, username, public_key, private_key_hash,
reset_token, registration_token, phase, created_at
FROM pending_user_operations_before_purge;
DROP TABLE pending_user_operations_before_purge;
CREATE INDEX idx_pending_user_operations_operation
ON pending_user_operations (operation, created_at);
"#,
)?;
}
conn.pragma_update(None, "user_version", 22)?;
}
if current_version < 23 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS user_invitations (
invitation_id TEXT PRIMARY KEY,
token_hash BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
state TEXT NOT NULL CHECK (state IN ('pending', 'redeemed', 'revoked', 'expired')),
redeemed_user_id INTEGER,
redeemed_at INTEGER,
revoked_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_user_invitations_state_created
ON user_invitations (state, created_at);
PRAGMA user_version = 23;
"#,
)?;
}
Ok(()) Ok(())
} }
@ -779,7 +883,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))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 20); assert_eq!(version, 23);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement = let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -798,7 +902,7 @@ mod tests {
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 20); assert_eq!(version, 23);
for table in [ for table in [
"sync_heads", "sync_heads",
"sync_events", "sync_events",
@ -813,6 +917,7 @@ mod tests {
"blocked_users", "blocked_users",
"user_receipt_policy", "user_receipt_policy",
"user_message_storage_policy", "user_message_storage_policy",
"user_invitations",
] { ] {
let exists: i64 = conn.query_row( let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
@ -837,7 +942,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))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 20); assert_eq!(version, 23);
for column in [ for column in [
"id", "id",
"user_id", "user_id",
@ -855,4 +960,84 @@ mod tests {
Ok(()) 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(())
}
} }

View file

@ -16,6 +16,39 @@ pub fn replace_private(path: &Path, contents: &[u8], backup_limit: usize) -> io:
replace_with_mode(path, contents, backup_limit, true) 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( fn replace_with_mode(
path: &Path, path: &Path,
contents: &[u8], contents: &[u8],
@ -133,7 +166,7 @@ fn sync_directory(_path: &Path) -> io::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::replace; use super::{create_private, replace};
#[test] #[test]
fn replace_preserves_a_previous_version_as_a_backup() { fn replace_preserves_a_previous_version_as_a_backup() {
@ -155,4 +188,32 @@ mod tests {
.count(); .count();
assert_eq!(backups, 1); 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
);
}
} }

View file

@ -0,0 +1,29 @@
use iota_util::atomic_file::create_private;
#[test]
fn private_export_refuses_to_replace_existing_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("alice.tu");
create_private(&path, b"first").unwrap();
assert_eq!(
create_private(&path, b"second").unwrap_err().kind(),
std::io::ErrorKind::AlreadyExists
);
assert_eq!(std::fs::read(path).unwrap(), b"first");
}
#[cfg(unix)]
#[test]
fn private_export_uses_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("alice.tu");
create_private(&path, b"secret").unwrap();
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}

View file

@ -117,20 +117,55 @@ enum UsersAction {
user_id: i64, user_id: i64,
}, },
Add { Add {
username: Option<String>, #[command(subcommand)]
#[arg(long, value_name = "PATH")] action: UserAddAction,
tu: Option<PathBuf>,
}, },
Release { Hosting {
user_id: i64, #[command(subcommand)]
#[arg(long)] action: UserHostingAction,
yes: bool,
}, },
Data { Data {
#[command(subcommand)] #[command(subcommand)]
action: UserDataAction, action: UserDataAction,
}, },
CompleteDelete { Account {
#[command(subcommand)]
action: UserAccountAction,
},
Repair {
#[command(subcommand)]
action: UserRepairAction,
},
Forget {
user_id: i64,
#[arg(long)]
yes: bool,
},
Apps {
#[command(subcommand)]
action: UserAppsAction,
},
Credential {
#[command(subcommand)]
action: UserCredentialAction,
},
}
#[derive(Subcommand, Debug)]
enum UserAddAction {
Create { username: String },
Tu { path: PathBuf },
}
#[derive(Subcommand, Debug)]
enum UserHostingAction {
Release {
user_id: i64,
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand, Debug)]
enum UserAccountAction {
Delete {
user_id: i64, user_id: i64,
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
tu: Option<PathBuf>, tu: Option<PathBuf>,
@ -139,6 +174,42 @@ enum UsersAction {
}, },
} }
#[derive(Subcommand, Debug)] #[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 { enum UserDataAction {
Purge { Purge {
user_id: i64, user_id: i64,
@ -310,6 +381,33 @@ pub enum Command {
tu: Option<PathBuf>, tu: Option<PathBuf>,
confirmed: bool, 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, OmikronReconnect,
IdentityRotate { IdentityRotate {
confirmed: bool, confirmed: bool,
@ -406,15 +504,21 @@ impl CliInvocation {
Some(CliCommand::Users(users)) => match users.action { Some(CliCommand::Users(users)) => match users.action {
UsersAction::List => Command::UsersList, UsersAction::List => Command::UsersList,
UsersAction::Show { user_id } => Command::UsersShow { user_id }, UsersAction::Show { user_id } => Command::UsersShow { user_id },
UsersAction::Add { username, tu } => { UsersAction::Add {
if username.is_some() == tu.is_some() { action: UserAddAction::Create { username },
return Err( } => Command::UsersAdd {
"users add requires exactly one of <username> or --tu <PATH>".into(), username: Some(username),
); tu: None,
} },
Command::UsersAdd { username, tu } UsersAction::Add {
} action: UserAddAction::Tu { path },
UsersAction::Release { user_id, yes } => Command::UsersRelease { } => Command::UsersAdd {
username: None,
tu: Some(path),
},
UsersAction::Hosting {
action: UserHostingAction::Release { user_id, yes },
} => Command::UsersRelease {
user_id, user_id,
confirmed: resolve_confirmed(yes), confirmed: resolve_confirmed(yes),
}, },
@ -424,11 +528,50 @@ impl CliInvocation {
user_id, user_id,
confirmed: resolve_confirmed(yes), confirmed: resolve_confirmed(yes),
}, },
UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete { UsersAction::Account {
action: UserAccountAction::Delete { user_id, tu, yes },
} => Command::UsersCompleteDelete {
user_id, user_id,
tu, tu,
confirmed: resolve_confirmed(yes), 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 { Some(CliCommand::Omikron(omikron)) => match omikron.action {
OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Reconnect => Command::OmikronReconnect,
@ -594,7 +737,8 @@ mod tests {
#[test] #[test]
fn command_schema_drives_help_and_completion_paths() { fn command_schema_drives_help_and_completion_paths() {
let paths = CliInvocation::command_paths(); let paths = CliInvocation::command_paths();
assert!(paths.contains(&"users release".to_owned())); assert!(paths.contains(&"users hosting release".to_owned()));
assert!(paths.contains(&"users add create".to_owned()));
assert!(paths.contains(&"daemon install".to_owned())); assert!(paths.contains(&"daemon install".to_owned()));
let help = CliInvocation::help_text(); let help = CliInvocation::help_text();
assert!(help.contains("users")); assert!(help.contains("users"));
@ -649,8 +793,13 @@ mod tests {
#[test] #[test]
fn parses_users_add() { fn parses_users_add() {
let invocation = let invocation = CliInvocation::parse([
CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); "users".into(),
"add".into(),
"create".into(),
"alice".into(),
])
.unwrap();
assert_eq!( assert_eq!(
invocation.command, invocation.command,
Command::UsersAdd { Command::UsersAdd {
@ -689,6 +838,7 @@ mod tests {
fn parses_users_release_with_confirmation() { fn parses_users_release_with_confirmation() {
let invocation = CliInvocation::parse([ let invocation = CliInvocation::parse([
"users".into(), "users".into(),
"hosting".into(),
"release".into(), "release".into(),
"42".into(), "42".into(),
"--yes".into(), "--yes".into(),
@ -703,6 +853,39 @@ mod tests {
); );
} }
#[test]
fn parses_credential_export_with_required_destination() {
let invocation = CliInvocation::parse([
"users".into(),
"credential".into(),
"export".into(),
"42".into(),
"--output".into(),
"alice.tu".into(),
])
.unwrap();
assert_eq!(
invocation.command,
Command::UsersExportCredential {
user_id: 42,
output: PathBuf::from("alice.tu"),
}
);
}
#[test]
fn credential_export_rejects_a_missing_destination() {
assert!(
CliInvocation::parse([
"users".into(),
"credential".into(),
"export".into(),
"42".into(),
])
.is_err()
);
}
#[test] #[test]
fn parses_omikron_reconnect() { fn parses_omikron_reconnect() {
let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap(); let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap();

View file

@ -400,10 +400,18 @@ fn print_help() {
println!(" tasks List active tasks"); println!(" tasks List active tasks");
println!(" users list List all users"); println!(" users list List all users");
println!(" users show <ID> Show user details"); println!(" users show <ID> Show user details");
println!(" users add <NAME> Create a new user"); println!(" users add create <NAME> Create a new user");
println!(" users add --tu <PATH> Add an existing account credential"); println!(" users add tu <PATH> Attach an existing account credential");
println!(" users data purge <ID> Purge hosted data (requires --yes)"); println!(" users data purge <ID> Purge hosted data (requires --yes)");
println!(" users release <ID> Release this Iota (requires --yes)"); println!(" users hosting release <ID> Release from this Iota (requires --yes)");
println!(" users account delete <ID> Delete the Tensamin account (requires --yes)");
println!(" users repair reconcile <ID> Compare local state with Omega");
println!(" users repair diagnostics <ID> Show local lifecycle diagnostics");
println!(" users repair force-detach <ID> Remove local management (requires --yes)");
println!(" users forget <ID> Forget an empty released residency (requires --yes)");
println!(" users apps revoke <ID> <APP> Revoke a trusted app (requires --yes)");
println!(" users apps revoke-all <ID> Revoke all trusted apps (requires --yes)");
println!(" users credential export <ID> --output <PATH> Export the local TU");
println!(" omikron status Show Omikron connection status"); println!(" omikron status Show Omikron connection status");
println!(" omikron reconnect Reconnect to Omikron"); println!(" omikron reconnect Reconnect to Omikron");
println!(" identity rotate Rotate identity keys (requires --yes)"); println!(" identity rotate Rotate identity keys (requires --yes)");
@ -549,6 +557,7 @@ async fn run_command(
output: OutputFormat, output: OutputFormat,
) -> Result<(), StartupError> { ) -> Result<(), StartupError> {
let color = ColorConfig::new(); let color = ColorConfig::new();
let mut credential_output = None;
let request = match command { let request = match command {
Command::Status => LocalRequest::GetStatus, Command::Status => LocalRequest::GetStatus,
Command::Tasks => LocalRequest::ListTasks, Command::Tasks => LocalRequest::ListTasks,
@ -622,6 +631,40 @@ async fn run_command(
credential, 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::OmikronReconnect => LocalRequest::ReconnectOmikron,
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity,
@ -650,6 +693,18 @@ async fn run_command(
| Command::UsersCompleteDelete { | Command::UsersCompleteDelete {
confirmed: false, .. confirmed: false, ..
} }
| Command::UsersForceDetach {
confirmed: false, ..
}
| Command::UsersForget {
confirmed: false, ..
}
| Command::UsersRevokeApp {
confirmed: false, ..
}
| Command::UsersRevokeAllApps {
confirmed: false, ..
}
| Command::IdentityRotate { confirmed: false } | Command::IdentityRotate { confirmed: false }
| Command::RegenerateKeys { confirmed: false } | Command::RegenerateKeys { confirmed: false }
| Command::DaemonRestart { confirmed: false } | Command::DaemonRestart { confirmed: false }
@ -685,6 +740,38 @@ async fn run_command(
.map_err(|e| StartupError::Other(e.to_string()))? .map_err(|e| StartupError::Other(e.to_string()))?
{ {
ResponseResult::Ok(payload) => { 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) { if !matches!(output, OutputFormat::Text) {
return render_structured(&payload, output); return render_structured(&payload, output);
} }
@ -725,11 +812,34 @@ async fn run_command(
if users.is_empty() { if users.is_empty() {
println!("{}", cli_color::muted(&color, "No users.")); println!("{}", cli_color::muted(&color, "No users."));
} else { } else {
println!(
"{:<12} {:<18} {:<10} {:<9} {:<12} PENDING",
"ID", "USERNAME", "STATE", "DATA", "CREDENTIAL"
);
for user in &users { for user in &users {
println!( println!(
"{} ({})", "{:<12} {:<18} {:<10} {:<9} {:<12} {}",
user.user_id,
cli_color::heading(&color, &user.username), cli_color::heading(&color, &user.username),
user.user_id match user.state {
iota_ipc::LocalUserState::Managed => "managed",
iota_ipc::LocalUserState::Released => "released",
},
if user.data_present {
"present"
} else {
"empty"
},
match user.credential_status {
iota_ipc::CredentialStatus::LocalPresent => "local",
iota_ipc::CredentialStatus::External => "external",
iota_ipc::CredentialStatus::Missing => "missing",
iota_ipc::CredentialStatus::None => "none",
},
user.pending_operation
.as_ref()
.map(format_pending_operation)
.unwrap_or_else(|| "-".into())
); );
} }
} }
@ -815,6 +925,34 @@ async fn run_command(
println!("Trusted Apps: {}", user.trusted_apps.join(", ")); 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) => { ResponsePayload::LogEntries(logs) => {
for entry in &logs.entries { for entry in &logs.entries {
let ts = entry.timestamp_ms; let ts = entry.timestamp_ms;
@ -860,6 +998,11 @@ async fn run_command(
/// Text remains an operator-oriented presentation; JSON and YAML must never /// Text remains an operator-oriented presentation; JSON and YAML must never
/// require consumers to parse it. /// require consumers to parse it.
fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { 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 { match output {
OutputFormat::Json => println!( OutputFormat::Json => println!(
"{}", "{}",
@ -886,10 +1029,35 @@ fn render_table(payload: &ResponsePayload) {
println!("No users."); println!("No users.");
return; return;
} }
println!("{:<8} {}", "ID", "USERNAME"); println!(
println!("{:<8} {}", "--------", "--------"); "{:<12} {:<18} {:<10} {:<9} {:<12} PENDING",
"ID", "USERNAME", "STATE", "DATA", "CREDENTIAL"
);
for user in users { for user in users {
println!("{:<8} {}", user.user_id, user.username); println!(
"{:<12} {:<18} {:<10} {:<9} {:<12} {}",
user.user_id,
user.username,
match user.state {
iota_ipc::LocalUserState::Managed => "managed",
iota_ipc::LocalUserState::Released => "released",
},
if user.data_present {
"present"
} else {
"empty"
},
match user.credential_status {
iota_ipc::CredentialStatus::LocalPresent => "local",
iota_ipc::CredentialStatus::External => "external",
iota_ipc::CredentialStatus::Missing => "missing",
iota_ipc::CredentialStatus::None => "none",
},
user.pending_operation
.as_ref()
.map(format_pending_operation)
.unwrap_or_else(|| "-".into())
);
} }
} }
ResponsePayload::Tasks(tasks) => { ResponsePayload::Tasks(tasks) => {
@ -990,6 +1158,33 @@ fn render_table(payload: &ResponsePayload) {
ResponsePayload::UserCreated { user_id, username } => { ResponsePayload::UserCreated { user_id, username } => {
println!("Created user {} ({})", username, user_id); 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 } => { ResponsePayload::UserRemoved { user_id } => {
println!("Removed user {}", user_id); println!("Removed user {}", user_id);
} }
@ -1014,3 +1209,13 @@ fn render_table(payload: &ResponsePayload) {
} }
} }
} }
fn format_pending_operation(operation: &iota_ipc::UserOperationSummary) -> String {
let kind = match operation.operation {
iota_ipc::UserOperationKind::Create => "create",
iota_ipc::UserOperationKind::Attach => "attach",
iota_ipc::UserOperationKind::Release => "release",
iota_ipc::UserOperationKind::Purge => "purge",
};
format!("{kind}/{}", operation.phase)
}

@ -1 +1 @@
Subproject commit 4b82f4f8139ed9aa74fa86f73ba8f0d565703c86 Subproject commit 909b3977cb6a233e74a925eb467412fb384844bc

View file

@ -1,5 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use mtp::codec::CommunicationValue; use mtp::codec::{CommunicationType, CommunicationValue};
use std::time::Duration; use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -7,6 +7,7 @@ pub enum OmikronError {
Disconnected(String), Disconnected(String),
Timeout(String), Timeout(String),
Authentication(String), Authentication(String),
Rejected(CommunicationType, String),
Internal(String), Internal(String),
} }
@ -16,6 +17,7 @@ impl std::fmt::Display for OmikronError {
Self::Disconnected(v) Self::Disconnected(v)
| Self::Timeout(v) | Self::Timeout(v)
| Self::Authentication(v) | Self::Authentication(v)
| Self::Rejected(_, v)
| Self::Internal(v) => f.write_str(v), | Self::Internal(v) => f.write_str(v),
} }
} }

View file

@ -38,7 +38,7 @@ use iota_util::route_target::RouteTarget;
const IOTA_KEYRING_PATH: &str = "iota.mk"; const IOTA_KEYRING_PATH: &str = "iota.mk";
static IDENTITY_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); static IDENTITY_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); static OMIKRON_TRUST_DIRECTORY: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) { fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) {
let Ok(storage_owner) = i64::try_from(signer_id) else { 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. * working directory, so restarts use the same trusted material.
*/ */
pub fn configure_identity_path(path: PathBuf) { pub fn configure_identity_path(path: PathBuf) {
let key_path = path.parent().map(|parent| parent.join("omikron.mpkb")); let trust_directory = path.parent().map(|parent| parent.join("omikrons"));
let _ = IDENTITY_PATH.set(path); let _ = IDENTITY_PATH.set(path);
if let Some(key_path) = key_path { if let Some(trust_directory) = trust_directory {
let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path); let _ = OMIKRON_TRUST_DIRECTORY.set(trust_directory);
} }
} }
fn identity_path() -> &'static Path { fn identity_path() -> &'static Path {
@ -72,11 +72,22 @@ fn identity_path() -> &'static Path {
.map(PathBuf::as_path) .map(PathBuf::as_path)
.unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH))
} }
fn omikron_public_key_path() -> &'static Path { fn omikron_trust_directory() -> &'static Path {
OMIKRON_PUBLIC_KEY_PATH OMIKRON_TRUST_DIRECTORY
.get() .get()
.map(PathBuf::as_path) .map(PathBuf::as_path)
.unwrap_or_else(|| Path::new("omikron.mpkb")) .unwrap_or_else(|| Path::new("omikrons"))
}
fn omikron_public_key_path(id: i64) -> PathBuf {
omikron_trust_directory().join(format!("{id}.mpkb"))
}
fn legacy_omikron_public_key_path() -> PathBuf {
identity_path()
.parent()
.map(|parent| parent.join("omikron.mpkb"))
.unwrap_or_else(|| PathBuf::from("omikron.mpkb"))
} }
fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> { fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> {
@ -107,13 +118,20 @@ fn serialization_path(path: &Path) -> Result<PathBuf, String> {
const RECONNECT_DELAY: Duration = Duration::from_secs(5); const RECONNECT_DELAY: Duration = Duration::from_secs(5);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300);
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); const CONNECTION_TIMEOUT: Duration = Duration::from_secs(45);
const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5);
const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
const TASK_MAX_AGE: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60);
const MAX_CONCURRENT_HANDLERS: usize = 20; const MAX_CONCURRENT_HANDLERS: usize = 20;
const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000;
struct ResolvedOmikronEndpoint {
id: Option<i64>,
host: String,
port: u16,
public_key: PublicKeyBundle,
}
#[derive(Debug)] #[derive(Debug)]
pub enum IdentityError { pub enum IdentityError {
Storage(mtp::files::FileError), Storage(mtp::files::FileError),
@ -159,6 +177,18 @@ fn jittered_reconnect_delay(delay: Duration) -> Duration {
Duration::from_millis(u64::try_from(jittered).expect("bounded jitter must be non-negative")) 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 { fn wire_user_id(user_id: i64) -> u64 {
u64::try_from(user_id).expect("validated user ID is non-negative") u64::try_from(user_id).expect("validated user ID is non-negative")
} }
@ -443,10 +473,9 @@ impl OmikronConnection {
let existing_iota_id = CONFIG.load().iota_id; let existing_iota_id = CONFIG.load().iota_id;
let (host, port, omikron_public_key) = let endpoint = self.resolve_omikron_endpoint(existing_iota_id).await?;
self.resolve_omikron_endpoint(existing_iota_id).await?;
let addr_str = format!("https://{}:{}", host, port); let addr_str = format!("https://{}:{}", endpoint.host, endpoint.port);
log!("Connecting to Omikron at {}", addr_str); log!("Connecting to Omikron at {}", addr_str);
@ -470,26 +499,24 @@ impl OmikronConnection {
client_config, client_config,
existing_iota_id, existing_iota_id,
&keyring, &keyring,
&omikron_public_key, &endpoint.public_key,
) )
.await .await
{ {
Ok(connection) => connection, Ok(connection) => connection,
Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => { Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => {
let reason = format!( /* MTP currently combines invalid proofs with timeouts and backend
"Authentication failed: {}. Your Iota keys may be invalid or the private key has changed on the server.", * failures in this variant. Retrying is safe; stopping here can
reason * strand an Iota during a temporary Omega outage. */
); return Err(format!("Authentication attempt failed: {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)), Err(e) => return Err(format!("Connection failed: {}", e)),
}; };
log_t!("omikron_connection_success"); log_t!("omikron_connection_success");
self.persist_authenticated_omikron(&endpoint)?;
if existing_iota_id.is_none() { if existing_iota_id.is_none() {
modify_config(|cfg| cfg.iota_id = Some(connection.client_id)); modify_config(|cfg| cfg.iota_id = Some(connection.client_id));
log!("Registered with Iota-ID: {}", connection.client_id); log!("Registered with Iota-ID: {}", connection.client_id);
@ -564,22 +591,20 @@ impl OmikronConnection {
* override for local dev/testing against a hand-run Omikron without a * override for local dev/testing against a hand-run Omikron without a
* live Omega. * live Omega.
* *
* The fetched Omikron public key is pinned to `omikron.mpkb` (trust on * Keys are pinned per Omikron ID. A different relay can therefore be used
* first use): if a cached key exists and a fresh discovery response * after failover, while an unexpected key change for one relay remains a
* disagrees with it, the mismatch is logged loudly and the cached key is * security error. Discovery data becomes durable only after MTP
* kept rather than silently trusting whatever Omega's HTTP API returned * authentication has completed.
* this time - the same trust boundary the previous manual-file-drop
* model had, just automated for the common case.
*/ */
async fn resolve_omikron_endpoint( async fn resolve_omikron_endpoint(
&self, &self,
existing_iota_id: Option<u64>, existing_iota_id: Option<u64>,
) -> Result<(String, u16, PublicKeyBundle), String> { ) -> Result<ResolvedOmikronEndpoint, String> {
if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) { if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) {
let port: u16 = port_str let port: u16 = port_str
.parse() .parse()
.map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?; .map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?;
let key_path = omikron_public_key_path(); let key_path = Path::new("omikron.mpkb");
let public_key = mtp::files::load_public_key_bundle(key_path) let public_key = mtp::files::load_public_key_bundle(key_path)
.map_err(|e| { .map_err(|e| {
format!( format!(
@ -587,21 +612,41 @@ impl OmikronConnection {
key_path.display(), e, key_path.display() key_path.display(), e, key_path.display()
) )
})?; })?;
return Ok((host, port, public_key)); return Ok(ResolvedOmikronEndpoint {
id: None,
host,
port,
public_key,
});
} }
let key_path = omikron_public_key_path(); let cached_endpoint = {
let cached_key = mtp::files::load_public_key_bundle(key_path).ok();
let cached_host_port = {
let conf = CONFIG.load(); let conf = CONFIG.load();
match (&conf.omikron_host, conf.omikron_port) { match (&conf.omikron_id, &conf.omikron_host, conf.omikron_port) {
(Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { (Some(id), Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
Some((host.clone(), port)) mtp::files::load_public_key_bundle(&omikron_public_key_path(*id))
.ok()
.map(|public_key| ResolvedOmikronEndpoint {
id: Some(*id),
host: host.clone(),
port,
public_key,
})
} }
(Some(_), Some(_)) => { (Some(_), Some(_), Some(_)) => {
log!("Ignoring invalid cached Omikron endpoint in Iota configuration"); log!("Ignoring invalid cached Omikron endpoint in Iota configuration");
None 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, _ => None,
} }
}; };
@ -620,8 +665,9 @@ impl OmikronConnection {
None => omega_discovery::discover_random().await.ok(), None => omega_discovery::discover_random().await.ok(),
}; };
let (host, port, public_key) = if let Some(endpoint) = discovered { let endpoint = if let Some(endpoint) = discovered {
match &cached_key { let key_path = omikron_public_key_path(endpoint.id);
match mtp::files::load_public_key_bundle(&key_path).ok() {
Some(cached) => { Some(cached) => {
let keys_match = let keys_match =
match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) { match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) {
@ -632,49 +678,63 @@ impl OmikronConnection {
}; };
if !keys_match { if !keys_match {
log!( log!(
"Fetched Omikron public key differs from the cached {} - keeping the \ "Fetched Omikron public key differs from the trusted {}. \
cached key. Delete {} manually if this is an expected key rotation.", Omikron key rotation requires an explicit trust refresh.",
key_path.display(), key_path.display(),
key_path.display()
); );
if let Some((cached_host, cached_port)) = &cached_host_port {
(cached_host.clone(), *cached_port, cached.clone())
} else {
return Err(format!( return Err(format!(
"Omega returned an Omikron key that differs from {} and no validated cached endpoint is available", "Omega returned a changed public key for Omikron {}",
key_path.display() endpoint.id
)); ));
}
} else { } else {
(endpoint.host, endpoint.port, cached.clone()) ResolvedOmikronEndpoint {
id: Some(endpoint.id),
host: endpoint.host,
port: endpoint.port,
public_key: cached,
} }
} }
None => {
if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) {
log!("Failed to cache Omikron public key: {}", e);
} }
(endpoint.host, endpoint.port, endpoint.public_key) None => ResolvedOmikronEndpoint {
id: Some(endpoint.id),
host: endpoint.host,
port: endpoint.port,
public_key: endpoint.public_key,
},
} }
} } else if let Some(cached) = cached_endpoint {
} else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) {
log!( log!(
"Omega discovery unreachable, falling back to last-known Omikron {}:{}", "Omega discovery unreachable, falling back to last-known Omikron {}:{}",
host, cached.host,
port cached.port
); );
(host.clone(), *port, cached.clone()) cached
} else { } else {
return Err( return Err(
"Omega discovery failed and no cached Omikron address/key is available".to_string(), "Omega discovery failed and no cached Omikron address/key is available".to_string(),
); );
}; };
modify_config(|cfg| { Ok(endpoint)
cfg.omikron_host = Some(host.clone()); }
cfg.omikron_port = Some(port);
});
Ok((host, port, public_key)) fn persist_authenticated_omikron(
&self,
endpoint: &ResolvedOmikronEndpoint,
) -> Result<(), String> {
let Some(id) = endpoint.id else {
return Ok(());
};
let key_path = omikron_public_key_path(id);
std::fs::create_dir_all(omikron_trust_directory())
.map_err(|error| format!("create Omikron trust directory: {error}"))?;
save_omikron_public_key(&endpoint.public_key, &key_path)?;
modify_config(|cfg| {
cfg.omikron_id = Some(id);
cfg.omikron_host = Some(endpoint.host.clone());
cfg.omikron_port = Some(endpoint.port);
});
Ok(())
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -1946,6 +2006,7 @@ impl OmikronConnection {
dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set); dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set);
dispatch!(UserBlockCheck, handle_user_block_check); dispatch!(UserBlockCheck, handle_user_block_check);
dispatch!(EraseHostedUserData, handle_erase_hosted_user_data); dispatch!(EraseHostedUserData, handle_erase_hosted_user_data);
dispatch!(ProvisionIotaUser, handle_iota_user_provisioning);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -1980,6 +2041,54 @@ impl OmikronConnection {
let _ = self.send_message(&acknowledgement).await; 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<Self>, 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<Self>, cv: &CommunicationValue) { async fn handle_get_chat_secret(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self let _ = self
.send_message(&message_handlers::handle_get_chat_secret(cv)) .send_message(&message_handlers::handle_get_chat_secret(cv))
@ -2781,9 +2890,13 @@ impl OmikronConnection {
.or_else(|| response_cv.get_data(DataType::ErrorType).as_str()) .or_else(|| response_cv.get_data(DataType::ErrorType).as_str())
.unwrap_or("connection error") .unwrap_or("connection error")
.to_string(); .to_string();
let rejection_kind = if response_cv.is_type(CommunicationType::ErrorNotFound) {
"not_found"
} else {
"other"
};
Err(format!( Err(format!(
"Request rejected (msg_id={}, reason={})", "Request rejected (kind={rejection_kind}, msg_id={msg_id}, reason={reason})"
msg_id, reason
)) ))
} else { } else {
Ok(response_cv) Ok(response_cv)
@ -3029,15 +3142,7 @@ impl OmikronClient for OmikronConnection {
) -> Result<CommunicationValue, OmikronError> { ) -> Result<CommunicationValue, OmikronError> {
Self::await_response(self, value, Some(timeout)) Self::await_response(self, value, Some(timeout))
.await .await
.map_err(|error| { .map_err(map_await_response_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> { async fn reconnect(&self) -> Result<(), OmikronError> {
@ -3165,4 +3270,21 @@ mod tests {
assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY); 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, _)
));
}
} }

View file

@ -37,6 +37,13 @@ pub enum LifecycleUserError {
LocalPersistence(String), LocalPersistence(String),
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectedTuCredential {
pub user_id: i64,
pub username: String,
pub assigned_iota_id: Option<i64>,
}
impl From<crate::OmikronError> for LifecycleUserError { impl From<crate::OmikronError> for LifecycleUserError {
fn from(value: crate::OmikronError) -> Self { fn from(value: crate::OmikronError) -> Self {
Self::Transport(value) Self::Transport(value)
@ -120,6 +127,58 @@ async fn inspect_credential_account(
Ok((username, public_key, created_at)) 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<InspectedTuCredential, LifecycleUserError> {
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<Option<i64>, 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( async fn credential_proof(
connection: &dyn OmikronClient, connection: &dyn OmikronClient,
credential: &TuCredential, credential: &TuCredential,
@ -181,8 +240,8 @@ pub async fn attach_user_from_tu(
username, username,
None, None,
public_key, public_key,
hex_hash(contents), Some(hex_hash(contents)),
String::new(), Some(String::new()),
created_at, created_at,
); );
pending_operations::upsert(&PendingUserOperation { pending_operations::upsert(&PendingUserOperation {
@ -190,8 +249,8 @@ pub async fn attach_user_from_tu(
operation: PendingUserOperationKind::Attach, operation: PendingUserOperationKind::Attach,
username: profile.username.clone(), username: profile.username.clone(),
public_key: Some(profile.public_key.clone()), public_key: Some(profile.public_key.clone()),
private_key_hash: Some(profile.private_key_hash.clone()), private_key_hash: profile.private_key_hash.clone(),
reset_token: Some(profile.reset_token.clone()), reset_token: profile.reset_token.clone(),
registration_token: None, registration_token: None,
phase: PendingUserOperationPhase::Prepared, phase: PendingUserOperationPhase::Prepared,
created_at: now_millis(), created_at: now_millis(),
@ -254,16 +313,17 @@ pub async fn complete_delete_user_with_tu(
/// Repair local management state after a release or migration committed in /// Repair local management state after a release or migration committed in
/// Omega but local cleanup was interrupted. Hosted data is retained. /// Omega but local cleanup was interrupted. Hosted data is retained.
pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
let Ok(local_iota_id) = configured_iota_id() else { let mut pending = match pending_operations::get_all() {
return;
};
let pending = match pending_operations::get_all() {
Ok(pending) => pending, Ok(pending) => pending,
Err(error) => { Err(error) => {
log!("Pending user operation reconciliation could not read storage: {error}"); log!("Pending user operation reconciliation could not read storage: {error}");
return; return;
} }
}; };
pending.retain(|operation| operation.operation != PendingUserOperationKind::Purge);
let Ok(local_iota_id) = configured_iota_id() else {
return;
};
for operation in pending { for operation in pending {
let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
DataType::UserId, DataType::UserId,
@ -319,15 +379,20 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
operation.username, operation.username,
None, None,
public_key, public_key,
operation.private_key_hash.unwrap_or_default(), operation.private_key_hash,
operation.reset_token.unwrap_or_default(), operation.reset_token,
); );
if try_add_user(profile).is_ok() { if try_add_user(profile).is_ok() {
let _ = pending_operations::remove(operation.user_id); let _ = pending_operations::remove(operation.user_id);
} }
} }
PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => { PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => {
if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() { if iota_storage::users::user_manager::finalize_local_release(
operation.user_id,
Some(&operation.username),
)
.is_ok()
{
let _ = pending_operations::remove(operation.user_id); let _ = pending_operations::remove(operation.user_id);
} }
} }
@ -339,19 +404,47 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
DataType::UserId, DataType::UserId,
DataValue::SignedNumber(user.user_id.into()), DataValue::SignedNumber(user.user_id.into()),
); );
let Ok(response) = connection let response = connection
.await_response(&request, Duration::from_secs(10)) .await_response(&request, Duration::from_secs(10))
.await .await;
else { 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; continue;
}
}; };
let remote_iota_id = response if should_release {
if let Err(error) = iota_storage::users::user_manager::finalize_local_release(
user.user_id,
Some(&user.username),
) {
log!(
"Could not release local user {} after Omega reconciliation: {}",
user.user_id,
error
);
}
}
}
}
fn should_release_reconciled_user(
response: Result<CommunicationValue, crate::OmikronError>,
local_iota_id: i64,
) -> Result<bool, crate::OmikronError> {
match response {
Ok(response) => Ok(response
.get_data(DataType::IotaId) .get_data(DataType::IotaId)
.as_signed_number() .as_signed_number()
.and_then(|value| i64::try_from(value).ok()); .and_then(|value| i64::try_from(value).ok())
if remote_iota_id != Some(local_iota_id) { != Some(local_iota_id)),
let _ = iota_storage::users::user_manager::release_user(user.user_id); Err(crate::OmikronError::Rejected(CommunicationType::ErrorNotFound, _)) => Ok(true),
} Err(error) => Err(error),
} }
} }
@ -505,8 +598,8 @@ pub async fn create_user(
username.to_string(), username.to_string(),
None, None,
public_key_bundle_to_base64(&pub_key_bundle), public_key_bundle_to_base64(&pub_key_bundle),
private_key_hash, Some(private_key_hash),
reset_token.clone(), Some(reset_token.clone()),
); );
let credential = format!( let credential = format!(
"{}@{}::{}", "{}@{}::{}",
@ -519,8 +612,8 @@ pub async fn create_user(
operation: PendingUserOperationKind::Create, operation: PendingUserOperationKind::Create,
username: user_profile.username.clone(), username: user_profile.username.clone(),
public_key: Some(user_profile.public_key.clone()), public_key: Some(user_profile.public_key.clone()),
private_key_hash: Some(user_profile.private_key_hash.clone()), private_key_hash: user_profile.private_key_hash.clone(),
reset_token: Some(user_profile.reset_token.clone()), reset_token: user_profile.reset_token.clone(),
registration_token: Some(registration_token.clone()), registration_token: Some(registration_token.clone()),
phase: PendingUserOperationPhase::Prepared, phase: PendingUserOperationPhase::Prepared,
created_at: now_millis(), created_at: now_millis(),
@ -563,7 +656,9 @@ pub async fn create_user(
} else { } else {
log_t!("User creation: {}", error.to_string()); log_t!("User creation: {}", error.to_string());
return Err(match error { return Err(match error {
crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, crate::OmikronError::Rejected(_, _) | crate::OmikronError::Internal(_) => {
CreateUserError::RemoteRejected
}
error => CreateUserError::Transport(error), error => CreateUserError::Transport(error),
}); });
} }
@ -583,10 +678,15 @@ pub async fn create_user(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{CreateUserError, request_user_id, valid_username}; use super::{
CreateUserError, LifecycleUserError, inspect_tu_credential, request_user_id,
should_release_reconciled_user, valid_username,
};
use crate::{OmikronClient, OmikronError}; use crate::{OmikronClient, OmikronError};
use async_trait::async_trait; use async_trait::async_trait;
use iota_connection::message_common::CommunicationResponseExt; 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 mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use std::time::Duration; use std::time::Duration;
@ -594,6 +694,34 @@ mod tests {
response: CommunicationValue, 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<CommunicationValue, OmikronError> {
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] #[async_trait]
impl OmikronClient for RegistrationClient { impl OmikronClient for RegistrationClient {
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
@ -634,6 +762,42 @@ mod tests {
assert!(!valid_username("line\nbreak")); 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] #[tokio::test]
async fn uses_user_id_allocated_by_omega() { async fn uses_user_id_allocated_by_omega() {
let client = RegistrationClient { let client = RegistrationClient {
@ -663,4 +827,24 @@ mod tests {
Err(CreateUserError::InvalidResponse) 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(_))
));
}
} }