domain: add Identity & Access entities (User, Role, Permission, Group)

This commit is contained in:
2026-05-31 03:20:18 +02:00
parent aa432e6594
commit 656da7e945
11 changed files with 234 additions and 2 deletions

View File

@@ -0,0 +1,46 @@
use std::collections::HashSet;
use crate::errors::DomainError;
use crate::value_objects::SystemId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Group {
pub group_id: SystemId,
pub name: String,
pub owner_user_id: SystemId,
pub members: HashSet<SystemId>,
}
impl Group {
pub fn new(name: impl Into<String>, owner_user_id: SystemId) -> Self {
let mut members = HashSet::new();
members.insert(owner_user_id);
Self {
group_id: SystemId::new(),
name: name.into(),
owner_user_id,
members,
}
}
pub fn add_member(&mut self, user_id: SystemId) -> Result<(), DomainError> {
if self.members.contains(&user_id) {
return Err(DomainError::Conflict(format!("User {user_id} is already a member")));
}
self.members.insert(user_id);
Ok(())
}
pub fn remove_member(&mut self, user_id: SystemId) -> Result<(), DomainError> {
if user_id == self.owner_user_id {
return Err(DomainError::Validation("Cannot remove the group owner".to_string()));
}
if !self.members.remove(&user_id) {
return Err(DomainError::NotFound(format!("User {user_id} is not a member")));
}
Ok(())
}
pub fn is_member(&self, user_id: &SystemId) -> bool {
self.members.contains(user_id)
}
}

View File

@@ -1,2 +1,9 @@
pub mod permission;
pub mod role;
mod user; mod user;
mod group;
pub use permission::{Permission, PermissionAction, ResourceType};
pub use role::Role;
pub use user::User; pub use user::User;
pub use group::Group;

View File

@@ -0,0 +1,60 @@
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum PermissionAction {
ReadAsset,
ReadMetadata,
ReadLocation,
ReadPerson,
WriteMetadata,
DeleteAsset,
ManageAccess,
ManageUsers,
ManageSystem,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ResourceType {
Asset,
Album,
Collection,
Person,
Directory,
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Permission {
pub action: PermissionAction,
pub resource_type: ResourceType,
}
impl Permission {
pub fn new(action: PermissionAction, resource_type: ResourceType) -> Self {
Self { action, resource_type }
}
}
pub fn viewer_permissions() -> HashSet<Permission> {
HashSet::from([
Permission::new(PermissionAction::ReadAsset, ResourceType::Global),
Permission::new(PermissionAction::ReadMetadata, ResourceType::Global),
])
}
pub fn contributor_permissions() -> HashSet<Permission> {
let mut perms = viewer_permissions();
perms.insert(Permission::new(PermissionAction::WriteMetadata, ResourceType::Global));
perms
}
pub fn admin_permissions() -> HashSet<Permission> {
let mut perms = contributor_permissions();
perms.insert(Permission::new(PermissionAction::DeleteAsset, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageAccess, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageUsers, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageSystem, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ReadLocation, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ReadPerson, ResourceType::Global));
perms
}

View File

@@ -0,0 +1,26 @@
use std::collections::HashSet;
use crate::value_objects::SystemId;
use super::permission::{Permission, PermissionAction, ResourceType};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Role {
pub role_id: SystemId,
pub name: String,
pub permissions: HashSet<Permission>,
pub is_system_default: bool,
}
impl Role {
pub fn new(name: impl Into<String>, permissions: HashSet<Permission>, is_system_default: bool) -> Self {
Self {
role_id: SystemId::new(),
name: name.into(),
permissions,
is_system_default,
}
}
pub fn has_permission(&self, action: PermissionAction, resource_type: ResourceType) -> bool {
self.permissions.contains(&Permission::new(action, resource_type))
}
}

View File

@@ -4,13 +4,20 @@ use crate::value_objects::{Email, PasswordHash, SystemId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User { pub struct User {
pub id: SystemId, pub id: SystemId,
pub username: String,
pub email: Email, pub email: Email,
pub password_hash: PasswordHash, pub password_hash: PasswordHash,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
impl User { impl User {
pub fn new(id: SystemId, email: Email, password_hash: PasswordHash) -> Self { pub fn new(username: impl Into<String>, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash, created_at: Utc::now() } Self {
id: SystemId::new(),
username: username.into(),
email,
password_hash,
created_at: Utc::now(),
}
} }
} }

View File

@@ -1,2 +1,4 @@
mod entities;
mod events; mod events;
mod services;
mod value_objects; mod value_objects;

View File

@@ -0,0 +1,41 @@
use domain::entities::Group;
use domain::errors::DomainError;
use domain::value_objects::SystemId;
#[test]
fn owner_auto_member() {
let owner = SystemId::new();
let g = Group::new("team", owner);
assert!(g.is_member(&owner));
assert_eq!(g.members.len(), 1);
}
#[test]
fn add_and_remove() {
let owner = SystemId::new();
let member = SystemId::new();
let mut g = Group::new("team", owner);
g.add_member(member).unwrap();
assert!(g.is_member(&member));
assert_eq!(g.members.len(), 2);
g.remove_member(member).unwrap();
assert!(!g.is_member(&member));
}
#[test]
fn cannot_remove_owner() {
let owner = SystemId::new();
let mut g = Group::new("team", owner);
let result = g.remove_member(owner);
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn cannot_add_duplicate() {
let owner = SystemId::new();
let member = SystemId::new();
let mut g = Group::new("team", owner);
g.add_member(member).unwrap();
let result = g.add_member(member);
assert!(matches!(result, Err(DomainError::Conflict(_))));
}

View File

@@ -0,0 +1,4 @@
mod group;
mod permission;
mod role;
mod user;

View File

@@ -0,0 +1,19 @@
use domain::entities::permission::{
admin_permissions, contributor_permissions, viewer_permissions,
Permission, PermissionAction, ResourceType,
};
#[test]
fn admin_is_superset_of_contributor() {
let admin = admin_permissions();
let contrib = contributor_permissions();
assert!(contrib.is_subset(&admin));
assert!(admin.len() > contrib.len());
}
#[test]
fn viewer_cannot_write() {
let viewer = viewer_permissions();
let write = Permission::new(PermissionAction::WriteMetadata, ResourceType::Global);
assert!(!viewer.contains(&write));
}

View File

@@ -0,0 +1,9 @@
use domain::entities::{Role, PermissionAction, ResourceType};
use domain::entities::permission::viewer_permissions;
#[test]
fn role_checks_permission() {
let role = Role::new("viewer", viewer_permissions(), true);
assert!(role.has_permission(PermissionAction::ReadAsset, ResourceType::Global));
assert!(!role.has_permission(PermissionAction::DeleteAsset, ResourceType::Global));
}

View File

@@ -0,0 +1,11 @@
use domain::entities::User;
use domain::value_objects::{Email, PasswordHash};
#[test]
fn creates_user_with_unique_id() {
let a = User::new("alice", Email::new("a@example.com").unwrap(), PasswordHash::from_hash("h".into()));
let b = User::new("bob", Email::new("b@example.com").unwrap(), PasswordHash::from_hash("h".into()));
assert_ne!(a.id, b.id);
assert_eq!(a.username, "alice");
assert_eq!(b.username, "bob");
}