Skip to main content

manta_server/server/handlers/
group.rs

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