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::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::idm::ClientAuthInfo;
25use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27use std::str::FromStr;
28use uuid::Uuid;
29
30pub const GROUP_ATTRIBUTES: [Attribute; 3] =
31    [Attribute::Uuid, Attribute::Name, Attribute::Description];
32
33#[derive(Template)]
34#[template(path = "admin/admin_panel_template.html")]
35pub(crate) struct GroupsView {
36    navbar_ctx: NavbarCtx,
37    partial: GroupsPartialView,
38}
39
40#[derive(Template)]
41#[template(path = "admin/admin_groups_partial.html")]
42struct GroupsPartialView {
43    groups: Vec<(ScimGroup, ScimEffectiveAccess)>,
44}
45
46#[derive(Template)]
47#[template(path = "admin/admin_panel_template.html")]
48struct GroupView {
49    partial: GroupViewPartial,
50    navbar_ctx: NavbarCtx,
51}
52
53#[derive(Template)]
54#[template(path = "admin/admin_group_view_partial.html")]
55struct GroupViewPartial {
56    group: ScimGroup,
57    can_rw: bool,
58    scim_effective_access: ScimEffectiveAccess,
59}
60
61#[derive(Template)]
62#[template(path = "admin/saved_toast.html")]
63struct SavedToast {}
64
65pub(crate) async fn view_group_view_get(
66    State(state): State<ServerState>,
67    HxRequest(is_htmx): HxRequest,
68    Extension(kopid): Extension<KOpId>,
69    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
70    Path(uuid): Path<Uuid>,
71    DomainInfo(domain_info): DomainInfo,
72) -> axum::response::Result<Response> {
73    let (group, scim_effective_access) =
74        get_group_info(uuid, state.clone(), &kopid, client_auth_info.clone()).await?;
75    let uat: &UserAuthToken = client_auth_info
76        .pre_validated_uat()
77        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
78
79    let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
80    let can_rw = uat.purpose_readwrite_active(time);
81    let group_partial = GroupViewPartial {
82        group,
83        can_rw,
84        scim_effective_access,
85    };
86
87    let path_string = format!("/ui/admin/group/{uuid}/view");
88    let uri = Uri::from_str(path_string.as_str())
89        .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?;
90    let push_url = HxPushUrl(uri);
91    Ok(if is_htmx {
92        (push_url, group_partial).into_response()
93    } else {
94        (
95            push_url,
96            GroupView {
97                partial: group_partial,
98                navbar_ctx: NavbarCtx { domain_info },
99            },
100        )
101            .into_response()
102    })
103}
104
105pub(crate) async fn view_groups_get(
106    State(state): State<ServerState>,
107    HxRequest(is_htmx): HxRequest,
108    Extension(kopid): Extension<KOpId>,
109    DomainInfo(domain_info): DomainInfo,
110    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
111) -> axum::response::Result<Response> {
112    let groups = get_groups_info(state, &kopid, client_auth_info).await?;
113    let groups_partial = GroupsPartialView { groups };
114
115    let push_url = HxPushUrl(Uri::from_static("/ui/admin/groups"));
116    Ok(if is_htmx {
117        (push_url, groups_partial).into_response()
118    } else {
119        (
120            push_url,
121            GroupsView {
122                navbar_ctx: NavbarCtx { domain_info },
123                partial: groups_partial,
124            },
125        )
126            .into_response()
127    })
128}
129
130pub async fn get_group_info(
131    uuid: Uuid,
132    state: ServerState,
133    kopid: &KOpId,
134    client_auth_info: ClientAuthInfo,
135) -> Result<(ScimGroup, ScimEffectiveAccess), WebError> {
136    let scim_entry: ScimEntryKanidm = state
137        .qe_r_ref
138        .scim_entry_id_get(
139            client_auth_info.clone(),
140            kopid.eventid,
141            uuid.to_string(),
142            EntryClass::Group,
143            ScimEntryGetQuery {
144                attributes: Some(Vec::from(GROUP_ATTRIBUTES)),
145                ext_access_check: true,
146                ..Default::default()
147            },
148        )
149        .await?;
150
151    if let Some(groupinfo_info) = scimentry_into_groupinfo(scim_entry) {
152        Ok(groupinfo_info)
153    } else {
154        Err(WebError::from(OperationError::InvalidState))
155    }
156}
157
158async fn get_groups_info(
159    state: ServerState,
160    kopid: &KOpId,
161    client_auth_info: ClientAuthInfo,
162) -> Result<Vec<(ScimGroup, ScimEffectiveAccess)>, WebError> {
163    let filter = ScimFilter::Equal(Attribute::Class.into(), EntryClass::Group.into());
164
165    let base: ScimListResponse = state
166        .qe_r_ref
167        .scim_entry_search(
168            client_auth_info.clone(),
169            kopid.eventid,
170            filter,
171            ScimEntryGetQuery {
172                attributes: Some(Vec::from(GROUP_ATTRIBUTES)),
173                ext_access_check: true,
174                sort_by: Some(Attribute::Name),
175                ..Default::default()
176            },
177        )
178        .await?;
179
180    let groups: Vec<_> = base
181        .resources
182        .into_iter()
183        // TODO: Filtering away unsuccessful entries may not be desired.
184        .filter_map(scimentry_into_groupinfo)
185        .collect();
186
187    Ok(groups)
188}
189
190#[derive(Clone, Debug, Serialize, Deserialize)]
191pub(crate) struct SaveGroupForm {
192    #[serde(rename = "name")]
193    account_name: String,
194    description: Option<String>,
195}
196
197pub(crate) async fn edit_group(
198    State(state): State<ServerState>,
199    Extension(kopid): Extension<KOpId>,
200    DomainInfo(domain_info): DomainInfo,
201    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
202    Path(group_uuid): Path<Uuid>,
203    // Form must be the last parameter because it consumes the request body
204    Form(query): Form<SaveGroupForm>,
205) -> axum::response::Result<Response> {
206    let mut attrs = BTreeMap::new();
207    attrs.insert(
208        Attribute::Name,
209        Some(ScimValueKanidm::String(query.account_name)),
210    );
211
212    let (group_info, _) =
213        get_group_info(group_uuid, state.clone(), &kopid, client_auth_info.clone()).await?;
214
215    // query.description can't be Some("") since axum deserializes "" to None.
216    // Also meaning that I can't check if someone wants to unset a field or couldn't set the field.
217    // Thus, I check if there's a difference below to make up for this.
218    if group_info.description != query.description {
219        attrs.insert(
220            Attribute::Description,
221            query.description.map(ScimValueKanidm::String),
222        );
223    }
224
225    let generic = ScimEntryPutKanidm {
226        id: group_uuid,
227        attrs,
228    }
229    .try_into()
230    .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?;
231
232    state
233        .qe_w_ref
234        .handle_scim_entry_put(client_auth_info.clone(), kopid.eventid, generic)
235        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
236        .await?;
237
238    // return floating notification: saved/failed
239    Ok((SavedToast {}).into_response())
240}
241
242fn scimentry_into_groupinfo(
243    scim_entry: ScimEntryKanidm,
244) -> Option<(ScimGroup, ScimEffectiveAccess)> {
245    let scim_effective_access = scim_entry.ext_access_check.clone()?; // TODO: This should be an error msg.
246    let group = ScimGroup::try_from(scim_entry).ok()?;
247
248    Some((group, scim_effective_access))
249}