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)]
pub enum UserCreationError {
DomainNotFound(String),
UserAlreadyExists(UserRef),
Permissions,
}
@ -13,6 +14,7 @@ pub enum UserCreationError {
impl fmt::Display for UserCreationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DomainNotFound(domain) => write!(f, "No domain {domain} to create user in"),
Self::UserAlreadyExists(user) => write!(f, "User already exists: {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 {}
#[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::{Database, User, UserRef};
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
use crate::db::{Database, Domain, User, UserRef};
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> {
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 {
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(
&self,
user: &UserRef,
@ -45,6 +62,10 @@ pub trait DatabaseInterface: Clone + Send + Sync + 'static {
new_user: User,
current_user: &User,
) -> 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(
&self,
domain: Option<String>,

View file

@ -1,46 +1,90 @@
use std::future::{Future, ready};
use crate::db::error::{BoxedError, UserCreationError};
use crate::db::{Database, DatabaseInterface, Group, Operation, User, UserRef};
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
use crate::db::{Database, DatabaseInterface, Domain, Operation, User, UserRef};
#[derive(Clone, Debug, Default)]
pub struct MemoryDatabase {
pub users: Vec<User>,
#[expect(unused)]
pub groups: Vec<Group>,
pub domains: Vec<Domain>,
/// Where global users/groups are registered
pub global_domain: Domain,
}
impl MemoryDatabase {
pub fn new() -> Database<Self> {
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 {
fn get_user(
&self,
req_user: &UserRef,
) -> impl Future<Output = Result<Option<User>, BoxedError>> {
for user in &self.users {
if user.username == req_user.username && user.domain == req_user.domain {
return ready(Ok(Some(user.clone())));
}
fn create_domain(
&mut self,
domain: &str,
) -> impl Future<Output = Result<Result<(), DomainCreationError>, BoxedError>> {
if domain.is_empty() {
return ready(Ok(Err(DomainCreationError::InvalidDomain(
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,
user: User,
) -> Result<Result<(), UserCreationError>, BoxedError> {
) -> impl Future<Output = Result<Result<(), UserCreationError>, BoxedError>> {
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);
Ok(Ok(()))
let Some(req_domain) = &user.domain else {
// 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(
@ -65,19 +109,23 @@ impl DatabaseInterface for MemoryDatabase {
self.create_user(new_user).await
}
fn list_users(
&self,
domain: Option<String>,
) -> impl Future<Output = Result<Vec<User>, BoxedError>> {
let users = if let Some(domain) = domain {
self.users
.iter()
.filter(|user| user.domain.as_ref() == Some(&domain))
.cloned()
.collect()
async fn list_users(&self, domain: Option<String>) -> Result<Vec<User>, BoxedError> {
if let Some(domain) = domain {
if domain.is_empty() {
Ok(self.global_domain.users.clone())
} else {
let Some(domain) = self.get_domain(&domain).await? else {
return Ok(vec![]);
};
Ok(domain.users.clone())
}
} else {
self.users.clone()
};
ready(Ok(users))
// Aggregate all users
let mut users = self.global_domain.users.clone();
for domain in &self.domains {
users.extend(domain.users.clone());
}
Ok(users)
}
}
}

View file

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

View file

@ -29,22 +29,24 @@ async fn create_dummy_users<D: DatabaseInterface>(db: &mut Database<D>) {
.await
.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 {
username: domain.to_string(),
domain: Some(format!("{domain}.localhost")),
username: letter.to_string(),
domain: Some(domain.clone()),
password: "adminadmin".to_string(),
mail: format!("{domain}@{domain}.localhost"),
role: Role::DomainAdmin(format!("{domain}.localhost")),
mail: format!("{letter}@{domain}"),
role: Role::DomainAdmin(domain.clone()),
})
.await
.unwrap()
.unwrap();
db.create_user(User {
username: format!("user{domain}"),
domain: Some(format!("{domain}.localhost")),
username: format!("user{letter}"),
domain: Some(domain.clone()),
password: "adminadmin".to_string(),
mail: format!("user{domain}@{domain}.localhost"),
mail: format!("user{letter}@{domain}"),
role: Role::User,
})
.await