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}