heddle_api/v2/
approval_groups.rs1use crate::heddle::api::v1alpha2::{
3 ApprovalGroupRecord, ApprovalGroupView, ResourceRole, ReviewPolicyRecord, SuggestedPrincipal,
4};
5use crate::v2::people::{classify_handle_kind, validate_person};
6use std::collections::BTreeSet;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
9pub enum ApprovalGroupError {
10 #[error("APPROVAL_ROLE_BELOW_ELIGIBILITY_FLOOR")]
11 RoleBelowEligibilityFloor,
12 #[error("approval-group administrator required")]
13 Administrator,
14 #[error("explicit member handle unavailable")]
15 HandleNotFound,
16 #[error("invalid ResourceRole")]
17 Role,
18 #[error("invalid resolved member metadata")]
19 Metadata,
20 #[error("approval group projection differs from live membership")]
21 Projection,
22}
23
24pub struct ApprovalPrincipal {
28 pub subject: String,
29 pub person: SuggestedPrincipal,
30 pub effective_role: ResourceRole,
31 pub is_agent: bool,
32 pub explicit_member: bool,
35 pub handle_visible: bool,
36}
37
38pub fn effective_resource_role(roles: &[ResourceRole]) -> ResourceRole {
41 roles
42 .iter()
43 .copied()
44 .max_by_key(|role| *role as i32)
45 .unwrap_or(ResourceRole::Unspecified)
46}
47
48fn validate_threshold(role: i32) -> Result<(), ApprovalGroupError> {
49 let role = ResourceRole::try_from(role).map_err(|_| ApprovalGroupError::Role)?;
50 if role == ResourceRole::Reader {
51 return Err(ApprovalGroupError::RoleBelowEligibilityFloor);
52 }
53 Ok(())
54}
55
56pub fn validate_approval_group(group: &ApprovalGroupRecord) -> Result<(), ApprovalGroupError> {
59 validate_threshold(group.member_role)?;
60 for handle in &group.explicit_member_handles {
61 validate_person(&SuggestedPrincipal {
62 handle: handle.clone(),
63 kind: classify_handle_kind(handle).map_err(|_| ApprovalGroupError::Metadata)? as i32,
64 ..Default::default()
65 })
66 .map_err(|_| ApprovalGroupError::Metadata)?;
67 }
68 Ok(())
69}
70
71pub fn validate_review_policy(policy: &ReviewPolicyRecord) -> Result<(), ApprovalGroupError> {
73 validate_threshold(policy.minimum_role)
74}
75
76fn role_member(group: &ApprovalGroupRecord, principal: &ApprovalPrincipal) -> bool {
77 group.member_role != ResourceRole::Unspecified as i32
78 && principal.effective_role as i32 >= group.member_role
79}
80
81fn is_member(group: &ApprovalGroupRecord, principal: &ApprovalPrincipal) -> bool {
82 !principal.is_agent
83 && !principal.subject.is_empty()
84 && principal.effective_role as i32 >= ResourceRole::Writer as i32
85 && (principal.explicit_member || role_member(group, principal))
86}
87
88pub struct ApprovalGroupViewContext {
91 pub can_read_members: bool,
92 pub is_administrator: bool,
93}
94
95pub fn resolve_approval_group_members(
102 group: &ApprovalGroupRecord,
103 is_administrator: bool,
104 current: &[ApprovalPrincipal],
105 mut resolve_visible_human: impl FnMut(&str) -> Option<String>,
106) -> Result<Vec<String>, ApprovalGroupError> {
107 if !is_administrator {
108 return Err(ApprovalGroupError::Administrator);
109 }
110 validate_approval_group(group)?;
111 group
112 .explicit_member_handles
113 .iter()
114 .map(|handle| {
115 resolve_visible_human(handle)
116 .filter(|subject| !subject.is_empty())
117 .ok_or(ApprovalGroupError::HandleNotFound)
118 })
119 .collect::<Result<BTreeSet<_>, _>>()
120 .map(|mut subjects| {
121 subjects.extend(
122 current
123 .iter()
124 .filter(|p| {
125 p.explicit_member
126 && !p.is_agent
127 && !p.subject.is_empty()
128 && (!p.handle_visible || p.person.handle.is_empty())
129 })
130 .map(|p| p.subject.clone()),
131 );
132 subjects.into_iter().collect()
133 })
134}
135
136pub fn approval_group_view(
139 group: &ApprovalGroupRecord,
140 current: &[ApprovalPrincipal],
141 context: &ApprovalGroupViewContext,
142) -> Result<ApprovalGroupView, ApprovalGroupError> {
143 validate_approval_group(group)?;
144 let members: Vec<_> = current.iter().filter(|p| is_member(group, p)).collect();
145 let count = |principals: Vec<&ApprovalPrincipal>| -> u32 {
146 principals
147 .iter()
148 .map(|p| &p.subject)
149 .collect::<BTreeSet<_>>()
150 .len() as u32
151 };
152 let mut view = ApprovalGroupView {
153 r#ref: group.r#ref.clone(),
154 version: group.version.clone(),
155 name: group.name.clone(),
156 description: group.description.clone(),
157 member_role: group.member_role,
158 resolved_member_count: count(members.clone()),
159 role_member_count: count(
160 members
161 .iter()
162 .copied()
163 .filter(|p| role_member(group, p))
164 .collect(),
165 ),
166 explicit_member_count: count(
167 current
168 .iter()
169 .filter(|p| p.explicit_member && !p.is_agent && !p.subject.is_empty())
170 .collect(),
171 ),
172 ..Default::default()
173 };
174 if context.can_read_members {
175 for principal in &members {
176 if !principal.handle_visible || principal.person.handle.is_empty() {
177 continue;
178 }
179 validate_person(&principal.person).map_err(|_| ApprovalGroupError::Metadata)?;
180 view.resolved_members.push(principal.person.clone());
181 if role_member(group, principal) {
182 view.role_member_handles
183 .push(principal.person.handle.clone());
184 }
185 }
186 }
187 if context.is_administrator && context.can_read_members {
188 for principal in current
189 .iter()
190 .filter(|p| p.explicit_member && !p.is_agent && !p.subject.is_empty())
191 {
192 if !principal.handle_visible || principal.person.handle.is_empty() {
193 continue;
194 }
195 validate_person(&principal.person).map_err(|_| ApprovalGroupError::Metadata)?;
196 view.explicit_member_handles
197 .push(principal.person.handle.clone());
198 }
199 }
200 view.resolved_members
201 .sort_by(|a, b| a.handle.cmp(&b.handle));
202 view.resolved_members.dedup_by(|a, b| a.handle == b.handle);
203 view.role_member_handles.sort();
204 view.role_member_handles.dedup();
205 view.explicit_member_handles.sort();
206 view.explicit_member_handles.dedup();
207 Ok(view)
208}
209
210pub fn validate_approval_group_view(
211 view: &ApprovalGroupView,
212 group: &ApprovalGroupRecord,
213 current: &[ApprovalPrincipal],
214 context: &ApprovalGroupViewContext,
215) -> Result<(), ApprovalGroupError> {
216 if view != &approval_group_view(group, current, context)? {
217 return Err(ApprovalGroupError::Projection);
218 }
219 Ok(())
220}
221
222pub fn group_approval_count(
228 group: &ApprovalGroupRecord,
229 policy: &ReviewPolicyRecord,
230 current: &[ApprovalPrincipal],
231 approvers: &[String],
232) -> Result<usize, ApprovalGroupError> {
233 validate_approval_group(group)?;
234 ResourceRole::try_from(policy.minimum_role).map_err(|_| ApprovalGroupError::Role)?;
235 let minimum = policy.minimum_role.max(ResourceRole::Writer as i32);
236 let eligible: BTreeSet<_> = current
237 .iter()
238 .filter(|principal| {
239 is_member(group, principal) && principal.effective_role as i32 >= minimum
240 })
241 .map(|principal| &principal.subject)
242 .collect();
243 Ok(approvers
244 .iter()
245 .filter(|subject| eligible.contains(subject))
246 .collect::<BTreeSet<_>>()
247 .len())
248}