diff --git a/crates/domain/src/id.rs b/crates/domain/src/id.rs index 6f7be8d..b3b4f4b 100644 --- a/crates/domain/src/id.rs +++ b/crates/domain/src/id.rs @@ -72,6 +72,10 @@ id_newtype!( /// Identifier for a flexible-field definition. FieldDefinitionId ); +id_newtype!( + /// Identifier for a user of this organization's instance. + UserId +); #[cfg(test)] mod tests { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index e3a4dbe..fa3f759 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -6,12 +6,14 @@ mod field_definition; mod id; mod label; mod object; +mod user; mod vocabulary; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; -pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, VocabularyId}; +pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId}; pub use label::{LocalizedLabel, pick_label}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; +pub use user::{Capability, Email, EmailError, NewUser, Role, User}; pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; diff --git a/crates/domain/src/user.rs b/crates/domain/src/user.rs new file mode 100644 index 0000000..1e70b4f --- /dev/null +++ b/crates/domain/src/user.rs @@ -0,0 +1,175 @@ +//! User identity, roles, and the capability policy. +//! +//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The +//! role→capability mapping (`Role::allows`) is the single source of authorization +//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth` +//! boundary, never in these types. + +use serde::{Deserialize, Serialize}; + +use crate::UserId; + +/// A validated email address (normalized to lowercase, trimmed). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Email(String); + +/// The supplied string is not a syntactically acceptable email. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmailError; + +impl std::fmt::Display for EmailError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid email address") + } +} + +impl std::error::Error for EmailError {} + +impl Email { + /// Parse and normalize an email. Light MVP validation: a single `@`, non-empty + /// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321 + /// validation is deferred.) + pub fn parse(raw: &str) -> Result { + let normalized = raw.trim().to_lowercase(); + + if normalized.contains(char::is_whitespace) { + return Err(EmailError); + } + + let mut parts = normalized.split('@'); + let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else { + return Err(EmailError); + }; + + let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'); + + if local.is_empty() || !domain_ok { + return Err(EmailError); + } + + Ok(Email(normalized)) + } + + /// The normalized string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Reconstruct from a stored (already-validated) value, without re-validating. + pub fn from_db(value: String) -> Email { + Email(value) + } +} + +/// A user's role within the organization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + /// Full access, including user management. + Admin, + /// Catalogue work: create/edit/publish records; cannot manage users. + Editor, +} + +impl Role { + pub const fn as_str(&self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Editor => "editor", + } + } + + pub fn from_db(s: &str) -> Option { + match s { + "admin" => Some(Role::Admin), + "editor" => Some(Role::Editor), + _ => None, + } + } + + /// The authorization policy: whether this role may perform `capability`. + pub fn allows(self, capability: Capability) -> bool { + match self { + Role::Admin => true, + Role::Editor => !matches!(capability, Capability::ManageUsers), + } + } +} + +/// A guarded action. `Authorized` (in the `auth` crate) gates a handler on one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Capability { + /// Create/list/modify users. + ManageUsers, + /// Create and edit catalogue records. + EditCatalogue, + /// Change a record's visibility (publish/unpublish). + PublishObjects, + /// Read internal (non-public) records. + ViewInternal, +} + +/// A user as read back from storage. Carries no password material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + pub id: UserId, + pub email: Email, + pub role: Role, +} + +/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`). +#[derive(Debug, Clone)] +pub struct NewUser { + pub email: Email, + pub password_hash: String, + pub role: Role, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_parses_and_normalizes() { + assert_eq!( + Email::parse(" Anna@Example.COM ").unwrap().as_str(), + "anna@example.com" + ); + } + + #[test] + fn email_rejects_garbage() { + for bad in [ + "", + "no-at", + "a@b", + "a@@b.com", + "a b@c.com", + "@example.com", + "x@.com", + "x@com.", + ] { + assert!(Email::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn role_round_trips() { + for r in [Role::Admin, Role::Editor] { + assert_eq!(Role::from_db(r.as_str()), Some(r)); + } + assert_eq!(Role::from_db("superuser"), None); + } + + #[test] + fn capability_policy_matrix() { + use Capability::*; + for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] { + assert!(Role::Admin.allows(cap)); + } + assert!(!Role::Editor.allows(ManageUsers)); + for cap in [EditCatalogue, PublishObjects, ViewInternal] { + assert!(Role::Editor.allows(cap)); + } + } +}