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