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