Skip to main content

azure_pim_cli/models/
roles.rs

1use 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    // NOTE: serde_json doesn't panic on failed index slicing, it returns a Value
86    // that allows further nested nulls
87    #[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}