[Fix] User deletion & migration

This commit is contained in:
Alex 2026-08-09 02:51:47 +02:00
commit 7dc98ef29b
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
20 changed files with 742 additions and 129 deletions

View file

@ -11,6 +11,7 @@ iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util" }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1"

View file

@ -1,6 +1,7 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig};
use iota_terms::TermsType;
use std::path::PathBuf;
#[derive(Debug)]
pub struct CliInvocation {
@ -115,15 +116,33 @@ enum UsersAction {
user_id: i64,
},
Add {
username: String,
username: Option<String>,
#[arg(long, value_name = "PATH")]
tu: Option<PathBuf>,
},
Remove {
Release {
user_id: i64,
#[arg(long)]
yes: bool,
},
Import {
username: String,
Data {
#[command(subcommand)]
action: UserDataAction,
},
CompleteDelete {
user_id: i64,
#[arg(long, value_name = "PATH")]
tu: Option<PathBuf>,
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand, Debug)]
enum UserDataAction {
Purge {
user_id: i64,
#[arg(long)]
yes: bool,
},
}
#[derive(Args, Debug)]
@ -264,14 +283,21 @@ pub enum Command {
user_id: i64,
},
UsersAdd {
username: String,
username: Option<String>,
tu: Option<PathBuf>,
},
UsersRemove {
UsersRelease {
user_id: i64,
confirmed: bool,
},
UsersImport {
username: String,
UsersPurgeData {
user_id: i64,
confirmed: bool,
},
UsersCompleteDelete {
user_id: i64,
tu: Option<PathBuf>,
confirmed: bool,
},
OmikronReconnect,
IdentityRotate {
@ -367,12 +393,29 @@ impl CliInvocation {
Some(CliCommand::Users(users)) => match users.action {
UsersAction::List => Command::UsersList,
UsersAction::Show { user_id } => Command::UsersShow { user_id },
UsersAction::Add { username } => Command::UsersAdd { username },
UsersAction::Remove { user_id, yes } => Command::UsersRemove {
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 {
user_id,
confirmed: resolve_confirmed(yes),
},
UsersAction::Import { username } => Command::UsersImport { username },
UsersAction::Data {
action: UserDataAction::Purge { user_id, yes },
} => Command::UsersPurgeData {
user_id,
confirmed: resolve_confirmed(yes),
},
UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete {
user_id,
tu,
confirmed: resolve_confirmed(yes),
},
},
Some(CliCommand::Omikron(omikron)) => match omikron.action {
OmikronAction::Reconnect => Command::OmikronReconnect,
@ -445,6 +488,7 @@ impl CliInvocation {
}
}
#[allow(unused)]
pub fn help_text() -> String {
Cli::command().render_long_help().to_string()
}
@ -528,7 +572,7 @@ mod tests {
#[test]
fn command_schema_drives_help_and_completion_paths() {
let paths = CliInvocation::command_paths();
assert!(paths.contains(&"users remove".to_owned()));
assert!(paths.contains(&"users release".to_owned()));
assert!(paths.contains(&"daemon install".to_owned()));
let help = CliInvocation::help_text();
assert!(help.contains("users"));
@ -558,12 +602,7 @@ mod tests {
#[test]
fn parses_unconfirmed_destructive_commands_explicitly() {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
assert_eq!(
invocation.command,
Command::DaemonStop {
confirmed: true
}
);
assert_eq!(invocation.command, Command::DaemonStop { confirmed: true });
}
#[test]
@ -573,7 +612,8 @@ mod tests {
assert_eq!(
invocation.command,
Command::UsersAdd {
username: "alice".into()
username: Some("alice".into()),
tu: None,
}
);
}
@ -597,26 +637,24 @@ mod tests {
}
#[test]
fn parses_users_remove_without_confirmation() {
let invocation =
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap();
assert_eq!(
invocation.command,
Command::UsersRemove {
user_id: 42,
confirmed: true,
}
);
fn rejects_ambiguous_users_remove() {
let error =
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err();
assert!(error.contains("remove"));
}
#[test]
fn parses_users_remove_with_confirmation() {
let invocation =
CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()])
.unwrap();
fn parses_users_release_with_confirmation() {
let invocation = CliInvocation::parse([
"users".into(),
"release".into(),
"42".into(),
"--yes".into(),
])
.unwrap();
assert_eq!(
invocation.command,
Command::UsersRemove {
Command::UsersRelease {
user_id: 42,
confirmed: true,
}

View file

@ -403,7 +403,9 @@ fn print_help() {
println!(" users list List all users");
println!(" users show <ID> Show user details");
println!(" users add <NAME> Create a new user");
println!(" users remove <ID> Remove a user (requires --yes)");
println!(" users add --tu <PATH> Add an existing account credential");
println!(" users data purge <ID> Purge hosted data (requires --yes)");
println!(" users release <ID> Release this Iota (requires --yes)");
println!(" omikron status Show Omikron connection status");
println!(" omikron reconnect Reconnect to Omikron");
println!(" identity rotate Rotate identity keys (requires --yes)");
@ -451,7 +453,7 @@ fn print_help() {
println!(" iota status Show daemon status");
println!(" iota users list --output=json List users in JSON format");
println!(" iota users add alice Create a user named 'alice'");
println!(" iota users remove 42 --yes Remove user 42");
println!(" iota users data purge 42 --yes Purge hosted data");
println!(" iota config get --output=yaml Show config in YAML format");
println!(" iota logs --limit 50 Show last 50 log entries");
println!(" iota completions bash Generate bash completions");
@ -564,12 +566,40 @@ async fn run_command(
Command::Tasks => LocalRequest::ListTasks,
Command::UsersList => LocalRequest::ListUsers,
Command::UsersShow { user_id } => LocalRequest::GetUser { user_id },
Command::UsersAdd { username } => LocalRequest::CreateUser { username },
Command::UsersRemove {
Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username },
Command::UsersAdd { username: None, tu: Some(path) } => {
let contents = std::fs::read_to_string(&path)
.map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?;
iota_util::tu::TuCredential::parse(&contents)
.map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?;
LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) }
}
Command::UsersAdd { .. } => {
return Err(StartupError::InvalidCommand(
"users add requires exactly one of <username> or --tu <PATH>".into(),
));
}
Command::UsersRelease {
user_id,
confirmed: true,
} => LocalRequest::RemoveUser { user_id },
Command::UsersImport { username } => LocalRequest::ImportUser { username },
} => LocalRequest::ReleaseUser { user_id },
Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id },
Command::UsersCompleteDelete { user_id, tu, confirmed: true } => {
let credential = match tu {
Some(path) => {
let contents = std::fs::read_to_string(&path)
.map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?;
let parsed = iota_util::tu::TuCredential::parse(&contents)
.map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?;
if parsed.user_id != user_id {
return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into()));
}
Some(iota_ipc::SecretString(contents))
}
None => None,
};
LocalRequest::CompleteDeleteUser { user_id, credential }
}
Command::OmikronReconnect => LocalRequest::ReconnectOmikron,
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity,
@ -588,9 +618,11 @@ async fn run_command(
Command::Logs { limit } => LocalRequest::GetLogs { limit },
Command::UpdateCheck => LocalRequest::CheckUpdate,
Command::CommunityList => LocalRequest::ListCommunities,
Command::UsersRemove {
Command::UsersRelease {
confirmed: false, ..
}
| Command::UsersPurgeData { confirmed: false, .. }
| Command::UsersCompleteDelete { confirmed: false, .. }
| Command::IdentityRotate { confirmed: false }
| Command::RegenerateKeys { confirmed: false }
| Command::DaemonRestart { confirmed: false }
@ -689,6 +721,9 @@ async fn run_command(
user_id
);
}
ResponsePayload::UserDataPurged { user_id } => {
println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id);
}
ResponsePayload::Acknowledged { message } => {
println!("{}", message);
}
@ -935,6 +970,9 @@ fn render_table(payload: &ResponsePayload) {
ResponsePayload::UserRemoved { user_id } => {
println!("Removed user {}", user_id);
}
ResponsePayload::UserDataPurged { user_id } => {
println!("Purged hosted data for {}", user_id);
}
ResponsePayload::Acknowledged { message } => {
println!("{}", message);
}