25 Commits

Author SHA1 Message Date
Austin Alvarado
5b817980a9 test point 2024-02-09 06:44:11 +00:00
Austin Alvarado
66097f1880 Merge branch 'main' into user-attribute-form 2024-02-09 05:37:50 +00:00
Austin Alvarado
adf3577f0e commit so i can pull in fixes from master 2024-02-09 05:31:46 +00:00
Valentin Tolmer
5c5b87d5af app,server: Switch /reset/step1 to a POST request
Otherwise, caching can become an issue. Also, it's not an idempotent request.
2024-02-09 00:20:31 +01:00
Valentin Tolmer
f65a6f524a app: Fix GetDetails rendering loop in avatar 2024-02-08 21:56:11 +01:00
Valentin Tolmer
96f5b31e0c server: Add graphQL methods to manage custom LDAP object classes 2024-02-06 22:39:05 +01:00
Valentin Tolmer
4955b7fac1 server: Add support for the custom LDAP object classes in LDAP filters 2024-02-06 22:39:05 +01:00
Valentin Tolmer
646fe32645 server: Add support for custom LDAP object classes for users and groups 2024-02-05 22:51:02 +01:00
Austin Alvarado
fa9743be6a app: create avatar component and reorganize a little bit (#830)
* Create avatar component and reorganize a little bit

* html fmt

* fmt
2024-02-05 07:55:49 -07:00
Valentin Tolmer
38c4296d62 github: Improve codecov integration with better config 2024-02-02 15:52:29 +01:00
Valentin Tolmer
1c65cd115e server: Fix panic due to database collation
When the database's collation is not "C", the DB order is not the same as the
Rust order. As such, asserting that the elements are in increasing order fails.
However, since both queries get the order from the database, they should be in
the same order.

With too many users, the query had a giant filter `IN (u1, u2, u3,
...)`. In PostgreSQL, we can pass the users as an array instead, but that
doesn't work with SQLite. Instead, we repeat the filter from the
previous query to get the same users/groups, as a subquery.
2024-02-02 15:39:16 +01:00
Austin Alvarado
8f2391a792 app: create group attribute schema page (#825) 2024-02-01 10:56:47 -07:00
shroomify-it
bb2654f9c2 example_configs: add radicale DAV server to the readme 2024-01-28 08:44:25 +01:00
shroomify-it
770e934859 example_configs: Create radicale.md 2024-01-28 08:42:19 +01:00
Austin Alvarado
cc0827f271 app: update forms to use new components (#818) 2024-01-27 09:10:02 -07:00
Austin Alvarado
93f3057b8f server: remove debug print 2024-01-25 22:35:42 +01:00
dependabot[bot]
206e98c986 build(deps): bump peter-evans/dockerhub-description from 3 to 4
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 3 to 4.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-25 21:34:56 +01:00
HighwayStar
28e6fa0f10 example_configs: Fix docker-mailserver example
* Fixes following issues:
 - double braces around mail= filter cause:
 ldap_search_ext: Bad search filter (-7)
 - too wide/upper level base DN cause, changed to ou= level helps
 result: 53 Server is unwilling to perform
 text: Unsupported group attribute for substring filter: "mail"
2024-01-24 08:11:15 +01:00
Valentin Tolmer
d4b3b4649e server: Clean up main, make more functions async 2024-01-24 00:04:43 +01:00
Austin Alvarado
b78e093205 app: add user attributes schema page (#802) 2024-01-22 21:53:33 -07:00
Valentin Tolmer
c2eed8909a server: Only call expand_attributes at most once per request 2024-01-23 00:17:08 +01:00
Valentin Tolmer
b82a2d5705 server: Treat the database password as a secret 2024-01-22 23:12:33 +01:00
Valentin Tolmer
addd453287 server: don't error on global searches if only one side fails 2024-01-22 22:30:54 +01:00
Valentin Tolmer
e308a5e9a1 server: Add the attribute schema to the attributes in graphql
And make sure that we only request the schema once per top-level query
2024-01-21 23:25:57 +01:00
elmodor
1f2f034a48 Added maddy example config
Updated README.md for Maddy

i
2024-01-18 22:01:57 +01:00
26 changed files with 645 additions and 119 deletions

View File

@@ -37,9 +37,11 @@ version = "0.3"
features = [
"Document",
"Element",
"Event",
"FileReader",
"FormData",
"HtmlDocument",
"HtmlFormElement",
"HtmlInputElement",
"HtmlOptionElement",
"HtmlOptionsCollection",

View File

@@ -11,17 +11,15 @@ query GetUserDetails($id: String!) {
groups {
id
displayName
}
}
attributes {
name
value
}
}
schema {
user_attrubutes {
attributes {
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
}

View File

@@ -1,5 +1,7 @@
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties, NodeRef};
use crate::infra::schema::AttributeType;
use yew::{
function_component, html, virtual_dom::AttrValue, Callback, InputEvent, NodeRef, Properties,
};
/*
<input
@@ -43,7 +45,7 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
#[derive(Properties, PartialEq)]
pub struct SingleAttributeInputProps {
pub name: AttrValue,
pub name: String,
pub attribute_type: AttributeType,
#[prop_or(None)]
pub value: Option<String>,
@@ -59,9 +61,9 @@ pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
</label>
<div class="col-8">
<AttributeInput
attribute_type={props.attribute_type}
name={props.name}
value={props.value} />
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.value.clone()} />
</div>
</div>
}

View File

@@ -4,8 +4,8 @@ use crate::{
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
infra::common_component::{CommonComponent, CommonComponentParts},
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
convert_attribute_type
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
@@ -22,6 +22,10 @@ pub struct GetUserDetails;
pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups;
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
pub type AttributeSchema = get_user_details::GetUserDetailsUserAttributesSchema;
convert_attribute_type!(get_user_details::AttributeType);
pub struct UserDetails {
common: CommonComponentParts<Self>,

View File

@@ -2,22 +2,24 @@ use std::str::FromStr;
use crate::{
components::{
form::{field::Field, static_value::StaticValue, submit::Submit},
user_details::User,
},
infra::common_component::{CommonComponent, CommonComponentParts},
form::{attribute_input::SingleAttributeInput, field::Field, static_value::StaticValue, submit::Submit},
user_details::{AttributeSchema, User},
}, convert_attribute_type, infra::{common_component::{CommonComponent, CommonComponentParts}, schema::AttributeType}
};
use anyhow::{bail, Error, Result};
use anyhow::{anyhow, bail, Error, Ok, Result};
use gloo_console::log;
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use graphql_client::GraphQLQuery;
use validator::HasLen;
use validator_derive::Validate;
use web_sys::{FileList, HtmlInputElement, InputEvent, SubmitEvent};
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
use yew::prelude::*;
use yew_form_derive::Model;
use gloo_console::log;
use super::user_details::Attribute;
#[derive(Default)]
struct JsFile {
@@ -74,6 +76,7 @@ pub struct UserDetailsForm {
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
user: User,
form_ref: NodeRef,
}
pub enum Msg {
@@ -89,8 +92,6 @@ pub enum Msg {
FileLoaded(String, Result<Vec<u8>>),
/// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>),
/// The "Submit" button was clicked.
OnSubmit(SubmitEvent),
}
#[derive(yew::Properties, Clone, PartialEq, Eq)]
@@ -153,11 +154,14 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
}
self.reader = None;
Ok(false)
}
Msg::OnSubmit(e) => {
log!(format!("{:#?}", e));
Ok(true)
}
} // Msg::OnSubmit(e) => {
// e.prevent_default();
// let form: HtmlFormElement = e.target_unchecked_into();
// let data = FormData::new_with_form(&form).unwrap();
// log!(format!("form data{:#?}", data));
// log!(format!("form data data{:#?}", *data));
// Ok(true)
// }
}
}
@@ -184,6 +188,7 @@ impl Component for UserDetailsForm {
just_updated: false,
reader: None,
user: ctx.props().user.clone(),
form_ref: NodeRef::default(),
}
}
@@ -204,7 +209,7 @@ impl Component for UserDetailsForm {
};
html! {
<div class="py-3">
<form class="form" onsubmit={link.callback(|e: SubmitEvent| {e.prevent_default(); Msg::OnSubmit(e)})}>
<form class="form">
<StaticValue label="User ID" id="userId">
<i>{&self.user.id}</i>
</StaticValue>
@@ -282,6 +287,7 @@ impl Component for UserDetailsForm {
</div>
</div>
</div>
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
<Submit
text="Save changes"
disabled={self.common.is_task_running()}
@@ -304,6 +310,45 @@ impl Component for UserDetailsForm {
}
}
type AttributeValue = (String, Vec<String>);
fn get_values_from_form_data(
schema: Vec<AttributeSchema>,
form: &FormData,
) -> Result<Vec<AttributeValue>> {
schema
.into_iter()
.map(|attr| -> Result<AttributeValue> {
let val = form
.get_all(attr.name.as_str())
.iter()
.map(|js_val| js_val.as_string().unwrap())
.filter(|val| !val.is_empty())
.collect::<Vec<String>>();
if val.length() > 1 && !attr.is_list {
return Err(anyhow!(
"Multiple values supplied for non-list attribute {}",
attr.name
));
}
Ok((attr.name.clone(), val))
})
.collect()
}
fn get_custom_attribute_input(attribute: &Attribute) -> Html {
if attribute.schema.is_list {
html!{<p>{"list attr"}</p>}
} else {
let value = if attribute.value.is_empty() {
None
} else {
Some(attribute.value[0].clone())
};
html!{<SingleAttributeInput name={attribute.name.clone()} attribute_type={Into::<AttributeType>::into(attribute.schema.attribute_type.clone())} value={value}/>}
}
}
impl UserDetailsForm {
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
if !self.form.validate() {
@@ -316,7 +361,40 @@ impl UserDetailsForm {
{
bail!("Image file hasn't finished loading, try again");
}
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
let form_data = FormData::new_with_form(&form)
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
let mut all_values = get_values_from_form_data(
self.user
.attributes
.iter()
.map(|attr| attr.schema.clone())
.filter(|attr| !attr.is_hardcoded)
.filter(|attr| attr.is_editable)
.collect(),
&form_data,
)?;
let base_user = &self.user;
let base_attrs = &self.user.attributes;
all_values.retain(|(name, val)| {
let name = name.clone();
let base_val = base_attrs
.into_iter()
.find(|base_val| base_val.name == name)
.unwrap();
let new_values = val.clone();
base_val.value != new_values
});
let remove_names: Option<Vec<String>> = if all_values.is_empty() {
None
} else {
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
};
let insert_attrs: Option<Vec<update_user::AttributeValueInput>> = if remove_names.is_none() {
None
} else {
Some(all_values.into_iter().map(|(name, value)| update_user::AttributeValueInput{name, value}).collect())
};
let mut user_input = update_user::UpdateUserInput {
id: self.user.id.clone(),
email: None,
@@ -324,8 +402,8 @@ impl UserDetailsForm {
firstName: None,
lastName: None,
avatar: None,
removeAttributes: None,
insertAttributes: None,
removeAttributes: remove_names,
insertAttributes: insert_attrs,
};
let default_user_input = user_input.clone();
let model = self.form.model();

View File

@@ -16,21 +16,26 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
Ok(token.claims().clone())
}
const NO_BODY: Option<()> = None;
enum RequestType<Body: Serialize> {
Get,
Post(Body),
}
const GET_REQUEST: RequestType<()> = RequestType::Get;
fn base_url() -> String {
yew_router::utils::base_url().unwrap_or_default()
}
async fn call_server(
async fn call_server<Body: Serialize>(
url: &str,
body: Option<impl Serialize>,
body: RequestType<Body>,
error_message: &'static str,
) -> Result<String> {
let mut request = Request::new(url)
.header("Content-Type", "application/json")
.credentials(RequestCredentials::SameOrigin);
if let Some(b) = body {
if let RequestType::Post(b) = body {
request = request
.body(serde_json::to_string(&b)?)
.method(Method::POST);
@@ -51,7 +56,7 @@ async fn call_server(
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
url: &str,
request: Option<Body>,
request: RequestType<Body>,
error_message: &'static str,
) -> Result<CallbackResult>
where
@@ -63,7 +68,7 @@ where
async fn call_server_empty_response_with_error_message<Body: Serialize>(
url: &str,
request: Option<Body>,
request: RequestType<Body>,
error_message: &'static str,
) -> Result<()> {
call_server(url, request, error_message).await.map(|_| ())
@@ -102,7 +107,7 @@ impl HostService {
let request_body = QueryType::build_query(variables);
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
&(base_url() + "/api/graphql"),
Some(request_body),
RequestType::Post(request_body),
error_message,
)
.await
@@ -114,7 +119,7 @@ impl HostService {
) -> Result<Box<login::ServerLoginStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/login/start"),
Some(request),
RequestType::Post(request),
"Could not start authentication: ",
)
.await
@@ -123,7 +128,7 @@ impl HostService {
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/opaque/login/finish"),
Some(request),
RequestType::Post(request),
"Could not finish authentication",
)
.await
@@ -135,7 +140,7 @@ impl HostService {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/register/start"),
Some(request),
RequestType::Post(request),
"Could not start registration: ",
)
.await
@@ -146,7 +151,7 @@ impl HostService {
) -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/opaque/register/finish"),
Some(request),
RequestType::Post(request),
"Could not finish registration",
)
.await
@@ -155,7 +160,7 @@ impl HostService {
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/refresh"),
NO_BODY,
GET_REQUEST,
"Could not start authentication: ",
)
.await
@@ -166,7 +171,7 @@ impl HostService {
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/logout"),
NO_BODY,
GET_REQUEST,
"Could not logout",
)
.await
@@ -179,7 +184,7 @@ impl HostService {
base_url(),
url_escape::encode_query(&username)
),
NO_BODY,
RequestType::Post(""),
"Could not initiate password reset",
)
.await
@@ -190,7 +195,7 @@ impl HostService {
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
call_server_json_with_error_message(
&format!("{}/auth/reset/step2/{}", base_url(), token),
NO_BODY,
GET_REQUEST,
"Could not validate token",
)
.await

View File

@@ -2,7 +2,7 @@ use crate::infra::api::HostService;
use anyhow::Result;
use graphql_client::GraphQLQuery;
use wasm_bindgen_futures::spawn_local;
use yew::{use_effect, use_state, UseStateHandle};
use yew::{use_effect, use_state_eq, UseStateHandle};
// Enum to represent a result that is fetched asynchronously.
#[derive(Debug)]
@@ -13,14 +13,28 @@ pub enum LoadableResult<T> {
Loaded(Result<T>),
}
impl<T: PartialEq> PartialEq for LoadableResult<T> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(LoadableResult::Loading, LoadableResult::Loading) => true,
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
e1.to_string().eq(&e2.to_string())
}
_ => false,
}
}
}
pub fn use_graphql_call<QueryType>(
variables: QueryType::Variables,
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
where
QueryType: GraphQLQuery + 'static,
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
{
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
use_state(|| LoadableResult::Loading);
use_state_eq(|| LoadableResult::Loading);
{
let loadable_result = loadable_result.clone();
use_effect(move || {

View File

@@ -59,38 +59,8 @@ macro_rules! convert_attribute_type {
};
}
<<<<<<< HEAD
#[derive(Clone, PartialEq, Eq)]
pub struct Attribute {
pub name: String,
pub value: Vec<String>,
pub attribute_type: AttributeType,
pub is_list: bool,
pub is_editable: bool,
pub is_hardcoded: bool,
}
// Macro to generate traits for converting between AttributeType and the
// graphql generated equivalents.
#[macro_export]
macro_rules! combine_schema_and_values {
($schema_list:ident, $value_list:ident, $output_list:ident) => {
let set_attributes = value_list.clone();
let mut attribute_schema = schema_list.clone();
attribute_schema.retain(|schema| !schema.is_hardcoded);
let $output_list = attribute_schema.into_iter().map(|schema| {
Attribute {
name: schema.name.clone(),
value: set_attributes.iter().find(|attribute_value| attribute_value.name == schema.name).unwrap().value.clone(),
attribute_type: AttributeType::from(schema.attribute_type),
is_list: schema.is_list,
}
}).collect();
};
=======
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
AttributeType::from_str(attribute_type)
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
Ok(())
>>>>>>> 8f2391a (app: create group attribute schema page (#825))
}

5
schema.graphql generated
View File

@@ -18,6 +18,10 @@ type Mutation {
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(name: String!): Success!
deleteGroupAttribute(name: String!): Success!
addUserObjectClass(name: String!): Success!
addGroupObjectClass(name: String!): Success!
deleteUserObjectClass(name: String!): Success!
deleteGroupObjectClass(name: String!): Success!
}
type Group {
@@ -162,6 +166,7 @@ enum AttributeType {
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
}
type Success {

View File

@@ -2,7 +2,8 @@ use crate::domain::{
error::Result,
types::{
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid,
GroupName, JpegPhoto, LdapObjectClass, Serialized, User, UserAndGroups, UserColumn, UserId,
Uuid,
},
};
use async_trait::async_trait;
@@ -175,6 +176,8 @@ impl AttributeList {
pub struct Schema {
pub user_attributes: AttributeList,
pub group_attributes: AttributeList,
pub extra_user_object_classes: Vec<LdapObjectClass>,
pub extra_group_object_classes: Vec<LdapObjectClass>,
}
#[async_trait]
@@ -227,6 +230,11 @@ pub trait SchemaBackendHandler: ReadSchemaBackendHandler {
// Note: It's up to the caller to make sure that the attribute is not hardcoded.
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
}
#[async_trait]

View File

@@ -9,7 +9,7 @@ use crate::domain::{
handler::{GroupListerBackendHandler, GroupRequestFilter},
ldap::error::LdapError,
schema::{PublicSchema, SchemaGroupAttributeExtractor},
types::{AttributeName, AttributeType, Group, UserId, Uuid},
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
};
use super::{
@@ -30,7 +30,17 @@ pub fn get_group_attribute(
) -> Option<Vec<Vec<u8>>> {
let attribute = AttributeName::from(attribute);
let attribute_values = match map_group_field(&attribute, schema) {
GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()],
GroupFieldType::ObjectClass => {
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
classes.extend(
schema
.get_schema()
.extra_group_object_classes
.iter()
.map(|c| c.as_str().as_bytes().to_vec()),
);
classes
}
// Always returned as part of the base response.
GroupFieldType::Dn => return None,
GroupFieldType::EntryDn => {
@@ -167,10 +177,13 @@ fn convert_group_filter(
)?;
Ok(GroupRequestFilter::Member(user_name))
}
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(matches!(
value.as_str(),
"groupofuniquenames" | "groupofnames"
))),
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|| schema
.get_schema()
.extra_group_object_classes
.contains(&LdapObjectClass::from(value)),
)),
GroupFieldType::Dn | GroupFieldType::EntryDn => {
Ok(get_group_id_from_distinguished_name(
value.as_str(),

View File

@@ -15,7 +15,10 @@ use crate::domain::{
},
},
schema::{PublicSchema, SchemaUserAttributeExtractor},
types::{AttributeName, AttributeType, GroupDetails, User, UserAndGroups, UserColumn, UserId},
types::{
AttributeName, AttributeType, GroupDetails, LdapObjectClass, User, UserAndGroups,
UserColumn, UserId,
},
};
pub fn get_user_attribute(
@@ -28,12 +31,22 @@ pub fn get_user_attribute(
) -> Option<Vec<Vec<u8>>> {
let attribute = AttributeName::from(attribute);
let attribute_values = match map_user_field(&attribute, schema) {
UserFieldType::ObjectClass => vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
],
UserFieldType::ObjectClass => {
let mut classes = vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
];
classes.extend(
schema
.get_schema()
.extra_user_object_classes
.iter()
.map(|c| c.as_str().as_bytes().to_vec()),
);
classes
}
// dn is always returned as part of the base response.
UserFieldType::Dn => return None,
UserFieldType::EntryDn => {
@@ -196,10 +209,15 @@ fn convert_user_filter(
}
Ok(UserRequestFilter::from(false))
}
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(matches!(
value.as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
))),
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
matches!(
value.as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
) || schema
.get_schema()
.extra_user_object_classes
.contains(&LdapObjectClass::from(value)),
)),
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
get_group_id_from_distinguished_name(
&value,

View File

@@ -0,0 +1,23 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::LdapObjectClass;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "group_object_classes")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub lower_object_class: String,
pub object_class: LdapObjectClass,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for LdapObjectClass {
fn from(value: Model) -> Self {
value.object_class
}
}

View File

@@ -1,5 +1,3 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
pub mod prelude;
pub mod groups;
@@ -11,8 +9,10 @@ pub mod users;
pub mod user_attribute_schema;
pub mod user_attributes;
pub mod user_object_classes;
pub mod group_attribute_schema;
pub mod group_attributes;
pub mod group_object_classes;
pub use prelude::*;

View File

@@ -4,6 +4,8 @@ pub use super::group_attribute_schema::Column as GroupAttributeSchemaColumn;
pub use super::group_attribute_schema::Entity as GroupAttributeSchema;
pub use super::group_attributes::Column as GroupAttributesColumn;
pub use super::group_attributes::Entity as GroupAttributes;
pub use super::group_object_classes::Column as GroupObjectClassesColumn;
pub use super::group_object_classes::Entity as GroupObjectClasses;
pub use super::groups::Column as GroupColumn;
pub use super::groups::Entity as Group;
pub use super::jwt_refresh_storage::Column as JwtRefreshStorageColumn;
@@ -18,5 +20,7 @@ pub use super::user_attribute_schema::Column as UserAttributeSchemaColumn;
pub use super::user_attribute_schema::Entity as UserAttributeSchema;
pub use super::user_attributes::Column as UserAttributesColumn;
pub use super::user_attributes::Entity as UserAttributes;
pub use super::user_object_classes::Column as UserObjectClassesColumn;
pub use super::user_object_classes::Entity as UserObjectClasses;
pub use super::users::Column as UserColumn;
pub use super::users::Entity as User;

View File

@@ -0,0 +1,23 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::LdapObjectClass;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_object_classes")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub lower_object_class: String,
pub object_class: LdapObjectClass,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for LdapObjectClass {
fn from(value: Model) -> Self {
value.object_class
}
}

View File

@@ -88,6 +88,20 @@ pub enum GroupAttributes {
GroupAttributeValue,
}
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
pub enum UserObjectClasses {
Table,
LowerObjectClass,
ObjectClass,
}
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
pub enum GroupObjectClasses {
Table,
LowerObjectClass,
ObjectClass,
}
// Metadata about the SQL DB.
#[derive(DeriveIden)]
pub enum Metadata {
@@ -1031,6 +1045,51 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result<DatabaseTrans
Ok(transaction)
}
async fn migrate_to_v9(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
let builder = transaction.get_database_backend();
transaction
.execute(
builder.build(
Table::create()
.table(UserObjectClasses::Table)
.if_not_exists()
.col(
ColumnDef::new(UserObjectClasses::LowerObjectClass)
.string_len(255)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(UserObjectClasses::ObjectClass)
.string_len(255)
.not_null(),
),
),
)
.await?;
transaction
.execute(
builder.build(
Table::create()
.table(GroupObjectClasses::Table)
.if_not_exists()
.col(
ColumnDef::new(GroupObjectClasses::LowerObjectClass)
.string_len(255)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(GroupObjectClasses::ObjectClass)
.string_len(255)
.not_null(),
),
),
)
.await?;
Ok(transaction)
}
// This is needed to make an array of async functions.
macro_rules! to_sync {
($l:ident) => {
@@ -1059,6 +1118,7 @@ pub async fn migrate_from_version(
to_sync!(migrate_to_v6),
to_sync!(migrate_to_v7),
to_sync!(migrate_to_v8),
to_sync!(migrate_to_v9),
];
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
for migration in 2..=last_version.0 {

View File

@@ -6,7 +6,7 @@ use crate::domain::{
},
model,
sql_backend_handler::SqlBackendHandler,
types::AttributeName,
types::{AttributeName, LdapObjectClass},
};
use async_trait::async_trait;
use sea_orm::{
@@ -66,6 +66,44 @@ impl SchemaBackendHandler for SqlBackendHandler {
.await?;
Ok(())
}
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
let mut name_key = name.to_string();
name_key.make_ascii_lowercase();
model::user_object_classes::ActiveModel {
lower_object_class: Set(name_key),
object_class: Set(name.clone()),
}
.insert(&self.sql_pool)
.await?;
Ok(())
}
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
let mut name_key = name.to_string();
name_key.make_ascii_lowercase();
model::group_object_classes::ActiveModel {
lower_object_class: Set(name_key),
object_class: Set(name.clone()),
}
.insert(&self.sql_pool)
.await?;
Ok(())
}
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
model::UserObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase())
.exec(&self.sql_pool)
.await?;
Ok(())
}
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
model::GroupObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase())
.exec(&self.sql_pool)
.await?;
Ok(())
}
}
impl SqlBackendHandler {
@@ -79,6 +117,8 @@ impl SqlBackendHandler {
group_attributes: AttributeList {
attributes: Self::get_group_attributes(transaction).await?,
},
extra_user_object_classes: Self::get_user_object_classes(transaction).await?,
extra_group_object_classes: Self::get_group_object_classes(transaction).await?,
})
}
@@ -105,6 +145,30 @@ impl SqlBackendHandler {
.map(|m| m.into())
.collect())
}
async fn get_user_object_classes(
transaction: &DatabaseTransaction,
) -> Result<Vec<LdapObjectClass>> {
Ok(model::UserObjectClasses::find()
.order_by_asc(model::UserObjectClassesColumn::ObjectClass)
.all(transaction)
.await?
.into_iter()
.map(Into::into)
.collect())
}
async fn get_group_object_classes(
transaction: &DatabaseTransaction,
) -> Result<Vec<LdapObjectClass>> {
Ok(model::GroupObjectClasses::find()
.order_by_asc(model::GroupObjectClassesColumn::ObjectClass)
.all(transaction)
.await?
.into_iter()
.map(Into::into)
.collect())
}
}
#[cfg(test)]
@@ -151,7 +215,9 @@ mod tests {
},
group_attributes: AttributeList {
attributes: Vec::new()
}
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
}
);
}
@@ -247,4 +313,50 @@ mod tests {
.attributes
.contains(&expected_value));
}
#[tokio::test]
async fn test_user_object_class_add_and_delete() {
let fixture = TestFixture::new().await;
let new_object_class = LdapObjectClass::new("newObjectClass");
fixture
.handler
.add_user_object_class(&new_object_class)
.await
.unwrap();
assert_eq!(
fixture
.handler
.get_schema()
.await
.unwrap()
.extra_user_object_classes,
vec![new_object_class.clone()]
);
fixture
.handler
.add_user_object_class(&LdapObjectClass::new("newobjEctclass"))
.await
.expect_err("Should not be able to add the same object class twice");
assert_eq!(
fixture
.handler
.get_schema()
.await
.unwrap()
.extra_user_object_classes,
vec![new_object_class.clone()]
);
fixture
.handler
.delete_user_object_class(&new_object_class)
.await
.unwrap();
assert!(fixture
.handler
.get_schema()
.await
.unwrap()
.extra_user_object_classes
.is_empty());
}
}

View File

@@ -11,7 +11,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
pub struct SchemaVersion(pub i16);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(8);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(9);
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
pub struct PrivateKeyHash(pub [u8; 32]);

View File

@@ -271,6 +271,8 @@ impl TryFromU64 for AttributeName {
))
}
}
make_case_insensitive_comparable_string!(LdapObjectClass);
make_case_insensitive_comparable_string!(Email);
make_case_insensitive_comparable_string!(GroupName);

View File

@@ -12,7 +12,10 @@ use crate::domain::{
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
},
schema::PublicSchema,
types::{AttributeName, Group, GroupDetails, GroupId, GroupName, User, UserAndGroups, UserId},
types::{
AttributeName, Group, GroupDetails, GroupId, GroupName, LdapObjectClass, User,
UserAndGroups, UserId,
},
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -112,6 +115,10 @@ pub trait AdminBackendHandler:
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
}
#[async_trait]
@@ -187,6 +194,18 @@ impl<Handler: BackendHandler> AdminBackendHandler for Handler {
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()> {
<Handler as SchemaBackendHandler>::delete_group_attribute(self, name).await
}
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::add_user_object_class(self, name).await
}
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::add_group_object_class(self, name).await
}
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::delete_user_object_class(self, name).await
}
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
<Handler as SchemaBackendHandler>::delete_group_object_class(self, name).await
}
}
pub struct AccessControlledBackendHandler<Handler> {

View File

@@ -677,7 +677,7 @@ where
if enable_password_reset {
cfg.service(
web::resource("/reset/step1/{user_id}")
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
.route(web::post().to(get_password_reset_step1_handler::<Backend>)),
)
.service(
web::resource("/reset/step2/{token}")

View File

@@ -9,7 +9,7 @@ use crate::{
},
types::{
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
JpegPhoto, UserId,
JpegPhoto, LdapObjectClass, UserId,
},
},
infra::{
@@ -490,6 +490,90 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.await?;
Ok(Success::new())
}
async fn add_user_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_user_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class addition",
))?;
handler
.add_user_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn add_group_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] add_group_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class addition",
))?;
handler
.add_group_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_user_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_user_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class deletion",
))?;
handler
.delete_user_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_group_object_class(
context: &Context<Handler>,
name: String,
) -> FieldResult<Success> {
let span = debug_span!("[GraphQL mutation] delete_group_object_class");
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(
&span,
"Unauthorized object class deletion",
))?;
handler
.delete_group_object_class(&LdapObjectClass::from(name))
.instrument(span)
.await?;
Ok(Success::new())
}
}
async fn create_group_with_details<Handler: BackendHandler>(

View File

@@ -7,7 +7,7 @@ use crate::{
ldap::utils::{map_user_field, UserFieldType},
model::UserColumn,
schema::PublicSchema,
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, UserId},
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId},
},
infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
@@ -523,26 +523,32 @@ impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Ha
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeList<Handler: BackendHandler> {
schema: DomainAttributeList,
attributes: DomainAttributeList,
extra_classes: Vec<LdapObjectClass>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeList<Handler> {
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
self.schema
self.attributes
.attributes
.clone()
.into_iter()
.map(Into::into)
.collect()
}
fn extra_ldap_object_classes(&self) -> Vec<String> {
self.extra_classes.iter().map(|c| c.to_string()).collect()
}
}
impl<Handler: BackendHandler> From<DomainAttributeList> for AttributeList<Handler> {
fn from(value: DomainAttributeList) -> Self {
impl<Handler: BackendHandler> AttributeList<Handler> {
fn new(attributes: DomainAttributeList, extra_classes: Vec<LdapObjectClass>) -> Self {
Self {
schema: value,
attributes,
extra_classes,
_phantom: std::marker::PhantomData,
}
}
@@ -557,10 +563,16 @@ pub struct Schema<Handler: BackendHandler> {
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Schema<Handler> {
fn user_schema(&self) -> AttributeList<Handler> {
self.schema.get_schema().user_attributes.clone().into()
AttributeList::<Handler>::new(
self.schema.get_schema().user_attributes.clone(),
self.schema.get_schema().extra_user_object_classes.clone(),
)
}
fn group_schema(&self) -> AttributeList<Handler> {
self.schema.get_schema().group_attributes.clone().into()
AttributeList::<Handler>::new(
self.schema.get_schema().group_attributes.clone(),
self.schema.get_schema().extra_group_object_classes.clone(),
)
}
}
@@ -670,7 +682,7 @@ mod tests {
use crate::{
domain::{
handler::AttributeList,
types::{AttributeName, AttributeType, Serialized},
types::{AttributeName, AttributeType, LdapObjectClass, Serialized},
},
infra::{
access_control::{Permission, ValidationResults},
@@ -755,6 +767,11 @@ mod tests {
is_hardcoded: false,
}],
},
extra_user_object_classes: vec![
LdapObjectClass::from("customUserClass"),
LdapObjectClass::from("myUserClass"),
],
extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")],
})
});
mock.expect_get_user_details()
@@ -946,6 +963,7 @@ mod tests {
isEditable
isHardcoded
}
extraLdapObjectClasses
}
groupSchema {
attributes {
@@ -956,6 +974,7 @@ mod tests {
isEditable
isHardcoded
}
extraLdapObjectClasses
}
}
}"#;
@@ -1040,7 +1059,8 @@ mod tests {
"isEditable": false,
"isHardcoded": true,
},
]
],
"extraLdapObjectClasses": ["customUserClass"],
},
"groupSchema": {
"attributes": [
@@ -1076,7 +1096,8 @@ mod tests {
"isEditable": false,
"isHardcoded": true,
},
]
],
"extraLdapObjectClasses": [],
}
}
}),
@@ -1093,6 +1114,7 @@ mod tests {
attributes {
name
}
extraLdapObjectClasses
}
}
}"#;
@@ -1114,6 +1136,8 @@ mod tests {
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
extra_group_object_classes: Vec::new(),
})
});
@@ -1139,7 +1163,8 @@ mod tests {
{"name": "mail"},
{"name": "user_id"},
{"name": "uuid"},
]
],
"extraLdapObjectClasses": ["customUserClass"],
}
}
} ),

View File

@@ -1290,7 +1290,8 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec()
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},
LdapPartialAttribute {
@@ -1332,7 +1333,8 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec()
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},
LdapPartialAttribute {
@@ -1919,7 +1921,49 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec()
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},]
}),
make_search_success()
])
);
}
#[tokio::test]
async fn test_search_filters_custom_object_class() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(eq(Some(UserRequestFilter::from(true))), eq(false))
.times(1)
.return_once(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("bob_1"),
..Default::default()
},
groups: None,
}])
});
let mut ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(
LdapFilter::Equality("objectClass".to_owned(), "CUSTOMuserCLASS".to_owned()),
vec!["objectclass"],
);
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Ok(vec![
LdapOp::SearchResultEntry(LdapSearchResultEntry {
dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(),
attributes: vec![LdapPartialAttribute {
atype: "objectclass".to_string(),
vals: vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},]
}),
@@ -1983,7 +2027,8 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec()
b"person".to_vec(),
b"customUserClass".to_vec(),
]
},
LdapPartialAttribute {
@@ -2068,6 +2113,7 @@ mod tests {
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
],
},
LdapPartialAttribute {
@@ -2849,6 +2895,11 @@ mod tests {
is_hardcoded: false,
}],
},
extra_user_object_classes: vec![
LdapObjectClass::from("customUserClass"),
LdapObjectClass::from("myUserClass"),
],
extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")],
})
});
let mut ldap_handler = setup_bound_readonly_handler(mock).await;

View File

@@ -47,6 +47,10 @@ mockall::mock! {
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
async fn add_user_object_class(&self, request: &LdapObjectClass) -> Result<()>;
async fn add_group_object_class(&self, request: &LdapObjectClass) -> Result<()>;
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
}
#[async_trait]
impl BackendHandler for TestBackendHandler {}
@@ -102,6 +106,8 @@ pub fn setup_default_schema(mock: &mut MockTestBackendHandler) {
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
extra_group_object_classes: Vec::new(),
})
});
}