1use crate::https::errors::WebError;
2use crate::https::extractors::{DomainInfo, VerifiedClientInformation};
3use crate::https::middleware::KOpId;
4use crate::https::views::errors::HtmxError;
5use crate::https::views::navbar::NavbarCtx;
6use crate::https::views::{ErrorToastPartial, Urls};
7use crate::https::ServerState;
8use askama::Template;
9use axum::extract::{Path, State};
10use axum::http::Uri;
11use axum::response::{IntoResponse, Response};
12use axum::Extension;
13use axum_extra::extract::Form;
14use axum_htmx::{HxPushUrl, HxRequest};
15use futures_util::TryFutureExt;
16use kanidm_proto::attribute::Attribute;
17use kanidm_proto::internal::{OperationError, UserAuthToken};
18use kanidm_proto::scim_v1::server::{
19 ScimEffectiveAccess, ScimEntryKanidm, ScimGroup, ScimListResponse, ScimValueKanidm,
20};
21use kanidm_proto::scim_v1::ScimEntryGetQuery;
22use kanidm_proto::scim_v1::{client::ScimEntryPutKanidm, ScimFilter};
23use kanidmd_lib::constants::EntryClass;
24use kanidmd_lib::filter::{f_eq, Filter};
25use kanidmd_lib::idm::ClientAuthInfo;
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::str::FromStr;
29use uuid::Uuid;
30
31pub const GROUP_ATTRIBUTES: [Attribute; 4] = [
32 Attribute::Uuid,
33 Attribute::Name,
34 Attribute::Description,
35 Attribute::Member,
36];
37
38#[derive(Template)]
39#[template(path = "admin/admin_panel_template.html")]
40pub(crate) struct GroupsView {
41 navbar_ctx: NavbarCtx,
42 partial: GroupsPartialView,
43}
44
45#[derive(Template)]
46#[template(path = "admin/admin_groups_partial.html")]
47struct GroupsPartialView {
48 groups: Vec<(ScimGroup, ScimEffectiveAccess)>,
49}
50
51#[derive(Template)]
52#[template(path = "admin/admin_panel_template.html")]
53struct GroupView {
54 partial: GroupViewPartial,
55 navbar_ctx: NavbarCtx,
56}
57
58#[derive(Template)]
59#[template(path = "admin/admin_group_view_partial.html")]
60struct GroupViewPartial {
61 group: ScimGroup,
62 can_rw: bool,
63 scim_effective_access: ScimEffectiveAccess,
64}
65
66#[derive(Template)]
67#[template(
68 ext = "html",
69 source = "\
70(% include \"admin/admin_group_member_entry_partial.html\" %)\
71(% include \"admin/saved_toast.html\" %)\
72"
73)]
74struct GroupMemberEntryResponse {
75 group_uuid: Uuid,
76 member_name: String,
77 can_edit_member: bool,
78}
79
80#[derive(Template)]
81#[template(path = "admin/saved_toast.html")]
82struct SavedToast {}
83
84pub(crate) async fn view_group_view_get(
85 State(state): State<ServerState>,
86 HxRequest(is_htmx): HxRequest,
87 Extension(kopid): Extension<KOpId>,
88 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
89 Path(uuid): Path<Uuid>,
90 DomainInfo(domain_info): DomainInfo,
91) -> axum::response::Result<Response> {
92 let (group, scim_effective_access) =
93 get_group_info(uuid, state.clone(), &kopid, client_auth_info.clone()).await?;
94 let uat: &UserAuthToken = client_auth_info
95 .pre_validated_uat()
96 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
97
98 let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
99 let can_rw = uat.purpose_readwrite_active(time);
100 let group_partial = GroupViewPartial {
101 group,
102 can_rw,
103 scim_effective_access,
104 };
105
106 let path_string = format!("/ui/admin/group/{uuid}/view");
107 let uri = Uri::from_str(path_string.as_str())
108 .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?;
109 let push_url = HxPushUrl(uri);
110 Ok(if is_htmx {
111 (push_url, group_partial).into_response()
112 } else {
113 (
114 push_url,
115 GroupView {
116 partial: group_partial,
117 navbar_ctx: NavbarCtx::new(domain_info, &uat.ui_hints),
118 },
119 )
120 .into_response()
121 })
122}
123
124pub(crate) async fn view_groups_get(
125 State(state): State<ServerState>,
126 HxRequest(is_htmx): HxRequest,
127 Extension(kopid): Extension<KOpId>,
128 DomainInfo(domain_info): DomainInfo,
129 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
130) -> axum::response::Result<Response> {
131 let groups = get_groups_info(state, &kopid, client_auth_info.clone()).await?;
132 let groups_partial = GroupsPartialView { groups };
133 let uat: &UserAuthToken = client_auth_info
134 .pre_validated_uat()
135 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
136
137 let push_url = HxPushUrl(Uri::from_static("/ui/admin/groups"));
138 Ok(if is_htmx {
139 (push_url, groups_partial).into_response()
140 } else {
141 (
142 push_url,
143 GroupsView {
144 navbar_ctx: NavbarCtx::new(domain_info, &uat.ui_hints),
145 partial: groups_partial,
146 },
147 )
148 .into_response()
149 })
150}
151
152pub async fn get_group_info(
153 uuid: Uuid,
154 state: ServerState,
155 kopid: &KOpId,
156 client_auth_info: ClientAuthInfo,
157) -> Result<(ScimGroup, ScimEffectiveAccess), WebError> {
158 let scim_entry: ScimEntryKanidm = state
159 .qe_r_ref
160 .scim_entry_id_get(
161 client_auth_info.clone(),
162 kopid.eventid,
163 uuid.to_string(),
164 EntryClass::Group,
165 ScimEntryGetQuery {
166 attributes: Some(Vec::from(GROUP_ATTRIBUTES)),
167 ext_access_check: true,
168 ..Default::default()
169 },
170 )
171 .await?;
172
173 if let Some(groupinfo_info) = scimentry_into_groupinfo(scim_entry) {
174 Ok(groupinfo_info)
175 } else {
176 Err(WebError::from(OperationError::InvalidState))
177 }
178}
179
180async fn get_groups_info(
181 state: ServerState,
182 kopid: &KOpId,
183 client_auth_info: ClientAuthInfo,
184) -> Result<Vec<(ScimGroup, ScimEffectiveAccess)>, WebError> {
185 let filter = ScimFilter::Equal(Attribute::Class.into(), EntryClass::Group.into());
186
187 let base: ScimListResponse = state
188 .qe_r_ref
189 .scim_entry_search(
190 client_auth_info.clone(),
191 kopid.eventid,
192 filter,
193 ScimEntryGetQuery {
194 attributes: Some(Vec::from(GROUP_ATTRIBUTES)),
195 ext_access_check: true,
196 sort_by: Some(Attribute::Name),
197 ..Default::default()
198 },
199 )
200 .await?;
201
202 let groups: Vec<_> = base
203 .resources
204 .into_iter()
205 .filter_map(scimentry_into_groupinfo)
207 .collect();
208
209 Ok(groups)
210}
211
212#[derive(Clone, Debug, Serialize, Deserialize)]
213pub(crate) struct SaveGroupForm {
214 #[serde(rename = "name")]
215 account_name: String,
216 description: Option<String>,
217}
218
219pub(crate) async fn edit_group(
220 State(state): State<ServerState>,
221 Extension(kopid): Extension<KOpId>,
222 DomainInfo(domain_info): DomainInfo,
223 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
224 Path(group_uuid): Path<Uuid>,
225 Form(query): Form<SaveGroupForm>,
227) -> axum::response::Result<Response> {
228 let mut attrs = BTreeMap::new();
229 attrs.insert(
230 Attribute::Name,
231 Some(ScimValueKanidm::String(query.account_name)),
232 );
233
234 let (group_info, _) =
235 get_group_info(group_uuid, state.clone(), &kopid, client_auth_info.clone()).await?;
236
237 if group_info.description != query.description {
241 attrs.insert(
242 Attribute::Description,
243 query.description.map(ScimValueKanidm::String),
244 );
245 }
246
247 let generic = ScimEntryPutKanidm {
248 id: group_uuid,
249 attrs,
250 }
251 .try_into()
252 .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?;
253
254 state
255 .qe_w_ref
256 .handle_scim_entry_put(client_auth_info.clone(), kopid.eventid, generic)
257 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
258 .await?;
259
260 Ok((SavedToast {}).into_response())
262}
263
264#[derive(Clone, Debug, Serialize, Deserialize)]
265pub(crate) struct AddMemberForm {
266 member: String,
267}
268
269pub(crate) async fn add_member(
270 State(state): State<ServerState>,
271 Extension(kopid): Extension<KOpId>,
272 DomainInfo(domain_info): DomainInfo,
273 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
274 Path(group_uuid): Path<Uuid>,
275 Form(query): Form<AddMemberForm>,
277) -> axum::response::Result<Response> {
278 let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into()));
279
280 let get_query = ScimEntryGetQuery {
281 attributes: Some(vec![Attribute::Member]),
282 ext_access_check: false,
283 sort_by: None,
284 sort_order: None,
285 start_index: None,
286 count: None,
287 filter: None,
288 };
289 let get_member_query = ScimEntryGetQuery {
290 attributes: Some(vec![Attribute::Spn]),
291 ext_access_check: false,
292 sort_by: None,
293 sort_order: None,
294 start_index: None,
295 count: None,
296 filter: None,
297 };
298 let group_uuid_str = String::from(group_uuid);
299 let before = state
300 .qe_r_ref
301 .scim_entry_id_get(
302 client_auth_info.clone(),
303 kopid.eventid,
304 group_uuid_str.clone(),
305 EntryClass::Group,
306 get_query.clone(),
307 )
308 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
309 .await?;
310 state
311 .qe_w_ref
312 .handle_appendattribute(
313 client_auth_info.clone(),
314 group_uuid_str.clone(),
315 "member".to_string(),
316 vec![query.member.clone()],
317 filter,
318 kopid.eventid,
319 )
320 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
321 .await?;
322
323 let after = state
324 .qe_r_ref
325 .scim_entry_id_get(
326 client_auth_info.clone(),
327 kopid.eventid,
328 group_uuid_str.clone(),
329 EntryClass::Group,
330 get_query,
331 )
332 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
333 .await?;
334
335 let before_len = if let Some(ScimValueKanidm::EntryReferences(members_before)) =
336 before.attrs.get(&Attribute::Member)
337 {
338 members_before.len()
339 } else {
340 0
341 };
342 let after_len = if let Some(ScimValueKanidm::EntryReferences(members_after)) =
343 after.attrs.get(&Attribute::Member)
344 {
345 members_after.len()
346 } else {
347 0
348 };
349
350 if before_len + 1 == after_len {
351 let added_member_scim = state
352 .qe_r_ref
353 .scim_entry_id_get(
354 client_auth_info.clone(),
355 kopid.eventid,
356 query.member.clone(),
357 EntryClass::Object,
358 get_member_query,
359 )
360 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
361 .await?;
362
363 let Some(ScimValueKanidm::String(added_member_spn)) =
364 added_member_scim.attrs.get(&Attribute::Spn)
365 else {
366 return Ok((ErrorToastPartial {
367 err_code: OperationError::UI0004MemberAlreadyExists,
368 operation_id: kopid.eventid,
369 })
370 .into_response());
371 };
372 Ok((GroupMemberEntryResponse {
374 group_uuid,
375 member_name: added_member_spn.to_string(),
376 can_edit_member: true,
377 })
378 .into_response())
379 } else {
380 Ok((ErrorToastPartial {
382 err_code: OperationError::UI0004MemberAlreadyExists,
383 operation_id: kopid.eventid,
384 })
385 .into_response())
386 }
387}
388
389pub(crate) async fn remove_member(
390 State(state): State<ServerState>,
391 Extension(kopid): Extension<KOpId>,
392 DomainInfo(domain_info): DomainInfo,
393 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
394 Path(group_uuid): Path<Uuid>,
395 Form(query): Form<AddMemberForm>,
397) -> axum::response::Result<Response> {
398 let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into()));
399
400 state
401 .qe_w_ref
402 .handle_removeattributevalues(
403 client_auth_info.clone(),
404 String::from(group_uuid),
405 "member".to_string(),
406 vec![query.member],
407 filter,
408 kopid.eventid,
409 )
410 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
411 .await?;
412
413 Ok((SavedToast {}).into_response())
415}
416
417fn scimentry_into_groupinfo(
418 scim_entry: ScimEntryKanidm,
419) -> Option<(ScimGroup, ScimEffectiveAccess)> {
420 let scim_effective_access = scim_entry.ext_access_check.clone()?; let group = ScimGroup::try_from(scim_entry).ok()?;
422
423 Some((group, scim_effective_access))
424}