Skip to main content

heddle_api/v2/
approval_groups.rs

1//! Live role-group membership; hosts bind this lookup to landing evaluation.
2use crate::heddle::api::v1alpha2::{
3    ApprovalGroupRecord, ApprovalGroupView, ResourceRole, ReviewPolicyRecord, SuggestedPrincipal,
4};
5use crate::v2::people::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
24/// Trusted current human/agent record. `effective_role` is resolved over the
25/// spool and all applicable ancestor grants at this evaluation, not approval
26/// creation. Missing/removed membership is UNSPECIFIED.
27pub struct ApprovalPrincipal {
28    pub subject: String,
29    pub person: SuggestedPrincipal,
30    pub effective_role: ResourceRole,
31    pub is_agent: bool,
32    /// Derived from THIS group's private stable bindings, never wire handles.
33    /// Rebuild this flag for each group evaluated; it is not a global person flag.
34    pub explicit_member: bool,
35    pub handle_visible: bool,
36}
37
38/// Combine current direct and applicable inherited roles. The host must load
39/// only ancestor grants whose include_descendants permits this spool.
40pub 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
56/// PutApprovalGroup: map RoleBelowEligibilityFloor to INVALID_ARGUMENT,
57/// ErrorReason::ApprovalRoleBelowEligibilityFloor, field group.member_role.
58pub 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: 1,
64            ..Default::default()
65        })
66        .map_err(|_| ApprovalGroupError::Metadata)?;
67    }
68    Ok(())
69}
70
71/// PutReviewPolicy: same typed reason, field policy.minimum_role.
72pub 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
88/// Trusted live disclosure decisions. can_read_members means BOTH current
89/// membership and MEMBERS-section read, in addition to approval-group read.
90pub struct ApprovalGroupViewContext {
91    pub can_read_members: bool,
92    pub is_administrator: bool,
93}
94
95/// SERVER ONLY: PutApprovalGroup after current admin authorization. Resolve each
96/// visible human handle within authorized indexes and bind stable subjects in
97/// private storage. A missing/hidden/agent handle uniformly returns unavailable.
98/// Returns the full replacement binding set, preserving current hidden/no-handle
99/// explicit humans when replacing visible configuration. Load current for THIS
100/// group under the same write/authorization lock.
101pub 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
136/// Counts use stable subjects, including members whose handle is hidden/absent.
137/// Hosts load all explicit bindings, including ineligible extras, for edits.
138pub 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
222/// Count distinct admitted approvers who are STILL eligible and members.
223/// `approvers` must already exclude revoked, stale, expired, author-forbidden,
224/// wrong-revision, or otherwise invalid approvals. This is the group/role
225/// portion of evaluation, not a replacement for the full landing evaluator.
226/// Stored legacy minimum_role=READER evaluates at WRITER; new writes refuse it.
227pub 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}