Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::auth_flow::{
};
use crate::cli::{
AuthAction, Cli, Commands, ConfigAction, DeviceAction, DeviceServiceAction, LocalConfigAction,
UserAction,
};
use crate::commands;
use crate::device::{
Expand Down Expand Up @@ -163,6 +164,68 @@ pub(crate) async fn run() -> Result<(), Box<dyn std::error::Error>> {
.await
}
},
Commands::User { action } => match action {
UserAction::Create {
username,
new_password,
} => {
let password = commands::resolve_new_user_password(new_password)?;
let create_action = UserAction::Create {
username,
new_password: Some(password),
};
run_with_auto_setup_and_login_retry(
&url,
&cfg,
cli_token_override.clone(),
cli_user_override.clone(),
cli_password_override.clone(),
"user",
|auth| async { commands::run_user(&url, auth, create_action.clone()).await },
)
.await
}
UserAction::Register {
username,
new_password,
ttl_hours,
} => {
if ttl_hours == 0 {
return Err("--ttl-hours must be greater than 0".into());
}
let password = commands::resolve_new_user_password(new_password)?;
let create_action = UserAction::Create {
username: username.clone(),
new_password: Some(password.clone()),
};
run_with_auto_setup_and_login_retry(
&url,
&cfg,
cli_token_override.clone(),
cli_user_override.clone(),
cli_password_override.clone(),
"user",
|auth| async { commands::run_user(&url, auth, create_action.clone()).await },
)
.await?;

run_auth_login(&url, &cfg, Some(username), Some(password), ttl_hours).await
}
permissions_action @ UserAction::Permissions { .. } => {
run_with_auto_setup_and_login_retry(
&url,
&cfg,
cli_token_override.clone(),
cli_user_override.clone(),
cli_password_override.clone(),
"user",
|auth| async {
commands::run_user(&url, auth, permissions_action.clone()).await
},
)
.await
}
},
Commands::Device { action } => match action {
DeviceAction::Run { id, workspace } => {
let device_id = resolve_device_id(id.clone(), &cfg);
Expand Down
160 changes: 160 additions & 0 deletions cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ pub(crate) enum Commands {
action: AuthAction,
},

/// User account and permission management
User {
#[command(subcommand)]
action: UserAction,
},

/// Run and manage the device daemon
Device {
#[command(subcommand)]
Expand Down Expand Up @@ -443,6 +449,55 @@ pub(crate) enum AuthAction {
},
}

#[derive(Subcommand, Clone)]
pub(crate) enum UserAction {
/// Create a human account
Create {
/// Username for the new account
username: String,

/// Password for the new account (if omitted, prompts interactively)
#[arg(long = "new-password")]
new_password: Option<String>,
},

/// Create a human account and log in as it
Register {
/// Username for the new account
username: String,

/// Password for the new account (if omitted, prompts interactively)
#[arg(long = "new-password")]
new_password: Option<String>,

/// Session lifetime in hours (default: 8)
#[arg(long, default_value_t = 8)]
ttl_hours: u32,
},

/// View or modify a user's capabilities and group memberships
Permissions {
/// Username to inspect or modify
username: String,

/// Capability to grant directly (repeat for multiple)
#[arg(long)]
grant: Vec<String>,

/// Direct capability to revoke (repeat for multiple)
#[arg(long)]
revoke: Vec<String>,

/// Supplementary group to add (repeat for multiple)
#[arg(long = "add-group")]
add_groups: Vec<String>,

/// Supplementary group to remove (repeat for multiple)
#[arg(long = "remove-group")]
remove_groups: Vec<String>,
},
}

#[derive(Subcommand, Clone)]
pub(crate) enum AuthTokenAction {
/// Create a new auth token
Expand Down Expand Up @@ -660,3 +715,108 @@ pub(crate) enum LocalConfigAction {
value: String,
},
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_user_create() {
let cli = Cli::try_parse_from([
"gsv",
"user",
"create",
"alice",
"--new-password",
"correct-horse",
])
.expect("user create should parse");

let Commands::User {
action:
UserAction::Create {
username,
new_password,
},
} = cli.command
else {
panic!("expected user create");
};
assert_eq!(username, "alice");
assert_eq!(new_password.as_deref(), Some("correct-horse"));
}

#[test]
fn parses_user_register_with_default_ttl() {
let cli = Cli::try_parse_from(["gsv", "user", "register", "bob"])
.expect("user register should parse");

let Commands::User {
action:
UserAction::Register {
username,
new_password,
ttl_hours,
},
} = cli.command
else {
panic!("expected user register");
};
assert_eq!(username, "bob");
assert!(new_password.is_none());
assert_eq!(ttl_hours, 8);
}

#[test]
fn parses_repeated_user_permission_changes() {
let cli = Cli::try_parse_from([
"gsv",
"user",
"permissions",
"carol",
"--grant",
"user.admin",
"--grant",
"fs.*",
"--revoke",
"shell.*",
"--add-group",
"operators",
"--add-group",
"reviewers",
"--remove-group",
"users",
])
.expect("user permissions should parse");

let Commands::User {
action:
UserAction::Permissions {
username,
grant,
revoke,
add_groups,
remove_groups,
},
} = cli.command
else {
panic!("expected user permissions");
};
assert_eq!(username, "carol");
assert_eq!(grant, ["user.admin", "fs.*"]);
assert_eq!(revoke, ["shell.*"]);
assert_eq!(add_groups, ["operators", "reviewers"]);
assert_eq!(remove_groups, ["users"]);
}

#[test]
fn user_create_requires_a_username() {
let result = Cli::try_parse_from(["gsv", "user", "create"]);
assert!(result.is_err(), "missing username should be rejected");
let error = result.err().expect("parse error should be present");
assert_eq!(
error.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
}
2 changes: 2 additions & 0 deletions cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod config;
mod infra;
mod packages;
mod proc;
mod user;

pub(crate) use adapter::run_adapter;
pub(crate) use auth::run_auth;
Expand All @@ -13,6 +14,7 @@ pub(crate) use config::run_config;
pub(crate) use infra::run_infra;
pub(crate) use packages::run_packages;
pub(crate) use proc::run_proc;
pub(crate) use user::{resolve_new_user_password, run_user};

use chrono::{TimeZone, Utc};

Expand Down
Loading
Loading