From 656da7e9457e2d54e3986f42f717ef8617f28753 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 03:20:18 +0200 Subject: [PATCH] domain: add Identity & Access entities (User, Role, Permission, Group) --- crates/domain/src/entities/group.rs | 46 +++++++++++++++++ crates/domain/src/entities/mod.rs | 7 +++ crates/domain/src/entities/permission.rs | 60 ++++++++++++++++++++++ crates/domain/src/entities/role.rs | 26 ++++++++++ crates/domain/src/entities/user.rs | 11 +++- crates/domain/tests/domain_tests.rs | 2 + crates/domain/tests/entities/group.rs | 41 +++++++++++++++ crates/domain/tests/entities/mod.rs | 4 ++ crates/domain/tests/entities/permission.rs | 19 +++++++ crates/domain/tests/entities/role.rs | 9 ++++ crates/domain/tests/entities/user.rs | 11 ++++ 11 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 crates/domain/src/entities/group.rs create mode 100644 crates/domain/src/entities/permission.rs create mode 100644 crates/domain/src/entities/role.rs create mode 100644 crates/domain/tests/entities/group.rs create mode 100644 crates/domain/tests/entities/mod.rs create mode 100644 crates/domain/tests/entities/permission.rs create mode 100644 crates/domain/tests/entities/role.rs create mode 100644 crates/domain/tests/entities/user.rs diff --git a/crates/domain/src/entities/group.rs b/crates/domain/src/entities/group.rs new file mode 100644 index 0000000..ac63851 --- /dev/null +++ b/crates/domain/src/entities/group.rs @@ -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, +} + +impl Group { + pub fn new(name: impl Into, 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) + } +} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs index 7068ef2..4a56f3d 100644 --- a/crates/domain/src/entities/mod.rs +++ b/crates/domain/src/entities/mod.rs @@ -1,2 +1,9 @@ +pub mod permission; +pub mod role; mod user; +mod group; + +pub use permission::{Permission, PermissionAction, ResourceType}; +pub use role::Role; pub use user::User; +pub use group::Group; diff --git a/crates/domain/src/entities/permission.rs b/crates/domain/src/entities/permission.rs new file mode 100644 index 0000000..977f1f4 --- /dev/null +++ b/crates/domain/src/entities/permission.rs @@ -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 { + HashSet::from([ + Permission::new(PermissionAction::ReadAsset, ResourceType::Global), + Permission::new(PermissionAction::ReadMetadata, ResourceType::Global), + ]) +} + +pub fn contributor_permissions() -> HashSet { + let mut perms = viewer_permissions(); + perms.insert(Permission::new(PermissionAction::WriteMetadata, ResourceType::Global)); + perms +} + +pub fn admin_permissions() -> HashSet { + 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 +} diff --git a/crates/domain/src/entities/role.rs b/crates/domain/src/entities/role.rs new file mode 100644 index 0000000..688b2ec --- /dev/null +++ b/crates/domain/src/entities/role.rs @@ -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, + pub is_system_default: bool, +} + +impl Role { + pub fn new(name: impl Into, permissions: HashSet, 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)) + } +} diff --git a/crates/domain/src/entities/user.rs b/crates/domain/src/entities/user.rs index 6e07b7b..41e39b1 100644 --- a/crates/domain/src/entities/user.rs +++ b/crates/domain/src/entities/user.rs @@ -4,13 +4,20 @@ use crate::value_objects::{Email, PasswordHash, SystemId}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct User { pub id: SystemId, + pub username: String, pub email: Email, pub password_hash: PasswordHash, pub created_at: DateTime, } impl User { - pub fn new(id: SystemId, email: Email, password_hash: PasswordHash) -> Self { - Self { id, email, password_hash, created_at: Utc::now() } + pub fn new(username: impl Into, email: Email, password_hash: PasswordHash) -> Self { + Self { + id: SystemId::new(), + username: username.into(), + email, + password_hash, + created_at: Utc::now(), + } } } diff --git a/crates/domain/tests/domain_tests.rs b/crates/domain/tests/domain_tests.rs index abe1467..e32a9dc 100644 --- a/crates/domain/tests/domain_tests.rs +++ b/crates/domain/tests/domain_tests.rs @@ -1,2 +1,4 @@ +mod entities; mod events; +mod services; mod value_objects; diff --git a/crates/domain/tests/entities/group.rs b/crates/domain/tests/entities/group.rs new file mode 100644 index 0000000..b4f8637 --- /dev/null +++ b/crates/domain/tests/entities/group.rs @@ -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(_)))); +} diff --git a/crates/domain/tests/entities/mod.rs b/crates/domain/tests/entities/mod.rs new file mode 100644 index 0000000..ea52b41 --- /dev/null +++ b/crates/domain/tests/entities/mod.rs @@ -0,0 +1,4 @@ +mod group; +mod permission; +mod role; +mod user; diff --git a/crates/domain/tests/entities/permission.rs b/crates/domain/tests/entities/permission.rs new file mode 100644 index 0000000..800c9d6 --- /dev/null +++ b/crates/domain/tests/entities/permission.rs @@ -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)); +} diff --git a/crates/domain/tests/entities/role.rs b/crates/domain/tests/entities/role.rs new file mode 100644 index 0000000..67299cc --- /dev/null +++ b/crates/domain/tests/entities/role.rs @@ -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)); +} diff --git a/crates/domain/tests/entities/user.rs b/crates/domain/tests/entities/user.rs new file mode 100644 index 0000000..793645e --- /dev/null +++ b/crates/domain/tests/entities/user.rs @@ -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"); +}