Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b38b68ad96 |
41 changed files with 4415 additions and 1127 deletions
142
Cargo.lock
generated
142
Cargo.lock
generated
|
|
@ -51,7 +51,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"sha1",
|
||||
"sha1 0.11.0",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
|
@ -224,13 +224,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -337,6 +337,15 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
|
|
@ -532,7 +541,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -644,7 +652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher 0.4.4",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
|
|
@ -667,7 +675,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
|||
dependencies = [
|
||||
"aead",
|
||||
"chacha20 0.9.1",
|
||||
"cipher 0.4.4",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -692,20 +700,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout 0.1.4",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
|
|
@ -819,9 +817,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
|||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
|
|
@ -869,12 +867,6 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
|
|
@ -893,6 +885,21 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -1132,6 +1139,17 @@ version = "0.5.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
|
|
@ -1176,7 +1194,6 @@ dependencies = [
|
|||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1705,7 +1722,16 @@ version = "0.13.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2025,15 +2051,6 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.13"
|
||||
|
|
@ -2562,11 +2579,12 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.16.5"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b"
|
||||
checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a"
|
||||
dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"crc",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3110,12 +3128,12 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.13.0"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"hmac",
|
||||
"digest 0.10.7",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4066,6 +4084,17 @@ dependencies = [
|
|||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
|
|
@ -4404,7 +4433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -4533,7 +4562,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
|
|
@ -4731,12 +4759,6 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
|
|
@ -5531,26 +5553,26 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"hmac",
|
||||
"getrandom 0.3.4",
|
||||
"hmac 0.12.1",
|
||||
"indexmap",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"sha1 0.10.7",
|
||||
"time",
|
||||
"typed-path",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,36 @@ use ratatui::{
|
|||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HeaderItem {
|
||||
pub label: &'static str,
|
||||
pub intent: ButtonIntent,
|
||||
pub action: AppAction,
|
||||
}
|
||||
|
||||
pub const HEADER_ITEMS: &[HeaderItem] = &[
|
||||
HeaderItem {
|
||||
label: "Overview",
|
||||
intent: ButtonIntent::Primary,
|
||||
action: AppAction::OpenOverview,
|
||||
},
|
||||
HeaderItem {
|
||||
label: "Users",
|
||||
intent: ButtonIntent::Neutral,
|
||||
action: AppAction::OpenUsers,
|
||||
},
|
||||
HeaderItem {
|
||||
label: "Settings",
|
||||
intent: ButtonIntent::Neutral,
|
||||
action: AppAction::OpenSettings,
|
||||
},
|
||||
HeaderItem {
|
||||
label: "Quit",
|
||||
intent: ButtonIntent::Destructive,
|
||||
action: AppAction::Quit,
|
||||
},
|
||||
];
|
||||
|
||||
fn connection_badge(
|
||||
state: &IpcConnectionState,
|
||||
theme: &ResolvedTheme,
|
||||
|
|
@ -59,22 +89,14 @@ pub fn render_header(
|
|||
|
||||
let rows =
|
||||
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
|
||||
let cells = Layout::horizontal([
|
||||
Constraint::Min(28),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.split(rows[0]);
|
||||
let cells2 = Layout::horizontal([
|
||||
Constraint::Min(28),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.split(rows[1]);
|
||||
let constraints = std::iter::once(Constraint::Min(28))
|
||||
.chain(
|
||||
HEADER_ITEMS
|
||||
.iter()
|
||||
.map(|item| Constraint::Length(if item.label == "Quit" { 8 } else { 12 })),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let cells = Layout::horizontal(constraints.clone()).split(rows[0]);
|
||||
|
||||
let (ipc_label, ipc_style) = connection_badge(connection, theme);
|
||||
let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
|
||||
|
|
@ -99,46 +121,15 @@ pub fn render_header(
|
|||
);
|
||||
hits.register(brand_area, AppAction::OpenMain);
|
||||
|
||||
for (index, (top, _bottom, label, intent, action)) in [
|
||||
(
|
||||
cells[1],
|
||||
cells2[1],
|
||||
"Overview",
|
||||
ButtonIntent::Primary,
|
||||
AppAction::OpenOverview,
|
||||
),
|
||||
(
|
||||
cells[2],
|
||||
cells2[2],
|
||||
"Users",
|
||||
ButtonIntent::Neutral,
|
||||
AppAction::OpenUsers,
|
||||
),
|
||||
(
|
||||
cells[3],
|
||||
cells2[3],
|
||||
"Settings",
|
||||
ButtonIntent::Neutral,
|
||||
AppAction::OpenSettings,
|
||||
),
|
||||
(
|
||||
cells[4],
|
||||
cells2[4],
|
||||
"Quit",
|
||||
ButtonIntent::Destructive,
|
||||
AppAction::Quit,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
for (index, item) in HEADER_ITEMS.iter().enumerate() {
|
||||
let top = cells[index + 1];
|
||||
let button_area = Rect {
|
||||
x: top.x,
|
||||
y: top.y,
|
||||
width: top.width,
|
||||
height: area.height,
|
||||
};
|
||||
let style = match (intent, focused_action == Some(index)) {
|
||||
let style = match (item.intent, focused_action == Some(index)) {
|
||||
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
|
||||
(ButtonIntent::Primary, false) => theme.buttons.primary,
|
||||
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
|
||||
|
|
@ -148,9 +139,9 @@ pub fn render_header(
|
|||
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
|
||||
};
|
||||
let display_label = if focused_action == Some(index) {
|
||||
format!("› {label}")
|
||||
format!("› {}", item.label)
|
||||
} else {
|
||||
label.to_owned()
|
||||
item.label.to_owned()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
|
|
@ -159,6 +150,6 @@ pub fn render_header(
|
|||
]),
|
||||
button_area,
|
||||
);
|
||||
hits.register(button_area, action);
|
||||
hits.register(button_area, item.action);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
iota-cli/src/controls/menu.rs
Normal file
119
iota-cli/src/controls/menu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ pub mod checkbox_group;
|
|||
pub mod choice;
|
||||
pub mod dialog;
|
||||
pub mod header;
|
||||
pub mod menu;
|
||||
pub mod navigation;
|
||||
pub mod panel;
|
||||
pub mod radio_group;
|
||||
pub mod scroll;
|
||||
pub mod text_input;
|
||||
|
|
|
|||
99
iota-cli/src/controls/text_input.rs
Normal file
99
iota-cli/src/controls/text_input.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ use iota_state::{ClientState, UiLogEntry};
|
|||
use std::collections::HashMap;
|
||||
use std::io::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UnixStream;
|
||||
|
|
@ -44,6 +44,7 @@ pub struct DaemonStatus {
|
|||
pub health: iota_ipc::HealthStatus,
|
||||
pub deployment_mode: Option<iota_ipc::DeploymentMode>,
|
||||
pub supervisor: Option<iota_ipc::SupervisorKind>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>,
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +72,7 @@ pub struct IpcClient {
|
|||
writer: Mutex<Option<ActiveWriter>>,
|
||||
next_generation: AtomicU64,
|
||||
next_request_id: AtomicU64,
|
||||
protocol_version: AtomicU16,
|
||||
pending: Mutex<HashMap<u64, PendingRequest>>,
|
||||
connection_state: watch::Sender<IpcConnectionState>,
|
||||
daemon_status: watch::Sender<DaemonStatus>,
|
||||
|
|
@ -103,6 +105,7 @@ impl IpcClient {
|
|||
health: negotiated.ack.health,
|
||||
deployment_mode: Some(negotiated.ack.deployment_mode),
|
||||
supervisor: Some(negotiated.ack.supervisor),
|
||||
capabilities: negotiated.ack.capabilities.clone(),
|
||||
components: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
|
||||
|
|
@ -115,6 +118,7 @@ impl IpcClient {
|
|||
})),
|
||||
next_generation: AtomicU64::new(2),
|
||||
next_request_id: AtomicU64::new(1),
|
||||
protocol_version: AtomicU16::new(negotiated.ack.protocol_version),
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
connection_state: conn_state_tx,
|
||||
daemon_status: daemon_status_tx,
|
||||
|
|
@ -180,7 +184,7 @@ impl IpcClient {
|
|||
write_msg(
|
||||
&mut writer,
|
||||
&ClientMessage::Hello {
|
||||
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
|
||||
supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -432,6 +436,8 @@ impl IpcClient {
|
|||
}
|
||||
|
||||
fn update_hello_ack(&self, ack: HelloAck) {
|
||||
self.protocol_version
|
||||
.store(ack.protocol_version, Ordering::Release);
|
||||
self.daemon_status.send_modify(|status| {
|
||||
status.version = ack.daemon_version;
|
||||
status.instance_id = ack.instance_id;
|
||||
|
|
@ -440,6 +446,7 @@ impl IpcClient {
|
|||
status.health = ack.health;
|
||||
status.deployment_mode = Some(ack.deployment_mode);
|
||||
status.supervisor = Some(ack.supervisor);
|
||||
status.capabilities = ack.capabilities;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -480,6 +487,23 @@ impl IpcClient {
|
|||
ResponsePayload::UserCreated { user_id, username } => {
|
||||
format!("Created user {} ({})", username, user_id)
|
||||
}
|
||||
ResponsePayload::TuCredentialPreview(preview) => {
|
||||
format!("Inspected {} ({})", preview.username, preview.user_id)
|
||||
}
|
||||
ResponsePayload::UserReconciled(result) => {
|
||||
format!("Reconciled user {}: {:?}", result.user_id, result.action)
|
||||
}
|
||||
ResponsePayload::UserDiagnostics(diagnostics) => format!(
|
||||
"User {}: state={:?}, data={}, credential={:?}, trusted_apps={}",
|
||||
diagnostics.user_id,
|
||||
diagnostics.local_state,
|
||||
diagnostics.data_present,
|
||||
diagnostics.credential_status,
|
||||
diagnostics.trusted_app_count,
|
||||
),
|
||||
ResponsePayload::UserCredentialExport { .. } => {
|
||||
"Credential export payload withheld.".into()
|
||||
}
|
||||
ResponsePayload::UserRemoved { user_id } => {
|
||||
format!("Removed user {}", user_id)
|
||||
}
|
||||
|
|
@ -589,7 +613,7 @@ impl IpcClient {
|
|||
|
||||
let envelope = RequestEnvelope {
|
||||
request_id,
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: self.protocol_version.load(Ordering::Acquire),
|
||||
request,
|
||||
};
|
||||
if let Err(error) = self.send(ClientMessage::Request(envelope)).await {
|
||||
|
|
|
|||
|
|
@ -35,11 +35,9 @@ pub enum AppEvent {
|
|||
},
|
||||
ThemeSaved(Result<(), String>),
|
||||
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
||||
UserCreated(Result<crate::screens::users::UserEntry, String>),
|
||||
UserRemoved {
|
||||
user_id: i64,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
TuInspected(Result<iota_ipc::TuCredentialPreview, String>),
|
||||
UserOperationFinished(Result<String, String>),
|
||||
CredentialExportFinished(Result<String, String>),
|
||||
RegenerateKeysRequested,
|
||||
KeysRegenerated(Result<(), String>),
|
||||
}
|
||||
|
|
@ -52,14 +50,16 @@ pub enum AppAction {
|
|||
OpenMetrics,
|
||||
ToggleMetrics,
|
||||
AddUser,
|
||||
RemoveUser,
|
||||
OpenUserGroup(crate::screens::users::model::UserActionGroup),
|
||||
SetUserAdminTab(crate::screens::users::model::UserAdminTab),
|
||||
ActivateUserAction(crate::screens::users::model::UserAction),
|
||||
Back,
|
||||
Quit,
|
||||
FocusLogs,
|
||||
FocusConsole,
|
||||
FocusMetrics,
|
||||
OpenMain,
|
||||
SelectUser(usize),
|
||||
SelectUser(i64),
|
||||
ConfirmDialog,
|
||||
CancelDialog,
|
||||
RegenerateKeys,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
66
iota-cli/src/screens/users/action_menu.rs
Normal file
66
iota-cli/src/screens/users/action_menu.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
126
iota-cli/src/screens/users/add_flow.rs
Normal file
126
iota-cli/src/screens/users/add_flow.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
55
iota-cli/src/screens/users/confirmations.rs
Normal file
55
iota-cli/src/screens/users/confirmations.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
29
iota-cli/src/screens/users/context.rs
Normal file
29
iota-cli/src/screens/users/context.rs
Normal 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)
|
||||
}
|
||||
85
iota-cli/src/screens/users/credential_export.rs
Normal file
85
iota-cli/src/screens/users/credential_export.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
38
iota-cli/src/screens/users/invitations.rs
Normal file
38
iota-cli/src/screens/users/invitations.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
7
iota-cli/src/screens/users/list.rs
Normal file
7
iota-cli/src/screens/users/list.rs
Normal 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)
|
||||
}
|
||||
1281
iota-cli/src/screens/users/mod.rs
Normal file
1281
iota-cli/src/screens/users/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
230
iota-cli/src/screens/users/model.rs
Normal file
230
iota-cli/src/screens/users/model.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::{
|
||||
controls::header::render_header,
|
||||
controls::header::{HEADER_ITEMS, render_header},
|
||||
help_overlay::HelpOverlay,
|
||||
input_handler::setup_input_handler,
|
||||
interaction_result::InteractionResult,
|
||||
|
|
@ -432,19 +432,16 @@ impl UI {
|
|||
if header_is_focused {
|
||||
let mut action = None;
|
||||
if let Ok(mut focus) = self.header_focus.lock() {
|
||||
let index = focus.unwrap_or(0);
|
||||
let index = focus.unwrap_or(0).min(HEADER_ITEMS.len().saturating_sub(1));
|
||||
match key.code {
|
||||
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4),
|
||||
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4),
|
||||
KeyCode::Left | KeyCode::BackTab => {
|
||||
*focus = Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len())
|
||||
}
|
||||
KeyCode::Right | KeyCode::Tab => {
|
||||
*focus = Some((index + 1) % HEADER_ITEMS.len())
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
action = Some(
|
||||
[
|
||||
AppAction::OpenOverview,
|
||||
AppAction::OpenUsers,
|
||||
AppAction::OpenSettings,
|
||||
AppAction::Quit,
|
||||
][index],
|
||||
);
|
||||
action = HEADER_ITEMS.get(index).map(|item| item.action);
|
||||
*focus = None;
|
||||
}
|
||||
KeyCode::Esc => *focus = None,
|
||||
|
|
@ -607,7 +604,8 @@ impl UI {
|
|||
username: u.username,
|
||||
state: u.state,
|
||||
data_present: u.data_present,
|
||||
credential_present: u.credential_present,
|
||||
credential_status: u.credential_status,
|
||||
pending_operation: u.pending_operation,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ use crate::{DaemonRuntime, DaemonServices};
|
|||
use iota_ipc::{
|
||||
CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent,
|
||||
IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE,
|
||||
OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse,
|
||||
TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary,
|
||||
OmikronStatusResponse, ReconcileAction, ResponseEnvelope, ResponsePayload, ResponseResult,
|
||||
StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserDiagnostics,
|
||||
UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary,
|
||||
};
|
||||
use iota_logger::{log, log_command};
|
||||
use iota_storage::users::pending_operations::{
|
||||
|
|
@ -29,6 +30,30 @@ pub struct PeerContext {
|
|||
|
||||
const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512;
|
||||
|
||||
fn credential_status(
|
||||
residency: &user_manager::UserResidency,
|
||||
profile: Option<&iota_storage::users::user_profile::UserProfile>,
|
||||
) -> iota_ipc::CredentialStatus {
|
||||
match (residency.state, residency.credential_origin) {
|
||||
(user_manager::LocalUserState::Released, _) => iota_ipc::CredentialStatus::None,
|
||||
(_, user_manager::CredentialOrigin::External) => iota_ipc::CredentialStatus::External,
|
||||
(_, user_manager::CredentialOrigin::Local)
|
||||
if profile.is_some_and(|profile| {
|
||||
iota_util::file_util::read_user_credential_with_legacy(
|
||||
residency.user_id,
|
||||
&profile.username,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}) =>
|
||||
{
|
||||
iota_ipc::CredentialStatus::LocalPresent
|
||||
}
|
||||
_ => iota_ipc::CredentialStatus::Missing,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
|
@ -119,7 +144,9 @@ impl CommandRouter {
|
|||
let needs_omikron = matches!(
|
||||
request,
|
||||
LocalRequest::CreateUser { .. }
|
||||
| LocalRequest::InspectTuCredential { .. }
|
||||
| LocalRequest::AttachUserFromTu { .. }
|
||||
| LocalRequest::ReconcileUser { .. }
|
||||
| LocalRequest::ReleaseUser { .. }
|
||||
| LocalRequest::CompleteDeleteUser { .. }
|
||||
);
|
||||
|
|
@ -162,22 +189,35 @@ impl CommandRouter {
|
|||
ResponseResult::Ok(ResponsePayload::Tasks(tasks))
|
||||
}
|
||||
LocalRequest::ListUsers => {
|
||||
let pending = match pending_operations::get_all() {
|
||||
Ok(operations) => operations
|
||||
.into_iter()
|
||||
.map(|operation| {
|
||||
let kind = match operation.operation {
|
||||
PendingUserOperationKind::Create => UserOperationKind::Create,
|
||||
PendingUserOperationKind::Attach => UserOperationKind::Attach,
|
||||
PendingUserOperationKind::Release => UserOperationKind::Release,
|
||||
PendingUserOperationKind::Purge => UserOperationKind::Purge,
|
||||
};
|
||||
(
|
||||
operation.user_id,
|
||||
UserOperationSummary {
|
||||
operation: kind,
|
||||
phase: operation.phase.as_str().into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>(),
|
||||
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
};
|
||||
let users = user_manager::get_residency()
|
||||
.into_iter()
|
||||
.map(|user| {
|
||||
let user_id = user.user_id;
|
||||
let profile = user_manager::get_user(user.user_id)?;
|
||||
Ok(UserSummary {
|
||||
credential_present: user.state == user_manager::LocalUserState::Managed
|
||||
&& profile.is_some_and(|profile| {
|
||||
iota_util::file_util::read_user_credential_with_legacy(
|
||||
user.user_id,
|
||||
&profile.username,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}),
|
||||
user_id: user.user_id,
|
||||
credential_status: credential_status(&user, profile.as_ref()),
|
||||
user_id,
|
||||
username: user.username,
|
||||
state: match user.state {
|
||||
user_manager::LocalUserState::Managed => {
|
||||
|
|
@ -188,6 +228,7 @@ impl CommandRouter {
|
|||
}
|
||||
},
|
||||
data_present: user.data_present,
|
||||
pending_operation: pending.get(&user_id).cloned(),
|
||||
})
|
||||
})
|
||||
.collect::<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 {
|
||||
user_id,
|
||||
credential,
|
||||
|
|
@ -457,33 +655,71 @@ impl CommandRouter {
|
|||
.collect();
|
||||
ResponseResult::Ok(ResponsePayload::Components(components))
|
||||
}
|
||||
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
|
||||
Ok(Some(user)) => {
|
||||
let credential_present =
|
||||
iota_util::file_util::read_user_credential_with_legacy(
|
||||
user_id,
|
||||
&user.username,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
created_at: user.created_at,
|
||||
trusted_apps: user.trusted_apps.keys().cloned().collect(),
|
||||
state: iota_ipc::LocalUserState::Managed,
|
||||
data_present: user_manager::get_residency()
|
||||
.iter()
|
||||
.find(|entry| entry.user_id == user_id)
|
||||
.is_none_or(|entry| entry.data_present),
|
||||
credential_present,
|
||||
}))
|
||||
LocalRequest::GetUser { user_id } => {
|
||||
let residency = match user_manager::get_residency_by_id(user_id) {
|
||||
Ok(Some(residency)) => residency,
|
||||
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
|
||||
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
};
|
||||
match user_manager::get_user(user_id) {
|
||||
Ok(Some(user)) => {
|
||||
let credential_status = credential_status(&residency, Some(&user));
|
||||
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
created_at: user.created_at,
|
||||
trusted_apps: user.trusted_apps.keys().cloned().collect(),
|
||||
state: iota_ipc::LocalUserState::Managed,
|
||||
data_present: residency.data_present,
|
||||
credential_status,
|
||||
}))
|
||||
}
|
||||
Ok(None) if residency.state == user_manager::LocalUserState::Released => {
|
||||
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
|
||||
user_id: residency.user_id,
|
||||
username: residency.username,
|
||||
display_name: None,
|
||||
created_at: 0,
|
||||
trusted_apps: Vec::new(),
|
||||
state: iota_ipc::LocalUserState::Released,
|
||||
data_present: residency.data_present,
|
||||
credential_status: iota_ipc::CredentialStatus::None,
|
||||
}))
|
||||
}
|
||||
Ok(None) => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
}
|
||||
Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound),
|
||||
Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
},
|
||||
}
|
||||
LocalRequest::ExportUserCredential { user_id } => {
|
||||
let residency = match user_manager::get_residency_by_id(user_id) {
|
||||
Ok(Some(residency)) => residency,
|
||||
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
|
||||
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
};
|
||||
if residency.state != user_manager::LocalUserState::Managed
|
||||
|| residency.credential_origin != user_manager::CredentialOrigin::Local
|
||||
{
|
||||
return ResponseResult::Error(IpcErrorCode::Conflict);
|
||||
}
|
||||
let credential = match iota_util::file_util::read_user_credential_with_legacy(
|
||||
user_id,
|
||||
&residency.username,
|
||||
) {
|
||||
Ok(Some(credential)) => credential,
|
||||
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
|
||||
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
};
|
||||
let parsed = match iota_util::tu::TuCredential::parse(&credential) {
|
||||
Ok(parsed) if parsed.user_id == user_id => parsed,
|
||||
Ok(_) | Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
};
|
||||
ResponseResult::Ok(ResponsePayload::UserCredentialExport {
|
||||
user_id,
|
||||
username: residency.username,
|
||||
credential: iota_ipc::SecretString(parsed.to_canonical_string()),
|
||||
})
|
||||
}
|
||||
LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
||||
LocalRequest::GetLogs { limit } => {
|
||||
let entries = if let Ok(buf) = self.log_buffer.lock() {
|
||||
|
|
@ -538,6 +774,19 @@ mod tests {
|
|||
LocalRequest::AttachUserFromTu {
|
||||
credential: SecretString("credential".into()),
|
||||
},
|
||||
LocalRequest::InspectTuCredential {
|
||||
credential: SecretString("credential".into()),
|
||||
},
|
||||
LocalRequest::ReconcileUser { user_id: 1 },
|
||||
LocalRequest::ForceDetachUser { user_id: 1 },
|
||||
LocalRequest::ForgetReleasedUser { user_id: 1 },
|
||||
LocalRequest::GetUserDiagnostics { user_id: 1 },
|
||||
LocalRequest::RevokeTrustedApp {
|
||||
user_id: 1,
|
||||
app_id: "desktop".into(),
|
||||
},
|
||||
LocalRequest::RevokeAllTrustedApps { user_id: 1 },
|
||||
LocalRequest::ExportUserCredential { user_id: 1 },
|
||||
LocalRequest::PurgeUserData { user_id: 1 },
|
||||
LocalRequest::ReleaseUser { user_id: 1 },
|
||||
LocalRequest::CompleteDeleteUser {
|
||||
|
|
@ -570,7 +819,7 @@ mod tests {
|
|||
LocalRequest::ListCommunities,
|
||||
];
|
||||
|
||||
assert_eq!(requests.len(), 25);
|
||||
assert_eq!(requests.len(), 33);
|
||||
for request in requests {
|
||||
let required = request.required_role();
|
||||
assert!(IpcRole::Admin.allows(required));
|
||||
|
|
|
|||
|
|
@ -401,7 +401,14 @@ async fn handle_client(
|
|||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
instance_id: instance_id.clone(),
|
||||
startup_phase: runtime.current_startup_phase().into(),
|
||||
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
|
||||
capabilities: vec![
|
||||
"commands".into(),
|
||||
"metrics".into(),
|
||||
"logs".into(),
|
||||
"user_management_v2".into(),
|
||||
"tu_inspection_v1".into(),
|
||||
"credential_export_v1".into(),
|
||||
],
|
||||
lifecycle: *runtime.lifecycle.borrow(),
|
||||
health: runtime.overall_health(),
|
||||
deployment_mode: from_environment().mode,
|
||||
|
|
|
|||
|
|
@ -149,8 +149,7 @@ async fn main() -> ExitCode {
|
|||
Err(omikron_connector::OmikronStartupError::Authentication { connection }) => {
|
||||
runtime.set_component_failed(
|
||||
iota_ipc::ComponentId::Omikron,
|
||||
"Omikron authentication failed; regenerate the Iota identity to register again"
|
||||
.into(),
|
||||
"Omikron authentication failed; inspect the authenticated relay and Omega status before rotating the Iota identity".into(),
|
||||
);
|
||||
// Keep IPC alive: identity rotation is the supported recovery
|
||||
// action and must remain available after authentication fails.
|
||||
|
|
@ -221,6 +220,23 @@ async fn main() -> ExitCode {
|
|||
.spawn_tracked("user-lifecycle-reconciliation", async move {
|
||||
let mut states = omikron_reconcile.connection_state();
|
||||
loop {
|
||||
match iota_storage::users::pending_operations::get_all() {
|
||||
Ok(operations) => {
|
||||
for operation in operations.into_iter().filter(|operation| {
|
||||
operation.operation
|
||||
== iota_storage::users::pending_operations::PendingUserOperationKind::Purge
|
||||
}) {
|
||||
if let Err(error) = user_manager::purge_user_data(operation.user_id) {
|
||||
log!(
|
||||
"Pending purge reconciliation failed for {}: {}",
|
||||
operation.user_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => log!("Pending purge reconciliation could not read storage: {error}"),
|
||||
}
|
||||
if matches!(
|
||||
*states.borrow(),
|
||||
omikron_connector::omikron_connection::ConnectionState::Connected { .. }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@ edition = "2024"
|
|||
[dependencies]
|
||||
anyhow = "1"
|
||||
tempfile = "3"
|
||||
zip = "8"
|
||||
zip = "6"
|
||||
serde_json = "1"
|
||||
iota-paths = { path = "../iota-paths" }
|
||||
|
|
|
|||
|
|
@ -4,16 +4,17 @@ pub mod transport;
|
|||
|
||||
pub use protocol::{
|
||||
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
|
||||
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
|
||||
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase,
|
||||
LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
|
||||
OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult,
|
||||
SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary,
|
||||
UpdateStatusResponse, UserDetailResponse, UserSummary,
|
||||
ConfigResponse, ConnectionStatus, CredentialStatus, DaemonMessage, DaemonStatusResponse,
|
||||
DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent,
|
||||
LifecyclePhase, LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
|
||||
OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload,
|
||||
ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind,
|
||||
TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics,
|
||||
UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary,
|
||||
};
|
||||
pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg};
|
||||
|
||||
/// Current IPC protocol version.
|
||||
pub const PROTOCOL_VERSION: u16 = 2;
|
||||
pub const PROTOCOL_VERSION: u16 = 4;
|
||||
/// Minimum protocol version this daemon understands.
|
||||
pub const MIN_PROTOCOL_VERSION: u16 = 2;
|
||||
|
|
|
|||
|
|
@ -48,9 +48,34 @@ pub enum LocalRequest {
|
|||
CreateUser {
|
||||
username: String,
|
||||
},
|
||||
InspectTuCredential {
|
||||
credential: SecretString,
|
||||
},
|
||||
AttachUserFromTu {
|
||||
credential: SecretString,
|
||||
},
|
||||
ReconcileUser {
|
||||
user_id: i64,
|
||||
},
|
||||
ForceDetachUser {
|
||||
user_id: i64,
|
||||
},
|
||||
ForgetReleasedUser {
|
||||
user_id: i64,
|
||||
},
|
||||
GetUserDiagnostics {
|
||||
user_id: i64,
|
||||
},
|
||||
RevokeTrustedApp {
|
||||
user_id: i64,
|
||||
app_id: String,
|
||||
},
|
||||
RevokeAllTrustedApps {
|
||||
user_id: i64,
|
||||
},
|
||||
ExportUserCredential {
|
||||
user_id: i64,
|
||||
},
|
||||
PurgeUserData {
|
||||
user_id: i64,
|
||||
},
|
||||
|
|
@ -127,6 +152,7 @@ impl LocalRequest {
|
|||
| Self::GetOmikronStatus
|
||||
| Self::ListComponents
|
||||
| Self::GetUser { .. }
|
||||
| Self::GetUserDiagnostics { .. }
|
||||
| Self::GetLogs { .. }
|
||||
| Self::CheckUpdate
|
||||
| Self::ListCommunities => IpcRole::Read,
|
||||
|
|
@ -134,7 +160,14 @@ impl LocalRequest {
|
|||
Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate,
|
||||
|
||||
Self::CreateUser { .. }
|
||||
| Self::InspectTuCredential { .. }
|
||||
| Self::AttachUserFromTu { .. }
|
||||
| Self::ReconcileUser { .. }
|
||||
| Self::ForceDetachUser { .. }
|
||||
| Self::ForgetReleasedUser { .. }
|
||||
| Self::RevokeTrustedApp { .. }
|
||||
| Self::RevokeAllTrustedApps { .. }
|
||||
| Self::ExportUserCredential { .. }
|
||||
| Self::PurgeUserData { .. }
|
||||
| Self::ReleaseUser { .. }
|
||||
| Self::CompleteDeleteUser { .. }
|
||||
|
|
@ -220,6 +253,14 @@ pub enum ResponsePayload {
|
|||
user_id: i64,
|
||||
username: String,
|
||||
},
|
||||
TuCredentialPreview(TuCredentialPreview),
|
||||
UserReconciled(UserReconcileResult),
|
||||
UserDiagnostics(UserDiagnostics),
|
||||
UserCredentialExport {
|
||||
user_id: i64,
|
||||
username: String,
|
||||
credential: SecretString,
|
||||
},
|
||||
/// Retained only for wire compatibility. New lifecycle code never emits it.
|
||||
UserRemoved {
|
||||
user_id: i64,
|
||||
|
|
@ -267,7 +308,7 @@ pub struct UserDetailResponse {
|
|||
pub trusted_apps: Vec<String>,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_present: bool,
|
||||
pub credential_status: CredentialStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -304,7 +345,66 @@ pub struct UserSummary {
|
|||
pub username: String,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_present: bool,
|
||||
pub credential_status: CredentialStatus,
|
||||
#[serde(default)]
|
||||
pub pending_operation: Option<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)]
|
||||
|
|
@ -359,6 +459,11 @@ impl std::fmt::Display for IpcErrorCode {
|
|||
|
||||
#[cfg(test)]
|
||||
mod error_tests {
|
||||
use super::{
|
||||
CredentialStatus, LocalUserState, ResponsePayload, SecretString, UserOperationKind,
|
||||
UserOperationSummary, UserSummary,
|
||||
};
|
||||
|
||||
use super::IpcErrorCode;
|
||||
|
||||
#[test]
|
||||
|
|
@ -378,6 +483,50 @@ mod error_tests {
|
|||
.contains("required role")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_export_debug_output_is_redacted() {
|
||||
let payload = ResponsePayload::UserCredentialExport {
|
||||
user_id: 42,
|
||||
username: "alice".into(),
|
||||
credential: SecretString("private-tu-contents".into()),
|
||||
};
|
||||
let output = format!("{payload:?}");
|
||||
|
||||
assert!(!output.contains("private-tu-contents"));
|
||||
assert!(output.contains("<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)]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,16 @@ pub const COMMANDS: &[&str] = &[
|
|||
"tasks",
|
||||
"users list",
|
||||
"users show ",
|
||||
"users add ",
|
||||
"users remove ",
|
||||
"users import ",
|
||||
"users add create ",
|
||||
"users hosting release ",
|
||||
"users data purge ",
|
||||
"users account delete ",
|
||||
"users repair reconcile ",
|
||||
"users repair diagnostics ",
|
||||
"users repair force-detach ",
|
||||
"users forget ",
|
||||
"users apps revoke ",
|
||||
"users apps revoke-all ",
|
||||
"omikron status",
|
||||
"reconnect",
|
||||
"identity rotate",
|
||||
|
|
@ -59,16 +66,47 @@ pub fn parse(line: &str) -> Option<LocalRequest> {
|
|||
let user_id = id_str.parse::<i64>().ok()?;
|
||||
Some(LocalRequest::GetUser { user_id })
|
||||
}
|
||||
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
|
||||
["user" | "users", "add", "create", username] => Some(LocalRequest::CreateUser {
|
||||
username: username.to_string(),
|
||||
}),
|
||||
["user" | "users", "remove", id_str] => {
|
||||
["user" | "users", "hosting", "release", id_str] => {
|
||||
let user_id = id_str.parse::<i64>().ok()?;
|
||||
Some(LocalRequest::RemoveUser { user_id })
|
||||
Some(LocalRequest::ReleaseUser { user_id })
|
||||
}
|
||||
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
|
||||
username: username.to_string(),
|
||||
["user" | "users", "data", "purge", id_str] => Some(LocalRequest::PurgeUserData {
|
||||
user_id: id_str.parse::<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),
|
||||
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
|
||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||
|
|
@ -116,7 +154,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parses_user_add() {
|
||||
let req = parse("user add alice").unwrap();
|
||||
let req = parse("user add create alice").unwrap();
|
||||
match req {
|
||||
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
|
||||
_ => panic!("expected CreateUser"),
|
||||
|
|
@ -127,12 +165,20 @@ mod tests {
|
|||
fn accepts_the_headless_cli_user_vocabulary() {
|
||||
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
|
||||
assert!(matches!(
|
||||
parse("users add alice"),
|
||||
parse("users add create alice"),
|
||||
Some(LocalRequest::CreateUser { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("users remove 42"),
|
||||
Some(LocalRequest::RemoveUser { user_id: 42 })
|
||||
parse("users hosting release 42"),
|
||||
Some(LocalRequest::ReleaseUser { user_id: 42 })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("users apps revoke 42 desktop"),
|
||||
Some(LocalRequest::RevokeTrustedApp { user_id: 42, .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("users apps revoke-all 42"),
|
||||
Some(LocalRequest::RevokeAllTrustedApps { user_id: 42 })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("identity rotate"),
|
||||
|
|
@ -141,17 +187,18 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_remove_by_id() {
|
||||
let req = parse("user remove 42").unwrap();
|
||||
fn parses_user_release_by_id() {
|
||||
let req = parse("user hosting release 42").unwrap();
|
||||
match req {
|
||||
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
|
||||
_ => panic!("expected RemoveUser"),
|
||||
LocalRequest::ReleaseUser { user_id } => assert_eq!(user_id, 42),
|
||||
_ => panic!("expected ReleaseUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_remove_requires_numeric_id() {
|
||||
assert!(parse("user remove alice").is_none());
|
||||
fn generic_remove_is_not_routed() {
|
||||
assert!(parse("user remove 42").is_none());
|
||||
assert!(parse("users remove alice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -295,12 +342,8 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parses_users_import() {
|
||||
let req = parse("users import alice").unwrap();
|
||||
match req {
|
||||
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
|
||||
_ => panic!("expected ImportUser"),
|
||||
}
|
||||
fn deprecated_import_is_not_routed() {
|
||||
assert!(parse("users import alice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -317,7 +360,15 @@ mod tests {
|
|||
#[test]
|
||||
fn completion_is_prefix_based_and_deterministic() {
|
||||
assert_eq!(completions("identity r"), vec!["identity rotate"]);
|
||||
assert_eq!(completions("/users a"), vec!["users add "]);
|
||||
assert_eq!(
|
||||
completions("/users a"),
|
||||
vec![
|
||||
"users add create ",
|
||||
"users account delete ",
|
||||
"users apps revoke ",
|
||||
"users apps revoke-all ",
|
||||
]
|
||||
);
|
||||
assert!(completions("definitely-unknown").is_empty());
|
||||
}
|
||||
|
||||
|
|
|
|||
83
iota-storage/src/users/invitations.rs
Normal file
83
iota-storage/src/users/invitations.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod contact;
|
||||
pub mod invitations;
|
||||
pub mod pending_operations;
|
||||
pub mod user_manager;
|
||||
pub mod user_profile;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub enum PendingUserOperationKind {
|
|||
Create,
|
||||
Attach,
|
||||
Release,
|
||||
Purge,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -15,15 +16,21 @@ pub enum PendingUserOperationPhase {
|
|||
CredentialWritten,
|
||||
RemoteCommitted,
|
||||
LocalCommitted,
|
||||
DatabasePurged,
|
||||
E2eePurged,
|
||||
FilesystemPurged,
|
||||
}
|
||||
|
||||
impl PendingUserOperationPhase {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Prepared => "prepared",
|
||||
Self::CredentialWritten => "credential_written",
|
||||
Self::RemoteCommitted => "remote_committed",
|
||||
Self::LocalCommitted => "local_committed",
|
||||
Self::DatabasePurged => "database_purged",
|
||||
Self::E2eePurged => "e2ee_purged",
|
||||
Self::FilesystemPurged => "filesystem_purged",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +40,9 @@ impl PendingUserOperationPhase {
|
|||
"credential_written" => Ok(Self::CredentialWritten),
|
||||
"remote_committed" => Ok(Self::RemoteCommitted),
|
||||
"local_committed" => Ok(Self::LocalCommitted),
|
||||
"database_purged" => Ok(Self::DatabasePurged),
|
||||
"e2ee_purged" => Ok(Self::E2eePurged),
|
||||
"filesystem_purged" => Ok(Self::FilesystemPurged),
|
||||
_ => Err(StorageError::Other(
|
||||
"unknown pending user operation phase".into(),
|
||||
)),
|
||||
|
|
@ -41,11 +51,12 @@ impl PendingUserOperationPhase {
|
|||
}
|
||||
|
||||
impl PendingUserOperationKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "create",
|
||||
Self::Attach => "attach",
|
||||
Self::Release => "release",
|
||||
Self::Purge => "purge",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +65,7 @@ impl PendingUserOperationKind {
|
|||
"create" => Ok(Self::Create),
|
||||
"attach" => Ok(Self::Attach),
|
||||
"release" => Ok(Self::Release),
|
||||
"purge" => Ok(Self::Purge),
|
||||
_ => Err(StorageError::Other("unknown pending user operation".into())),
|
||||
}
|
||||
}
|
||||
|
|
@ -171,3 +183,20 @@ pub fn remove(user_id: i64) -> Result<(), StorageError> {
|
|||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn complete_purge(user_id: i64, updated_at: i64) -> Result<(), StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
let changed = tx.execute(
|
||||
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
|
||||
params![user_id, updated_at],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Err(StorageError::Other("user residency was not found".into()));
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM pending_user_operations WHERE user_id = ?1 AND operation = 'purge'",
|
||||
[user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::users::pending_operations;
|
||||
use crate::users::user_profile::UserProfile;
|
||||
use crate::util::db;
|
||||
use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file};
|
||||
|
|
@ -10,12 +11,19 @@ pub enum LocalUserState {
|
|||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CredentialOrigin {
|
||||
Local,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserResidency {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_origin: CredentialOrigin,
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
|
|
@ -32,6 +40,13 @@ pub fn add_user(user: UserProfile) {
|
|||
}
|
||||
|
||||
pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> {
|
||||
try_add_user_with_credential_origin(user, CredentialOrigin::Local)
|
||||
}
|
||||
|
||||
pub fn try_add_user_with_credential_origin(
|
||||
user: UserProfile,
|
||||
credential_origin: CredentialOrigin,
|
||||
) -> Result<(), crate::storage_error::StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
tx.execute(
|
||||
r#"
|
||||
|
|
@ -65,10 +80,10 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora
|
|||
)?;
|
||||
}
|
||||
tx.execute(
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at)
|
||||
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#,
|
||||
params![user.user_id, user.username, now_millis()],
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at)
|
||||
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3, ?4)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', credential_origin = excluded.credential_origin, updated_at = excluded.updated_at"#,
|
||||
params![user.user_id, user.username, match credential_origin { CredentialOrigin::Local => "local", CredentialOrigin::External => "external" }, now_millis()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -155,8 +170,8 @@ pub fn get_users() -> Vec<UserProfile> {
|
|||
let user_id: i64 = r.get(0)?;
|
||||
let username: String = r.get(1)?;
|
||||
let public_key: String = r.get(2)?;
|
||||
let private_key_hash: String = r.get(3)?;
|
||||
let reset_token: String = r.get(4)?;
|
||||
let private_key_hash: Option<String> = r.get(3)?;
|
||||
let reset_token: Option<String> = r.get(4)?;
|
||||
let created_at: i64 = r.get(5)?;
|
||||
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) {
|
||||
if let Err(e) = db::with_db(|conn| {
|
||||
conn.execute(
|
||||
|
|
@ -232,6 +270,31 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
|
|||
.ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("managed user was not found".into())
|
||||
})?;
|
||||
finalize_local_release(user_id, Some(&username))
|
||||
}
|
||||
|
||||
/*
|
||||
* Finish local release after Omega has changed assignment. Every step accepts
|
||||
* an earlier attempt completing it, so reconciliation can safely retry it.
|
||||
*/
|
||||
pub fn finalize_local_release(
|
||||
user_id: i64,
|
||||
username_hint: Option<&str>,
|
||||
) -> Result<(), crate::storage_error::StorageError> {
|
||||
let username = get_user(user_id)?
|
||||
.map(|user| user.username)
|
||||
.or_else(|| {
|
||||
get_residency_by_id(user_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|residency| residency.username)
|
||||
})
|
||||
.or_else(|| username_hint.map(str::to_owned));
|
||||
let Some(username) = username else {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"user residency was not found".into(),
|
||||
));
|
||||
};
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
|
|
@ -252,12 +315,84 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
|
|||
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
/// Authoritative hosted-data erasure used by local purge and future Omega
|
||||
/// erasure delivery. Management metadata and credentials are left intact.
|
||||
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
if crate::util::relay_queue::has_unclassified_relays()? {
|
||||
return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown);
|
||||
pub fn force_local_detach(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
finalize_local_release(user_id, None)
|
||||
}
|
||||
|
||||
pub fn forget_released_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
let residency = get_residency_by_id(user_id)?.ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("user residency was not found".into())
|
||||
})?;
|
||||
if residency.state != LocalUserState::Released || residency.data_present {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"only released users with empty hosted data can be forgotten".into(),
|
||||
));
|
||||
}
|
||||
if pending_operations::get_all()?
|
||||
.iter()
|
||||
.any(|operation| operation.user_id == user_id)
|
||||
{
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"user has a pending lifecycle operation".into(),
|
||||
));
|
||||
}
|
||||
db::with_db(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM user_residency WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PurgeStage {
|
||||
Database,
|
||||
E2ee,
|
||||
Filesystem,
|
||||
Complete,
|
||||
}
|
||||
|
||||
fn remaining_purge_stages(
|
||||
phase: pending_operations::PendingUserOperationPhase,
|
||||
) -> Result<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| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
|
|
@ -315,10 +450,6 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
|
|||
"DELETE FROM client_message_deliveries WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM trusted_apps WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1",
|
||||
params![user_id],
|
||||
|
|
@ -331,17 +462,84 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
|
|||
"DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
|
||||
params![user_id, now_millis()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/* Keep the residency authoritative until every hosted-data store has been
|
||||
* erased. Each completed phase is durable, and every cleanup stage is safe to
|
||||
* repeat after a crash between cleanup and phase persistence. */
|
||||
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
use pending_operations::{
|
||||
PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase,
|
||||
};
|
||||
|
||||
let residency = get_residency_by_id(user_id)?.ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("user residency was not found".into())
|
||||
})?;
|
||||
crate::util::e2ee_storage::purge_user(user_id)
|
||||
.map_err(crate::storage_error::StorageError::Other)?;
|
||||
delete_user_directory(user_id)
|
||||
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
let existing = pending_operations::get_all()?
|
||||
.into_iter()
|
||||
.find(|operation| operation.user_id == user_id);
|
||||
let phase = match existing {
|
||||
Some(operation) if operation.operation == PendingUserOperationKind::Purge => {
|
||||
operation.phase
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"user has another pending lifecycle operation".into(),
|
||||
));
|
||||
}
|
||||
None if !residency.data_present => return Ok(()),
|
||||
None => {
|
||||
let operation = PendingUserOperation {
|
||||
user_id,
|
||||
operation: PendingUserOperationKind::Purge,
|
||||
username: residency.username,
|
||||
public_key: None,
|
||||
private_key_hash: None,
|
||||
reset_token: None,
|
||||
registration_token: None,
|
||||
phase: PendingUserOperationPhase::Prepared,
|
||||
created_at: now_millis(),
|
||||
};
|
||||
pending_operations::upsert(&operation)?;
|
||||
PendingUserOperationPhase::Prepared
|
||||
}
|
||||
};
|
||||
|
||||
run_purge_stages(phase, |stage| {
|
||||
match stage {
|
||||
PurgeStage::Database => {
|
||||
if crate::util::relay_queue::has_unclassified_relays()? {
|
||||
return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown);
|
||||
}
|
||||
purge_database_rows(user_id)?;
|
||||
pending_operations::update_phase(
|
||||
user_id,
|
||||
PendingUserOperationPhase::DatabasePurged,
|
||||
)?;
|
||||
}
|
||||
PurgeStage::E2ee => {
|
||||
crate::util::e2ee_storage::purge_user(user_id)
|
||||
.map_err(crate::storage_error::StorageError::Other)?;
|
||||
pending_operations::update_phase(user_id, PendingUserOperationPhase::E2eePurged)?;
|
||||
}
|
||||
PurgeStage::Filesystem => {
|
||||
delete_user_directory(user_id).map_err(|error| {
|
||||
crate::storage_error::StorageError::Other(error.to_string())
|
||||
})?;
|
||||
pending_operations::update_phase(
|
||||
user_id,
|
||||
PendingUserOperationPhase::FilesystemPurged,
|
||||
)?;
|
||||
}
|
||||
PurgeStage::Complete => {
|
||||
pending_operations::complete_purge(user_id, now_millis())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Complete local erasure is idempotent and is the target for a durable
|
||||
|
|
@ -372,19 +570,50 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor
|
|||
|
||||
pub fn get_residency() -> Vec<UserResidency> {
|
||||
db::with_db(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?;
|
||||
let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state, credential_origin FROM user_residency ORDER BY username")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let lifecycle: String = row.get(2)?;
|
||||
Ok(UserResidency {
|
||||
user_id: row.get(0)?, username: row.get(1)?,
|
||||
state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released },
|
||||
data_present: row.get::<_, String>(3)? == "present",
|
||||
credential_origin: if row.get::<_, String>(4)? == "external" { CredentialOrigin::External } else { CredentialOrigin::Local },
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}).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() {
|
||||
if let Err(e) = db::with_db(|conn| {
|
||||
conn.execute_batch(
|
||||
|
|
@ -424,6 +653,75 @@ pub fn load_users_sync() -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purge_tests {
|
||||
use super::{PurgeStage, remaining_purge_stages, run_purge_stages};
|
||||
use crate::users::pending_operations::PendingUserOperationPhase;
|
||||
|
||||
#[test]
|
||||
fn prepared_purge_runs_every_stage_before_completion() {
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::Prepared).unwrap(),
|
||||
vec![
|
||||
PurgeStage::Database,
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
PurgeStage::Complete,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_resumes_after_each_persisted_phase() {
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::DatabasePurged).unwrap(),
|
||||
vec![
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
PurgeStage::Complete,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::E2eePurged).unwrap(),
|
||||
vec![PurgeStage::Filesystem, PurgeStage::Complete]
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::FilesystemPurged).unwrap(),
|
||||
vec![PurgeStage::Complete]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_phases_are_rejected_for_pending_purge() {
|
||||
assert!(remaining_purge_stages(PendingUserOperationPhase::RemoteCommitted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_does_not_complete_after_an_interrupted_cleanup_stage() {
|
||||
for failed_stage in [
|
||||
PurgeStage::Database,
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
] {
|
||||
let mut data_empty = false;
|
||||
let result = run_purge_stages(PendingUserOperationPhase::Prepared, |stage| {
|
||||
if stage == failed_stage {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"interrupted".into(),
|
||||
));
|
||||
}
|
||||
if stage == PurgeStage::Complete {
|
||||
data_empty = true;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(!data_empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) {
|
||||
let path = format!("users/{}/apps", user_id);
|
||||
let name = format!("{}.json", app_identifier);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ pub struct UserProfile {
|
|||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub public_key: String,
|
||||
pub private_key_hash: String,
|
||||
pub reset_token: String,
|
||||
pub private_key_hash: Option<String>,
|
||||
pub reset_token: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub display_name: Option<String>,
|
||||
pub trusted_apps: std::collections::HashMap<String, String>,
|
||||
|
|
@ -25,8 +25,8 @@ impl UserProfile {
|
|||
username: String,
|
||||
display_name: Option<String>,
|
||||
public_key: String,
|
||||
private_key_hash: String,
|
||||
reset_token: String,
|
||||
private_key_hash: Option<String>,
|
||||
reset_token: Option<String>,
|
||||
) -> Self {
|
||||
Self::new_with_created_at(
|
||||
user_id,
|
||||
|
|
@ -47,8 +47,8 @@ impl UserProfile {
|
|||
username: String,
|
||||
display_name: Option<String>,
|
||||
public_key: String,
|
||||
private_key_hash: String,
|
||||
reset_token: String,
|
||||
private_key_hash: Option<String>,
|
||||
reset_token: Option<String>,
|
||||
created_at: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
|
@ -68,7 +68,6 @@ impl UserProfile {
|
|||
"uuid" => self.user_id,
|
||||
"username" => self.username.clone(),
|
||||
"public_key" => self.public_key.clone(),
|
||||
"private_key_hash" => self.private_key_hash.clone(),
|
||||
"created_at" => self.created_at,
|
||||
"storage" => used_dir_space(&format!("users/{}", self.user_id.to_string())),
|
||||
};
|
||||
|
|
@ -88,8 +87,8 @@ impl UserProfile {
|
|||
let user_id = j["uuid"].as_i64()?;
|
||||
let username = j["username"].as_str()?.to_string();
|
||||
let public_key = j["public_key"].as_str()?.to_string();
|
||||
let private_key_hash = j["private_key_hash"].as_str()?.to_string();
|
||||
let reset_token = j["reset_token"].as_str()?.to_string();
|
||||
let private_key_hash = j["private_key_hash"].as_str().map(str::to_owned);
|
||||
let reset_token = j["reset_token"].as_str().map(str::to_owned);
|
||||
let created_at = j["created_at"].as_i64()?;
|
||||
let display_name = j["display_name"].as_str().map(|s| s.to_string());
|
||||
|
||||
|
|
@ -127,7 +126,7 @@ impl UserProfile {
|
|||
let mut bytes = [0u8; 192];
|
||||
OsRng.fill(bytes.as_mut());
|
||||
let new_token = general_purpose::STANDARD.encode(&bytes);
|
||||
self.reset_token = new_token.clone();
|
||||
self.reset_token = Some(new_token.clone());
|
||||
new_token
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ pub struct IotaConfig {
|
|||
pub omikron_host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub omikron_port: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub omikron_id: Option<i64>,
|
||||
#[serde(skip_serializing)]
|
||||
pub keyring: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
|
|
@ -101,6 +103,7 @@ impl Default for IotaConfig {
|
|||
web: WebSettings::default(),
|
||||
omikron_host: None,
|
||||
omikron_port: None,
|
||||
omikron_id: None,
|
||||
keyring: None,
|
||||
public_key: None,
|
||||
private_key: None,
|
||||
|
|
|
|||
|
|
@ -710,6 +710,110 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
|
|||
)?;
|
||||
}
|
||||
|
||||
if current_version < 21 {
|
||||
let users_exist: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if users_exist {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE users RENAME TO users_before_credential_origin;
|
||||
CREATE TABLE users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_hash TEXT,
|
||||
reset_token TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
display_name TEXT
|
||||
);
|
||||
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name)
|
||||
SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name
|
||||
FROM users_before_credential_origin;
|
||||
DROP TABLE users_before_credential_origin;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
let residency_exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_residency')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if residency_exists {
|
||||
add_table_column_if_missing(
|
||||
conn,
|
||||
"user_residency",
|
||||
"credential_origin",
|
||||
"credential_origin TEXT NOT NULL DEFAULT 'local' CHECK (credential_origin IN ('local', 'external'))",
|
||||
)?;
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 21)?;
|
||||
}
|
||||
|
||||
if current_version < 22 {
|
||||
let pending_exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_user_operations')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if pending_exists {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE pending_user_operations RENAME TO pending_user_operations_before_purge;
|
||||
CREATE TABLE pending_user_operations (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN ('create', 'attach', 'release', 'purge')),
|
||||
username TEXT NOT NULL,
|
||||
public_key TEXT,
|
||||
private_key_hash TEXT,
|
||||
reset_token TEXT,
|
||||
registration_token TEXT,
|
||||
phase TEXT NOT NULL DEFAULT 'prepared'
|
||||
CHECK (phase IN (
|
||||
'prepared', 'credential_written', 'remote_committed', 'local_committed',
|
||||
'database_purged', 'e2ee_purged', 'filesystem_purged'
|
||||
)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO pending_user_operations (
|
||||
user_id, operation, username, public_key, private_key_hash,
|
||||
reset_token, registration_token, phase, created_at
|
||||
)
|
||||
SELECT user_id, operation, username, public_key, private_key_hash,
|
||||
reset_token, registration_token, phase, created_at
|
||||
FROM pending_user_operations_before_purge;
|
||||
DROP TABLE pending_user_operations_before_purge;
|
||||
CREATE INDEX idx_pending_user_operations_operation
|
||||
ON pending_user_operations (operation, created_at);
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 22)?;
|
||||
}
|
||||
|
||||
if current_version < 23 {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS user_invitations (
|
||||
invitation_id TEXT PRIMARY KEY,
|
||||
token_hash BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'redeemed', 'revoked', 'expired')),
|
||||
redeemed_user_id INTEGER,
|
||||
redeemed_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invitations_state_created
|
||||
ON user_invitations (state, created_at);
|
||||
PRAGMA user_version = 23;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -779,7 +883,7 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 20);
|
||||
assert_eq!(version, 23);
|
||||
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
|
||||
let mut statement =
|
||||
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
|
||||
|
|
@ -798,7 +902,7 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
run_migrations_on_connection(&conn)?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 20);
|
||||
assert_eq!(version, 23);
|
||||
for table in [
|
||||
"sync_heads",
|
||||
"sync_events",
|
||||
|
|
@ -813,6 +917,7 @@ mod tests {
|
|||
"blocked_users",
|
||||
"user_receipt_policy",
|
||||
"user_message_storage_policy",
|
||||
"user_invitations",
|
||||
] {
|
||||
let exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
|
|
@ -837,7 +942,7 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 20);
|
||||
assert_eq!(version, 23);
|
||||
for column in [
|
||||
"id",
|
||||
"user_id",
|
||||
|
|
@ -855,4 +960,84 @@ mod tests {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_origin_migration_allows_external_profiles() -> Result<(), StorageError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_hash TEXT NOT NULL,
|
||||
reset_token TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
display_name TEXT
|
||||
);
|
||||
CREATE TABLE user_residency (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
lifecycle_state TEXT NOT NULL,
|
||||
data_state TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 20;
|
||||
"#,
|
||||
)?;
|
||||
run_migrations_on_connection(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at) VALUES (1, 'alice', 'key', NULL, NULL, 1)",
|
||||
[],
|
||||
)?;
|
||||
let origin_exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('user_residency') WHERE name = 'credential_origin'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(origin_exists, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_purge_migration_preserves_lifecycle_operations() -> Result<(), StorageError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE pending_user_operations (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN ('create', 'attach', 'release')),
|
||||
username TEXT NOT NULL,
|
||||
public_key TEXT,
|
||||
private_key_hash TEXT,
|
||||
reset_token TEXT,
|
||||
registration_token TEXT,
|
||||
phase TEXT NOT NULL DEFAULT 'prepared'
|
||||
CHECK (phase IN ('prepared', 'credential_written', 'remote_committed', 'local_committed')),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO pending_user_operations (
|
||||
user_id, operation, username, phase, created_at
|
||||
) VALUES (1, 'release', 'alice', 'remote_committed', 1);
|
||||
PRAGMA user_version = 21;
|
||||
"#,
|
||||
)?;
|
||||
|
||||
run_migrations_on_connection(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO pending_user_operations (user_id, operation, username, phase, created_at) VALUES (2, 'purge', 'bob', 'filesystem_purged', 2)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let preserved: String = conn.query_row(
|
||||
"SELECT phase FROM pending_user_operations WHERE user_id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(preserved, "remote_committed");
|
||||
assert_eq!(version, 23);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ tokio = { version = "1.50.0", features = ["full"] }
|
|||
sysinfo = "0.38.0"
|
||||
uuid = { version = "*", features = ["v4"] }
|
||||
walkdir = "2.5.0"
|
||||
zip = "8.0.0"
|
||||
zip = "6.0.0"
|
||||
base64 = "0.22.1"
|
||||
hex = "*"
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,39 @@ pub fn replace_private(path: &Path, contents: &[u8], backup_limit: usize) -> io:
|
|||
replace_with_mode(path, contents, backup_limit, true)
|
||||
}
|
||||
|
||||
/* Create exported secret material without replacing an existing destination.
|
||||
* Operators must choose another path explicitly if a file already exists. */
|
||||
pub fn create_private(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"export file has no parent directory",
|
||||
)
|
||||
})?;
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
if let Err(error) = set_private_permissions(path, true) {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(path);
|
||||
return Err(error);
|
||||
}
|
||||
let write_result = (|| {
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
if !parent.as_os_str().is_empty() {
|
||||
sync_directory(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if write_result.is_err() {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
fn replace_with_mode(
|
||||
path: &Path,
|
||||
contents: &[u8],
|
||||
|
|
@ -133,7 +166,7 @@ fn sync_directory(_path: &Path) -> io::Result<()> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::replace;
|
||||
use super::{create_private, replace};
|
||||
|
||||
#[test]
|
||||
fn replace_preserves_a_previous_version_as_a_backup() {
|
||||
|
|
@ -155,4 +188,32 @@ mod tests {
|
|||
.count();
|
||||
assert_eq!(backups, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_private_refuses_to_replace_existing_file() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("alice.tu");
|
||||
create_private(&path, b"first").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
create_private(&path, b"second").unwrap_err().kind(),
|
||||
std::io::ErrorKind::AlreadyExists
|
||||
);
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"first");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn create_private_uses_owner_only_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("alice.tu");
|
||||
create_private(&path, b"secret").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
iota-util/tests/private_export.rs
Normal file
29
iota-util/tests/private_export.rs
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -117,20 +117,55 @@ enum UsersAction {
|
|||
user_id: i64,
|
||||
},
|
||||
Add {
|
||||
username: Option<String>,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
tu: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
action: UserAddAction,
|
||||
},
|
||||
Release {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
Hosting {
|
||||
#[command(subcommand)]
|
||||
action: UserHostingAction,
|
||||
},
|
||||
Data {
|
||||
#[command(subcommand)]
|
||||
action: UserDataAction,
|
||||
},
|
||||
CompleteDelete {
|
||||
Account {
|
||||
#[command(subcommand)]
|
||||
action: UserAccountAction,
|
||||
},
|
||||
Repair {
|
||||
#[command(subcommand)]
|
||||
action: UserRepairAction,
|
||||
},
|
||||
Forget {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
Apps {
|
||||
#[command(subcommand)]
|
||||
action: UserAppsAction,
|
||||
},
|
||||
Credential {
|
||||
#[command(subcommand)]
|
||||
action: UserCredentialAction,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserAddAction {
|
||||
Create { username: String },
|
||||
Tu { path: PathBuf },
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserHostingAction {
|
||||
Release {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserAccountAction {
|
||||
Delete {
|
||||
user_id: i64,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
tu: Option<PathBuf>,
|
||||
|
|
@ -139,6 +174,42 @@ enum UsersAction {
|
|||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserRepairAction {
|
||||
Reconcile {
|
||||
user_id: i64,
|
||||
},
|
||||
Diagnostics {
|
||||
user_id: i64,
|
||||
},
|
||||
ForceDetach {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserAppsAction {
|
||||
Revoke {
|
||||
user_id: i64,
|
||||
app_id: String,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
RevokeAll {
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserCredentialAction {
|
||||
Export {
|
||||
user_id: i64,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output: PathBuf,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserDataAction {
|
||||
Purge {
|
||||
user_id: i64,
|
||||
|
|
@ -310,6 +381,33 @@ pub enum Command {
|
|||
tu: Option<PathBuf>,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersReconcile {
|
||||
user_id: i64,
|
||||
},
|
||||
UsersDiagnostics {
|
||||
user_id: i64,
|
||||
},
|
||||
UsersForceDetach {
|
||||
user_id: i64,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersForget {
|
||||
user_id: i64,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersRevokeApp {
|
||||
user_id: i64,
|
||||
app_id: String,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersRevokeAllApps {
|
||||
user_id: i64,
|
||||
confirmed: bool,
|
||||
},
|
||||
UsersExportCredential {
|
||||
user_id: i64,
|
||||
output: PathBuf,
|
||||
},
|
||||
OmikronReconnect,
|
||||
IdentityRotate {
|
||||
confirmed: bool,
|
||||
|
|
@ -406,15 +504,21 @@ impl CliInvocation {
|
|||
Some(CliCommand::Users(users)) => match users.action {
|
||||
UsersAction::List => Command::UsersList,
|
||||
UsersAction::Show { user_id } => Command::UsersShow { user_id },
|
||||
UsersAction::Add { username, tu } => {
|
||||
if username.is_some() == tu.is_some() {
|
||||
return Err(
|
||||
"users add requires exactly one of <username> or --tu <PATH>".into(),
|
||||
);
|
||||
}
|
||||
Command::UsersAdd { username, tu }
|
||||
}
|
||||
UsersAction::Release { user_id, yes } => Command::UsersRelease {
|
||||
UsersAction::Add {
|
||||
action: UserAddAction::Create { username },
|
||||
} => Command::UsersAdd {
|
||||
username: Some(username),
|
||||
tu: None,
|
||||
},
|
||||
UsersAction::Add {
|
||||
action: UserAddAction::Tu { path },
|
||||
} => Command::UsersAdd {
|
||||
username: None,
|
||||
tu: Some(path),
|
||||
},
|
||||
UsersAction::Hosting {
|
||||
action: UserHostingAction::Release { user_id, yes },
|
||||
} => Command::UsersRelease {
|
||||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
|
|
@ -424,11 +528,50 @@ impl CliInvocation {
|
|||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete {
|
||||
UsersAction::Account {
|
||||
action: UserAccountAction::Delete { user_id, tu, yes },
|
||||
} => Command::UsersCompleteDelete {
|
||||
user_id,
|
||||
tu,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Repair {
|
||||
action: UserRepairAction::Reconcile { user_id },
|
||||
} => Command::UsersReconcile { user_id },
|
||||
UsersAction::Repair {
|
||||
action: UserRepairAction::Diagnostics { user_id },
|
||||
} => Command::UsersDiagnostics { user_id },
|
||||
UsersAction::Repair {
|
||||
action: UserRepairAction::ForceDetach { user_id, yes },
|
||||
} => Command::UsersForceDetach {
|
||||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Forget { user_id, yes } => Command::UsersForget {
|
||||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Apps {
|
||||
action:
|
||||
UserAppsAction::Revoke {
|
||||
user_id,
|
||||
app_id,
|
||||
yes,
|
||||
},
|
||||
} => Command::UsersRevokeApp {
|
||||
user_id,
|
||||
app_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Apps {
|
||||
action: UserAppsAction::RevokeAll { user_id, yes },
|
||||
} => Command::UsersRevokeAllApps {
|
||||
user_id,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Credential {
|
||||
action: UserCredentialAction::Export { user_id, output },
|
||||
} => Command::UsersExportCredential { user_id, output },
|
||||
},
|
||||
Some(CliCommand::Omikron(omikron)) => match omikron.action {
|
||||
OmikronAction::Reconnect => Command::OmikronReconnect,
|
||||
|
|
@ -594,7 +737,8 @@ mod tests {
|
|||
#[test]
|
||||
fn command_schema_drives_help_and_completion_paths() {
|
||||
let paths = CliInvocation::command_paths();
|
||||
assert!(paths.contains(&"users release".to_owned()));
|
||||
assert!(paths.contains(&"users hosting release".to_owned()));
|
||||
assert!(paths.contains(&"users add create".to_owned()));
|
||||
assert!(paths.contains(&"daemon install".to_owned()));
|
||||
let help = CliInvocation::help_text();
|
||||
assert!(help.contains("users"));
|
||||
|
|
@ -649,8 +793,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parses_users_add() {
|
||||
let invocation =
|
||||
CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap();
|
||||
let invocation = CliInvocation::parse([
|
||||
"users".into(),
|
||||
"add".into(),
|
||||
"create".into(),
|
||||
"alice".into(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::UsersAdd {
|
||||
|
|
@ -689,6 +838,7 @@ mod tests {
|
|||
fn parses_users_release_with_confirmation() {
|
||||
let invocation = CliInvocation::parse([
|
||||
"users".into(),
|
||||
"hosting".into(),
|
||||
"release".into(),
|
||||
"42".into(),
|
||||
"--yes".into(),
|
||||
|
|
@ -703,6 +853,39 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_credential_export_with_required_destination() {
|
||||
let invocation = CliInvocation::parse([
|
||||
"users".into(),
|
||||
"credential".into(),
|
||||
"export".into(),
|
||||
"42".into(),
|
||||
"--output".into(),
|
||||
"alice.tu".into(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::UsersExportCredential {
|
||||
user_id: 42,
|
||||
output: PathBuf::from("alice.tu"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_export_rejects_a_missing_destination() {
|
||||
assert!(
|
||||
CliInvocation::parse([
|
||||
"users".into(),
|
||||
"credential".into(),
|
||||
"export".into(),
|
||||
"42".into(),
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_omikron_reconnect() {
|
||||
let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap();
|
||||
|
|
|
|||
221
iota/src/main.rs
221
iota/src/main.rs
|
|
@ -400,10 +400,18 @@ fn print_help() {
|
|||
println!(" tasks List active tasks");
|
||||
println!(" users list List all users");
|
||||
println!(" users show <ID> Show user details");
|
||||
println!(" users add <NAME> Create a new user");
|
||||
println!(" users add --tu <PATH> Add an existing account credential");
|
||||
println!(" users add create <NAME> Create a new user");
|
||||
println!(" users add tu <PATH> Attach an existing account credential");
|
||||
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 reconnect Reconnect to Omikron");
|
||||
println!(" identity rotate Rotate identity keys (requires --yes)");
|
||||
|
|
@ -549,6 +557,7 @@ async fn run_command(
|
|||
output: OutputFormat,
|
||||
) -> Result<(), StartupError> {
|
||||
let color = ColorConfig::new();
|
||||
let mut credential_output = None;
|
||||
let request = match command {
|
||||
Command::Status => LocalRequest::GetStatus,
|
||||
Command::Tasks => LocalRequest::ListTasks,
|
||||
|
|
@ -622,6 +631,40 @@ async fn run_command(
|
|||
credential,
|
||||
}
|
||||
}
|
||||
Command::UsersReconcile { user_id } => LocalRequest::ReconcileUser { user_id },
|
||||
Command::UsersDiagnostics { user_id } => LocalRequest::GetUserDiagnostics { user_id },
|
||||
Command::UsersForceDetach {
|
||||
user_id,
|
||||
confirmed: true,
|
||||
} => LocalRequest::ForceDetachUser { user_id },
|
||||
Command::UsersForget {
|
||||
user_id,
|
||||
confirmed: true,
|
||||
} => LocalRequest::ForgetReleasedUser { user_id },
|
||||
Command::UsersRevokeApp {
|
||||
user_id,
|
||||
app_id,
|
||||
confirmed: true,
|
||||
} => LocalRequest::RevokeTrustedApp { user_id, app_id },
|
||||
Command::UsersRevokeAllApps {
|
||||
user_id,
|
||||
confirmed: true,
|
||||
} => LocalRequest::RevokeAllTrustedApps { user_id },
|
||||
Command::UsersExportCredential { user_id, output } => {
|
||||
let daemon_status = ipc.daemon_status();
|
||||
if !daemon_status
|
||||
.borrow()
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability == "credential_export_v1")
|
||||
{
|
||||
return Err(StartupError::InvalidCommand(
|
||||
"The connected daemon does not support credential export.".into(),
|
||||
));
|
||||
}
|
||||
credential_output = Some(output);
|
||||
LocalRequest::ExportUserCredential { user_id }
|
||||
}
|
||||
Command::OmikronReconnect => LocalRequest::ReconnectOmikron,
|
||||
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
|
||||
Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity,
|
||||
|
|
@ -650,6 +693,18 @@ async fn run_command(
|
|||
| Command::UsersCompleteDelete {
|
||||
confirmed: false, ..
|
||||
}
|
||||
| Command::UsersForceDetach {
|
||||
confirmed: false, ..
|
||||
}
|
||||
| Command::UsersForget {
|
||||
confirmed: false, ..
|
||||
}
|
||||
| Command::UsersRevokeApp {
|
||||
confirmed: false, ..
|
||||
}
|
||||
| Command::UsersRevokeAllApps {
|
||||
confirmed: false, ..
|
||||
}
|
||||
| Command::IdentityRotate { confirmed: false }
|
||||
| Command::RegenerateKeys { confirmed: false }
|
||||
| Command::DaemonRestart { confirmed: false }
|
||||
|
|
@ -685,6 +740,38 @@ async fn run_command(
|
|||
.map_err(|e| StartupError::Other(e.to_string()))?
|
||||
{
|
||||
ResponseResult::Ok(payload) => {
|
||||
if let ResponsePayload::UserCredentialExport {
|
||||
user_id,
|
||||
username,
|
||||
credential,
|
||||
} = &payload
|
||||
{
|
||||
let path = credential_output.as_deref().ok_or_else(|| {
|
||||
StartupError::Other(
|
||||
"Refusing to render a credential without an export destination.".into(),
|
||||
)
|
||||
})?;
|
||||
iota_util::atomic_file::create_private(path, credential.0.as_bytes()).map_err(
|
||||
|error| {
|
||||
StartupError::Other(format!(
|
||||
"Cannot export credential to {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
},
|
||||
)?;
|
||||
println!(
|
||||
"Exported credential for {} ({}) to {}.",
|
||||
username,
|
||||
user_id,
|
||||
path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if credential_output.is_some() {
|
||||
return Err(StartupError::Other(
|
||||
"The daemon returned an unexpected credential export response.".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(output, OutputFormat::Text) {
|
||||
return render_structured(&payload, output);
|
||||
}
|
||||
|
|
@ -725,11 +812,34 @@ async fn run_command(
|
|||
if users.is_empty() {
|
||||
println!("{}", cli_color::muted(&color, "No users."));
|
||||
} else {
|
||||
println!(
|
||||
"{:<12} {:<18} {:<10} {:<9} {:<12} PENDING",
|
||||
"ID", "USERNAME", "STATE", "DATA", "CREDENTIAL"
|
||||
);
|
||||
for user in &users {
|
||||
println!(
|
||||
"{} ({})",
|
||||
"{:<12} {:<18} {:<10} {:<9} {:<12} {}",
|
||||
user.user_id,
|
||||
cli_color::heading(&color, &user.username),
|
||||
user.user_id
|
||||
match user.state {
|
||||
iota_ipc::LocalUserState::Managed => "managed",
|
||||
iota_ipc::LocalUserState::Released => "released",
|
||||
},
|
||||
if user.data_present {
|
||||
"present"
|
||||
} else {
|
||||
"empty"
|
||||
},
|
||||
match user.credential_status {
|
||||
iota_ipc::CredentialStatus::LocalPresent => "local",
|
||||
iota_ipc::CredentialStatus::External => "external",
|
||||
iota_ipc::CredentialStatus::Missing => "missing",
|
||||
iota_ipc::CredentialStatus::None => "none",
|
||||
},
|
||||
user.pending_operation
|
||||
.as_ref()
|
||||
.map(format_pending_operation)
|
||||
.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -815,6 +925,34 @@ async fn run_command(
|
|||
println!("Trusted Apps: {}", user.trusted_apps.join(", "));
|
||||
}
|
||||
}
|
||||
ResponsePayload::TuCredentialPreview(preview) => {
|
||||
println!("User: {} ({})", preview.username, preview.user_id);
|
||||
println!(
|
||||
"Current Iota: {}",
|
||||
preview
|
||||
.assigned_iota_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "Unassigned".into())
|
||||
);
|
||||
}
|
||||
ResponsePayload::UserReconciled(result) => {
|
||||
println!("Reconciled user {}: {:?}", result.user_id, result.action);
|
||||
}
|
||||
ResponsePayload::UserDiagnostics(diagnostics) => {
|
||||
println!("User: {} ({})", diagnostics.username, diagnostics.user_id);
|
||||
println!("Local state: {:?}", diagnostics.local_state);
|
||||
println!("Hosted data: {}", diagnostics.data_present);
|
||||
println!("Credential: {:?}", diagnostics.credential_status);
|
||||
println!("Trusted applications: {}", diagnostics.trusted_app_count);
|
||||
if let Some(operation) = &diagnostics.pending_operation {
|
||||
println!("Pending operation: {operation}");
|
||||
}
|
||||
}
|
||||
ResponsePayload::UserCredentialExport { .. } => {
|
||||
return Err(StartupError::Other(
|
||||
"Refusing to display credential material.".into(),
|
||||
));
|
||||
}
|
||||
ResponsePayload::LogEntries(logs) => {
|
||||
for entry in &logs.entries {
|
||||
let ts = entry.timestamp_ms;
|
||||
|
|
@ -860,6 +998,11 @@ async fn run_command(
|
|||
/// Text remains an operator-oriented presentation; JSON and YAML must never
|
||||
/// require consumers to parse it.
|
||||
fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> {
|
||||
if matches!(payload, ResponsePayload::UserCredentialExport { .. }) {
|
||||
return Err(StartupError::Other(
|
||||
"Refusing to encode credential material as ordinary output.".into(),
|
||||
));
|
||||
}
|
||||
match output {
|
||||
OutputFormat::Json => println!(
|
||||
"{}",
|
||||
|
|
@ -886,10 +1029,35 @@ fn render_table(payload: &ResponsePayload) {
|
|||
println!("No users.");
|
||||
return;
|
||||
}
|
||||
println!("{:<8} {}", "ID", "USERNAME");
|
||||
println!("{:<8} {}", "--------", "--------");
|
||||
println!(
|
||||
"{:<12} {:<18} {:<10} {:<9} {:<12} PENDING",
|
||||
"ID", "USERNAME", "STATE", "DATA", "CREDENTIAL"
|
||||
);
|
||||
for user in users {
|
||||
println!("{:<8} {}", user.user_id, user.username);
|
||||
println!(
|
||||
"{:<12} {:<18} {:<10} {:<9} {:<12} {}",
|
||||
user.user_id,
|
||||
user.username,
|
||||
match user.state {
|
||||
iota_ipc::LocalUserState::Managed => "managed",
|
||||
iota_ipc::LocalUserState::Released => "released",
|
||||
},
|
||||
if user.data_present {
|
||||
"present"
|
||||
} else {
|
||||
"empty"
|
||||
},
|
||||
match user.credential_status {
|
||||
iota_ipc::CredentialStatus::LocalPresent => "local",
|
||||
iota_ipc::CredentialStatus::External => "external",
|
||||
iota_ipc::CredentialStatus::Missing => "missing",
|
||||
iota_ipc::CredentialStatus::None => "none",
|
||||
},
|
||||
user.pending_operation
|
||||
.as_ref()
|
||||
.map(format_pending_operation)
|
||||
.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Tasks(tasks) => {
|
||||
|
|
@ -990,6 +1158,33 @@ fn render_table(payload: &ResponsePayload) {
|
|||
ResponsePayload::UserCreated { user_id, username } => {
|
||||
println!("Created user {} ({})", username, user_id);
|
||||
}
|
||||
ResponsePayload::TuCredentialPreview(preview) => {
|
||||
println!("{:<15} {}", "Username", preview.username);
|
||||
println!("{:<15} {}", "User ID", preview.user_id);
|
||||
println!(
|
||||
"{:<15} {}",
|
||||
"Current Iota",
|
||||
preview
|
||||
.assigned_iota_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "Unassigned".into())
|
||||
);
|
||||
}
|
||||
ResponsePayload::UserReconciled(result) => {
|
||||
println!("{:<15} {}", "User ID", result.user_id);
|
||||
println!("{:<15} {:?}", "Action", result.action);
|
||||
}
|
||||
ResponsePayload::UserDiagnostics(diagnostics) => {
|
||||
println!("{:<15} {}", "Username", diagnostics.username);
|
||||
println!("{:<15} {}", "User ID", diagnostics.user_id);
|
||||
println!("{:<15} {:?}", "Local state", diagnostics.local_state);
|
||||
println!("{:<15} {}", "Hosted data", diagnostics.data_present);
|
||||
println!("{:<15} {:?}", "Credential", diagnostics.credential_status);
|
||||
println!("{:<15} {}", "Trusted apps", diagnostics.trusted_app_count);
|
||||
}
|
||||
ResponsePayload::UserCredentialExport { .. } => {
|
||||
println!("Credential export payload withheld.");
|
||||
}
|
||||
ResponsePayload::UserRemoved { user_id } => {
|
||||
println!("Removed user {}", user_id);
|
||||
}
|
||||
|
|
@ -1014,3 +1209,13 @@ fn render_table(payload: &ResponsePayload) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_pending_operation(operation: &iota_ipc::UserOperationSummary) -> String {
|
||||
let kind = match operation.operation {
|
||||
iota_ipc::UserOperationKind::Create => "create",
|
||||
iota_ipc::UserOperationKind::Attach => "attach",
|
||||
iota_ipc::UserOperationKind::Release => "release",
|
||||
iota_ipc::UserOperationKind::Purge => "purge",
|
||||
};
|
||||
format!("{kind}/{}", operation.phase)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 4b82f4f8139ed9aa74fa86f73ba8f0d565703c86
|
||||
Subproject commit 909b3977cb6a233e74a925eb467412fb384844bc
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use async_trait::async_trait;
|
||||
use mtp::codec::CommunicationValue;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -7,6 +7,7 @@ pub enum OmikronError {
|
|||
Disconnected(String),
|
||||
Timeout(String),
|
||||
Authentication(String),
|
||||
Rejected(CommunicationType, String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ impl std::fmt::Display for OmikronError {
|
|||
Self::Disconnected(v)
|
||||
| Self::Timeout(v)
|
||||
| Self::Authentication(v)
|
||||
| Self::Rejected(_, v)
|
||||
| Self::Internal(v) => f.write_str(v),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ use iota_util::route_target::RouteTarget;
|
|||
|
||||
const IOTA_KEYRING_PATH: &str = "iota.mk";
|
||||
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) {
|
||||
let Ok(storage_owner) = i64::try_from(signer_id) else {
|
||||
|
|
@ -60,10 +60,10 @@ fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failur
|
|||
* working directory, so restarts use the same trusted material.
|
||||
*/
|
||||
pub fn configure_identity_path(path: PathBuf) {
|
||||
let key_path = path.parent().map(|parent| parent.join("omikron.mpkb"));
|
||||
let trust_directory = path.parent().map(|parent| parent.join("omikrons"));
|
||||
let _ = IDENTITY_PATH.set(path);
|
||||
if let Some(key_path) = key_path {
|
||||
let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path);
|
||||
if let Some(trust_directory) = trust_directory {
|
||||
let _ = OMIKRON_TRUST_DIRECTORY.set(trust_directory);
|
||||
}
|
||||
}
|
||||
fn identity_path() -> &'static Path {
|
||||
|
|
@ -72,11 +72,22 @@ fn identity_path() -> &'static Path {
|
|||
.map(PathBuf::as_path)
|
||||
.unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH))
|
||||
}
|
||||
fn omikron_public_key_path() -> &'static Path {
|
||||
OMIKRON_PUBLIC_KEY_PATH
|
||||
fn omikron_trust_directory() -> &'static Path {
|
||||
OMIKRON_TRUST_DIRECTORY
|
||||
.get()
|
||||
.map(PathBuf::as_path)
|
||||
.unwrap_or_else(|| Path::new("omikron.mpkb"))
|
||||
.unwrap_or_else(|| Path::new("omikrons"))
|
||||
}
|
||||
|
||||
fn omikron_public_key_path(id: i64) -> PathBuf {
|
||||
omikron_trust_directory().join(format!("{id}.mpkb"))
|
||||
}
|
||||
|
||||
fn legacy_omikron_public_key_path() -> PathBuf {
|
||||
identity_path()
|
||||
.parent()
|
||||
.map(|parent| parent.join("omikron.mpkb"))
|
||||
.unwrap_or_else(|| PathBuf::from("omikron.mpkb"))
|
||||
}
|
||||
|
||||
fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> {
|
||||
|
|
@ -107,13 +118,20 @@ fn serialization_path(path: &Path) -> Result<PathBuf, String> {
|
|||
|
||||
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
|
||||
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300);
|
||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TASK_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
const MAX_CONCURRENT_HANDLERS: usize = 20;
|
||||
const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
struct ResolvedOmikronEndpoint {
|
||||
id: Option<i64>,
|
||||
host: String,
|
||||
port: u16,
|
||||
public_key: PublicKeyBundle,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityError {
|
||||
Storage(mtp::files::FileError),
|
||||
|
|
@ -159,6 +177,18 @@ fn jittered_reconnect_delay(delay: Duration) -> Duration {
|
|||
Duration::from_millis(u64::try_from(jittered).expect("bounded jitter must be non-negative"))
|
||||
}
|
||||
|
||||
fn map_await_response_error(error: String) -> OmikronError {
|
||||
if error.contains("timed out") {
|
||||
OmikronError::Timeout(error)
|
||||
} else if error.contains("kind=not_found") {
|
||||
OmikronError::Rejected(CommunicationType::ErrorNotFound, error)
|
||||
} else if error.starts_with("Request rejected") {
|
||||
OmikronError::Rejected(CommunicationType::Error, error)
|
||||
} else {
|
||||
OmikronError::Disconnected(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_user_id(user_id: i64) -> u64 {
|
||||
u64::try_from(user_id).expect("validated user ID is non-negative")
|
||||
}
|
||||
|
|
@ -443,10 +473,9 @@ impl OmikronConnection {
|
|||
|
||||
let existing_iota_id = CONFIG.load().iota_id;
|
||||
|
||||
let (host, port, omikron_public_key) =
|
||||
self.resolve_omikron_endpoint(existing_iota_id).await?;
|
||||
let endpoint = self.resolve_omikron_endpoint(existing_iota_id).await?;
|
||||
|
||||
let addr_str = format!("https://{}:{}", host, port);
|
||||
let addr_str = format!("https://{}:{}", endpoint.host, endpoint.port);
|
||||
|
||||
log!("Connecting to Omikron at {}", addr_str);
|
||||
|
||||
|
|
@ -470,26 +499,24 @@ impl OmikronConnection {
|
|||
client_config,
|
||||
existing_iota_id,
|
||||
&keyring,
|
||||
&omikron_public_key,
|
||||
&endpoint.public_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => {
|
||||
let reason = format!(
|
||||
"Authentication failed: {}. Your Iota keys may be invalid or the private key has changed on the server.",
|
||||
reason
|
||||
);
|
||||
*self.reconnect_on_close.write().await = false;
|
||||
*self.auth_failure.write().await = Some(reason.clone());
|
||||
self.set_state(ConnectionState::Disconnected).await;
|
||||
return Err(reason);
|
||||
/* MTP currently combines invalid proofs with timeouts and backend
|
||||
* failures in this variant. Retrying is safe; stopping here can
|
||||
* strand an Iota during a temporary Omega outage. */
|
||||
return Err(format!("Authentication attempt failed: {reason}"));
|
||||
}
|
||||
Err(e) => return Err(format!("Connection failed: {}", e)),
|
||||
};
|
||||
|
||||
log_t!("omikron_connection_success");
|
||||
|
||||
self.persist_authenticated_omikron(&endpoint)?;
|
||||
|
||||
if existing_iota_id.is_none() {
|
||||
modify_config(|cfg| cfg.iota_id = Some(connection.client_id));
|
||||
log!("Registered with Iota-ID: {}", connection.client_id);
|
||||
|
|
@ -564,22 +591,20 @@ impl OmikronConnection {
|
|||
* override for local dev/testing against a hand-run Omikron without a
|
||||
* live Omega.
|
||||
*
|
||||
* The fetched Omikron public key is pinned to `omikron.mpkb` (trust on
|
||||
* first use): if a cached key exists and a fresh discovery response
|
||||
* disagrees with it, the mismatch is logged loudly and the cached key is
|
||||
* kept rather than silently trusting whatever Omega's HTTP API returned
|
||||
* this time - the same trust boundary the previous manual-file-drop
|
||||
* model had, just automated for the common case.
|
||||
* Keys are pinned per Omikron ID. A different relay can therefore be used
|
||||
* after failover, while an unexpected key change for one relay remains a
|
||||
* security error. Discovery data becomes durable only after MTP
|
||||
* authentication has completed.
|
||||
*/
|
||||
async fn resolve_omikron_endpoint(
|
||||
&self,
|
||||
existing_iota_id: Option<u64>,
|
||||
) -> Result<(String, u16, PublicKeyBundle), String> {
|
||||
) -> Result<ResolvedOmikronEndpoint, String> {
|
||||
if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) {
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?;
|
||||
let key_path = omikron_public_key_path();
|
||||
let key_path = Path::new("omikron.mpkb");
|
||||
let public_key = mtp::files::load_public_key_bundle(key_path)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
|
|
@ -587,21 +612,41 @@ impl OmikronConnection {
|
|||
key_path.display(), e, key_path.display()
|
||||
)
|
||||
})?;
|
||||
return Ok((host, port, public_key));
|
||||
return Ok(ResolvedOmikronEndpoint {
|
||||
id: None,
|
||||
host,
|
||||
port,
|
||||
public_key,
|
||||
});
|
||||
}
|
||||
|
||||
let key_path = omikron_public_key_path();
|
||||
let cached_key = mtp::files::load_public_key_bundle(key_path).ok();
|
||||
let cached_host_port = {
|
||||
let cached_endpoint = {
|
||||
let conf = CONFIG.load();
|
||||
match (&conf.omikron_host, conf.omikron_port) {
|
||||
(Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
|
||||
Some((host.clone(), port))
|
||||
match (&conf.omikron_id, &conf.omikron_host, conf.omikron_port) {
|
||||
(Some(id), Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
|
||||
mtp::files::load_public_key_bundle(&omikron_public_key_path(*id))
|
||||
.ok()
|
||||
.map(|public_key| ResolvedOmikronEndpoint {
|
||||
id: Some(*id),
|
||||
host: host.clone(),
|
||||
port,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
(Some(_), Some(_), Some(_)) => {
|
||||
log!("Ignoring invalid cached Omikron endpoint in Iota configuration");
|
||||
None
|
||||
}
|
||||
(None, Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
|
||||
mtp::files::load_public_key_bundle(legacy_omikron_public_key_path())
|
||||
.ok()
|
||||
.map(|public_key| ResolvedOmikronEndpoint {
|
||||
id: None,
|
||||
host: host.clone(),
|
||||
port,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
|
@ -620,8 +665,9 @@ impl OmikronConnection {
|
|||
None => omega_discovery::discover_random().await.ok(),
|
||||
};
|
||||
|
||||
let (host, port, public_key) = if let Some(endpoint) = discovered {
|
||||
match &cached_key {
|
||||
let endpoint = if let Some(endpoint) = discovered {
|
||||
let key_path = omikron_public_key_path(endpoint.id);
|
||||
match mtp::files::load_public_key_bundle(&key_path).ok() {
|
||||
Some(cached) => {
|
||||
let keys_match =
|
||||
match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) {
|
||||
|
|
@ -632,49 +678,63 @@ impl OmikronConnection {
|
|||
};
|
||||
if !keys_match {
|
||||
log!(
|
||||
"Fetched Omikron public key differs from the cached {} - keeping the \
|
||||
cached key. Delete {} manually if this is an expected key rotation.",
|
||||
"Fetched Omikron public key differs from the trusted {}. \
|
||||
Omikron key rotation requires an explicit trust refresh.",
|
||||
key_path.display(),
|
||||
key_path.display()
|
||||
);
|
||||
if let Some((cached_host, cached_port)) = &cached_host_port {
|
||||
(cached_host.clone(), *cached_port, cached.clone())
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Omega returned an Omikron key that differs from {} and no validated cached endpoint is available",
|
||||
key_path.display()
|
||||
));
|
||||
}
|
||||
return Err(format!(
|
||||
"Omega returned a changed public key for Omikron {}",
|
||||
endpoint.id
|
||||
));
|
||||
} else {
|
||||
(endpoint.host, endpoint.port, cached.clone())
|
||||
ResolvedOmikronEndpoint {
|
||||
id: Some(endpoint.id),
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
public_key: cached,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) {
|
||||
log!("Failed to cache Omikron public key: {}", e);
|
||||
}
|
||||
(endpoint.host, endpoint.port, endpoint.public_key)
|
||||
}
|
||||
None => ResolvedOmikronEndpoint {
|
||||
id: Some(endpoint.id),
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
public_key: endpoint.public_key,
|
||||
},
|
||||
}
|
||||
} else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) {
|
||||
} else if let Some(cached) = cached_endpoint {
|
||||
log!(
|
||||
"Omega discovery unreachable, falling back to last-known Omikron {}:{}",
|
||||
host,
|
||||
port
|
||||
cached.host,
|
||||
cached.port
|
||||
);
|
||||
(host.clone(), *port, cached.clone())
|
||||
cached
|
||||
} else {
|
||||
return Err(
|
||||
"Omega discovery failed and no cached Omikron address/key is available".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
modify_config(|cfg| {
|
||||
cfg.omikron_host = Some(host.clone());
|
||||
cfg.omikron_port = Some(port);
|
||||
});
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
Ok((host, port, public_key))
|
||||
fn persist_authenticated_omikron(
|
||||
&self,
|
||||
endpoint: &ResolvedOmikronEndpoint,
|
||||
) -> Result<(), String> {
|
||||
let Some(id) = endpoint.id else {
|
||||
return Ok(());
|
||||
};
|
||||
let key_path = omikron_public_key_path(id);
|
||||
std::fs::create_dir_all(omikron_trust_directory())
|
||||
.map_err(|error| format!("create Omikron trust directory: {error}"))?;
|
||||
save_omikron_public_key(&endpoint.public_key, &key_path)?;
|
||||
modify_config(|cfg| {
|
||||
cfg.omikron_id = Some(id);
|
||||
cfg.omikron_host = Some(endpoint.host.clone());
|
||||
cfg.omikron_port = Some(endpoint.port);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -1946,6 +2006,7 @@ impl OmikronConnection {
|
|||
dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set);
|
||||
dispatch!(UserBlockCheck, handle_user_block_check);
|
||||
dispatch!(EraseHostedUserData, handle_erase_hosted_user_data);
|
||||
dispatch!(ProvisionIotaUser, handle_iota_user_provisioning);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -1980,6 +2041,54 @@ impl OmikronConnection {
|
|||
let _ = self.send_message(&acknowledgement).await;
|
||||
}
|
||||
|
||||
/* Omega sends only public account metadata here. The locally created
|
||||
* profile records an external credential origin and never receives a TU. */
|
||||
async fn handle_iota_user_provisioning(self: Arc<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) {
|
||||
let _ = self
|
||||
.send_message(&message_handlers::handle_get_chat_secret(cv))
|
||||
|
|
@ -2781,9 +2890,13 @@ impl OmikronConnection {
|
|||
.or_else(|| response_cv.get_data(DataType::ErrorType).as_str())
|
||||
.unwrap_or("connection error")
|
||||
.to_string();
|
||||
let rejection_kind = if response_cv.is_type(CommunicationType::ErrorNotFound) {
|
||||
"not_found"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
Err(format!(
|
||||
"Request rejected (msg_id={}, reason={})",
|
||||
msg_id, reason
|
||||
"Request rejected (kind={rejection_kind}, msg_id={msg_id}, reason={reason})"
|
||||
))
|
||||
} else {
|
||||
Ok(response_cv)
|
||||
|
|
@ -3029,15 +3142,7 @@ impl OmikronClient for OmikronConnection {
|
|||
) -> Result<CommunicationValue, OmikronError> {
|
||||
Self::await_response(self, value, Some(timeout))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.contains("timed out") {
|
||||
OmikronError::Timeout(error)
|
||||
} else if error.starts_with("Request rejected") {
|
||||
OmikronError::Internal(error)
|
||||
} else {
|
||||
OmikronError::Disconnected(error)
|
||||
}
|
||||
})
|
||||
.map_err(map_await_response_error)
|
||||
}
|
||||
|
||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
||||
|
|
@ -3165,4 +3270,21 @@ mod tests {
|
|||
|
||||
assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_omikron_trust_paths_do_not_collide() {
|
||||
assert_ne!(omikron_public_key_path(1), omikron_public_key_path(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_response_is_preserved_for_callers() {
|
||||
let error = map_await_response_error(
|
||||
"Request rejected (kind=not_found, msg_id=1, reason=connection error)".into(),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OmikronError::Rejected(CommunicationType::ErrorNotFound, _)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ pub enum LifecycleUserError {
|
|||
LocalPersistence(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InspectedTuCredential {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub assigned_iota_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<crate::OmikronError> for LifecycleUserError {
|
||||
fn from(value: crate::OmikronError) -> Self {
|
||||
Self::Transport(value)
|
||||
|
|
@ -120,6 +127,58 @@ async fn inspect_credential_account(
|
|||
Ok((username, public_key, created_at))
|
||||
}
|
||||
|
||||
/*
|
||||
* Inspection verifies the credential against Omega but deliberately stops
|
||||
* before proof, assignment, pending-operation, credential, or profile writes.
|
||||
*/
|
||||
pub async fn inspect_tu_credential(
|
||||
connection: &dyn OmikronClient,
|
||||
contents: &str,
|
||||
) -> Result<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(
|
||||
connection: &dyn OmikronClient,
|
||||
credential: &TuCredential,
|
||||
|
|
@ -181,8 +240,8 @@ pub async fn attach_user_from_tu(
|
|||
username,
|
||||
None,
|
||||
public_key,
|
||||
hex_hash(contents),
|
||||
String::new(),
|
||||
Some(hex_hash(contents)),
|
||||
Some(String::new()),
|
||||
created_at,
|
||||
);
|
||||
pending_operations::upsert(&PendingUserOperation {
|
||||
|
|
@ -190,8 +249,8 @@ pub async fn attach_user_from_tu(
|
|||
operation: PendingUserOperationKind::Attach,
|
||||
username: profile.username.clone(),
|
||||
public_key: Some(profile.public_key.clone()),
|
||||
private_key_hash: Some(profile.private_key_hash.clone()),
|
||||
reset_token: Some(profile.reset_token.clone()),
|
||||
private_key_hash: profile.private_key_hash.clone(),
|
||||
reset_token: profile.reset_token.clone(),
|
||||
registration_token: None,
|
||||
phase: PendingUserOperationPhase::Prepared,
|
||||
created_at: now_millis(),
|
||||
|
|
@ -254,16 +313,17 @@ pub async fn complete_delete_user_with_tu(
|
|||
/// Repair local management state after a release or migration committed in
|
||||
/// Omega but local cleanup was interrupted. Hosted data is retained.
|
||||
pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
|
||||
let Ok(local_iota_id) = configured_iota_id() else {
|
||||
return;
|
||||
};
|
||||
let pending = match pending_operations::get_all() {
|
||||
let mut pending = match pending_operations::get_all() {
|
||||
Ok(pending) => pending,
|
||||
Err(error) => {
|
||||
log!("Pending user operation reconciliation could not read storage: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
pending.retain(|operation| operation.operation != PendingUserOperationKind::Purge);
|
||||
let Ok(local_iota_id) = configured_iota_id() else {
|
||||
return;
|
||||
};
|
||||
for operation in pending {
|
||||
let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
|
||||
DataType::UserId,
|
||||
|
|
@ -319,15 +379,20 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
|
|||
operation.username,
|
||||
None,
|
||||
public_key,
|
||||
operation.private_key_hash.unwrap_or_default(),
|
||||
operation.reset_token.unwrap_or_default(),
|
||||
operation.private_key_hash,
|
||||
operation.reset_token,
|
||||
);
|
||||
if try_add_user(profile).is_ok() {
|
||||
let _ = pending_operations::remove(operation.user_id);
|
||||
}
|
||||
}
|
||||
PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => {
|
||||
if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() {
|
||||
if iota_storage::users::user_manager::finalize_local_release(
|
||||
operation.user_id,
|
||||
Some(&operation.username),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = pending_operations::remove(operation.user_id);
|
||||
}
|
||||
}
|
||||
|
|
@ -339,19 +404,47 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
|
|||
DataType::UserId,
|
||||
DataValue::SignedNumber(user.user_id.into()),
|
||||
);
|
||||
let Ok(response) = connection
|
||||
let response = connection
|
||||
.await_response(&request, Duration::from_secs(10))
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
.await;
|
||||
let should_release = match should_release_reconciled_user(response, local_iota_id) {
|
||||
Ok(should_release) => should_release,
|
||||
Err(error) => {
|
||||
log!(
|
||||
"Could not reconcile local user {} with Omega: {}",
|
||||
user.user_id,
|
||||
error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let remote_iota_id = response
|
||||
if should_release {
|
||||
if let Err(error) = iota_storage::users::user_manager::finalize_local_release(
|
||||
user.user_id,
|
||||
Some(&user.username),
|
||||
) {
|
||||
log!(
|
||||
"Could not release local user {} after Omega reconciliation: {}",
|
||||
user.user_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_release_reconciled_user(
|
||||
response: Result<CommunicationValue, crate::OmikronError>,
|
||||
local_iota_id: i64,
|
||||
) -> Result<bool, crate::OmikronError> {
|
||||
match response {
|
||||
Ok(response) => Ok(response
|
||||
.get_data(DataType::IotaId)
|
||||
.as_signed_number()
|
||||
.and_then(|value| i64::try_from(value).ok());
|
||||
if remote_iota_id != Some(local_iota_id) {
|
||||
let _ = iota_storage::users::user_manager::release_user(user.user_id);
|
||||
}
|
||||
.and_then(|value| i64::try_from(value).ok())
|
||||
!= Some(local_iota_id)),
|
||||
Err(crate::OmikronError::Rejected(CommunicationType::ErrorNotFound, _)) => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,8 +598,8 @@ pub async fn create_user(
|
|||
username.to_string(),
|
||||
None,
|
||||
public_key_bundle_to_base64(&pub_key_bundle),
|
||||
private_key_hash,
|
||||
reset_token.clone(),
|
||||
Some(private_key_hash),
|
||||
Some(reset_token.clone()),
|
||||
);
|
||||
let credential = format!(
|
||||
"{}@{}::{}",
|
||||
|
|
@ -519,8 +612,8 @@ pub async fn create_user(
|
|||
operation: PendingUserOperationKind::Create,
|
||||
username: user_profile.username.clone(),
|
||||
public_key: Some(user_profile.public_key.clone()),
|
||||
private_key_hash: Some(user_profile.private_key_hash.clone()),
|
||||
reset_token: Some(user_profile.reset_token.clone()),
|
||||
private_key_hash: user_profile.private_key_hash.clone(),
|
||||
reset_token: user_profile.reset_token.clone(),
|
||||
registration_token: Some(registration_token.clone()),
|
||||
phase: PendingUserOperationPhase::Prepared,
|
||||
created_at: now_millis(),
|
||||
|
|
@ -563,7 +656,9 @@ pub async fn create_user(
|
|||
} else {
|
||||
log_t!("User creation: {}", error.to_string());
|
||||
return Err(match error {
|
||||
crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected,
|
||||
crate::OmikronError::Rejected(_, _) | crate::OmikronError::Internal(_) => {
|
||||
CreateUserError::RemoteRejected
|
||||
}
|
||||
error => CreateUserError::Transport(error),
|
||||
});
|
||||
}
|
||||
|
|
@ -583,10 +678,15 @@ pub async fn create_user(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CreateUserError, request_user_id, valid_username};
|
||||
use super::{
|
||||
CreateUserError, LifecycleUserError, inspect_tu_credential, request_user_id,
|
||||
should_release_reconciled_user, valid_username,
|
||||
};
|
||||
use crate::{OmikronClient, OmikronError};
|
||||
use async_trait::async_trait;
|
||||
use iota_connection::message_common::CommunicationResponseExt;
|
||||
use iota_util::crypto_helper::{generate_keyring, public_key_bundle_to_base64};
|
||||
use iota_util::tu::TuCredential;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -594,6 +694,34 @@ mod tests {
|
|||
response: CommunicationValue,
|
||||
}
|
||||
|
||||
struct InspectionClient {
|
||||
response: CommunicationValue,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OmikronClient for InspectionClient {
|
||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn await_response(
|
||||
&self,
|
||||
request: &CommunicationValue,
|
||||
_: Duration,
|
||||
) -> Result<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]
|
||||
impl OmikronClient for RegistrationClient {
|
||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
||||
|
|
@ -634,6 +762,42 @@ mod tests {
|
|||
assert!(!valid_username("line\nbreak"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspection_returns_verified_identity_without_lifecycle_requests() {
|
||||
let credential = TuCredential {
|
||||
user_id: 42,
|
||||
omega_host: crate::omega_discovery::omega_host(),
|
||||
keyring: generate_keyring(),
|
||||
};
|
||||
let client = InspectionClient {
|
||||
response: CommunicationValue::new(CommunicationType::GetUserData)
|
||||
.add_typed_default(DataType::Username, DataValue::Str("alice".into()))
|
||||
.add_typed_default(
|
||||
DataType::PublicKey,
|
||||
DataValue::Str(public_key_bundle_to_base64(&credential.public_key_bundle())),
|
||||
)
|
||||
.add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(1))
|
||||
.add_typed_default(DataType::IotaId, DataValue::SignedNumber(7)),
|
||||
};
|
||||
let preview = inspect_tu_credential(&client, &credential.to_canonical_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preview.user_id, 42);
|
||||
assert_eq!(preview.username, "alice");
|
||||
assert_eq!(preview.assigned_iota_id, Some(7));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspection_rejects_malformed_credentials_before_remote_access() {
|
||||
let client = InspectionClient {
|
||||
response: CommunicationValue::new(CommunicationType::GetUserData),
|
||||
};
|
||||
assert!(matches!(
|
||||
inspect_tu_credential(&client, "malformed").await,
|
||||
Err(LifecycleUserError::InvalidCredential(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_user_id_allocated_by_omega() {
|
||||
let client = RegistrationClient {
|
||||
|
|
@ -663,4 +827,24 @@ mod tests {
|
|||
Err(CreateUserError::InvalidResponse)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_a_user_that_omega_no_longer_has() {
|
||||
let response = Err(OmikronError::Rejected(
|
||||
CommunicationType::ErrorNotFound,
|
||||
"user does not exist".into(),
|
||||
));
|
||||
|
||||
assert_eq!(should_release_reconciled_user(response, 7), Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_a_user_when_omega_cannot_be_reached() {
|
||||
let response = Err(OmikronError::Timeout("timed out".into()));
|
||||
|
||||
assert!(matches!(
|
||||
should_release_reconciled_user(response, 7),
|
||||
Err(OmikronError::Timeout(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue