kanidmd_core/https/views/admin/
groups.rs

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        // TODO: Filtering away unsuccessful entries may not be desired.
206        .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 must be the last parameter because it consumes the request body
226    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    // query.description can't be Some("") since axum deserializes "" to None.
238    // Also meaning that I can't check if someone wants to unset a field or couldn't set the field.
239    // Thus, I check if there's a difference below to make up for this.
240    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    // return floating notification: saved/failed
261    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 must be the last parameter because it consumes the request body
276    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        // New entry + saved toast.
373        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        // Duplicate entry toast.
381        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 must be the last parameter because it consumes the request body
396    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    // return floating notification: saved/failed
414    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()?; // TODO: This should be an error msg.
421    let group = ScimGroup::try_from(scim_entry).ok()?;
422
423    Some((group, scim_effective_access))
424}