manta_server/server/handlers/
group.rs

1//! HSM group CRUD + membership handlers.
2//!
3//! - `GET    /api/v1/groups`                  → [`get_groups`]
4//! - `GET    /api/v1/groups/available`        → [`get_available_groups`]
5//! - `POST   /api/v1/groups`                  → [`create_group`]
6//! - `DELETE /api/v1/groups/{label}`          → [`delete_group`]
7//! - `POST   /api/v1/groups/{name}/members`   → [`add_nodes_to_group`]
8//! - `DELETE /api/v1/groups/{name}/members`   → [`delete_group_members`]
9//!
10//! All wrap `crate::service::group::*`.
11
12use axum::{
13  Json,
14  extract::{Path, Query},
15  http::StatusCode,
16  response::IntoResponse,
17};
18
19use super::{ErrorResponse, RequestCtx, SiteHeader, to_handler_error};
20use crate::service;
21
22// ---------------------------------------------------------------------------
23// GET /api/v1/groups
24// ---------------------------------------------------------------------------
25
26pub use manta_shared::types::api::queries::{DeleteGroupQuery, GroupQuery};
27
28/// GET /groups/available — list HSM group names the token can access.
29#[utoipa::path(get, path = "/groups/available", tag = "groups",
30  params(SiteHeader),
31  security(("bearerAuth" = [])),
32  responses(
33    (status = 200, description = "List of accessible group names", body = Vec<String>),
34    (status = 401, description = "Unauthorized",                   body = ErrorResponse),
35    (status = 500, description = "Internal error",                 body = ErrorResponse),
36  )
37)]
38#[tracing::instrument(skip_all)]
39pub async fn get_available_groups(
40  ctx: RequestCtx,
41) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
42  let infra = ctx.infra();
43  let names = service::group::get_available_groups(&infra, &ctx.token)
44    .await
45    .map_err(to_handler_error)?;
46  Ok(Json(names))
47}
48
49/// GET /groups — list HSM groups, optionally filtered by name.
50#[utoipa::path(get, path = "/groups", tag = "groups",
51  params(GroupQuery, SiteHeader),
52  security(("bearerAuth" = [])),
53  responses(
54    (status = 200, description = "List of groups", body = Vec<manta_backend_dispatcher::types::Group>),
55    (status = 401, description = "Unauthorized",   body = ErrorResponse),
56    (status = 500, description = "Internal error", body = ErrorResponse),
57  )
58)]
59#[tracing::instrument(skip_all)]
60pub async fn get_groups(
61  ctx: RequestCtx,
62  Query(q): Query<GroupQuery>,
63) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
64  let infra = ctx.infra();
65
66  let params = service::group::GetGroupParams {
67    group_name: q.name,
68    settings_group_name: None,
69  };
70
71  let groups = service::group::get_groups(&infra, &ctx.token, &params)
72    .await
73    .map_err(to_handler_error)?;
74
75  Ok(Json(groups))
76}
77
78// ---------------------------------------------------------------------------
79// DELETE /api/v1/groups/{label}
80// ---------------------------------------------------------------------------
81
82/// DELETE /groups/{label} — remove an HSM group.
83#[utoipa::path(delete, path = "/groups/{label}", tag = "groups",
84  params(("label" = String, Path, description = "Group label"), DeleteGroupQuery, SiteHeader),
85  security(("bearerAuth" = [])),
86  responses(
87    (status = 204, description = "Group removed"),
88    (status = 401, description = "Unauthorized",   body = ErrorResponse),
89    (status = 404, description = "Not found",      body = ErrorResponse),
90    (status = 500, description = "Internal error", body = ErrorResponse),
91  )
92)]
93#[tracing::instrument(skip_all)]
94pub async fn delete_group(
95  ctx: RequestCtx,
96  Path(label): Path<String>,
97  Query(q): Query<DeleteGroupQuery>,
98) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
99  tracing::info!("delete_group label={} force={}", label, q.force);
100  let infra = ctx.infra();
101
102  // Authorization: caller must have access to the target group.
103  service::authorization::validate_user_group_access(
104    &infra, &ctx.token, &label,
105  )
106  .await
107  .map_err(to_handler_error)?;
108
109  service::group::delete_group(&infra, &ctx.token, &label, q.force)
110    .await
111    .map_err(to_handler_error)?;
112
113  Ok(StatusCode::NO_CONTENT)
114}
115
116// ---------------------------------------------------------------------------
117// POST /api/v1/groups
118// ---------------------------------------------------------------------------
119
120/// POST /groups — create a new HSM group.
121#[utoipa::path(post, path = "/groups", tag = "groups",
122  params(SiteHeader),
123  request_body = manta_backend_dispatcher::types::Group,
124  security(("bearerAuth" = [])),
125  responses(
126    (status = 201, description = "Group created",    body = manta_shared::types::api::responses::CreatedResponse),
127    (status = 401, description = "Unauthorized",     body = ErrorResponse),
128    (status = 409, description = "Conflict",         body = ErrorResponse),
129    (status = 500, description = "Internal error",   body = ErrorResponse),
130  )
131)]
132#[tracing::instrument(skip_all)]
133pub async fn create_group(
134  ctx: RequestCtx,
135  Json(group): Json<::manta_backend_dispatcher::types::Group>,
136) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
137  tracing::info!("create_group");
138  let infra = ctx.infra();
139
140  // Authorization: group creation is admin-only. A new label has no
141  // existing ownership to validate against, so the only sensible
142  // policy without a separate provisioning system is to require the
143  // pa_admin role.
144  service::authorization::require_admin(&ctx.token)
145    .map_err(to_handler_error)?;
146
147  service::group::create_group(&infra, &ctx.token, group)
148    .await
149    .map_err(to_handler_error)?;
150
151  Ok((
152    StatusCode::CREATED,
153    Json(serde_json::json!({ "created": true })),
154  ))
155}
156
157// ---------------------------------------------------------------------------
158// POST /api/v1/groups/{name}/members
159// ---------------------------------------------------------------------------
160
161pub use manta_shared::types::api::group::{
162  AddNodesToGroupRequest, AddNodesToGroupResponse, DeleteGroupMembersRequest,
163};
164
165/// POST /groups/{name}/members — replace a group's member list from a host expression.
166#[utoipa::path(post, path = "/groups/{name}/members", tag = "groups",
167  params(("name" = String, Path, description = "Group name"), SiteHeader),
168  request_body = AddNodesToGroupRequest,
169  security(("bearerAuth" = [])),
170  responses(
171    (status = 200, description = "Members updated",   body = AddNodesToGroupResponse),
172    (status = 400, description = "Bad request",       body = ErrorResponse),
173    (status = 401, description = "Unauthorized",      body = ErrorResponse),
174    (status = 500, description = "Internal error",    body = ErrorResponse),
175  )
176)]
177#[tracing::instrument(skip_all)]
178pub async fn add_nodes_to_group(
179  ctx: RequestCtx,
180  Path(name): Path<String>,
181  Json(body): Json<AddNodesToGroupRequest>,
182) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
183  tracing::info!(
184    "add_nodes_to_group group={} hosts={}",
185    name,
186    body.hosts_expression
187  );
188  let infra = ctx.infra();
189
190  // Authorization: caller must have access to the target group.
191  service::authorization::validate_user_group_access(&infra, &ctx.token, &name)
192    .await
193    .map_err(to_handler_error)?;
194
195  let (added, removed) = service::group::add_nodes_to_group(
196    &infra,
197    &ctx.token,
198    &name,
199    &body.hosts_expression,
200  )
201  .await
202  .map_err(to_handler_error)?;
203
204  // Emit both `final_members` (canonical) and `removed` (deprecated
205  // alias). One release of overlap so existing CLI clients reading
206  // `removed` keep working; the next major bump drops `removed`.
207  Ok(Json(AddNodesToGroupResponse {
208    added,
209    final_members: removed.clone(),
210    removed,
211  }))
212}
213
214// ---------------------------------------------------------------------------
215// DELETE /api/v1/groups/{name}/members — Remove nodes from HSM group
216// ---------------------------------------------------------------------------
217
218/// `DELETE /api/v1/groups/{name}/members` — remove nodes from an HSM group.
219#[utoipa::path(delete, path = "/groups/{name}/members", tag = "groups",
220  params(("name" = String, Path, description = "Group name"), SiteHeader),
221  request_body = DeleteGroupMembersRequest,
222  security(("bearerAuth" = [])),
223  responses(
224    (status = 204, description = "Members removed"),
225    (status = 400, description = "Bad request",      body = ErrorResponse),
226    (status = 401, description = "Unauthorized",     body = ErrorResponse),
227    (status = 500, description = "Internal error",   body = ErrorResponse),
228  )
229)]
230#[tracing::instrument(skip_all)]
231pub async fn delete_group_members(
232  ctx: RequestCtx,
233  Path(name): Path<String>,
234  Json(body): Json<DeleteGroupMembersRequest>,
235) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
236  tracing::info!(
237    "delete_group_members group={} xnames_expression={} dry_run={}",
238    name,
239    body.xnames_expression,
240    body.dry_run
241  );
242  let infra = ctx.infra();
243
244  // Authorization: caller must have access to the target group.
245  service::authorization::validate_user_group_access(&infra, &ctx.token, &name)
246    .await
247    .map_err(to_handler_error)?;
248
249  service::group::delete_group_members(
250    &infra,
251    &ctx.token,
252    &name,
253    &body.xnames_expression,
254    body.dry_run,
255  )
256  .await
257  .map_err(to_handler_error)?;
258
259  Ok(StatusCode::NO_CONTENT)
260}