manta_server/service/
authorization.rs

1//! Authorization helpers: validate user access to HSM groups and
2//! their members.
3//!
4//! Every service-layer function that takes a node, group, or session
5//! label from the caller runs one of these checks before touching the
6//! backend. The standard pattern is:
7//!
8//! 1. Resolve the caller's request to a `Vec<String>` of xnames or
9//!    group labels (often via [`crate::service::node_ops`]).
10//! 2. Call [`validate_user_group_members_access`] (xnames) or
11//!    [`validate_user_group_vec_access`] (group labels).
12//! 3. Proceed to the actual backend mutation.
13//!
14//! Admin tokens carrying the [`PA_ADMIN`] role short-circuit every
15//! check to `Ok(())` without touching the backend, mirroring the
16//! "admin sees everything" expectation. Listing endpoints still
17//! validate so the response can't disclose more than the caller
18//! could have asked for directly.
19//!
20//! The short-circuit is centralised in the `pub(crate)` `is_admin`
21//! helper so that a future change — e.g. adding audit logging for
22//! admin bypasses, or gating on JWKS verification before skipping
23//! group-scope checks — only needs to touch one place.
24
25use manta_backend_dispatcher::error::Error;
26use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
27
28use crate::server::common::{app_context::InfraContext, jwt_ops};
29
30/// Keycloak role name that grants full admin access. The canonical
31/// definition lives in [`manta_shared::common::jwt_ops::PA_ADMIN`];
32/// this re-export keeps `service::authorization::PA_ADMIN` callers
33/// compiling after the relocation.
34pub use crate::server::common::jwt_ops::PA_ADMIN;
35
36/// Returns `true` when the caller is admin (carries `PA_ADMIN` in
37/// `realm_access.roles`). Admin tokens short-circuit every group-scope
38/// check in this module — see the module-level security note and the
39/// `jwt_ops.rs` module doc for the no-local-signature-verification posture.
40///
41/// This is the canonical admin-detection entry point for the service layer.
42/// All callers that need to branch on admin status must use this function
43/// rather than calling `jwt_ops::is_user_admin` directly, so that a future
44/// policy change (e.g. a different role name, audit logging) touches one place.
45pub(crate) fn is_admin(token: &str) -> bool {
46  jwt_ops::is_user_admin(token)
47}
48
49/// Return `Ok(())` when the caller carries the admin role (`pa_admin`);
50/// `Err(Error::BadRequest(...))` otherwise.
51///
52/// Use at handler boundaries that restrict an operation to admin users.
53/// Centralises the check so a future policy change (e.g. a different
54/// admin-role name, or audit-logging on admin access) touches one place.
55pub fn require_admin(token: &str) -> Result<(), Error> {
56  if is_admin(token) {
57    Ok(())
58  } else {
59    Err(Error::BadRequest(
60      "this operation requires admin privileges".to_string(),
61    ))
62  }
63}
64
65/// Validate that `group_name` is in the set this token can access.
66///
67/// Used by handlers that perform privileged HSM-group operations and
68/// need a server-side authorization check before delegating to the
69/// service layer. Returns `Error::BadRequest` with a usable error
70/// message when the group is not accessible.
71pub async fn validate_user_group_access(
72  infra: &InfraContext<'_>,
73  token: &str,
74  group_name: &str,
75) -> Result<(), Error> {
76  if is_admin(token) {
77    return Ok(());
78  }
79
80  let group_available_vec =
81    infra.backend.get_group_name_available(token).await?;
82
83  validate_group_vec_access(&[group_name.to_string()], &group_available_vec)
84}
85
86/// Validate that every label in `group_vec` is in the set the token
87/// can access.
88///
89/// Admin tokens (carrying the [`PA_ADMIN`] role) short-circuit to
90/// `Ok` without touching the backend. Otherwise the available-group
91/// list is fetched once and matched against `group_vec`. Use the
92/// single-group variant [`validate_user_group_access`] when you only
93/// need to check one label.
94pub async fn validate_user_group_vec_access(
95  infra: &InfraContext<'_>,
96  token: &str,
97  group_vec: &[String],
98) -> Result<(), Error> {
99  if is_admin(token) {
100    return Ok(());
101  }
102
103  let group_available_vec =
104    infra.backend.get_group_name_available(token).await?;
105
106  validate_group_vec_access(group_vec, &group_available_vec)
107}
108
109/// Pure check that every label in `group_target_vec` appears in
110/// `group_available_vec`.
111///
112/// The async wrappers above resolve `group_available_vec` from the
113/// backend; this entry point exists for callers that already have
114/// the available list in hand (or for unit tests). On failure the
115/// `BadRequest` message lists the offending labels followed by the
116/// allowed set, so the user gets an actionable hint without a second
117/// round-trip.
118pub fn validate_group_vec_access(
119  group_target_vec: &[String],
120  group_available_vec: &[String],
121) -> Result<(), Error> {
122  let mut invalid_group_vec: Vec<String> = group_target_vec
123    .iter()
124    .filter(|group| !group_available_vec.contains(group))
125    .cloned()
126    .collect();
127
128  if invalid_group_vec.is_empty() {
129    Ok(())
130  } else {
131    invalid_group_vec.sort();
132
133    Err(Error::BadRequest(format!(
134      "Invalid groups '{:?}'.\nPlease choose one from the list below:\n{}",
135      invalid_group_vec,
136      group_available_vec.join(", ")
137    )))
138  }
139}
140
141/// Fetch the caller's accessible group list from the backend and, for
142/// non-admin callers, validate that every label in `target_groups` is
143/// in the accessible set.
144///
145/// Returns the full fetched list so the caller can forward it to the
146/// backend without a second round-trip. Admin tokens skip the
147/// validation step but still return the fetched list (some callers
148/// need it for other purposes regardless of admin status).
149///
150/// This is the canonical helper for functions that need *both*:
151/// 1. An admin-bypass guard around group-scope validation.
152/// 2. The fetched available-group list for a downstream backend call.
153///
154/// The classic anti-pattern was to call `get_group_name_available` →
155/// `is_admin` → `validate_group_vec_access` inline at each call site;
156/// this helper folds those three steps into one.
157pub(crate) async fn fetch_group_names_and_validate_access(
158  infra: &InfraContext<'_>,
159  token: &str,
160  target_groups: &[String],
161) -> Result<Vec<String>, Error> {
162  let available = infra.backend.get_group_name_available(token).await?;
163  if !is_admin(token) {
164    validate_group_vec_access(target_groups, &available)?;
165  }
166  Ok(available)
167}
168
169/// Validate every xname in a comma-separated `ansible_limit`-style
170/// string against the caller's accessible groups.
171///
172/// Splits on `,`, trims, and forwards to
173/// [`validate_user_group_members_access`]. Admin tokens skip the
174/// check entirely. Use this at handler boundaries where the request
175/// shape is the raw ansible-limit string (e.g. CFS session creation).
176pub async fn validate_ansible_limit_membership_access(
177  infra: &InfraContext<'_>,
178  token: &str,
179  ansible_limit: &str,
180) -> Result<(), Error> {
181  if is_admin(token) {
182    return Ok(());
183  }
184
185  let xnames: Vec<String> = ansible_limit
186    .split(',')
187    .map(|s| s.trim().to_string())
188    .collect();
189  validate_user_group_members_access(infra, token, &xnames).await
190}
191
192/// Validate that every xname in `group_members_target_vec` is a
193/// member of at least one group the token can access.
194///
195/// Admin tokens skip the check. Otherwise the caller's accessible
196/// group list is fetched, expanded to member xnames, and matched
197/// against the request. This is the standard membership gate used by
198/// the per-node and per-host service helpers.
199pub async fn validate_user_group_members_access(
200  infra: &InfraContext<'_>,
201  token: &str,
202  group_members_target_vec: &[String],
203) -> Result<(), Error> {
204  if is_admin(token) {
205    return Ok(());
206  }
207
208  let hsm_groups_user_has_access =
209    infra.backend.get_group_name_available(token).await?;
210
211  validate_group_members_access(
212    infra,
213    token,
214    group_members_target_vec,
215    &hsm_groups_user_has_access,
216  )
217  .await
218}
219
220/// Like [`validate_user_group_members_access`] but with the
221/// caller-accessible group list supplied explicitly.
222///
223/// Lets a caller that has already fetched `hsm_groups_user_has_access`
224/// reuse it across several membership checks without an extra
225/// round-trip. Admin tokens still short-circuit.
226pub async fn validate_group_members_access(
227  infra: &InfraContext<'_>,
228  token: &str,
229  group_members_target_vec: &[String],
230  hsm_groups_user_has_access: &[String],
231) -> Result<(), Error> {
232  if is_admin(token) {
233    return Ok(());
234  }
235
236  let all_xnames_user_has_access = infra
237    .backend
238    .get_member_vec_from_group_name_vec(token, hsm_groups_user_has_access)
239    .await?;
240
241  // Hash the accessible-xname set once. It can be cluster-scale (every
242  // xname in every group the caller can see), so the previous
243  // `.contains()` per target was O(target_count · accessible_count).
244  let accessible_set: std::collections::HashSet<&str> =
245    all_xnames_user_has_access
246      .iter()
247      .map(String::as_str)
248      .collect();
249  let invalid_xnames: Vec<String> = group_members_target_vec
250    .iter()
251    .filter(|group| !accessible_set.contains(group.as_str()))
252    .cloned()
253    .collect();
254
255  if invalid_xnames.is_empty() {
256    Ok(())
257  } else {
258    Err(Error::BadRequest(format!(
259      "Invalid group members:\n'{:?}'.\nPlease choose members from the list of groups below:\n{}",
260      invalid_xnames,
261      hsm_groups_user_has_access.join(", ")
262    )))
263  }
264}
265
266#[cfg(test)]
267mod tests {
268  use super::*;
269
270  fn s(v: &[&str]) -> Vec<String> {
271    v.iter().map(|s| (*s).to_string()).collect()
272  }
273
274  #[test]
275  fn allows_when_every_target_is_in_available_set() {
276    let result = validate_group_vec_access(
277      &s(&["compute", "login"]),
278      &s(&["compute", "login", "storage"]),
279    );
280    assert!(result.is_ok(), "got {result:?}");
281  }
282
283  #[test]
284  fn allows_empty_target_set() {
285    let result = validate_group_vec_access(&[], &s(&["compute"]));
286    assert!(result.is_ok(), "got {result:?}");
287  }
288
289  #[test]
290  fn rejects_when_any_target_is_missing_from_available_set() {
291    let err =
292      validate_group_vec_access(&s(&["compute", "secret"]), &s(&["compute"]))
293        .unwrap_err();
294    let Error::BadRequest(msg) = err else {
295      panic!("expected BadRequest, got {err:?}");
296    };
297    assert!(
298      msg.contains("\"secret\""),
299      "error message should name the offending group: {msg}"
300    );
301    assert!(
302      !msg.contains("\"compute\""),
303      "error message should not name the allowed group: {msg}"
304    );
305  }
306
307  #[test]
308  fn rejects_when_available_set_is_empty() {
309    let err = validate_group_vec_access(&s(&["compute"]), &[]).unwrap_err();
310    assert!(matches!(err, Error::BadRequest(_)));
311  }
312
313  // Sorting the offending list keeps the error message deterministic
314  // across runs — important for CLI users grepping their failure log.
315  #[test]
316  fn error_message_sorts_offending_groups_alphabetically() {
317    let err =
318      validate_group_vec_access(&s(&["zeta", "alpha", "mu"]), &s(&["other"]))
319        .unwrap_err();
320    let Error::BadRequest(msg) = err else {
321      panic!("expected BadRequest, got {err:?}");
322    };
323    let alpha = msg.find("alpha").expect("alpha listed");
324    let mu = msg.find("mu").expect("mu listed");
325    let zeta = msg.find("zeta").expect("zeta listed");
326    assert!(alpha < mu && mu < zeta, "got: {msg}");
327  }
328}