//! Yours: `puerto new --auth` writes this file once. Every authenticated caller is a `User` row
//! (ADR-0021 § 3 of Puerto): what other entities point at (`owner: UserId`), whichever provider
//! signed the caller in.
use chrono::{DateTime, Utc};
use uuid::Uuid;
{%- if local %}
use super::errors::UserError;
/// The issuer of the accounts this API keeps itself (`POST /auth/register`).
pub const LOCAL_ISSUER: &str = "local";
{%- endif %}
/// The id of every `User`: a `Uuid` that cannot be passed where another entity's id is expected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Uuid);
impl UserId {
/// A new, random id — for a user being created.
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
/// An id read from the outside (a path parameter, a row) — no validation: any `Uuid` is an id.
pub fn from_uuid(value: Uuid) -> Self {
Self(value)
}
pub fn value(&self) -> Uuid {
self.0
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// Who signed in, as this project knows them. `(issuer, subject)` is the provider's name for the
/// person and is unique; `email` and `name` are what the provider said the first time.
#[derive(Debug, Clone, PartialEq)]
pub struct User {
pub id: UserId,
pub issuer: String,
pub subject: String,
pub email: Option<String>,
pub name: Option<String>,
/// Not read in an OIDC project: there the roles come from the token only, so one place
/// decides.
pub roles: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl User {
/// The user a first request from `(issuer, subject)` creates (just-in-time provisioning).
pub fn provision(
issuer: impl Into<String>,
subject: impl Into<String>,
email: Option<String>,
name: Option<String>,
) -> Self {
let now = Utc::now();
Self {
id: UserId::generate(),
issuer: issuer.into(),
subject: subject.into(),
email,
name,
roles: vec![],
created_at: now,
updated_at: now,
}
}
{% if local %} /// A new local account (`register_user`). Its subject is the email as [`User::local_subject`]
/// spells it, so `UNIQUE (issuer, subject)` makes the email unique.
pub fn register(email: &str, name: Option<String>) -> Result<Self, UserError> {
let email = Self::local_subject(email);
if !is_email(&email) {
return Err(UserError::ValidationError("email_invalid".into()));
}
let name = name
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty());
Ok(Self::provision(LOCAL_ISSUER, email.clone(), Some(email), name))
}
/// How a local account's email is compared: trimmed and lowercased.
pub fn local_subject(email: &str) -> String {
email.trim().to_lowercase()
}
{% endif %} /// Reconstructs from persistence — no validation. ONLY infrastructure uses this.
pub fn from_repository(data: User) -> Self {
data
}
}
{% if local %}/// One `@` between a non-empty name and a dotted domain, no spaces. Whether the address exists is
/// for a confirmation mail to find out, not for this check.
fn is_email(email: &str) -> bool {
let Some((name, domain)) = email.split_once('@') else {
return false;
};
!name.is_empty()
&& !domain.contains('@')
&& domain.contains('.')
&& !domain.starts_with('.')
&& !domain.ends_with('.')
&& !email.chars().any(char::is_whitespace)
}
{% endif %}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_hold_what_the_provider_said_when_provisioned() {
// Act
let user = User::provision(
"http://localhost:8180/realms/shop",
"f81d4fae",
Some("ada@example.com".into()),
Some("Ada Lovelace".into()),
);
// Assert
assert_eq!(user.issuer, "http://localhost:8180/realms/shop");
assert_eq!(user.subject, "f81d4fae");
assert_eq!(user.email.as_deref(), Some("ada@example.com"));
assert_eq!(user.name.as_deref(), Some("Ada Lovelace"));
assert!(user.roles.is_empty());
assert_eq!(user.created_at, user.updated_at);
}
#[test]
fn should_get_a_new_id_when_two_users_are_provisioned() {
// Act
let ada = User::provision("issuer", "ada", None, None);
let bob = User::provision("issuer", "bob", None, None);
// Assert
assert_ne!(ada.id, bob.id);
}
{%- if local %}
#[test]
fn should_register_a_local_account_under_its_lowercased_email() {
// Act
let user = User::register(" Ada@Example.COM ", Some(" Ada ".into())).expect("valid");
// Assert
assert_eq!(user.issuer, LOCAL_ISSUER);
assert_eq!(user.subject, "ada@example.com");
assert_eq!(user.email.as_deref(), Some("ada@example.com"));
assert_eq!(user.name.as_deref(), Some("Ada"));
assert!(user.roles.is_empty());
}
#[test]
fn should_keep_no_name_when_it_is_blank() {
let user = User::register("ada@example.com", Some(" ".into())).expect("valid");
assert_eq!(user.name, None);
}
#[test]
fn should_reject_an_account_when_the_email_is_not_one() {
for email in ["", "ada", "ada@", "@example.com", "ada@example", "a da@example.com", "a@b@c.com", "ada@.com"] {
let err = User::register(email, None).expect_err(email);
assert_eq!(err.to_string(), "user.validation_error.email_invalid", "{email}");
}
}
{%- endif %}
}