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}