feat(domain): user identity (UserId, Email), Role/Capability policy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:29:04 +02:00
parent 1ed9798a1f
commit 74b2cf65ed
3 changed files with 182 additions and 1 deletions
+4
View File
@@ -72,6 +72,10 @@ id_newtype!(
/// Identifier for a flexible-field definition. /// Identifier for a flexible-field definition.
FieldDefinitionId FieldDefinitionId
); );
id_newtype!(
/// Identifier for a user of this organization's instance.
UserId
);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+3 -1
View File
@@ -6,12 +6,14 @@ mod field_definition;
mod id; mod id;
mod label; mod label;
mod object; mod object;
mod user;
mod vocabulary; mod vocabulary;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; 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 label::{LocalizedLabel, pick_label};
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
pub use user::{Capability, Email, EmailError, NewUser, Role, User};
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
+175
View File
@@ -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));
}
}
}