feat(domain): user identity (UserId, Email), Role/Capability policy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Email, EmailError> {
|
||||
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<Self> {
|
||||
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<C>` (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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user