Skip to main content

azure_pim_cli/
lib.rs

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    /// List the roles available to the current user
115    ///
116    /// # Errors
117    /// Will return `Err` if the request fails or the response is not valid JSON
118    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    /// List the roles active role assignments for the current user
172    ///
173    /// # Errors
174    /// Will return `Err` if the request fails or the response is not valid JSON
175    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    /// Request extending the specified role eligibility
230    ///
231    /// # Errors
232    /// Will return `Err` if the request fails or the response is not valid JSON
233    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    /// Activates the specified role
281    ///
282    /// # Errors
283    /// Will return `Err` if the request fails or the response is not valid JSON
284    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    /// Deactivate the specified role
380    ///
381    /// # Errors
382    /// Will return `Err` if the request fails or the response is not valid JSON
383    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            // only check active assignments every `wait_timeout` seconds.
479            //
480            // While the list active assignments endpoint takes ~10-30 seconds
481            // today, it could go faster in the future and this should avoid
482            // spamming said API
483            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    /// List role assignments
512    ///
513    /// # Errors
514    /// Will return `Err` if the request fails or the response is not valid JSON
515    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    /// List eligible child resources for the specified scope
542    ///
543    /// # Errors
544    /// Will return `Err` if the request fails or the response is not valid JSON
545    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: Vec<Result<Result<BTreeSet<ChildResource>>>> = todo
557            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    /// List role definitions available at the target scope
589    ///
590    /// Note, this will cache the results for 10 minutes.
591    ///
592    /// # Errors
593    /// Will return `Err` if the request fails or the response is not valid JSON
594    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    /// Delete a role assignment
617    ///
618    /// # Errors
619    /// Will return `Err` if the request fails or the response is not valid JSON
620    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    /// Delete eligible role assignment
633    ///
634    /// This removes role assignments that are available via PIM.
635    ///
636    /// # Errors
637    /// Will return `Err` if the request fails or the response is not valid JSON
638    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}