Skip to content

Commit 532b9c4

Browse files
authored
Enable activating a set of roles (#1)
1 parent 831db79 commit 532b9c4

File tree

7 files changed

+346
-150
lines changed

7 files changed

+346
-150
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[package]
2-
authors = ["Brian Caswell <bcaswell@microsoft.com>"]
2+
authors = ["Brian Caswell <bcaswell@gmail.com>"]
33
name = "azure-pim-cli"
44
version = "0.0.1"
55
edition = "2021"

README.md

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ CLI to list and enable Azure Privileged Identity Management roles
66
Usage: az-pim <COMMAND>
77
88
Commands:
9-
list List eligible assignments
10-
elevate Elevate to a specific role
9+
list List eligible assignments
10+
activate Activate a specific role
11+
activate-set Activate a set of roles
1112
1213
Options:
1314
-h, --help
@@ -32,12 +33,12 @@ Options:
3233
Print version
3334
3435
```
35-
## az-pim elevate <ROLE> <SCOPE> <JUSTIFICATION>
36+
## az-pim activate <ROLE> <SCOPE> <JUSTIFICATION>
3637

3738
```
38-
Elevate to a specific role
39+
Activate a specific role
3940
40-
Usage: elevate [OPTIONS] <ROLE> <SCOPE> <JUSTIFICATION>
41+
Usage: activate [OPTIONS] <ROLE> <SCOPE> <JUSTIFICATION>
4142
4243
Arguments:
4344
<ROLE>
@@ -61,4 +62,40 @@ Options:
6162
-V, --version
6263
Print version
6364
65+
```
66+
## az-pim activate-set <JUSTIFICATION>
67+
68+
```
69+
Activate a set of roles
70+
71+
This command can be used to activate multiple roles at once. It can be used with a config file or by specifying roles on the command line.
72+
73+
Usage: activate-set [OPTIONS] <JUSTIFICATION>
74+
75+
Arguments:
76+
<JUSTIFICATION>
77+
Justification for the request
78+
79+
Options:
80+
--duration <DURATION>
81+
Duration in minutes
82+
83+
[default: 480]
84+
85+
--config <CONFIG>
86+
Path to a JSON config file containing a set of roles to elevate
87+
88+
Example config file: ` [ { "scope": "/subscriptions/00000000-0000-0000-0000-000000000000", "role": "Owner" }, { "scope": "/subscriptions/00000000-0000-0000-0000-000000000001", "role": "Owner" } ] `
89+
90+
--role <SCOPE=NAME>
91+
Specify a role to elevate
92+
93+
Specify multiple times to include multiple key/value pairs
94+
95+
-h, --help
96+
Print help (see a summary with '-h')
97+
98+
-V, --version
99+
Print version
100+
64101
```

src/activate.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use crate::az_cli::get_userid;
2+
use anyhow::{bail, Result};
3+
use reqwest::{blocking::Client, StatusCode};
4+
use serde_json::Value;
5+
use tracing::{debug, info};
6+
use uuid::Uuid;
7+
8+
// NOTE: serde_json doesn't panic on failed index slicing, it returns a Value
9+
// that allows further nested nulls
10+
#[allow(clippy::indexing_slicing)]
11+
fn check_error_response(body: &Value) -> Result<()> {
12+
if body["error"]["code"].as_str() == Some("RoleAssignmentExists") {
13+
info!("role already assigned");
14+
return Ok(());
15+
}
16+
if body["error"]["code"].as_str() == Some("RoleAssignmentRequestExists") {
17+
info!("role assignment request already exists");
18+
return Ok(());
19+
}
20+
bail!("unable to elevate: {body:#?}");
21+
}
22+
23+
pub fn activate_role(
24+
token: &str,
25+
scope: &str,
26+
role_definition_id: &str,
27+
justification: &str,
28+
duration: u32,
29+
) -> Result<()> {
30+
let principal_id = get_userid()?;
31+
32+
let request_id = Uuid::new_v4();
33+
let url = format!("https://management.azure.com{scope}/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/{request_id}");
34+
let body = serde_json::json!({
35+
"properties": {
36+
"principalId": principal_id,
37+
"roleDefinitionId": role_definition_id,
38+
"requestType": "SelfActivate",
39+
"justification": justification,
40+
"scheduleInfo": {
41+
"expiration": {
42+
"duration": format!("PT{}M", duration),
43+
"type": "AfterDuration",
44+
}
45+
}
46+
}
47+
});
48+
49+
let response = Client::new()
50+
.put(url)
51+
.query(&[("api-version", "2020-10-01")])
52+
.bearer_auth(token)
53+
.json(&body)
54+
.send()?;
55+
56+
let status = response.status();
57+
58+
if status == StatusCode::BAD_REQUEST {
59+
return check_error_response(&response.json()?);
60+
}
61+
62+
let body: Value = response.error_for_status()?.json()?;
63+
64+
debug!("body: {status:#?} - {body:#?}");
65+
info!("submitted request: {request_id}");
66+
67+
Ok(())
68+
}

src/bin/az-pim.rs

Lines changed: 180 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
use anyhow::{Context, Result};
2-
use azure_pim_cli::{
3-
az_cli::get_token,
4-
elevate::{elevate_role, ElevateConfig},
5-
roles::list,
6-
};
1+
use anyhow::{bail, Context, Result};
2+
use azure_pim_cli::{activate::activate_role, az_cli::get_token, roles::list_roles};
73
use clap::{Command, CommandFactory, Parser, Subcommand};
8-
use std::{cmp::min, io::stdout};
4+
use serde::Deserialize;
5+
use std::{
6+
cmp::min, collections::BTreeSet, error::Error, fs::File, io::stdout, path::PathBuf,
7+
str::FromStr,
8+
};
9+
use tracing::{error, info};
910

1011
#[derive(Parser)]
1112
#[command(
@@ -25,14 +26,76 @@ enum SubCommand {
2526
/// List eligible assignments
2627
List,
2728

28-
/// Elevate to a specific role
29-
Elevate(ElevateConfig),
29+
/// Activate a specific role
30+
Activate {
31+
/// Name of the role to elevate
32+
role: String,
33+
/// Scope to elevate
34+
scope: String,
35+
/// Justification for the request
36+
justification: String,
37+
/// Duration in minutes
38+
#[clap(long, default_value_t = 480)]
39+
duration: u32,
40+
},
41+
42+
/// Activate a set of roles
43+
///
44+
/// This command can be used to activate multiple roles at once. It can be
45+
/// used with a config file or by specifying roles on the command line.
46+
ActivateSet {
47+
/// Justification for the request
48+
justification: String,
49+
#[clap(long, default_value_t = 480)]
50+
/// Duration in minutes
51+
duration: u32,
52+
#[clap(long)]
53+
/// Path to a JSON config file containing a set of roles to elevate
54+
///
55+
/// Example config file:
56+
/// `
57+
/// [
58+
/// {
59+
/// "scope": "/subscriptions/00000000-0000-0000-0000-000000000000",
60+
/// "role": "Owner"
61+
/// },
62+
/// {
63+
/// "scope": "/subscriptions/00000000-0000-0000-0000-000000000001",
64+
/// "role": "Owner"
65+
/// }
66+
/// ]
67+
/// `
68+
config: Option<PathBuf>,
69+
#[clap(long, conflicts_with = "config", value_name = "SCOPE=NAME", value_parser = parse_key_val::<String, String>, action = clap::ArgAction::Append)]
70+
/// Specify a role to elevate
71+
///
72+
/// Specify multiple times to include multiple key/value pairs
73+
role: Option<Vec<(String, String)>>,
74+
},
3075

3176
#[command(hide = true)]
3277
Readme,
3378
}
3479

35-
fn build_readme(cmd: &mut Command, mut names: Vec<String>) -> String {
80+
/// Parse a single key-value pair of `X=Y` into a typed tuple of `(X, Y)`.
81+
///
82+
/// # Errors
83+
/// Returns an `Err` if any of the keys or values cannot be parsed or if no `=` is found.
84+
pub fn parse_key_val<T, U>(s: &str) -> Result<(T, U), Box<dyn Error + Send + Sync + 'static>>
85+
where
86+
T: FromStr,
87+
T::Err: Error + Send + Sync + 'static,
88+
U: FromStr,
89+
U::Err: Error + Send + Sync + 'static,
90+
{
91+
if let Some((key, value)) = s.split_once('=') {
92+
Ok((key.parse()?, value.parse()?))
93+
} else {
94+
Err(format!("invalid KEY=value: no `=` found in `{s}`").into())
95+
}
96+
}
97+
98+
fn build_readme_entry(cmd: &mut Command, mut names: Vec<String>) -> String {
3699
let mut readme = String::new();
37100
let base_name = cmd.get_name().to_owned();
38101

@@ -60,11 +123,37 @@ fn build_readme(cmd: &mut Command, mut names: Vec<String>) -> String {
60123
if cmd.get_name() == "readme" {
61124
continue;
62125
}
63-
readme.push_str(&build_readme(cmd, names.clone()));
126+
readme.push_str(&build_readme_entry(cmd, names.clone()));
64127
}
65128
readme
66129
}
67130

131+
fn build_readme() {
132+
let mut cmd = Cmd::command();
133+
let readme = build_readme_entry(&mut cmd, Vec::new())
134+
.replace("azure-pim-cli", "az-pim")
135+
.replacen(
136+
"# az-pim",
137+
&format!("# Azure PIM CLI\n\n{}", env!("CARGO_PKG_DESCRIPTION")),
138+
1,
139+
)
140+
.lines()
141+
.map(str::trim_end)
142+
.collect::<Vec<_>>()
143+
.join("\n")
144+
.replace("\n\n\n", "\n");
145+
print!("{readme}");
146+
}
147+
148+
#[derive(Deserialize)]
149+
struct Role {
150+
scope: String,
151+
role: String,
152+
}
153+
154+
#[derive(Deserialize)]
155+
struct Roles(Vec<Role>);
156+
68157
fn main() -> Result<()> {
69158
tracing_subscriber::fmt()
70159
.with_env_filter(
@@ -80,31 +169,93 @@ fn main() -> Result<()> {
80169
match args.commands {
81170
SubCommand::List => {
82171
let token = get_token().context("unable to obtain access token")?;
83-
let roles = list(&token).context("unable to list available roles in PIM")?;
172+
let roles = list_roles(&token).context("unable to list available roles in PIM")?;
84173
serde_json::to_writer_pretty(stdout(), &roles)?;
85174
Ok(())
86175
}
87-
SubCommand::Elevate(config) => {
176+
SubCommand::Activate {
177+
role,
178+
scope,
179+
justification,
180+
duration,
181+
} => {
88182
let token = get_token().context("unable to obtain access token")?;
89-
let roles = list(&token).context("unable to list available roles in PIM")?;
90-
elevate_role(&token, &config, &roles).context("unable to elevate to specified role")?;
183+
let roles = list_roles(&token).context("unable to list available roles in PIM")?;
184+
let entry = roles
185+
.iter()
186+
.find(|v| v.role == role && v.scope == scope)
187+
.context("role not found")?;
188+
189+
info!("activating {role:?} in {}", entry.scope_name);
190+
191+
activate_role(
192+
&token,
193+
&entry.scope,
194+
&entry.role_definition_id,
195+
&justification,
196+
duration,
197+
)
198+
.context("unable to elevate to specified role")?;
199+
Ok(())
200+
}
201+
SubCommand::ActivateSet {
202+
config,
203+
role,
204+
justification,
205+
duration,
206+
} => {
207+
let mut desired_roles = role
208+
.unwrap_or_default()
209+
.into_iter()
210+
.collect::<BTreeSet<_>>();
211+
212+
if let Some(path) = config {
213+
let handle = File::open(path).context("unable to open activate-set config file")?;
214+
let Roles(roles) =
215+
serde_json::from_reader(handle).context("unable to parse config file")?;
216+
for entry in roles {
217+
desired_roles.insert((entry.scope, entry.role));
218+
}
219+
}
220+
221+
if desired_roles.is_empty() {
222+
bail!("no roles specified");
223+
}
224+
225+
let token = get_token().context("unable to obtain access token")?;
226+
let available = list_roles(&token).context("unable to list available roles in PIM")?;
227+
228+
let mut to_add = BTreeSet::new();
229+
for (scope, role) in &desired_roles {
230+
let entry = &available
231+
.iter()
232+
.find(|v| &v.role == role && &v.scope == scope)
233+
.with_context(|| format!("role not found. role:{role} scope:{scope}"))?;
234+
235+
to_add.insert((scope, role, &entry.role_definition_id, &entry.scope_name));
236+
}
237+
238+
let mut success = true;
239+
for (scope, role, role_definition_id, scope_name) in to_add {
240+
info!("activating {role:?} in {scope_name}");
241+
if let Err(error) =
242+
activate_role(&token, scope, role_definition_id, &justification, duration)
243+
{
244+
error!(
245+
"scope: {scope} role_definition_id: {role_definition_id} error: {error:?}"
246+
);
247+
success = false;
248+
}
249+
}
250+
251+
if !success {
252+
bail!("unable to elevate to all roles");
253+
}
254+
91255
Ok(())
92256
}
93257
SubCommand::Readme => {
94-
let mut cmd = Cmd::command();
95-
let readme = build_readme(&mut cmd, Vec::new())
96-
.replace("azure-pim-cli", "az-pim")
97-
.replacen(
98-
"# az-pim",
99-
&format!("# Azure PIM CLI\n\n{}", env!("CARGO_PKG_DESCRIPTION")),
100-
1,
101-
)
102-
.lines()
103-
.map(str::trim_end)
104-
.collect::<Vec<_>>()
105-
.join("\n")
106-
.replace("\n\n\n", "\n");
107-
print!("{readme}");
258+
build_readme();
108259
Ok(())
109260
}
110261
}

0 commit comments

Comments
 (0)