//! 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. /// For reading values back from the database only — never to construct an `Email` /// destined to be written (writes must go through [`Email::parse`] so storage /// stays normalized). 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`. /// /// The `Editor` arm is an exhaustive `match` on purpose: adding a new /// [`Capability`] variant is a compile error here until its Editor access is /// decided explicitly, so the policy fails closed rather than silently granting /// new capabilities to Editors. pub fn allows(self, capability: Capability) -> bool { match self { Role::Admin => true, Role::Editor => match capability { Capability::EditCatalogue | Capability::PublishObjects | Capability::ViewInternal => true, Capability::ManageUsers => false, }, } } } /// 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)); } } }