manta_server/server/handlers/
group.rs

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