use anyhow::{ensure, Context, Result};
use azure_pim_cli::{
check_latest_version,
interactive::{interactive_ui, Selected},
logging::{setup_logging, Verbosity},
models::{
assignments::Assignment,
roles::{Role, RoleAssignment, RolesExt},
scope::{Scope, ScopeBuilder},
},
ListFilter, PimClient,
};
use clap::{Command, CommandFactory, Parser, Subcommand, ValueHint};
use clap_complete::{generate, Shell};
use humantime::Duration as HumanDuration;
use serde::{Deserialize, Serialize};
use std::{
cmp::min,
collections::BTreeSet,
error::Error,
fmt::Write,
fs::{read, File},
io::stdout,
path::PathBuf,
str::FromStr,
time::Duration,
};
use tracing::{debug, info};
const DEFAULT_DURATION: &str = "8 hours";
#[derive(Parser)]
#[command(version, disable_help_subcommand = true, name = "az-pim")]
struct Cmd {
#[command(flatten)]
verbose: Verbosity,
#[clap(subcommand)]
command: SubCommand,
}
impl Cmd {
fn shell_completion(shell: Shell) {
let mut cmd = Self::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut stdout());
}
fn example(cmd: &str) -> Option<&'static str> {
match cmd {
"az-pim"
| "az-pim activate interactive"
| "az-pim activate"
| "az-pim cleanup all"
| "az-pim cleanup auto"
| "az-pim cleanup orphaned-assignments"
| "az-pim cleanup orphaned-eligible-assignments"
| "az-pim cleanup"
| "az-pim deactivate interactive"
| "az-pim deactivate"
| "az-pim delete interactive"
| "az-pim delete orphaned-entries"
| "az-pim delete role <ROLE> <SCOPE>"
| "az-pim delete set"
| "az-pim delete"
| "az-pim role assignment"
| "az-pim role definition"
| "az-pim role resources"
| "az-pim role" => None,
"az-pim activate role <ROLE> <JUSTIFICATION>" => {
Some(include_str!("../help/az-pim-activate-role.txt"))
}
"az-pim activate set <JUSTIFICATION>" => {
Some(include_str!("../help/az-pim-activate-set.txt"))
}
"az-pim deactivate role <ROLE>" => {
Some(include_str!("../help/az-pim-deactivate-role.txt"))
}
"az-pim deactivate set" => Some(include_str!("../help/az-pim-deactivate-set.txt")),
"az-pim init <SHELL>" => Some(include_str!("../help/az-pim-init.txt")),
"az-pim list" => Some(include_str!("../help/az-pim-list.txt")),
"az-pim role assignment delete-orphaned-entries" => Some(include_str!(
"../help/az-pim-role-assignment-delete-orphan-entries.txt"
)),
"az-pim role assignment delete-set <CONFIG>" => Some(include_str!(
"../help/az-pim-role-assignment-delete-set.txt"
)),
"az-pim role assignment delete <ASSIGNMENT_NAME>" => {
Some(include_str!("../help/az-pim-role-assignment-delete.txt"))
}
"az-pim role assignment list" => {
Some(include_str!("../help/az-pim-role-assignment-list.txt"))
}
"az-pim role definition list" => {
Some(include_str!("../help/az-pim-role-definition-list.txt"))
}
"az-pim role resources list" => {
Some(include_str!("../help/az-pim-role-resources-list.txt"))
}
unsupported => unimplemented!("unable to generate example for {unsupported}"),
}
}
}
#[derive(Subcommand)]
enum SubCommand {
List {
#[clap(long)]
active: bool,
#[clap(long, default_value_t = ListFilter::AsTarget)]
filter: ListFilter,
#[clap(flatten)]
scope: ScopeBuilder,
},
Activate {
#[clap(subcommand)]
cmd: ActivateSubCommand,
},
Deactivate {
#[clap(subcommand)]
cmd: DeactivateSubCommand,
},
Role {
#[clap(subcommand)]
cmd: RoleSubCommand,
},
Cleanup {
#[clap(subcommand)]
cmd: CleanupSubCommand,
},
Init { shell: Shell },
#[command(hide = true)]
Readme,
}
#[derive(Subcommand)]
enum ActivateSubCommand {
Role {
role: Role,
justification: String,
#[clap(long, default_value = DEFAULT_DURATION)]
duration: HumanDuration,
#[clap(long)]
wait: Option<HumanDuration>,
#[clap(flatten)]
scope: ScopeBuilder,
},
Set {
justification: String,
#[clap(long, default_value = DEFAULT_DURATION)]
duration: HumanDuration,
#[clap(long, value_hint = ValueHint::FilePath)]
config: Option<PathBuf>,
#[clap(
long,
conflicts_with = "config",
value_name = "ROLE=SCOPE",
value_parser = parse_key_val::<Role, Scope>,
action = clap::ArgAction::Append
)]
role: Option<Vec<(Role, Scope)>>,
#[clap(long)]
wait: Option<HumanDuration>,
},
Interactive {
#[clap(long)]
justification: Option<String>,
#[clap(long, default_value = DEFAULT_DURATION)]
duration: HumanDuration,
#[clap(long)]
wait: Option<HumanDuration>,
},
}
impl ActivateSubCommand {
async fn run(self, client: &PimClient) -> Result<()> {
match self {
Self::Role {
role,
justification,
duration,
wait,
scope,
} => {
let roles = client
.list_eligible_role_assignments(None, Some(ListFilter::AsTarget))
.await
.context("unable to list eligible assignments")?;
let scope = scope.build().context("valid scope must be provided")?;
let entry = roles
.find_role(&role, &scope)
.with_context(|| format!("role not found ({role:?} {scope:?})"))?;
client
.activate_role_assignment(&entry, &justification, duration.into())
.await?;
if let Some(wait) = wait {
let assignments = [entry].into();
client
.wait_for_role_activation(&assignments, wait.into())
.await?;
}
}
Self::Set {
config,
role,
justification,
duration,
wait,
} => {
let set = build_set(client, config, role, false).await?;
ensure!(!set.is_empty(), "no roles to activate");
client
.activate_role_assignment_set(&set, &justification, duration.into())
.await?;
if let Some(wait) = wait {
client.wait_for_role_activation(&set, wait.into()).await?;
}
}
Self::Interactive {
justification,
duration,
wait,
} => {
let roles = client
.list_eligible_role_assignments(None, Some(ListFilter::AsTarget))
.await?;
if let Some(Selected {
assignments,
justification,
duration,
}) = interactive_ui(
roles,
Some(justification.unwrap_or_default()),
Some(duration.as_secs() / 60),
)? {
let duration = Duration::from_mins(duration);
client
.activate_role_assignment_set(&assignments, &justification, duration)
.await?;
if let Some(wait) = wait {
client
.wait_for_role_activation(&assignments, wait.into())
.await?;
}
}
}
}
Ok(())
}
}
#[derive(Subcommand)]
enum DeactivateSubCommand {
Role {
role: Role,
#[clap(flatten)]
scope: ScopeBuilder,
},
Set {
#[clap(long, value_hint = ValueHint::FilePath)]
config: Option<PathBuf>,
#[clap(
long,
conflicts_with = "config",
value_name = "ROLE=SCOPE",
value_parser = parse_key_val::<Role, Scope>,
action = clap::ArgAction::Append
)]
role: Option<Vec<(Role, Scope)>>,
},
Interactive {},
}
impl DeactivateSubCommand {
async fn run(self, client: &PimClient) -> Result<()> {
match self {
Self::Role { role, scope } => {
let scope = scope.build().context("valid scope must be provided")?;
let roles = client
.list_active_role_assignments(None, Some(ListFilter::AsTarget))
.await
.context("unable to list active assignments")?;
let entry = roles.find_role(&role, &scope).context("role not found")?;
client.deactivate_role_assignment(&entry).await?;
}
Self::Set { config, role } => {
let set = build_set(client, config, role, true).await?;
client.deactivate_role_assignment_set(&set).await?;
}
Self::Interactive {} => {
let roles = client
.list_active_role_assignments(None, Some(ListFilter::AsTarget))
.await?;
if let Some(Selected { assignments, .. }) = interactive_ui(roles, None, None)? {
client.deactivate_role_assignment_set(&assignments).await?;
}
}
}
Ok(())
}
}
#[derive(Subcommand)]
enum RoleSubCommand {
Assignment {
#[clap(subcommand)]
cmd: AssignmentSubCommand,
},
Definition {
#[clap(subcommand)]
cmd: DefinitionSubCommand,
},
Resources {
#[clap(subcommand)]
cmd: ResourcesSubCommand,
},
}
#[derive(Subcommand)]
enum AssignmentSubCommand {
List {
#[clap(flatten)]
scope: ScopeBuilder,
},
Delete {
assignment_name: String,
#[clap(flatten)]
scope: ScopeBuilder,
},
DeleteSet {
#[clap(value_hint = ValueHint::FilePath)]
config: PathBuf,
},
}
impl AssignmentSubCommand {
async fn run(self, client: &PimClient) -> Result<()> {
match self {
Self::List { scope } => {
let scope = scope.build().context("valid scope must be provided")?;
let objects = client
.role_assignments(&scope)
.await
.context("unable to list active assignments")?;
output(&objects)?;
}
Self::Delete {
assignment_name,
scope,
} => {
let scope = scope.build().context("valid scope must be provided")?;
client
.delete_role_assignment(&scope, &assignment_name)
.await
.context("unable to delete assignment")?;
}
Self::DeleteSet { config } => {
let data = read(config)?;
let entries = serde_json::from_slice::<Vec<Assignment>>(&data)
.context("unable to parse config file")?;
for entry in entries {
client
.delete_role_assignment(&entry.properties.scope, &entry.name)
.await
.context("unable to delete assignment")?;
}
}
}
Ok(())
}
}
#[derive(Subcommand)]
enum CleanupSubCommand {
All {
#[arg(long)]
yes: bool,
},
Auto {
#[clap(flatten)]
scope: ScopeBuilder,
#[arg(long)]
skip_nested: bool,
#[arg(long)]
yes: bool,
},
OrphanedAssignments {
#[clap(flatten)]
scope: ScopeBuilder,
#[arg(long)]
skip_nested: bool,
#[arg(long)]
yes: bool,
},
OrphanedEligibleAssignments {
#[clap(flatten)]
scope: ScopeBuilder,
#[arg(long)]
skip_nested: bool,
#[arg(long)]
yes: bool,
},
}
impl CleanupSubCommand {
async fn run(self, client: &PimClient) -> Result<()> {
match self {
Self::All { yes } => {
let active = client
.list_active_role_assignments(None, Some(ListFilter::AsTarget))
.await?;
let mut total = client
.list_eligible_role_assignments(None, Some(ListFilter::AsTarget))
.await?;
total.extend(active.clone());
let mut to_activate = BTreeSet::new();
let mut scopes = BTreeSet::new();
for role_assignment in total {
if role_assignment.scope.subscription().is_none() {
continue;
}
if !["Owner", "Role Based Access Control Administrator"]
.contains(&role_assignment.role.0.as_str())
{
continue;
}
if let Some(scope_name) = role_assignment.scope_name.as_ref() {
info!("checking {scope_name}");
} else {
info!("checking {}", role_assignment.scope.to_string());
}
if !active.contains(&role_assignment) {
to_activate.insert(role_assignment.clone());
}
scopes.insert(role_assignment.scope);
}
if !to_activate.is_empty() {
client
.activate_role_assignment_set(
&to_activate,
"cleaning up orphaned resources",
Duration::from_hours(8),
)
.await?;
client
.wait_for_role_activation(&to_activate, Duration::from_mins(5))
.await?;
}
for scope in scopes {
info!("deleting orphaned role assignments for {scope}");
client
.delete_orphaned_role_assignments(&scope, yes, true)
.await?;
info!("deleting orphaned eligible role assignments for {scope}");
client
.delete_orphaned_eligible_role_assignments(&scope, yes, true)
.await?;
}
}
Self::Auto {
scope,
skip_nested,
yes,
} => {
let scope = scope.build().context("valid scope must be provided")?;
client
.activate_role_admin(
&scope,
"cleaning up orphaned assignments",
Duration::from_mins(5),
)
.await?;
client
.delete_orphaned_role_assignments(&scope, yes, !skip_nested)
.await?;
client
.delete_orphaned_eligible_role_assignments(&scope, yes, !skip_nested)
.await?;
}
Self::OrphanedAssignments {
scope,
skip_nested,
yes,
} => {
let scope = scope.build().context("valid scope must be provided")?;
client
.delete_orphaned_role_assignments(&scope, yes, !skip_nested)
.await?;
}
Self::OrphanedEligibleAssignments {
scope,
skip_nested,
yes,
} => {
let scope = scope.build().context("valid scope must be provided")?;
client
.delete_orphaned_eligible_role_assignments(&scope, yes, !skip_nested)
.await?;
}
}
Ok(())
}
}
#[derive(Subcommand)]
enum DefinitionSubCommand {
List {
#[clap(flatten)]
scope: ScopeBuilder,
},
}
impl DefinitionSubCommand {
async fn run(self, client: &PimClient) -> Result<()> {
match self {
Self::List { scope } => {
let scope = scope.build().context("valid scope must be provided")?;
output(&client.role_definitions(&scope).await?)?;
}
}
Ok(())
}
}
#[derive(Subcommand)]
enum ResourcesSubCommand {
List {
#[clap(flatten)]
scope: ScopeBuilder,
#[arg(long)]
skip_nested: bool,
},
}
impl ResourcesSubCommand {
async fn run(self, client: &PimClient) -> Result<()> {
match self {
Self::List { scope, skip_nested } => {
let scope = scope.build().context("valid scope must be provided")?;
output(
&client
.eligible_child_resources(&scope, !skip_nested)
.await?,
)?;
}
}
Ok(())
}
}
pub fn parse_key_val<T, U>(s: &str) -> Result<(T, U), Box<dyn Error + Send + Sync + 'static>>
where
T: FromStr,
T::Err: Error + Send + Sync + 'static,
U: FromStr,
U::Err: Error + Send + Sync + 'static,
{
if let Some((key, value)) = s.split_once('=') {
Ok((key.parse()?, value.parse()?))
} else {
Err(format!("invalid KEY=value: no `=` found in `{s}`").into())
}
}
fn build_readme_entry(cmd: &mut Command, mut names: Vec<String>) -> Result<String> {
let mut readme = String::new();
let current = cmd.get_name().to_string();
names.push(current);
for positional in cmd.get_positionals() {
names.push(format!("<{}>", positional.get_id().as_str().to_uppercase()));
}
let name = names.join(" ");
let depth = min(names.iter().filter(|f| !f.starts_with('<')).count(), 5);
for _ in 0..depth {
readme.push('#');
}
let long_help = cmd.render_long_help().to_string().replace("```", "\n```\n");
write!(readme, " {name}\n\n```\n{long_help}\n```\n")?;
if let Some(example) = Cmd::example(&name) {
for _ in 0..=depth {
readme.push('#');
}
write!(readme, " Example Usage\n\n```\n{}\n```\n\n", example.trim())?;
}
for cmd in cmd.get_subcommands_mut() {
if cmd.get_name() == "readme" {
continue;
}
readme.push_str(&build_readme_entry(cmd, names.clone())?);
}
Ok(readme)
}
fn build_readme() -> Result<()> {
let mut cmd = Cmd::command();
let readme = build_readme_entry(&mut cmd, Vec::new())?
.replacen(
"# az-pim",
&format!("# Azure PIM CLI\n\n{}", env!("CARGO_PKG_DESCRIPTION")),
1,
)
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
.replace("\n\n\n", "\n");
print!("{readme}");
Ok(())
}
pub(crate) fn output<T>(value: &T) -> Result<()>
where
T: ?Sized + Serialize,
{
serde_json::to_writer_pretty(stdout(), value).context("unable to serialize results")
}
#[derive(Deserialize)]
struct ElevateEntry {
role: Role,
scope: Scope,
}
#[derive(Deserialize)]
struct Roles(Vec<ElevateEntry>);
#[tokio::main]
async fn main() -> Result<()> {
let args = Cmd::parse();
setup_logging(&args.verbose)?;
if let Err(err) = check_latest_version().await {
debug!("unable to check latest version: {err}");
}
let client = PimClient::new()?;
match args.command {
SubCommand::List {
active,
filter,
scope,
} => {
let scope = scope.build();
let roles = if active {
client
.list_active_role_assignments(scope, Some(filter))
.await?
} else {
client
.list_eligible_role_assignments(scope, Some(filter))
.await?
};
output(&roles)
}
SubCommand::Activate { cmd } => cmd.run(&client).await,
SubCommand::Deactivate { cmd } => cmd.run(&client).await,
SubCommand::Role { cmd } => match cmd {
RoleSubCommand::Assignment { cmd } => cmd.run(&client).await,
RoleSubCommand::Definition { cmd } => cmd.run(&client).await,
RoleSubCommand::Resources { cmd } => cmd.run(&client).await,
},
SubCommand::Cleanup { cmd } => cmd.run(&client).await,
SubCommand::Readme => build_readme(),
SubCommand::Init { shell } => {
Cmd::shell_completion(shell);
Ok(())
}
}
}
async fn build_set(
client: &PimClient,
config: Option<PathBuf>,
role: Option<Vec<(Role, Scope)>>,
active: bool,
) -> Result<BTreeSet<RoleAssignment>> {
let mut desired_roles = role.unwrap_or_default();
if let Some(path) = config {
let handle = File::open(path).context("unable to open activate-set config file")?;
let Roles(roles) =
serde_json::from_reader(handle).context("unable to parse config file")?;
for entry in roles {
desired_roles.push((entry.role, entry.scope));
}
}
let assignments = if active {
client
.list_active_role_assignments(None, Some(ListFilter::AsTarget))
.await
.context("unable to list active assignments in PIM")?
} else {
client
.list_eligible_role_assignments(None, Some(ListFilter::AsTarget))
.await
.context("unable to list available assignments in PIM")?
};
let mut to_add = BTreeSet::new();
for (role, scope) in desired_roles {
let entry = assignments
.find_role(&role, &scope)
.with_context(|| format!("role not found. role:{role} scope:{scope}"))?;
to_add.insert(entry);
}
Ok(to_add)
}