189 lines
5.5 KiB
Rust
189 lines
5.5 KiB
Rust
//! 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.
|
|
/// 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<Self> {
|
|
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<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));
|
|
}
|
|
}
|
|
}
|