feat: Basic domain creation API

This commit is contained in:
selfhoster selfhoster 2026-09-14 21:05:23 +02:00
commit f1a2721cbe
6 changed files with 176 additions and 44 deletions

35
src/db/domain.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::db::error::UserCreationError;
use crate::db::{Group, User, UserRef};
#[derive(Clone, Debug, Default)]
pub struct Domain {
pub name: String,
pub users: Vec<User>,
#[expect(unused)]
pub groups: Vec<Group>,
}
impl Domain {
pub fn get_user(&self, req_user: &UserRef) -> Option<User> {
assert!(
req_user.domain.as_deref().unwrap_or("") == self.name,
"Should only call Domain::get_user on the matching domain. Asked for {:?} on domain {}",
req_user,
self.name
);
self.users
.iter()
.find(|u| u.username == req_user.username)
.cloned()
}
pub fn create_user(&mut self, user: User, user_ref: UserRef) -> Result<(), UserCreationError> {
if self.get_user(&user_ref).is_some() {
return Err(UserCreationError::UserAlreadyExists(user_ref));
}
self.users.push(user);
Ok(())
}
}

View file

@ -6,6 +6,7 @@ pub type BoxedError = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Debug)] #[derive(Debug)]
pub enum UserCreationError { pub enum UserCreationError {
DomainNotFound(String),
UserAlreadyExists(UserRef), UserAlreadyExists(UserRef),
Permissions, Permissions,
} }
@ -13,6 +14,7 @@ pub enum UserCreationError {
impl fmt::Display for UserCreationError { impl fmt::Display for UserCreationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::DomainNotFound(domain) => write!(f, "No domain {domain} to create user in"),
Self::UserAlreadyExists(user) => write!(f, "User already exists: {user}"), Self::UserAlreadyExists(user) => write!(f, "User already exists: {user}"),
Self::Permissions => write!(f, "You do not have permissions to create this user"), Self::Permissions => write!(f, "You do not have permissions to create this user"),
} }
@ -20,3 +22,25 @@ impl fmt::Display for UserCreationError {
} }
impl std::error::Error for UserCreationError {} impl std::error::Error for UserCreationError {}
#[derive(Debug)]
pub enum DomainCreationError {
DomainAlreadyExists(String),
InvalidDomain(String),
}
impl fmt::Display for DomainCreationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DomainAlreadyExists(domain) => {
write!(f, "Cannot create domain {domain} because it already exists")
}
Self::InvalidDomain(domain) => write!(
f,
"Cannot create domain `{domain}` because it's not considered a valid domain"
),
}
}
}
impl std::error::Error for DomainCreationError {}

View file

@ -1,7 +1,18 @@
use crate::db::error::{BoxedError, UserCreationError}; use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
use crate::db::{Database, User, UserRef}; use crate::db::{Database, Domain, User, UserRef};
impl<D: DatabaseInterface> DatabaseInterface for Database<D> { impl<D: DatabaseInterface> DatabaseInterface for Database<D> {
async fn create_domain(
&mut self,
domain: &str,
) -> Result<Result<(), DomainCreationError>, BoxedError> {
self.inner.write().await.create_domain(domain).await
}
async fn get_domain(&self, domain: &str) -> Result<Option<Domain>, BoxedError> {
self.inner.read().await.get_domain(domain).await
}
async fn get_user(&self, user: &UserRef) -> Result<Option<User>, BoxedError> { async fn get_user(&self, user: &UserRef) -> Result<Option<User>, BoxedError> {
self.inner.read().await.get_user(user).await self.inner.read().await.get_user(user).await
} }
@ -31,6 +42,12 @@ impl<D: DatabaseInterface> DatabaseInterface for Database<D> {
} }
pub trait DatabaseInterface: Clone + Send + Sync + 'static { pub trait DatabaseInterface: Clone + Send + Sync + 'static {
fn create_domain(
&mut self,
domain: &str,
) -> impl Future<Output = Result<Result<(), DomainCreationError>, BoxedError>>;
fn get_domain(&self, domain: &str) -> impl Future<Output = Result<Option<Domain>, BoxedError>>;
fn get_user( fn get_user(
&self, &self,
user: &UserRef, user: &UserRef,
@ -45,6 +62,10 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static {
new_user: User, new_user: User,
current_user: &User, current_user: &User,
) -> Result<Result<(), UserCreationError>, BoxedError>; ) -> Result<Result<(), UserCreationError>, BoxedError>;
/// Use empty string domain to request global users (TODO: this is not very DX)
///
/// When domain does not exist, the returned list is empty.
fn list_users( fn list_users(
&self, &self,
domain: Option<String>, domain: Option<String>,

View file

@ -1,46 +1,90 @@
use std::future::{Future, ready}; use std::future::{Future, ready};
use crate::db::error::{BoxedError, UserCreationError}; use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
use crate::db::{Database, DatabaseInterface, Group, Operation, User, UserRef}; use crate::db::{Database, DatabaseInterface, Domain, Operation, User, UserRef};
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct MemoryDatabase { pub struct MemoryDatabase {
pub users: Vec<User>, pub domains: Vec<Domain>,
#[expect(unused)] /// Where global users/groups are registered
pub groups: Vec<Group>, pub global_domain: Domain,
} }
impl MemoryDatabase { impl MemoryDatabase {
pub fn new() -> Database<Self> { pub fn new() -> Database<Self> {
Database::new(Self::default()) Database::new(Self::default())
} }
pub fn get_domain_mut(&mut self, domain: &str) -> Option<&mut Domain> {
self.domains.iter_mut().find(|d| d.name == domain)
}
} }
impl DatabaseInterface for MemoryDatabase { impl DatabaseInterface for MemoryDatabase {
fn get_user( fn create_domain(
&self, &mut self,
req_user: &UserRef, domain: &str,
) -> impl Future<Output = Result<Option<User>, BoxedError>> { ) -> impl Future<Output = Result<Result<(), DomainCreationError>, BoxedError>> {
for user in &self.users { if domain.is_empty() {
if user.username == req_user.username && user.domain == req_user.domain { return ready(Ok(Err(DomainCreationError::InvalidDomain(
return ready(Ok(Some(user.clone()))); domain.to_string(),
} ))));
} }
ready(Ok(None)) if self.domains.iter().find(|d| d.name == domain).is_some() {
return ready(Ok(Err(DomainCreationError::DomainAlreadyExists(
domain.to_string(),
))));
}
self.domains.push(Domain {
name: domain.to_string(),
users: vec![],
groups: vec![],
});
ready(Ok(Ok(())))
} }
async fn create_user( fn get_domain(&self, domain: &str) -> impl Future<Output = Result<Option<Domain>, BoxedError>> {
let Some(domain) = self.domains.iter().find(|d| d.name == domain) else {
return ready(Ok(None));
};
ready(Ok(Some(domain.clone())))
}
async fn get_user(&self, req_user: &UserRef) -> Result<Option<User>, BoxedError> {
let Some(req_domain) = &req_user.domain else {
// If no domain is provided for the user query, look up the global users
return Ok(self.global_domain.get_user(req_user));
};
let Some(domain) = self.get_domain(req_domain).await? else {
return Ok(None);
};
Ok(domain.get_user(req_user))
}
fn create_user(
&mut self, &mut self,
user: User, user: User,
) -> Result<Result<(), UserCreationError>, BoxedError> { ) -> impl Future<Output = Result<Result<(), UserCreationError>, BoxedError>> {
let user_ref = user.user_ref(); let user_ref = user.user_ref();
if self.get_user(&user_ref).await?.is_some() {
return Ok(Err(UserCreationError::UserAlreadyExists(user_ref)));
}
self.users.push(user); let Some(req_domain) = &user.domain else {
Ok(Ok(())) // No domain requested, this is a global user creation
return ready(Ok(self.global_domain.create_user(user, user_ref)));
};
let Some(domain) = self.get_domain_mut(req_domain) else {
return ready(Ok(Err(UserCreationError::DomainNotFound(
req_domain.clone(),
))));
};
ready(Ok(domain.create_user(user, user_ref)))
} }
async fn try_create_user( async fn try_create_user(
@ -65,19 +109,23 @@ impl DatabaseInterface for MemoryDatabase {
self.create_user(new_user).await self.create_user(new_user).await
} }
fn list_users( async fn list_users(&self, domain: Option<String>) -> Result<Vec<User>, BoxedError> {
&self, if let Some(domain) = domain {
domain: Option<String>, if domain.is_empty() {
) -> impl Future<Output = Result<Vec<User>, BoxedError>> { Ok(self.global_domain.users.clone())
let users = if let Some(domain) = domain { } else {
self.users let Some(domain) = self.get_domain(&domain).await? else {
.iter() return Ok(vec![]);
.filter(|user| user.domain.as_ref() == Some(&domain)) };
.cloned() Ok(domain.users.clone())
.collect() }
} else { } else {
self.users.clone() // Aggregate all users
}; let mut users = self.global_domain.users.clone();
ready(Ok(users)) for domain in &self.domains {
users.extend(domain.users.clone());
}
Ok(users)
}
} }
} }

View file

@ -1,5 +1,7 @@
mod common; mod common;
pub use common::Database; pub use common::Database;
mod domain;
pub use domain::Domain;
pub mod error; pub mod error;
mod group; mod group;
pub use group::Group; pub use group::Group;

View file

@ -29,22 +29,24 @@ async fn create_dummy_users<D: DatabaseInterface>(db: &mut Database<D>) {
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
for domain in &["a", "b", "c"] { for letter in &["a", "b", "c"] {
let domain = format!("{letter}.localhost");
db.create_domain(&domain).await.unwrap().unwrap();
db.create_user(User { db.create_user(User {
username: domain.to_string(), username: letter.to_string(),
domain: Some(format!("{domain}.localhost")), domain: Some(domain.clone()),
password: "adminadmin".to_string(), password: "adminadmin".to_string(),
mail: format!("{domain}@{domain}.localhost"), mail: format!("{letter}@{domain}"),
role: Role::DomainAdmin(format!("{domain}.localhost")), role: Role::DomainAdmin(domain.clone()),
}) })
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
db.create_user(User { db.create_user(User {
username: format!("user{domain}"), username: format!("user{letter}"),
domain: Some(format!("{domain}.localhost")), domain: Some(domain.clone()),
password: "adminadmin".to_string(), password: "adminadmin".to_string(),
mail: format!("user{domain}@{domain}.localhost"), mail: format!("user{letter}@{domain}"),
role: Role::User, role: Role::User,
}) })
.await .await