silicon-iam-cli 1.2.0

Command-line client for Silicon IAM, built on the silicon-iam-client crate.
//! Members of an organization.

use silicon_iam_client::{api::members::MemberFilter, models};

use crate::{
    cli::MemberCommand,
    context::Context,
    error::Result,
    output::{Format, Table, json, label, next_cursor, or_dash, plain},
};

/// Runs a member command.
///
/// # Errors
///
/// Returns whatever the service reports.
#[allow(
    clippy::too_many_lines,
    reason = "one arm per verb; splitting the match would only move it elsewhere"
)]
pub async fn run(context: &Context, command: MemberCommand) -> Result<()> {
    let client = context.authenticated().await?;
    let org = context.organization()?;
    match command {
        MemberCommand::List {
            principal_type,
            tag,
            status,
            page,
        } => {
            let listed = client
                .members()
                .list(
                    org,
                    &MemberFilter {
                        principal_type,
                        tag_id: tag,
                        status,
                    },
                    &page.paging(),
                )
                .await?;
            match context.format {
                Format::Json => json(&listed),
                Format::Text => {
                    let mut table =
                        Table::new(["membership", "principal", "type", "role", "status", "tags"]);
                    for member in &listed.items {
                        table.row([
                            member.id.to_string(),
                            member.principal.public_id.clone(),
                            label(&member.principal.type_field),
                            label(&member.org_role),
                            label(&member.status),
                            member
                                .tags
                                .iter()
                                .map(|tag| tag.name.clone())
                                .collect::<Vec<_>>()
                                .join(","),
                        ]);
                    }
                    table.print();
                    next_cursor(listed.page.has_more, listed.page.next_cursor.as_deref());
                    Ok(())
                }
            }
        }
        MemberCommand::Show { membership_id } => {
            let member = client.members().get(org, membership_id).await?;
            report(context, &member)
        }
        MemberCommand::Authorization { membership_id } => {
            let authorization = client.members().authorization(org, membership_id).await?;
            match context.format {
                Format::Json => json(&authorization),
                Format::Text => {
                    let mut table = Table::new(["field", "value"]);
                    table.row(["membership", &authorization.membership_id.to_string()]);
                    table.row(["role", &label(&authorization.org_role)]);
                    table.row([
                        "capabilities",
                        &authorization
                            .capabilities
                            .iter()
                            .map(label)
                            .collect::<Vec<_>>()
                            .join(", "),
                    ]);
                    table.row(["epoch", &authorization.authorization_epoch.to_string()]);
                    table.print();
                    Ok(())
                }
            }
        }
        MemberCommand::Update {
            membership_id,
            first_silicon,
            clear_first_silicon,
            reports_to,
            clear_reports_to,
            profile_photo,
            clear_profile_photo,
        } => {
            let current = client.members().get(org, membership_id).await?;
            let updated = client
                .members()
                .update(
                    org,
                    membership_id,
                    current.version,
                    &models::MembershipDirectoryPatch {
                        first_silicon_membership_id: nullable_patch(
                            first_silicon,
                            clear_first_silicon,
                        ),
                        extra_silicon_membership_ids: None,
                        default_trust: None,
                        reports_to_membership_id: nullable_patch(reports_to, clear_reports_to),
                        profile_photo: nullable_patch(profile_photo, clear_profile_photo),
                    },
                    &context.mutation(),
                )
                .await?;
            report(context, &updated)
        }
        MemberCommand::Remove {
            membership_id,
            reassign_reports_to,
        } => {
            let current = client.members().get(org, membership_id).await?;
            client
                .members()
                .remove(
                    org,
                    membership_id,
                    current.version,
                    reassign_reports_to,
                    &context.mutation(),
                )
                .await?;
            println!("Removed {}.", current.principal.public_id);
            Ok(())
        }
        MemberCommand::Promote { membership_id } => {
            let current = client.members().get(org, membership_id).await?;
            let authorization = client
                .members()
                .promote_admin(org, membership_id, current.version, &context.mutation())
                .await?;
            json_or_line(context, &authorization, "Promoted to administrator.")
        }
        MemberCommand::Demote { membership_id } => {
            let current = client.members().get(org, membership_id).await?;
            let authorization = client
                .members()
                .demote_admin(org, membership_id, current.version, &context.mutation())
                .await?;
            json_or_line(context, &authorization, "Demoted to member.")
        }
        MemberCommand::Capabilities {
            membership_id,
            capabilities,
        } => {
            let current = client.members().get(org, membership_id).await?;
            let updated = client
                .members()
                .replace_capabilities(
                    org,
                    membership_id,
                    current.version,
                    &models::OrganizationCapabilitiesReplace {
                        capabilities: capabilities.into_iter().map(capability).collect(),
                    },
                    &context.mutation(),
                )
                .await?;
            json_or_line(context, &updated, "Capabilities replaced.")
        }
        MemberCommand::Directory { fields, page } => {
            let listed = client
                .members()
                .directory(org, fields.as_deref(), &page.paging())
                .await?;
            match context.format {
                Format::Json => json(&listed),
                Format::Text => {
                    let fields = directory_fields(fields.as_deref());
                    let mut table = Table::new(fields.iter().copied());
                    for entry in &listed.items {
                        table.row(fields.iter().map(|field| directory_value(entry, field)));
                    }
                    table.print();
                    next_cursor(listed.page.has_more, listed.page.next_cursor.as_deref());
                    Ok(())
                }
            }
        }
        MemberCommand::Self_ { fields } => {
            let entry = client
                .members()
                .directory_self(org, fields.as_deref())
                .await?;
            report_directory_member(context, &entry, fields.as_deref())
        }
        MemberCommand::DirectoryMember {
            membership_id,
            fields,
        } => {
            let entry = client
                .members()
                .directory_member(org, membership_id, fields.as_deref())
                .await?;
            report_directory_member(context, &entry, fields.as_deref())
        }
    }
}

fn report_directory_member(
    context: &Context,
    entry: &models::DirectoryMember,
    fields: Option<&str>,
) -> Result<()> {
    match context.format {
        Format::Json => json(entry),
        Format::Text => {
            let mut table = Table::new(["field", "value"]);
            for field in directory_fields(fields) {
                table.row([field.to_owned(), directory_value(entry, field)]);
            }
            table.print();
            Ok(())
        }
    }
}

const DIRECTORY_FIELDS: [&str; 6] = ["name", "id", "role", "org", "tags", "trust"];

fn directory_fields(fields: Option<&str>) -> Vec<&str> {
    fields.map_or_else(
        || DIRECTORY_FIELDS.to_vec(),
        |fields| fields.split(',').collect(),
    )
}

fn directory_value(entry: &models::DirectoryMember, field: &str) -> String {
    match field {
        "name" => or_dash(entry.name.as_deref()),
        "id" => or_dash(entry.id.as_deref()),
        "role" => entry.role.as_ref().map_or_else(
            || "-".to_owned(),
            |role| {
                if role.job_role.is_empty() {
                    label(&role.org_role)
                } else {
                    format!("{} ({})", label(&role.org_role), role.job_role)
                }
            },
        ),
        "org" => entry.org.as_ref().map_or_else(
            || "-".to_owned(),
            |organization| format!("{} ({})", organization.id, organization.name),
        ),
        "tags" => entry.tags.as_ref().map_or_else(
            || "-".to_owned(),
            |tags| {
                let rendered = tags
                    .iter()
                    .map(|tag| tag.name.clone())
                    .collect::<Vec<_>>()
                    .join(", ");
                if rendered.is_empty() {
                    "-".to_owned()
                } else {
                    rendered
                }
            },
        ),
        "trust" => entry.trust.as_ref().map_or_else(|| "-".to_owned(), plain),
        _ => "-".to_owned(),
    }
}

#[allow(
    clippy::option_option,
    reason = "JSON Merge Patch has distinct omitted, null, and value states"
)]
fn nullable_patch<T>(value: Option<T>, clear: bool) -> Option<Option<T>> {
    if clear { Some(None) } else { value.map(Some) }
}

/// Maps a capability name onto the contract's closed vocabulary, keeping
/// anything unrecognized verbatim so the service rejects it by name.
fn capability(name: String) -> models::OrganizationCapability {
    serde_json::from_value(serde_json::Value::String(name.clone()))
        .unwrap_or(models::OrganizationCapability::Other(name))
}

fn report(context: &Context, member: &models::Membership) -> Result<()> {
    match context.format {
        Format::Json => json(member),
        Format::Text => {
            let mut table = Table::new(["field", "value"]);
            table.row(["membership", &member.id.to_string()]);
            table.row(["principal", &member.principal.public_id]);
            table.row(["role", &label(&member.org_role)]);
            table.row(["job_role", &member.job_role]);
            table.row(["status", &label(&member.status)]);
            table.row([
                "tags",
                &member
                    .tags
                    .iter()
                    .map(|tag| tag.name.clone())
                    .collect::<Vec<_>>()
                    .join(", "),
            ]);
            table.row(["version", &member.version.to_string()]);
            table.print();
            Ok(())
        }
    }
}

fn json_or_line<T: serde::Serialize>(context: &Context, value: &T, line: &str) -> Result<()> {
    match context.format {
        Format::Json => json(value),
        Format::Text => {
            println!("{line}");
            Ok(())
        }
    }
}