pic admins create/set --password and pic login --token accepted secrets on argv, where they leak into shell history, ps aux, and /proc/<pid>/cmdline. Now only --password -/--token - (stdin) is allowed; an inline value is rejected with guidance. PICLOUD_TOKEN env still covers CI for login. Also (M2): the interactive admins password prompt used read_line, which echoes despite a 'no echo' claim — switched to rpassword::prompt_password (already a dep via login). Removed the now-dead ReadLine trait + Write import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
5.3 KiB
Rust
171 lines
5.3 KiB
Rust
//! `pic admins` subcommands: `ls`, `create`, `show`, `set`, `rm`.
|
|
//!
|
|
//! All endpoints require `Capability::InstanceManageUsers` (owner /
|
|
//! admin). Member accounts get a 403.
|
|
|
|
use std::io::{IsTerminal, Read};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use picloud_shared::InstanceRole;
|
|
|
|
use crate::client::{AdminDto, Client, CreateAdminBody, PatchAdminBody};
|
|
use crate::config;
|
|
use crate::output::{KvBlock, OutputMode, Table};
|
|
|
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let admins = client.admins_list().await?;
|
|
let mut table = Table::new([
|
|
"id",
|
|
"username",
|
|
"role",
|
|
"active",
|
|
"email",
|
|
"created_at",
|
|
"last_login_at",
|
|
]);
|
|
for a in admins {
|
|
table.row([
|
|
a.id.to_string(),
|
|
a.username,
|
|
a.instance_role.as_str().to_string(),
|
|
a.is_active.to_string(),
|
|
a.email.unwrap_or_else(|| "-".into()),
|
|
a.created_at.to_rfc3339(),
|
|
a.last_login_at
|
|
.map(|t| t.to_rfc3339())
|
|
.unwrap_or_else(|| "-".into()),
|
|
]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the password the user wants to set:
|
|
/// * `Some("-")` → read from stdin (no echo when interactive).
|
|
/// * `Some(other)` → **rejected**. Audit 2026-06-11 (H2) — an inline
|
|
/// password lands in shell history, `ps aux`, and
|
|
/// `/proc/<pid>/cmdline`, readable by any other UID on the host
|
|
/// while the process runs.
|
|
/// * `None` → require explicit `--password`.
|
|
fn resolve_password(password_arg: Option<&str>) -> Result<String> {
|
|
match password_arg {
|
|
None => Err(anyhow!(
|
|
"missing --password; pass --password - to read the password from stdin"
|
|
)),
|
|
Some("-") => read_password_from_stdin(),
|
|
Some(_) => Err(anyhow!(
|
|
"passing the password inline is not allowed — it leaks into shell history, \
|
|
`ps aux`, and /proc/<pid>/cmdline. Use `--password -` to read it from stdin \
|
|
(interactive prompt, or piped: `printf %s \"$PW\" | pic admins create … --password -`)."
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn read_password_from_stdin() -> Result<String> {
|
|
let stdin = std::io::stdin();
|
|
if stdin.is_terminal() {
|
|
// True no-echo prompt. Audit 2026-06-11 (M2) — the previous
|
|
// `read_line` path echoed the password despite a "no echo"
|
|
// claim; `rpassword` is already a dependency via the login flow.
|
|
rpassword::prompt_password("Password: ").context("read password from stdin")
|
|
} else {
|
|
// Piped input — read everything, strip trailing newline.
|
|
let mut buf = String::new();
|
|
stdin
|
|
.lock()
|
|
.read_to_string(&mut buf)
|
|
.context("read password from stdin")?;
|
|
Ok(buf.trim_end_matches(['\n', '\r']).to_string())
|
|
}
|
|
}
|
|
|
|
pub async fn create(
|
|
username: &str,
|
|
password: Option<&str>,
|
|
role: InstanceRole,
|
|
email: Option<&str>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let pw = resolve_password(password)?;
|
|
if pw.is_empty() {
|
|
return Err(anyhow!("password must not be empty"));
|
|
}
|
|
let body = CreateAdminBody {
|
|
username,
|
|
password: &pw,
|
|
instance_role: role,
|
|
email,
|
|
};
|
|
let created = client.admins_create(&body).await?;
|
|
print_admin(&created, mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn show(id: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let a = client.admins_get(id).await?;
|
|
print_admin(&a, mode);
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn set(
|
|
id: &str,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
active: Option<bool>,
|
|
role: Option<InstanceRole>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
// Only resolve the password if --password was passed.
|
|
let pw_owned;
|
|
let password_ref: Option<&str> = if let Some(raw) = password {
|
|
pw_owned = resolve_password(Some(raw))?;
|
|
Some(pw_owned.as_str())
|
|
} else {
|
|
None
|
|
};
|
|
let body = PatchAdminBody {
|
|
username,
|
|
password: password_ref,
|
|
is_active: active,
|
|
instance_role: role,
|
|
};
|
|
let updated = client.admins_patch(id, &body).await?;
|
|
print_admin(&updated, mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rm(id: &str) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
client.admins_delete(id).await?;
|
|
println!("Deleted admin {id}");
|
|
Ok(())
|
|
}
|
|
|
|
fn print_admin(a: &AdminDto, mode: OutputMode) {
|
|
let mut block = KvBlock::new();
|
|
block
|
|
.field("id", a.id.to_string())
|
|
.field("username", a.username.clone())
|
|
.field("instance_role", a.instance_role.as_str().to_string())
|
|
.field("is_active", a.is_active.to_string())
|
|
.field("email", a.email.clone().unwrap_or_else(|| "-".into()))
|
|
.field("created_at", a.created_at.to_rfc3339())
|
|
.field(
|
|
"last_login_at",
|
|
a.last_login_at
|
|
.map(|t| t.to_rfc3339())
|
|
.unwrap_or_else(|| "-".into()),
|
|
);
|
|
block.print(mode);
|
|
}
|