manta_server/service/
group.rs

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