azure_pim_cli/models/
roles.rs1use crate::{
2 graph::Object,
3 models::scope::{Scope, ScopeError},
4};
5use anyhow::{bail, Result};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::{
9 collections::BTreeSet,
10 fmt::{Display, Formatter, Result as FmtResult},
11 str::FromStr,
12};
13
14#[derive(Serialize, PartialOrd, Ord, PartialEq, Eq, Debug, Clone, Deserialize)]
15pub struct Role(pub String);
16impl Display for Role {
17 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
18 write!(f, "{}", self.0)
19 }
20}
21
22impl FromStr for Role {
23 type Err = ScopeError;
24 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
25 Ok(Self(s.to_string()))
26 }
27}
28
29pub trait RolesExt {
30 fn find_role(&self, role: &Role, scope: &Scope) -> Option<RoleAssignment>;
31 fn friendly(&self) -> String;
32}
33
34impl RolesExt for &BTreeSet<RoleAssignment> {
35 fn find_role(&self, role: &Role, scope: &Scope) -> Option<RoleAssignment> {
36 let role = role.0.to_lowercase();
37 self.iter()
38 .find(|v| v.role.0.to_lowercase() == role && &v.scope == scope)
39 .cloned()
40 }
41
42 fn friendly(&self) -> String {
43 self.iter()
44 .map(|x| format!("* {}", x.friendly()))
45 .collect::<Vec<_>>()
46 .join("\n")
47 }
48}
49
50impl RolesExt for BTreeSet<RoleAssignment> {
51 fn find_role(&self, role: &Role, scope: &Scope) -> Option<RoleAssignment> {
52 (&self).find_role(role, scope)
53 }
54
55 fn friendly(&self) -> String {
56 (&self).friendly()
57 }
58}
59
60#[derive(Serialize, PartialOrd, Ord, PartialEq, Eq, Debug, Clone)]
61pub struct RoleAssignment {
62 pub role: Role,
63 pub scope: Scope,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub scope_name: Option<String>,
66 #[serde(skip)]
67 pub role_definition_id: String,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub principal_id: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub principal_type: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub object: Option<Object>,
74}
75
76impl RoleAssignment {
77 pub(crate) fn friendly(&self) -> String {
78 if let Some(scope_name) = self.scope_name.as_ref() {
79 format!("\"{}\" in \"{}\" ({})", self.role, scope_name, self.scope)
80 } else {
81 format!("\"{}\" in {}", self.role, self.scope)
82 }
83 }
84
85 #[allow(clippy::indexing_slicing)]
88 pub(crate) fn parse(body: &Value, with_principal: bool) -> Result<BTreeSet<Self>> {
89 let Some(values) = body["value"].as_array() else {
90 bail!("unable to parse response: missing value array: {body:#?}");
91 };
92
93 let mut results = BTreeSet::new();
94 for entry in values {
95 let Some(role) = entry["properties"]["expandedProperties"]["roleDefinition"]
96 ["displayName"]
97 .as_str()
98 .and_then(|x| Role::from_str(x).ok())
99 else {
100 bail!("no role name: {entry:#?}");
101 };
102
103 let Some(scope) = entry["properties"]["expandedProperties"]["scope"]["id"]
104 .as_str()
105 .and_then(|x| Scope::from_str(x).ok())
106 else {
107 bail!("no scope id: {entry:#?}");
108 };
109
110 let scope_name = entry["properties"]["expandedProperties"]["scope"]["displayName"]
111 .as_str()
112 .map(ToString::to_string);
113
114 let Some(role_definition_id) = entry["properties"]["roleDefinitionId"]
115 .as_str()
116 .map(ToString::to_string)
117 else {
118 bail!("no role definition id: {entry:#?}");
119 };
120
121 let (principal_id, principal_type) = if with_principal {
122 let principal_id = entry["properties"]["principalId"]
123 .as_str()
124 .map(ToString::to_string);
125
126 let principal_type = entry["properties"]["principalType"]
127 .as_str()
128 .map(ToString::to_string);
129 (principal_id, principal_type)
130 } else {
131 (None, None)
132 };
133
134 results.insert(Self {
135 role,
136 scope,
137 scope_name,
138 role_definition_id,
139 principal_id,
140 principal_type,
141 object: None,
142 });
143 }
144
145 Ok(results)
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::{RoleAssignment, Scope};
152 use anyhow::Result;
153 use insta::assert_json_snapshot;
154 use uuid::Uuid;
155
156 #[test]
157 fn parse_active() -> Result<()> {
158 const ASSIGNMENTS: &str = include_str!("../../tests/data/role-assignments.json");
159 let assignments = RoleAssignment::parse(&serde_json::from_str(ASSIGNMENTS)?, false)?;
160 assert_json_snapshot!(&assignments);
161 let assignments = RoleAssignment::parse(&serde_json::from_str(ASSIGNMENTS)?, true)?;
162 assert_json_snapshot!(&assignments);
163 Ok(())
164 }
165
166 #[test]
167 fn test_scope() {
168 let uuid = Uuid::now_v7();
169 let scope = Scope::from_subscription(&uuid);
170 assert!(scope.is_subscription());
171 assert_eq!(scope.subscription(), Some(uuid));
172 let scope = Scope::from_resource_group(&uuid, "rg");
173 assert!(!scope.is_subscription());
174 assert_eq!(scope.subscription(), Some(uuid));
175 }
176}