1#![forbid(unsafe_code)]
2#![deny(
3 clippy::indexing_slicing,
4 clippy::manual_assert,
5 clippy::panic,
6 clippy::expect_used,
7 clippy::unwrap_used
8)]
9#![allow(clippy::module_name_repetitions)]
10
11mod activate;
12mod az_cli;
13mod backend;
14mod expiring;
15pub mod graph;
16pub mod interactive;
17mod latest;
18pub mod models;
19
20pub use crate::latest::check_latest_version;
21use crate::{
22 activate::check_error_response,
23 backend::Backend,
24 expiring::ExpiringMap,
25 graph::{get_objects_by_ids, group_members, Object, PrincipalType},
26 models::{
27 assignments::{Assignment, Assignments},
28 definitions::{Definition, Definitions},
29 resources::ChildResource,
30 roles::{RoleAssignment, RolesExt},
31 scope::Scope,
32 },
33};
34use anyhow::{bail, ensure, Context, Result};
35use backend::Operation;
36use clap::ValueEnum;
37use reqwest::Method;
38use std::{
39 collections::BTreeSet,
40 fmt::{Display, Formatter, Result as FmtResult},
41 io::stdin,
42 thread::sleep,
43 time::{Duration, Instant},
44};
45use tokio::sync::Mutex;
46use tracing::{debug, error, info, warn};
47use uuid::Uuid;
48
49const WAIT_DELAY: Duration = Duration::from_secs(5);
50const RBAC_ADMIN_ROLES: &[&str] = &["Owner", "Role Based Access Control Administrator"];
51
52#[allow(clippy::large_enum_variant)]
53pub enum ActivationResult {
54 Success,
55 Failed(RoleAssignment),
56}
57
58#[allow(clippy::manual_assert, clippy::panic)]
59#[derive(Clone, ValueEnum, PartialEq, Eq, PartialOrd, Ord)]
60pub enum ListFilter {
61 AtScope,
62 AsTarget,
63}
64
65impl Display for ListFilter {
66 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
67 match self {
68 Self::AtScope => write!(f, "at-scope"),
69 Self::AsTarget => write!(f, "as-target"),
70 }
71 }
72}
73
74impl ListFilter {
75 fn as_str(&self) -> &'static str {
76 match self {
77 Self::AtScope => "atScope()",
78 Self::AsTarget => "asTarget()",
79 }
80 }
81}
82
83pub struct PimClient {
84 backend: Backend,
85 object_cache: Mutex<ExpiringMap<String, Option<Object>>>,
86 group_cache: Mutex<ExpiringMap<String, BTreeSet<Object>>>,
87 role_definitions_cache: Mutex<ExpiringMap<Scope, Vec<Definition>>>,
88}
89
90impl PimClient {
91 pub fn new() -> Result<Self> {
92 let backend = Backend::new();
93 let object_cache = Mutex::new(ExpiringMap::new(Duration::from_secs(60 * 10)));
94 let group_cache = Mutex::new(ExpiringMap::new(Duration::from_secs(60 * 10)));
95 let role_definitions_cache = Mutex::new(ExpiringMap::new(Duration::from_secs(60 * 10)));
96 Ok(Self {
97 backend,
98 object_cache,
99 group_cache,
100 role_definitions_cache,
101 })
102 }
103
104 pub async fn clear_cache(&self) {
105 self.object_cache.lock().await.clear();
106 self.role_definitions_cache.lock().await.clear();
107 }
108
109 pub async fn current_user(&self) -> Result<String> {
110 self.backend.principal_id().await
111 }
112
113 pub async fn list_eligible_role_assignments(
118 &self,
119 scope: Option<Scope>,
120 filter: Option<ListFilter>,
121 ) -> Result<BTreeSet<RoleAssignment>> {
122 let with_principal = filter.as_ref() != Some(&ListFilter::AsTarget);
123 if let Some(scope) = &scope {
124 info!("listing eligible assignments for {scope}");
125 } else {
126 info!("listing eligible assignments");
127 }
128 let mut builder = self
129 .backend
130 .request(Method::GET, Operation::RoleEligibilityScheduleInstances);
131
132 if let Some(scope) = scope {
133 builder = builder.scope(scope);
134 }
135
136 if let Some(filter) = filter {
137 builder = builder.query("$filter", filter.as_str());
138 }
139
140 let response = builder
141 .send()
142 .await
143 .context("unable to list eligible assignments")?;
144 let mut results = RoleAssignment::parse(&response, with_principal)
145 .context("unable to parse eligible assignments")?;
146
147 if with_principal {
148 let ids = results
149 .iter()
150 .filter_map(|x| x.principal_id.as_deref())
151 .collect::<BTreeSet<_>>();
152
153 let objects = get_objects_by_ids(self, ids)
154 .await
155 .context("getting objects by id")?;
156 results = results
157 .into_iter()
158 .map(|mut x| {
159 if let Some(principal_id) = x.principal_id.as_ref() {
160 x.object = objects.get(principal_id).cloned();
161 }
162 x
163 })
164 .collect();
165 }
166
167 Ok(results)
168 }
169
170 pub async fn list_active_role_assignments(
175 &self,
176 scope: Option<Scope>,
177 filter: Option<ListFilter>,
178 ) -> Result<BTreeSet<RoleAssignment>> {
179 let with_principal = filter.as_ref() != Some(&ListFilter::AsTarget);
180
181 if let Some(scope) = &scope {
182 info!("listing active role assignments in {scope}");
183 } else {
184 info!("listing active role assignments");
185 }
186
187 let mut builder = self
188 .backend
189 .request(Method::GET, Operation::RoleAssignmentScheduleInstances);
190
191 if let Some(scope) = scope {
192 builder = builder.scope(scope);
193 }
194
195 if let Some(filter) = filter {
196 builder = builder.query("$filter", filter.as_str());
197 }
198
199 let response = builder
200 .send()
201 .await
202 .context("unable to list active role assignments")?;
203 let mut results = RoleAssignment::parse(&response, with_principal)
204 .context("unable to parse active role assignments")?;
205
206 if with_principal {
207 let ids = results
208 .iter()
209 .filter_map(|x| x.principal_id.as_deref())
210 .collect::<BTreeSet<_>>();
211
212 let objects = get_objects_by_ids(self, ids)
213 .await
214 .context("getting objects by id")?;
215 results = results
216 .into_iter()
217 .map(|mut x| {
218 if let Some(principal_id) = x.principal_id.as_ref() {
219 x.object = objects.get(principal_id).cloned();
220 }
221 x
222 })
223 .collect();
224 }
225 Ok(results)
226 }
227
228 pub async fn extend_role_assignment(
233 &self,
234 assignment: &RoleAssignment,
235 justification: &str,
236 duration: Duration,
237 ) -> Result<()> {
238 let RoleAssignment {
239 scope,
240 role_definition_id,
241 role,
242 scope_name,
243 principal_id: _,
244 principal_type: _,
245 object: _,
246 } = assignment;
247 if let Some(scope_name) = scope_name {
248 info!("extending {role} in {scope_name} ({scope})");
249 } else {
250 info!("extending {role} in {scope}");
251 }
252 let request_id = Uuid::now_v7();
253 let body = serde_json::json!({
254 "properties": {
255 "principalId": self.backend.principal_id().await?,
256 "roleDefinitionId": role_definition_id,
257 "requestType": "SelfExtend",
258 "justification": justification,
259 "scheduleInfo": {
260 "expiration": {
261 "duration": format_duration(duration)?,
262 "type": "AfterDuration",
263 }
264 }
265 }
266 });
267
268 self.backend
269 .request(Method::PUT, Operation::RoleAssignmentScheduleRequests)
270 .extra(format!("/{request_id}"))
271 .scope(scope.clone())
272 .json(body)
273 .validate(check_error_response)
274 .send()
275 .await?;
276 Ok(())
277 }
278
279 pub async fn activate_role_assignment(
284 &self,
285 assignment: &RoleAssignment,
286 justification: &str,
287 duration: Duration,
288 ) -> Result<()> {
289 let RoleAssignment {
290 scope,
291 role_definition_id,
292 role,
293 scope_name,
294 principal_id: _,
295 principal_type: _,
296 object: _,
297 } = assignment;
298 if let Some(scope_name) = scope_name {
299 info!("activating {role} in {scope_name} ({scope})");
300 } else {
301 info!("activating {role} in {scope}");
302 }
303 let request_id = Uuid::now_v7();
304 let body = serde_json::json!({
305 "properties": {
306 "principalId": self.backend.principal_id().await?,
307 "roleDefinitionId": role_definition_id,
308 "requestType": "SelfActivate",
309 "justification": justification,
310 "scheduleInfo": {
311 "expiration": {
312 "duration": format_duration(duration)?,
313 "type": "AfterDuration",
314 }
315 }
316 }
317 });
318
319 self.backend
320 .request(Method::PUT, Operation::RoleAssignmentScheduleRequests)
321 .extra(format!("/{request_id}"))
322 .scope(scope.clone())
323 .json(body)
324 .validate(check_error_response)
325 .send()
326 .await?;
327
328 Ok(())
329 }
330
331 pub async fn activate_role_assignment_set(
332 &self,
333 assignments: &BTreeSet<RoleAssignment>,
334 justification: &str,
335 duration: Duration,
336 ) -> Result<()> {
337 ensure!(!assignments.is_empty(), "no roles specified");
338
339 let results = assignments.iter().map(|x| async {
340 let result = self
341 .activate_role_assignment(x, justification, duration)
342 .await;
343 match result {
344 Ok(()) => ActivationResult::Success,
345 Err(error) => {
346 error!(
347 "scope: {} definition: {} error: {error:?}",
348 x.scope, x.role_definition_id
349 );
350 ActivationResult::Failed(x.clone())
351 }
352 }
353 });
354
355 let results = futures::future::join_all(results).await;
356
357 let mut failed = BTreeSet::new();
358
359 for result in results {
360 match result {
361 ActivationResult::Failed(entry) => {
362 failed.insert(entry);
363 }
364 ActivationResult::Success => {}
365 }
366 }
367
368 if !failed.is_empty() {
369 bail!(
370 "failed to activate the following roles:\n{}",
371 failed.friendly()
372 );
373 }
374
375 Ok(())
376 }
377
378 pub async fn deactivate_role_assignment(&self, assignment: &RoleAssignment) -> Result<()> {
383 let RoleAssignment {
384 scope,
385 role_definition_id,
386 role,
387 scope_name,
388 principal_id: _,
389 principal_type: _,
390 object: _,
391 } = assignment;
392 if let Some(scope_name) = scope_name {
393 info!("deactivating {role} in {scope_name} ({scope})");
394 } else {
395 info!("deactivating {role} in {scope}");
396 }
397 let request_id = Uuid::now_v7();
398 let body = serde_json::json!({
399 "properties": {
400 "principalId": self.backend.principal_id().await?,
401 "roleDefinitionId": role_definition_id,
402 "requestType": "SelfDeactivate",
403 "justification": "Deactivation request",
404 }
405 });
406
407 self.backend
408 .request(Method::PUT, Operation::RoleAssignmentScheduleRequests)
409 .extra(format!("/{request_id}"))
410 .scope(scope.clone())
411 .json(body)
412 .validate(check_error_response)
413 .send()
414 .await?;
415 Ok(())
416 }
417
418 pub async fn deactivate_role_assignment_set(
419 &self,
420 assignments: &BTreeSet<RoleAssignment>,
421 ) -> Result<()> {
422 ensure!(!assignments.is_empty(), "no roles specified");
423
424 let results = assignments.iter().map(|entry| async {
425 match self.deactivate_role_assignment(entry).await {
426 Ok(()) => ActivationResult::Success,
427 Err(error) => {
428 error!(
429 "scope: {} definition: {} error: {error:?}",
430 entry.scope, entry.role_definition_id
431 );
432 ActivationResult::Failed(entry.clone())
433 }
434 }
435 });
436 let results = futures::future::join_all(results).await;
437
438 let mut failed = BTreeSet::new();
439
440 for result in results {
441 match result {
442 ActivationResult::Failed(entry) => {
443 failed.insert(entry);
444 }
445 ActivationResult::Success => {}
446 }
447 }
448
449 if !failed.is_empty() {
450 bail!(
451 "failed to deactivate the following roles:\n{}",
452 failed.friendly()
453 );
454 }
455
456 Ok(())
457 }
458
459 pub async fn wait_for_role_activation(
460 &self,
461 assignments: &BTreeSet<RoleAssignment>,
462 wait_timeout: Duration,
463 ) -> Result<()> {
464 if assignments.is_empty() {
465 return Ok(());
466 }
467
468 let start = Instant::now();
469 let mut last = None::<Instant>;
470
471 let mut waiting = assignments.clone();
472 while !waiting.is_empty() {
473 if start.elapsed() > wait_timeout {
474 break;
475 }
476
477 let current = Instant::now();
483 if let Some(last) = last {
484 let to_wait = last.duration_since(current).saturating_sub(WAIT_DELAY);
485 if !to_wait.is_zero() {
486 debug!("sleeping {to_wait:?} before checking active assignments");
487 sleep(to_wait);
488 }
489 }
490 last = Some(current);
491
492 let active = self
493 .list_active_role_assignments(None, Some(ListFilter::AsTarget))
494 .await?;
495 debug!("active assignments: {active:#?}");
496 waiting.retain(|entry| !active.contains(entry));
497 debug!("still waiting: {waiting:#?}");
498 }
499
500 if !waiting.is_empty() {
501 bail!(
502 "timed out waiting for the following roles to activate:\n{}",
503 waiting.friendly()
504 );
505 }
506
507 Ok(())
508 }
509
510 pub async fn role_assignments(&self, scope: &Scope) -> Result<Vec<Assignment>> {
515 info!("listing assignments for {scope}");
516 let value = self
517 .backend
518 .request(Method::GET, Operation::RoleAssignments)
519 .scope(scope.clone())
520 .send()
521 .await
522 .with_context(|| format!("unable to list role assignments at {scope}"))?;
523 let assignments: Assignments = serde_json::from_value(value)
524 .with_context(|| format!("unable to parse role assignment response at {scope}"))?;
525 let mut assignments = assignments.value;
526 let ids = assignments
527 .iter()
528 .map(|x| x.properties.principal_id.as_str())
529 .collect();
530
531 let objects = get_objects_by_ids(self, ids)
532 .await
533 .context("getting objects by id")?;
534 for x in &mut assignments {
535 x.object = objects.get(&x.properties.principal_id).cloned();
536 }
537 Ok(assignments)
538 }
539
540 pub async fn eligible_child_resources(
545 &self,
546 scope: &Scope,
547 nested: bool,
548 ) -> Result<BTreeSet<ChildResource>> {
549 let mut todo = [scope.clone()].into_iter().collect::<BTreeSet<_>>();
550 let mut seen = BTreeSet::new();
551 let mut result = BTreeSet::new();
552
553 while !todo.is_empty() {
554 seen.extend(todo.clone());
555 let iteration = todo.iter().map(|scope| async {
557 let scope = scope.clone();
558 info!("listing eligible child resources for {scope}");
559 self.backend
560 .request(Method::GET, Operation::EligibleChildResources)
561 .scope(scope.clone())
562 .send()
563 .await
564 .with_context(|| format!("unable to list eligible child resources for {scope}"))
565 .map(|x| {
566 ChildResource::parse(&x).with_context(|| {
567 format!("unable to parse eligible child resources for {scope}")
568 })
569 })
570 });
571 let iteration = futures::future::join_all(iteration).await;
572
573 todo = BTreeSet::new();
574 for entry in iteration {
575 for child in entry?? {
576 if nested && !seen.contains(&child.id) {
577 todo.insert(child.id.clone());
578 }
579 result.insert(child);
580 }
581 }
582 }
583
584 Ok(result)
585 }
586
587 pub async fn role_definitions(&self, scope: &Scope) -> Result<Vec<Definition>> {
594 let mut cache = self.role_definitions_cache.lock().await;
595
596 if let Some(cached) = cache.get(scope) {
597 return Ok(cached.clone());
598 }
599
600 info!("listing role definitions for {scope}");
601 let definitions = self
602 .backend
603 .request(Method::GET, Operation::RoleDefinitions)
604 .scope(scope.clone())
605 .send()
606 .await
607 .with_context(|| format!("unable to list role definitions at {scope}"))?;
608 let definitions: Definitions = serde_json::from_value(definitions)
609 .with_context(|| format!("unable to parse role definitions at {scope}"))?;
610 cache.insert(scope.clone(), definitions.value.clone());
611
612 Ok(definitions.value)
613 }
614
615 pub async fn delete_role_assignment(&self, scope: &Scope, assignment_name: &str) -> Result<()> {
620 info!("deleting assignment {assignment_name} from {scope}");
621 self.backend
622 .request(Method::DELETE, Operation::RoleAssignments)
623 .extra(format!("/{assignment_name}"))
624 .scope(scope.clone())
625 .send()
626 .await
627 .with_context(|| format!("unable to delete assignment {assignment_name} at {scope}"))?;
628 Ok(())
629 }
630
631 pub async fn delete_eligible_role_assignment(&self, assignment: &RoleAssignment) -> Result<()> {
638 let RoleAssignment {
639 scope,
640 role_definition_id,
641 role,
642 scope_name,
643 principal_id,
644 principal_type: _,
645 object: _,
646 } = assignment;
647
648 let principal_id = principal_id.as_deref().context("missing principal id")?;
649 info!("deleting {role} in {scope_name:?} ({scope})");
650 let request_id = Uuid::now_v7();
651 let body = serde_json::json!({
652 "properties": {
653 "principalId": principal_id,
654 "roleDefinitionId": role_definition_id,
655 "requestType": "AdminRemove",
656 "ScheduleInfo": {
657 "Expiration": {
658 "Type": "NoExpiration",
659 }
660 }
661 }
662 });
663
664 self.backend
665 .request(Method::PUT, Operation::RoleEligibilityScheduleRequests)
666 .extra(format!("/{request_id}"))
667 .scope(scope.clone())
668 .json(body)
669 .validate(check_error_response)
670 .send()
671 .await
672 .with_context(|| {
673 format!("unable to delete role definition {role_definition_id} for {principal_id}")
674 })?;
675 Ok(())
676 }
677
678 pub async fn delete_orphaned_role_assignments(
679 &self,
680 scope: &Scope,
681 answer_yes: bool,
682 nested: bool,
683 ) -> Result<()> {
684 let scopes = if nested {
685 self.eligible_child_resources(scope, nested)
686 .await?
687 .into_iter()
688 .map(|x| x.id)
689 .collect::<BTreeSet<_>>()
690 } else {
691 [scope.clone()].into_iter().collect()
692 };
693
694 for scope in scopes {
695 let definitions = self.role_definitions(&scope).await?;
696
697 let mut objects = self
698 .role_assignments(&scope)
699 .await
700 .with_context(|| format!("unable to list role assignments at {scope}"))?;
701 debug!("{} total entries", objects.len());
702 objects.retain(|x| x.object.is_none());
703 debug!("{} orphaned entries", objects.len());
704 for entry in objects {
705 let definition = definitions
706 .iter()
707 .find(|x| x.id == entry.properties.role_definition_id);
708 let value = format!(
709 "role:\"{}\" principal:{} (type: {}) scope:{}",
710 definition.map_or(entry.name.as_str(), |x| x.properties.role_name.as_str()),
711 entry.properties.principal_id,
712 entry.properties.principal_type,
713 entry.properties.scope
714 );
715 if !answer_yes && !confirm(&format!("delete {value}")) {
716 info!("skipping {value}");
717 continue;
718 }
719
720 self.delete_role_assignment(&entry.properties.scope, &entry.name)
721 .await
722 .context("unable to delete assignment")?;
723 }
724 }
725 Ok(())
726 }
727
728 pub async fn delete_orphaned_eligible_role_assignments(
729 &self,
730 scope: &Scope,
731 answer_yes: bool,
732 nested: bool,
733 ) -> Result<()> {
734 let scopes = if nested {
735 self.eligible_child_resources(scope, nested)
736 .await?
737 .into_iter()
738 .map(|x| x.id)
739 .collect::<BTreeSet<_>>()
740 } else {
741 [scope.clone()].into_iter().collect()
742 };
743 for scope in scopes {
744 let definitions = self.role_definitions(&scope).await?;
745 for entry in self
746 .list_eligible_role_assignments(Some(scope), None)
747 .await?
748 {
749 if entry.object.is_some() {
750 continue;
751 }
752
753 let definition = definitions
754 .iter()
755 .find(|x| x.id == entry.role_definition_id);
756
757 let value = format!(
758 "role:\"{}\" principal:{} (type: {}) scope:{}",
759 definition.map_or(entry.role_definition_id.as_str(), |x| x
760 .properties
761 .role_name
762 .as_str()),
763 entry.principal_id.clone().unwrap_or_default(),
764 entry.principal_type.clone().unwrap_or_default(),
765 entry
766 .scope_name
767 .clone()
768 .unwrap_or_else(|| entry.scope.to_string())
769 );
770 if !answer_yes && !confirm(&format!("delete {value}")) {
771 info!("skipping {value}");
772 continue;
773 }
774 info!("deleting {value}");
775
776 self.delete_eligible_role_assignment(&entry).await?;
777 }
778 }
779
780 Ok(())
781 }
782
783 pub async fn activate_role_admin(
784 &self,
785 scope: &Scope,
786 justification: &str,
787 duration: Duration,
788 ) -> Result<()> {
789 let active = self
790 .list_active_role_assignments(None, Some(ListFilter::AsTarget))
791 .await?;
792
793 for entry in active {
794 if entry.scope.contains(scope) && RBAC_ADMIN_ROLES.contains(&entry.role.0.as_str()) {
795 info!("role already active: {entry:?}");
796 return Ok(());
797 }
798 }
799
800 let eligible = self
801 .list_eligible_role_assignments(None, Some(ListFilter::AsTarget))
802 .await?;
803 for entry in eligible {
804 if entry.scope.contains(scope) && RBAC_ADMIN_ROLES.contains(&entry.role.0.as_str()) {
805 return self
806 .activate_role_assignment(&entry, justification, duration)
807 .await;
808 }
809 }
810
811 bail!("unable to find role to administrate RBAC for {scope}");
812 }
813
814 pub async fn group_members(&self, id: &str, nested: bool) -> Result<BTreeSet<Object>> {
815 if !nested {
816 return group_members(self, id).await;
817 }
818
819 let mut results = BTreeSet::new();
820 let mut todo = [id.to_string()].into_iter().collect::<BTreeSet<_>>();
821 let mut done = BTreeSet::new();
822
823 while let Some(id) = todo.pop_first() {
824 if done.contains(&id) {
825 continue;
826 }
827 done.insert(id.clone());
828
829 let group_results = group_members(self, &id).await?;
830 todo.extend(
831 group_results
832 .iter()
833 .filter(|x| matches!(x.object_type, PrincipalType::Group))
834 .map(|x| x.id.clone()),
835 );
836 results.extend(group_results);
837 }
838 Ok(results)
839 }
840}
841
842fn format_duration(duration: Duration) -> Result<String> {
843 let mut as_secs = duration.as_secs();
844
845 let hours = as_secs / 3600;
846 as_secs %= 3600;
847
848 let minutes = as_secs / 60;
849 let seconds = as_secs % 60;
850
851 let mut data = vec![];
852 if hours > 0 {
853 data.push(format!("{hours}H"));
854 }
855 if minutes > 0 {
856 data.push(format!("{minutes}M"));
857 }
858 if seconds > 0 {
859 data.push(format!("{seconds}S"));
860 }
861
862 ensure!(!data.is_empty(), "duration must be at least 1 second");
863 Ok(format!("PT{}", data.join("")))
864}
865
866pub fn confirm(msg: &str) -> bool {
867 info!("Are you sure you want to {msg}? (y/n): ");
868 loop {
869 let mut input = String::new();
870 let Ok(_) = stdin().read_line(&mut input) else {
871 continue;
872 };
873 match input.trim().to_lowercase().as_str() {
874 "y" => break true,
875 "n" => break false,
876 _ => {
877 warn!("Please enter 'y' or 'n': ");
878 }
879 }
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn test_format_duration() -> Result<()> {
889 assert!(format_duration(Duration::from_secs(0)).is_err());
890
891 for (secs, parsed) in [
892 (1, "PT1S"),
893 (60, "PT1M"),
894 (61, "PT1M1S"),
895 (3600, "PT1H"),
896 (86400, "PT24H"),
897 (86401, "PT24H1S"),
898 (86460, "PT24H1M"),
899 (86520, "PT24H2M"),
900 (90061, "PT25H1M1S"),
901 ] {
902 assert_eq!(format_duration(Duration::from_secs(secs))?, parsed);
903 }
904
905 Ok(())
906 }
907}