manta_server/service/group.rs
1//! HSM group CRUD operations and membership management.
2//!
3//! Backs the `/groups` family of handlers. Every public function
4//! gates access through [`crate::service::authorization`] before
5//! reaching the backend so callers can only see and mutate groups
6//! their JWT grants them.
7//!
8//! Membership operations (`add_nodes_to_group`,
9//! `delete_group_members`) resolve a hosts expression via
10//! [`node_ops::from_user_hosts_expression_to_xname_vec`] first and
11//! re-validate per-xname group access before issuing per-node backend
12//! writes — the resolver runs against full cluster metadata so the
13//! caller-supplied expression may name nodes outside their reach.
14
15use manta_backend_dispatcher::error::Error;
16use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
17use manta_backend_dispatcher::types::Group;
18
19use crate::server::common::app_context::InfraContext;
20use crate::service::authorization::{
21 is_admin, validate_group_vec_access, validate_user_group_members_access,
22};
23use crate::service::node_ops;
24pub use manta_shared::types::api::group::GetGroupParams;
25
26/// Resolve the caller's accessible groups (`Vec<Group>`) and the
27/// target-label vector in a single backend round-trip.
28///
29/// Three places in the service layer repeated the same three-call
30/// dance:
31///
32/// 1. `get_group_available` to derive labels (used when no settings
33/// group is supplied).
34/// 2. `validate_user_group_vec_access` which itself called
35/// `get_group_name_available` to verify the labels.
36/// 3. A second `get_group_available` inside a `try_join!` to fetch the
37/// full `Vec<Group>` needed by the downstream filter.
38///
39/// All three steps want the same data; the helper folds them into one
40/// `get_group_available` call plus an in-memory check. Non-admin
41/// callers still get the same access-validation guarantee (they're
42/// rejected with `BadRequest` if `settings_group_name_opt` names a
43/// group they can't see); admin tokens short-circuit, matching the
44/// behaviour of [`crate::service::authorization::validate_user_group_vec_access`].
45///
46/// # Errors
47///
48/// - [`Error::BadRequest`] when the non-admin caller's
49/// `settings_group_name_opt` is not in their accessible-group set.
50/// - [`Error::NetError`] / [`Error::CsmError`] from
51/// `get_group_available`.
52pub async fn resolve_target_and_available_groups(
53 infra: &InfraContext<'_>,
54 token: &str,
55 settings_group_name_opt: Option<&str>,
56) -> Result<(Vec<Group>, Vec<String>), Error> {
57 let group_available_vec = infra.backend.get_group_available(token).await?;
58
59 let target_group_vec: Vec<String> = match settings_group_name_opt {
60 Some(label) => {
61 if !is_admin(token) {
62 let available_labels: Vec<String> = group_available_vec
63 .iter()
64 .map(|g| g.label.clone())
65 .collect();
66 validate_group_vec_access(
67 std::slice::from_ref(&label.to_string()),
68 &available_labels,
69 )?;
70 }
71 vec![label.to_string()]
72 }
73 None => group_available_vec
74 .iter()
75 .map(|g| g.label.clone())
76 .collect(),
77 };
78
79 Ok((group_available_vec, target_group_vec))
80}
81
82/// List the group names accessible to the caller.
83///
84/// Thin forwarder; used by handlers that need the raw accessible-group
85/// label list without the access-validation logic baked into
86/// [`crate::service::authorization::validate_user_group_access`]. Service code that needs the full
87/// `Vec<Group>` (including members) should call `get_group_available`
88/// on the backend directly — this helper is intentionally label-only.
89pub async fn get_available_groups(
90 infra: &InfraContext<'_>,
91 token: &str,
92) -> Result<Vec<String>, Error> {
93 infra.backend.get_group_name_available(token).await
94}
95
96/// List HSM groups visible to the caller.
97///
98/// When `params.group_name` is set the lookup is scoped to that
99/// single label; otherwise it spans every group the token already
100/// grants access to. Group access is re-validated before the backend
101/// call so the response can't leak labels the caller couldn't have
102/// listed directly.
103///
104/// # Errors
105///
106/// - [`Error::BadRequest`] when `params.group_name` is unreachable
107/// for a non-admin caller.
108/// - Any error from
109/// [`resolve_target_and_available_groups`] or the backend's
110/// `get_groups` call.
111pub async fn get_groups(
112 infra: &InfraContext<'_>,
113 token: &str,
114 params: &GetGroupParams,
115) -> Result<Vec<Group>, Error> {
116 // Single backend fetch + in-memory access validation replaces
117 // three sequential round-trips (label derivation, validation,
118 // backend fetch). See [`resolve_target_and_available_groups`].
119 let (_group_available_vec, target_group_vec) =
120 resolve_target_and_available_groups(
121 infra,
122 token,
123 params.group_name.as_deref(),
124 )
125 .await?;
126
127 infra
128 .backend
129 .get_groups(token, Some(&target_group_vec))
130 .await
131}
132
133/// Check that deleting `label` would not leave any node without a
134/// group.
135///
136/// An xname is "orphaned" if `label` is its only HSM group. When at
137/// least one such node exists, returns
138/// `Error::Conflict` listing the orphans so the operator can decide
139/// whether to move them first or pass `force` to
140/// [`delete_group`].
141///
142/// # Errors
143///
144/// - [`Error::Conflict`] when one or more members would be orphaned;
145/// the message lists the affected xnames sorted alphabetically.
146/// - [`Error::NetError`] / [`Error::CsmError`] from the
147/// `get_member_vec_from_group_name_vec` and
148/// `get_group_map_and_filter_by_group_vec` backend calls.
149pub async fn validate_group_deletion(
150 infra: &InfraContext<'_>,
151 token: &str,
152 label: &str,
153) -> Result<(), Error> {
154 let xname_vec = infra
155 .backend
156 .get_member_vec_from_group_name_vec(token, &[label.to_string()])
157 .await?;
158
159 let xname_vec_ref: Vec<&str> = xname_vec.iter().map(String::as_str).collect();
160 let mut xname_map = infra
161 .backend
162 .get_group_map_and_filter_by_group_vec(token, &xname_vec_ref)
163 .await?;
164
165 xname_map.retain(|_xname, group_name_vec| {
166 group_name_vec.len() == 1
167 && group_name_vec.first().is_some_and(|name| name == label)
168 });
169
170 let mut members_orphan_if_group_deleted: Vec<String> =
171 xname_map.into_keys().collect();
172 members_orphan_if_group_deleted.sort();
173
174 if !members_orphan_if_group_deleted.is_empty() {
175 return Err(Error::Conflict(format!(
176 "The hosts below will become orphan if group '{}' gets deleted: {}",
177 label,
178 members_orphan_if_group_deleted.join(", ")
179 )));
180 }
181
182 Ok(())
183}
184
185/// Delete the HSM group named `label`.
186///
187/// Unless `force` is set, [`validate_group_deletion`] runs first and
188/// the delete is rejected if any node would be orphaned.
189///
190/// # Errors
191///
192/// Any error from [`validate_group_deletion`] when `force` is false,
193/// plus [`Error::NetError`] / [`Error::CsmError`] from the backend
194/// `delete_group` call.
195pub async fn delete_group(
196 infra: &InfraContext<'_>,
197 token: &str,
198 label: &str,
199 force: bool,
200) -> Result<(), Error> {
201 if !force {
202 validate_group_deletion(infra, token, label).await?;
203 }
204 infra.backend.delete_group(token, label).await.map(|_| ())
205}
206
207/// Create the HSM group described by `group`.
208///
209/// The backend rejects duplicate labels; manta does no pre-check
210/// beyond the standard authorization layer applied by the handler.
211///
212/// # Errors
213///
214/// [`Error::NetError`] / [`Error::CsmError`] / [`Error::Conflict`]
215/// surfaced verbatim from the backend's `add_group` call (the latter
216/// when `group.label` already exists).
217pub async fn create_group(
218 infra: &InfraContext<'_>,
219 token: &str,
220 group: Group,
221) -> Result<(), Error> {
222 infra.backend.add_group(token, group).await.map(|_| ())
223}
224
225/// Resolve `host_expression` and remove the resolved nodes from
226/// `group_name`.
227///
228/// With `dry_run = true`, only the resolution runs — no backend
229/// mutation. Errors from the per-node deletion abort the loop and
230/// surface to the handler, so a partially completed batch is
231/// possible.
232///
233/// # Errors
234///
235/// - [`Error::InvalidNodeId`] / [`Error::BadRequest`] when
236/// `host_expression` can't be parsed by
237/// [`crate::service::node_ops::from_hosts_expression_to_xname_vec`].
238/// - [`Error::BadRequest`] when the resolution produces no xnames
239/// (a literal "nothing to do" guard).
240/// - [`Error::BadRequest`] when the caller lacks access to one of the
241/// resolved xnames (via
242/// [`crate::service::authorization::validate_user_group_members_access`]).
243/// - [`Error::NetError`] / [`Error::CsmError`] from
244/// `get_node_metadata_available` and per-node
245/// `delete_member_from_group`.
246pub async fn delete_group_members(
247 infra: &InfraContext<'_>,
248 token: &str,
249 group_name: &str,
250 host_expression: &str,
251 dry_run: bool,
252) -> Result<(), Error> {
253 let xname_vec = node_ops::from_user_hosts_expression_to_xname_vec(
254 infra,
255 token,
256 host_expression,
257 false,
258 )
259 .await?;
260
261 validate_user_group_members_access(infra, token, &xname_vec).await?;
262
263 for xname in &xname_vec {
264 if dry_run {
265 tracing::info!(
266 "Dryrun enabled: no changes persisted into the system.\nGroup member '{}' removed from group '{}'",
267 xname,
268 group_name
269 );
270 } else {
271 infra
272 .backend
273 .delete_member_from_group(token, group_name, xname)
274 .await?;
275 }
276 }
277
278 Ok(())
279}
280
281/// Resolve `hosts_expression` and add the resulting nodes to the
282/// existing HSM group `target_hsm_name`.
283///
284/// The target group must already exist (an explicit `NotFound` is
285/// returned rather than the backend's opaque error). An empty
286/// resolution is rejected with `BadRequest`. Returns the resolved
287/// xnames alongside the group's sorted, post-update membership.
288///
289/// # Errors
290///
291/// - [`Error::BadRequest`] when `hosts_expression` is invalid,
292/// resolves to an empty set, or names xnames the caller cannot
293/// reach.
294/// - [`Error::NotFound`] when `target_hsm_name` does not exist.
295/// - [`Error::NetError`] / [`Error::CsmError`] from
296/// `add_members_to_group`.
297pub async fn add_nodes_to_group(
298 infra: &InfraContext<'_>,
299 token: &str,
300 target_hsm_name: &str,
301 hosts_expression: &str,
302) -> Result<(Vec<String>, Vec<String>), Error> {
303 let xname_to_move_vec = node_ops::from_user_hosts_expression_to_xname_vec(
304 infra,
305 token,
306 hosts_expression,
307 false,
308 )
309 .await?;
310
311 validate_user_group_members_access(infra, token, &xname_to_move_vec).await?;
312
313 if infra
314 .backend
315 .get_group(token, target_hsm_name)
316 .await
317 .is_err()
318 {
319 return Err(Error::NotFound(format!(
320 "Target HSM group '{target_hsm_name}' does not exist"
321 )));
322 }
323
324 let xnames_to_move: Vec<&str> =
325 xname_to_move_vec.iter().map(String::as_str).collect();
326
327 let mut updated_members = infra
328 .backend
329 .add_members_to_group(token, target_hsm_name, &xnames_to_move)
330 .await?;
331
332 updated_members.sort();
333
334 Ok((xname_to_move_vec, updated_members))
335}