bucketwarden-cli 0.1.0

BucketWarden CLI command parsing, demos, and listener runtime.
Documentation
use crate::operator_cli::OperatorCommand;
use bucketwarden_auth::OperatorRole;
use bucketwarden_policy::PolicySimulationRequest;
use bucketwarden_server::{BucketWarden, RuntimeConfig};
use serde_json::json;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PolicyToolArgs {
    pub principal: String,
    pub action: String,
    pub resource: String,
    pub dry_run: bool,
}

pub fn parse_policy_command(args: &[String]) -> Result<OperatorCommand, String> {
    match args.first().map(String::as_str) {
        Some("explain") => Ok(OperatorCommand::PolicyExplain(parse_policy_tool_args(
            &args[1..],
            false,
            false,
        )?)),
        Some("simulate") => Ok(OperatorCommand::PolicySimulate(parse_policy_tool_args(
            &args[1..],
            true,
            true,
        )?)),
        Some("analyze") => Ok(OperatorCommand::PolicyAnalyze),
        Some(other) => Err(format!(
            "unknown policy command `{other}`; expected explain, simulate, or analyze"
        )),
        None => Err("missing policy command; expected explain, simulate, or analyze".to_string()),
    }
}

pub fn parse_auth_command(args: &[String]) -> Result<OperatorCommand, String> {
    match args {
        [family, kind] if family == "report" && kind == "identity-providers" => {
            Ok(OperatorCommand::AuthReportIdentityProviders)
        }
        [family, kind] if family == "report" && kind == "credentials" => {
            Ok(OperatorCommand::AuthReportCredentials)
        }
        [family, kind] if family == "report" && kind == "temporary-credentials" => {
            Ok(OperatorCommand::AuthReportTemporaryCredentials)
        }
        [family, command, rest @ ..] if family == "role" && command == "assign" => {
            let values = parse_flag_values(
                rest,
                &["--principal", "--role", "--scope"],
                "auth role assign",
            )?;
            let principal = take_required(&values, "--principal")?;
            let role = parse_role(&take_required(&values, "--role")?)?;
            let scope = take_required(&values, "--scope")?;
            Ok(OperatorCommand::AuthRoleAssign {
                principal,
                role,
                scope,
            })
        }
        [family, command, rest @ ..] if family == "role" && command == "list" => {
            let values = parse_flag_values(rest, &["--principal"], "auth role list")?;
            Ok(OperatorCommand::AuthRoleList {
                principal: take_required(&values, "--principal")?,
            })
        }
        [family, command, rest @ ..] if family == "key" && command == "rotate" => {
            let values = parse_flag_values(
                rest,
                &["--principal", "--old", "--new", "--secret"],
                "auth key rotate",
            )?;
            Ok(OperatorCommand::AuthKeyRotate {
                principal: take_required(&values, "--principal")?,
                old_access_key_id: take_required(&values, "--old")?,
                new_access_key_id: take_required(&values, "--new")?,
                secret_access_key: take_required(&values, "--secret")?,
            })
        }
        [family, command, rest @ ..] if family == "key" && command == "revoke" => {
            let values = parse_flag_values(rest, &["--principal", "--key"], "auth key revoke")?;
            Ok(OperatorCommand::AuthKeyRevoke {
                principal: take_required(&values, "--principal")?,
                access_key_id: take_required(&values, "--key")?,
            })
        }
        [family, command, rest @ ..] if family == "key" && command == "report-leaked" => {
            let values =
                parse_flag_values(rest, &["--principal", "--key"], "auth key report-leaked")?;
            Ok(OperatorCommand::AuthKeyReportLeaked {
                principal: take_required(&values, "--principal")?,
                access_key_id: take_required(&values, "--key")?,
            })
        }
        [family, command, ..] => Err(format!(
            "unknown auth command `{family} {command}`; expected report identity-providers/credentials/temporary-credentials, role assign/list, or key rotate/revoke/report-leaked"
        )),
        _ => Err("missing auth command; expected report identity-providers/credentials/temporary-credentials, role assign/list, or key rotate/revoke/report-leaked".to_string()),
    }
}

pub fn policy_explain_json(args: &PolicyToolArgs) -> anyhow::Result<String> {
    let runtime = seeded_policy_runtime()?;
    Ok(serde_json::to_string(&runtime.policy_explanation(
        &args.principal,
        &args.action,
        &args.resource,
    ))?)
}

pub fn policy_simulate_json(args: &PolicyToolArgs) -> anyhow::Result<String> {
    let runtime = seeded_policy_runtime()?;
    Ok(serde_json::to_string(&runtime.simulate_policy(
        PolicySimulationRequest {
            principal: args.principal.clone(),
            action: args.action.clone(),
            resource: args.resource.clone(),
            dry_run: args.dry_run,
            ..PolicySimulationRequest::default()
        },
    ))?)
}

pub fn policy_analyze_json() -> anyhow::Result<String> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.allow("*", "s3:*", "*");
    Ok(serde_json::to_string(&runtime.analyze_policy())?)
}

pub fn auth_role_assign_json(
    principal: &str,
    role: OperatorRole,
    scope: &str,
) -> anyhow::Result<String> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.create_local_user("operator");
    runtime.create_local_user(principal);
    let assignment = runtime.assign_operator_role("operator", principal, role, scope)?;
    Ok(serde_json::to_string(&assignment)?)
}

pub fn auth_report_identity_providers_json() -> anyhow::Result<String> {
    let runtime = BucketWarden::new(RuntimeConfig::development())?;
    Ok(serde_json::to_string(
        &runtime.identity_provider_support_report(),
    )?)
}

pub fn auth_report_credentials_json() -> anyhow::Result<String> {
    let runtime = BucketWarden::new(RuntimeConfig::development())?;
    Ok(serde_json::to_string(&runtime.credential_support_report())?)
}

pub fn auth_report_temporary_credentials_json() -> anyhow::Result<String> {
    let runtime = BucketWarden::new(RuntimeConfig::development())?;
    Ok(serde_json::to_string(
        &runtime.temporary_credential_support_report(),
    )?)
}

pub fn auth_role_list_json(principal: &str) -> anyhow::Result<String> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.create_local_user("operator");
    runtime.create_local_user(principal);
    runtime.assign_operator_role("operator", principal, OperatorRole::ReadOnlyOperator, "*")?;
    Ok(serde_json::to_string(
        &runtime.operator_role_assignments(principal),
    )?)
}

pub fn auth_key_rotate_json(
    principal: &str,
    old_access_key_id: &str,
    new_access_key_id: &str,
    secret_access_key: &str,
) -> anyhow::Result<String> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.create_local_user(principal);
    runtime.create_access_key(principal, old_access_key_id, "old-secret")?;
    let rotation = runtime.rotate_access_key(
        "operator",
        old_access_key_id,
        new_access_key_id,
        secret_access_key,
    )?;
    Ok(serde_json::to_string(&rotation)?)
}

pub fn auth_key_revoke_json(principal: &str, access_key_id: &str) -> anyhow::Result<String> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.create_local_user(principal);
    runtime.create_access_key(principal, access_key_id, "secret")?;
    runtime.revoke_credential(access_key_id)?;
    Ok(serde_json::to_string(&json!({
        "access_key_id": access_key_id,
        "principal_id": principal,
        "revoked": true
    }))?)
}

pub fn auth_key_report_leaked_json(principal: &str, access_key_id: &str) -> anyhow::Result<String> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.create_local_user(principal);
    runtime.create_access_key(principal, access_key_id, "secret")?;
    let response = runtime.report_leaked_access_key("operator", access_key_id)?;
    Ok(serde_json::to_string(&response)?)
}

fn seeded_policy_runtime() -> anyhow::Result<BucketWarden> {
    let mut runtime = BucketWarden::new(RuntimeConfig::development())?;
    runtime.allow("alice", "s3:GetObject", "archive/*");
    runtime.deny("alice", "s3:GetObject", "archive/private/*");
    runtime.allow("operator", "s3:*", "*");
    Ok(runtime)
}

fn parse_policy_tool_args(
    args: &[String],
    default_dry_run: bool,
    allow_dry_run: bool,
) -> Result<PolicyToolArgs, String> {
    let mut principal = None;
    let mut action = None;
    let mut resource = None;
    let mut dry_run = default_dry_run;
    let mut index = 0;

    while index < args.len() {
        match args[index].as_str() {
            "--principal" => {
                index += 1;
                principal = Some(required_value(args, index, "--principal")?);
            }
            "--action" => {
                index += 1;
                action = Some(required_value(args, index, "--action")?);
            }
            "--resource" => {
                index += 1;
                resource = Some(required_value(args, index, "--resource")?);
            }
            "--dry-run" if allow_dry_run => {
                dry_run = true;
            }
            "--dry-run" => return Err("unknown policy explain flag `--dry-run`".to_string()),
            other => return Err(format!("unknown policy flag `{other}`")),
        }
        index += 1;
    }

    Ok(PolicyToolArgs {
        principal: principal.ok_or_else(|| "missing required `--principal <value>`".to_string())?,
        action: action.ok_or_else(|| "missing required `--action <value>`".to_string())?,
        resource: resource.ok_or_else(|| "missing required `--resource <value>`".to_string())?,
        dry_run,
    })
}

fn parse_flag_values<'a>(
    args: &[String],
    allowed_flags: &'a [&'a str],
    command_name: &str,
) -> Result<Vec<(&'a str, String)>, String> {
    let mut values = Vec::new();
    let mut index = 0;

    while index < args.len() {
        let flag = args[index].as_str();
        let Some(&known_flag) = allowed_flags.iter().find(|known| **known == flag) else {
            return Err(format!("unknown {command_name} flag `{flag}`"));
        };
        index += 1;
        values.push((known_flag, required_value(args, index, known_flag)?));
        index += 1;
    }

    Ok(values)
}

fn take_required(values: &[(&str, String)], flag: &str) -> Result<String, String> {
    values
        .iter()
        .find(|(known_flag, _)| *known_flag == flag)
        .map(|(_, value)| value.clone())
        .ok_or_else(|| format!("missing required `{flag} <value>`"))
}

fn required_value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
    let Some(value) = args.get(index) else {
        return Err(format!("missing value for `{flag}`"));
    };
    if value.starts_with("--") {
        return Err(format!("missing value for `{flag}`"));
    }
    Ok(value.clone())
}

fn parse_role(value: &str) -> Result<OperatorRole, String> {
    match value {
        "cluster-admin" => Ok(OperatorRole::ClusterAdmin),
        "tenant-admin" => Ok(OperatorRole::TenantAdmin),
        "bucket-admin" => Ok(OperatorRole::BucketAdmin),
        "auditor" => Ok(OperatorRole::Auditor),
        "read-only-operator" => Ok(OperatorRole::ReadOnlyOperator),
        "security-officer" => Ok(OperatorRole::SecurityOfficer),
        other => Err(format!(
            "unknown operator role `{other}`; expected cluster-admin, tenant-admin, bucket-admin, auditor, read-only-operator, or security-officer"
        )),
    }
}

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

    #[test]
    fn auth_report_json_surfaces_existing_support_contracts() {
        let identity = auth_report_identity_providers_json().expect("identity providers");
        let credentials = auth_report_credentials_json().expect("credentials");
        let temporary = auth_report_temporary_credentials_json().expect("temporary credentials");

        assert!(identity.contains("oidc"));
        assert!(identity.contains("assume_role_with_web_identity"));
        assert!(credentials.contains("rotate_access_key"));
        assert!(credentials.contains("temporary-credentials"));
        assert!(temporary.contains("session scope is explicit and non-expanding"));
        assert!(temporary.contains("assume_role_with_web_identity"));
    }
}