manta_server/service/
authorization.rs

1//! Authorization helpers: validate user access to HSM groups and their members.
2
3use manta_backend_dispatcher::error::Error;
4use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
5
6use crate::server::common::{app_context::InfraContext, jwt_ops};
7
8/// Keycloak role name that grants full admin access (bypasses HSM-group
9/// scoping checks).
10pub static PA_ADMIN: &str = "pa_admin";
11
12/// Validate that `group_name` is in the set this token can access.
13///
14/// Used by handlers that perform privileged HSM-group operations and
15/// need a server-side authorization check before delegating to the
16/// service layer. Returns `Error::BadRequest` with a usable error
17/// message when the group is not accessible.
18pub async fn validate_user_group_access(
19  infra: &InfraContext<'_>,
20  token: &str,
21  group_name: &str,
22) -> Result<(), Error> {
23  if jwt_ops::is_user_admin(token) {
24    return Ok(());
25  }
26
27  let group_available_vec =
28    infra.backend.get_group_name_available(token).await?;
29
30  validate_group_vec_access(&[group_name.to_string()], &group_available_vec)
31}
32
33/// Validate that every label in `group_vec` is in the set the token
34/// can access.
35///
36/// Admin tokens (carrying the [`PA_ADMIN`] role) short-circuit to
37/// `Ok` without touching the backend. Otherwise the available-group
38/// list is fetched once and matched against `group_vec`. Use the
39/// single-group variant [`validate_user_group_access`] when you only
40/// need to check one label.
41pub async fn validate_user_group_vec_access(
42  infra: &InfraContext<'_>,
43  token: &str,
44  group_vec: &[String],
45) -> Result<(), Error> {
46  if jwt_ops::is_user_admin(token) {
47    return Ok(());
48  }
49
50  let group_available_vec =
51    infra.backend.get_group_name_available(token).await?;
52
53  validate_group_vec_access(group_vec, &group_available_vec)
54}
55
56/// Pure check that every label in `group_target_vec` appears in
57/// `group_available_vec`.
58///
59/// The async wrappers above resolve `group_available_vec` from the
60/// backend; this entry point exists for callers that already have
61/// the available list in hand (or for unit tests). On failure the
62/// `BadRequest` message lists the offending labels followed by the
63/// allowed set, so the user gets an actionable hint without a second
64/// round-trip.
65pub fn validate_group_vec_access(
66  group_target_vec: &[String],
67  group_available_vec: &[String],
68) -> Result<(), Error> {
69  let mut invalid_group_vec: Vec<String> = group_target_vec
70    .iter()
71    .filter(|group| !group_available_vec.contains(group))
72    .cloned()
73    .collect();
74
75  if invalid_group_vec.is_empty() {
76    Ok(())
77  } else {
78    invalid_group_vec.sort();
79
80    Err(Error::BadRequest(format!(
81      "Invalid groups '{:?}'.\nPlease choose one from the list below:\n{}",
82      invalid_group_vec,
83      group_available_vec.join(", ")
84    )))
85  }
86}
87
88/// Validate every xname in a comma-separated `ansible_limit`-style
89/// string against the caller's accessible groups.
90///
91/// Splits on `,`, trims, and forwards to
92/// [`validate_user_group_members_access`]. Admin tokens skip the
93/// check entirely. Use this at handler boundaries where the request
94/// shape is the raw ansible-limit string (e.g. CFS session creation).
95pub async fn validate_ansible_limit_membership_access(
96  infra: &InfraContext<'_>,
97  token: &str,
98  ansible_limit: &str,
99) -> Result<(), Error> {
100  if jwt_ops::is_user_admin(token) {
101    return Ok(());
102  }
103
104  let xnames: Vec<String> = ansible_limit
105    .split(',')
106    .map(|s| s.trim().to_string())
107    .collect();
108  validate_user_group_members_access(infra, token, &xnames).await
109}
110
111/// Validate that every xname in `group_members_target_vec` is a
112/// member of at least one group the token can access.
113///
114/// Admin tokens skip the check. Otherwise the caller's accessible
115/// group list is fetched, expanded to member xnames, and matched
116/// against the request. This is the standard membership gate used by
117/// the per-node and per-host service helpers.
118pub async fn validate_user_group_members_access(
119  infra: &InfraContext<'_>,
120  token: &str,
121  group_members_target_vec: &[String],
122) -> Result<(), Error> {
123  if jwt_ops::is_user_admin(token) {
124    return Ok(());
125  }
126
127  let hsm_groups_user_has_access =
128    infra.backend.get_group_name_available(token).await?;
129
130  validate_group_members_access(
131    infra,
132    token,
133    group_members_target_vec,
134    &hsm_groups_user_has_access,
135  )
136  .await
137}
138
139/// Like [`validate_user_group_members_access`] but with the
140/// caller-accessible group list supplied explicitly.
141///
142/// Lets a caller that has already fetched `hsm_groups_user_has_access`
143/// reuse it across several membership checks without an extra
144/// round-trip. Admin tokens still short-circuit.
145pub async fn validate_group_members_access(
146  infra: &InfraContext<'_>,
147  token: &str,
148  group_members_target_vec: &[String],
149  hsm_groups_user_has_access: &[String],
150) -> Result<(), Error> {
151  if jwt_ops::is_user_admin(token) {
152    return Ok(());
153  }
154
155  let all_xnames_user_has_access = infra
156    .backend
157    .get_member_vec_from_group_name_vec(token, hsm_groups_user_has_access)
158    .await?;
159
160  // Hash the accessible-xname set once. It can be cluster-scale (every
161  // xname in every group the caller can see), so the previous
162  // `.contains()` per target was O(target_count · accessible_count).
163  let accessible_set: std::collections::HashSet<&str> =
164    all_xnames_user_has_access
165      .iter()
166      .map(String::as_str)
167      .collect();
168  let invalid_xnames: Vec<String> = group_members_target_vec
169    .iter()
170    .filter(|group| !accessible_set.contains(group.as_str()))
171    .cloned()
172    .collect();
173
174  if invalid_xnames.is_empty() {
175    Ok(())
176  } else {
177    Err(Error::BadRequest(format!(
178      "Invalid group members:\n'{:?}'.\nPlease choose members from the list of groups below:\n{}",
179      invalid_xnames,
180      hsm_groups_user_has_access.join(", ")
181    )))
182  }
183}
184
185#[cfg(test)]
186mod tests {
187  use super::*;
188
189  fn s(v: &[&str]) -> Vec<String> {
190    v.iter().map(|s| (*s).to_string()).collect()
191  }
192
193  #[test]
194  fn allows_when_every_target_is_in_available_set() {
195    let result = validate_group_vec_access(
196      &s(&["compute", "login"]),
197      &s(&["compute", "login", "storage"]),
198    );
199    assert!(result.is_ok(), "got {result:?}");
200  }
201
202  #[test]
203  fn allows_empty_target_set() {
204    let result = validate_group_vec_access(&[], &s(&["compute"]));
205    assert!(result.is_ok(), "got {result:?}");
206  }
207
208  #[test]
209  fn rejects_when_any_target_is_missing_from_available_set() {
210    let err =
211      validate_group_vec_access(&s(&["compute", "secret"]), &s(&["compute"]))
212        .unwrap_err();
213    let Error::BadRequest(msg) = err else {
214      panic!("expected BadRequest, got {err:?}");
215    };
216    assert!(
217      msg.contains("\"secret\""),
218      "error message should name the offending group: {msg}"
219    );
220    assert!(
221      !msg.contains("\"compute\""),
222      "error message should not name the allowed group: {msg}"
223    );
224  }
225
226  #[test]
227  fn rejects_when_available_set_is_empty() {
228    let err = validate_group_vec_access(&s(&["compute"]), &[]).unwrap_err();
229    assert!(matches!(err, Error::BadRequest(_)));
230  }
231
232  // Sorting the offending list keeps the error message deterministic
233  // across runs — important for CLI users grepping their failure log.
234  #[test]
235  fn error_message_sorts_offending_groups_alphabetically() {
236    let err =
237      validate_group_vec_access(&s(&["zeta", "alpha", "mu"]), &s(&["other"]))
238        .unwrap_err();
239    let Error::BadRequest(msg) = err else {
240      panic!("expected BadRequest, got {err:?}");
241    };
242    let alpha = msg.find("alpha").expect("alpha listed");
243    let mu = msg.find("mu").expect("mu listed");
244    let zeta = msg.find("zeta").expect("zeta listed");
245    assert!(alpha < mu && mu < zeta, "got: {msg}");
246  }
247}