//! Instance identity: configuration sync, group-derived access, SSO accounts, bootstrap //! accounts and contact linking. Authorization stays on the server; the browser only //! displays what these functions decide. use crate::{ access::{Access, Config, Role, WorkspaceConfig, bootstrap_password, resolve_access}, domain::*, error::{Error, Result, ensure}, models::*, service, store::{self, Store, WorkspaceRecord}, }; use chrono::{DateTime, Utc}; use cot::db::{DatabaseError, Model, query}; use serde_json::json; use std::collections::BTreeSet; use uuid::Uuid; fn config_error(message: impl Into) -> Error { Error::Validation(format!("Invalid configuration: {}", message.into())) } pub fn normalize_email(value: &str) -> Option { let email = value.trim().to_lowercase(); (email.len() <= 254 && email_address::EmailAddress::is_valid(&email)).then_some(email) } pub fn new_user_row(username: String, display_name: String, hash: Option) -> UserRow { UserRow { language: None, demo_deleted_at: None, id: Uuid::new_v4().to_string(), username, display_name, country: "CY".into(), timezone: None, password_hash: hash, created_at: Utc::now(), email: None, instance_admin: false, bootstrap: false, groups: "[]".into(), } } async fn insert_user(db: &Store, row: &UserRow) -> Result { match row.clone().insert(&db.db).await { Ok(_) => Ok(true), Err(DatabaseError::UniqueViolation) => Ok(false), Err(e) => Err(e.into()), } } // ---- workspaces from configuration --------------------------------------------------- #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorkspaceChange { Created, Adopted, Enabled, Renamed, } #[derive(Debug, Clone)] pub struct EnsuredWorkspace { pub id: Uuid, pub changes: Vec, } const CREATE_CONFIGURED_WORKSPACE: &str = r#" WITH workspace AS ( INSERT INTO meguri__workspaces(id,key,document,revision,created_at) VALUES($1,$2,$3,1,now()) RETURNING * ), history AS ( INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),NULL FROM workspace ), jobs AS ( INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.created',$4,now() FROM workspace "#; const ENABLE_WORKSPACE: &str = r#" WITH enabled AS ( UPDATE meguri__workspaces SET disabled_at=NULL WHERE id=$1 AND disabled_at IS NOT NULL AND demo_deleted_at IS NULL RETURNING id,revision ), jobs AS ( INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,now() FROM enabled ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.enabled','{}',now() FROM enabled "#; const DISABLE_WORKSPACE: &str = r#" WITH disabled AS ( UPDATE meguri__workspaces SET disabled_at=now() WHERE id=$1 AND disabled_at IS NULL AND demo_deleted_at IS NULL RETURNING id ), jobs AS ( DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM disabled) ), members AS ( DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM disabled) ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.disabled','{}',now() FROM disabled "#; /// Active (not demo-expired) workspace rows, including disabled ones. async fn live_rows(db: &Store) -> Result> { Ok(WorkspaceRow::objects() .all(&db.db) .await? .into_iter() .filter(|r| r.demo_deleted_at.is_none()) .collect()) } fn record_of(row: &WorkspaceRow) -> Result { Ok(WorkspaceRecord { id: store::uuid(&row.id)?, revision: row.revision, document: serde_json::from_str(&row.document)?, }) } fn ambiguous_adoption(config: &WorkspaceConfig, matches: usize) -> Error { config_error(format!( "workspace {:?} matches {matches} existing workspaces named {:?}; rename or delete the extras so the key can adopt exactly one", config.key, config.name )) } /// Everything that can be refused from configuration alone is refused before the first write, /// so a rejected configuration never leaves a half-applied instance behind. async fn preflight(db: &Store, config: &Config, allow_disable_all: bool) -> Result<()> { let rows = live_rows(db).await?; if config.workspaces.is_empty() && !allow_disable_all { let active = rows.iter().filter(|r| r.disabled_at.is_none()).count(); ensure( active == 0, format!( "Refusing to disable {active} existing workspaces because the configuration lists none. Fix the configuration or set MEGURI_ALLOW_DISABLE_ALL=true to disable them all." ), )?; } for workspace in &config.workspaces { if rows .iter() .any(|r| r.key.as_deref() == Some(workspace.key.as_str())) { continue; } let mut matches = 0; for row in rows.iter().filter(|r| r.key.is_none()) { if record_of(row)?.document.name == workspace.name { matches += 1; } } if matches > 1 { return Err(ambiguous_adoption(workspace, matches)); } } for username in config.bootstrap_usernames() { let name = username.clone(); if let Some(row) = query!(UserRow,$username==name).get(&db.db).await? { let id = row.id; if query!(ExternalIdentityRow,$user_id==id) .exists(&db.db) .await? { return Err(config_error(format!( "bootstrap username {username:?} belongs to an SSO account; choose another name" ))); } } } Ok(()) } /// Creates, adopts (by exact name), enables or renames the workspace declared by `config`. /// Every step is idempotent, so replicas that run it concurrently converge. pub async fn ensure_workspace(db: &Store, config: &WorkspaceConfig) -> Result { for _ in 0..4 { let rows = live_rows(db).await?; if let Some(row) = rows .iter() .find(|r| r.key.as_deref() == Some(config.key.as_str())) { let id = store::uuid(&row.id)?; let mut changes = vec![]; if row.disabled_at.is_some() { let result = db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?; if result.rows_affected().0 > 0 { changes.push(WorkspaceChange::Enabled); } } let mut record = record_of(row)?; if record.document.name != config.name { let before = record.document.name.clone(); record.document.name = config.name.clone(); match db .system_commit( &mut record, None, "workspace.renamed", json!({"from":before,"to":config.name}), ) .await { Ok(()) => changes.push(WorkspaceChange::Renamed), Err(Error::Conflict(_)) => continue, Err(e) => return Err(e), } } return Ok(EnsuredWorkspace { id, changes }); } let mut candidates = vec![]; for row in rows.iter().filter(|r| r.key.is_none()) { if record_of(row)?.document.name == config.name { candidates.push(row); } } if candidates.len() > 1 { return Err(ambiguous_adoption(config, candidates.len())); } if let Some(row) = candidates.first() { let adopted = db .db .raw_with( "UPDATE meguri__workspaces SET key=$2 WHERE id=$1 AND key IS NULL", &[&row.id, &config.key], ) .await?; if adopted.rows_affected().0 == 0 { continue; } let mut changes = vec![WorkspaceChange::Adopted]; if row.disabled_at.is_some() { db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?; changes.push(WorkspaceChange::Enabled); } return Ok(EnsuredWorkspace { id: store::uuid(&row.id)?, changes, }); } let id = Uuid::new_v4(); let document = serde_json::to_string(&WorkspaceDocument::new(config.name.clone()))?; let details = json!({"key":config.key}).to_string(); match db .db .raw_with( CREATE_CONFIGURED_WORKSPACE, &[&id.to_string(), &config.key, &document, &details], ) .await { Ok(_) => { return Ok(EnsuredWorkspace { id, changes: vec![WorkspaceChange::Created], }); } Err(DatabaseError::UniqueViolation) => continue, Err(e) => return Err(e.into()), } } Err(Error::Internal(format!( "workspace {:?} kept changing while it was being created", config.key ))) } /// The enabled workspace declared with `key`, if it exists. pub async fn find_workspace(db: &Store, key: &str) -> Result> { live_rows(db) .await? .iter() .find(|r| r.key.as_deref() == Some(key) && r.disabled_at.is_none()) .map(record_of) .transpose() } pub async fn disable_workspace(db: &Store, id: &str) -> Result { Ok(db .db .raw_with(DISABLE_WORKSPACE, &[&id]) .await? .rows_affected() .0 > 0) } // ---- group-derived access ------------------------------------------------------------- const APPLY_ACCESS: &str = r#" WITH changed AS ( UPDATE meguri__users SET groups=$2,instance_admin=$3 WHERE id=$1 RETURNING id ), desired AS ( SELECT w.id AS workspace_id,d.is_owner FROM jsonb_to_recordset($4::jsonb) AS d(key text,is_owner boolean) JOIN meguri__workspaces w ON w.key=d.key AND w.demo_deleted_at IS NULL AND w.disabled_at IS NULL ), removed AS ( DELETE FROM meguri__workspace_members m WHERE m.user_id=$1 AND EXISTS(SELECT 1 FROM changed) AND NOT EXISTS(SELECT 1 FROM desired d WHERE d.workspace_id=m.workspace_id) ) INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT d.workspace_id||':'||$1,d.workspace_id,$1, CASE WHEN d.is_owner THEN $5 ELSE COALESCE((SELECT w.document::jsonb->'member_permissions' FROM meguri__workspaces w WHERE w.id=d.workspace_id)::text,$6) END, d.is_owner FROM desired d WHERE EXISTS(SELECT 1 FROM changed) ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions,is_owner=EXCLUDED.is_owner "#; pub fn stored_groups(row: &UserRow) -> Result> { Ok(serde_json::from_str(&row.groups)?) } /// The one place that turns groups into memberships and the instance-admin flag. SSO login, /// the administrator's password-user editor and startup reconciliation all end here. /// `groups` replaces the stored list when given. pub async fn apply_access( db: &Store, config: &Config, user: Uuid, groups: Option>, ) -> Result { let id = user.to_string(); let row = query!(UserRow,$id==id.clone()) .get(&db.db) .await? .ok_or(Error::NotFound)?; let mut groups = match groups { Some(groups) => groups, None => stored_groups(&row)?, }; groups.sort(); groups.dedup(); let access = resolve_access(config, &groups, row.bootstrap); let desired: Vec<_> = access .workspaces .iter() .map(|(key, role)| json!({"key":key,"is_owner":*role == Role::Admin})) .collect(); db.db .raw_with( APPLY_ACCESS, &[ &id, &serde_json::to_string(&groups)?, &access.instance_admin, &serde_json::to_string(&desired)?, &serde_json::to_string(&Permission::all())?, &serde_json::to_string(&Permission::employee_default())?, ], ) .await?; Ok(access) } pub async fn reconcile_all(db: &Store, config: &Config) -> Result { let mut count = 0; for row in UserRow::objects().all(&db.db).await? { if row.demo_deleted_at.is_some() { continue; } apply_access(db, config, store::uuid(&row.id)?, None).await?; count += 1; } Ok(count) } // ---- bootstrap accounts --------------------------------------------------------------- pub enum BootstrapPasswords { /// HMAC of a database-held secret and the current UTC hour: identical across replicas. Derived, /// One generated password for a single-process demonstration run. Fixed(String), } async fn instance_secret(db: &Store) -> Result> { let candidate = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); db.db .raw_with( "INSERT INTO meguri__instance_state(key,value) VALUES('bootstrap_secret',$1) ON CONFLICT(key) DO NOTHING", &[&candidate], ) .await?; let secret = query!(InstanceStateRow,$key=="bootstrap_secret".to_owned()) .get(&db.db) .await? .ok_or_else(|| Error::Internal("bootstrap secret is missing".into()))?; Ok(secret.value.into_bytes()) } /// Creates or refreshes one bootstrap account. Returns whether the password changed. async fn ensure_bootstrap_account(db: &Store, username: &str, password: &str) -> Result { for _ in 0..3 { let name = username.to_owned(); match query!(UserRow,$username==name).get(&db.db).await? { Some(row) => { let id = row.id.clone(); if query!(ExternalIdentityRow,$user_id==id) .exists(&db.db) .await? { return Err(config_error(format!( "bootstrap username {username:?} belongs to an SSO account; choose another name" ))); } if row.bootstrap && let Some(hash) = row.password_hash.clone() && service::password_matches(hash, password.to_owned()).await? { return Ok(false); } let hash = service::hash_password(password.to_owned()).await?; db.db .raw_with( "UPDATE meguri__users SET password_hash=$2,bootstrap=true,demo_deleted_at=NULL WHERE id=$1", &[&row.id, &hash], ) .await?; db.db .raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id]) .await?; return Ok(true); } None => { let hash = service::hash_password(password.to_owned()).await?; let mut row = new_user_row(username.to_owned(), username.to_owned(), Some(hash)); row.bootstrap = true; if insert_user(db, &row).await? { return Ok(true); } } } } Err(Error::Internal(format!( "could not create bootstrap account {username:?}" ))) } /// Accounts that stopped being bootstrap accounts lose their password and their sessions. async fn release_bootstrap_accounts(db: &Store, keep: &[String]) -> Result> { let mut released = vec![]; for row in query!(UserRow,$bootstrap==true).all(&db.db).await? { if keep.contains(&row.username) { continue; } db.db .raw_with( "UPDATE meguri__users SET bootstrap=false,password_hash=NULL WHERE id=$1", &[&row.id], ) .await?; db.db .raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id]) .await?; released.push(row.username); } Ok(released) } // ---- synchronization ---------------------------------------------------------------- #[derive(Debug, Default, Clone)] pub struct SyncReport { pub created: Vec, pub adopted: Vec, pub enabled: Vec, pub renamed: Vec, pub disabled: Vec, pub bootstrap_rotated: Vec, pub bootstrap_released: Vec, /// Current username/password pairs; the caller decides where to show them. pub bootstrap_credentials: Vec<(String, String)>, } pub struct SyncOptions { pub passwords: BootstrapPasswords, pub now: DateTime, /// Allows a configuration without workspaces to disable every existing one. pub allow_disable_all: bool, } /// Makes the database match the declarative configuration: configured workspaces exist and are /// enabled, other workspaces are disabled (never deleted), bootstrap accounts match the /// configured list, and every user's memberships follow their stored groups. pub async fn sync_config(db: &Store, config: &Config, options: SyncOptions) -> Result { config.validate()?; preflight(db, config, options.allow_disable_all).await?; let mut report = SyncReport::default(); let mut keys = BTreeSet::new(); for workspace in &config.workspaces { keys.insert(workspace.key.clone()); let ensured = ensure_workspace(db, workspace).await?; for change in &ensured.changes { let (list, action) = match change { WorkspaceChange::Created => (&mut report.created, "workspace.created"), WorkspaceChange::Adopted => (&mut report.adopted, "workspace.adopted"), WorkspaceChange::Enabled => (&mut report.enabled, "workspace.enabled"), WorkspaceChange::Renamed => (&mut report.renamed, "workspace.renamed"), }; list.push(workspace.key.clone()); db.instance_event( None, action, json!({"key":workspace.key,"id":ensured.id,"name":workspace.name}), ) .await?; } } for row in live_rows(db).await? { let configured = row.key.as_ref().is_some_and(|k| keys.contains(k)); if !configured && row.disabled_at.is_none() && disable_workspace(db, &row.id).await? { let name = record_of(&row)?.document.name; report .disabled .push(row.key.clone().unwrap_or_else(|| name.clone())); db.instance_event( None, "workspace.disabled", json!({"key":row.key,"id":row.id,"name":name}), ) .await?; } } let usernames = config.bootstrap_usernames(); report.bootstrap_released = release_bootstrap_accounts(db, usernames).await?; if !usernames.is_empty() { let secret = match options.passwords { BootstrapPasswords::Derived => Some(instance_secret(db).await?), BootstrapPasswords::Fixed(_) => None, }; for username in usernames { let password = match (&options.passwords, &secret) { (BootstrapPasswords::Fixed(password), _) => password.clone(), (BootstrapPasswords::Derived, Some(secret)) => { bootstrap_password(secret, username, options.now) } _ => unreachable!("derived passwords always load the instance secret"), }; if ensure_bootstrap_account(db, username, &password).await? { report.bootstrap_rotated.push(username.clone()); db.instance_event(None, "bootstrap.password_set", json!({"username":username})) .await?; } report .bootstrap_credentials .push((username.clone(), password)); } } reconcile_all(db, config).await?; for (list, label) in [ (&report.created, "created"), (&report.adopted, "adopted"), (&report.enabled, "enabled"), (&report.renamed, "renamed"), (&report.disabled, "disabled"), ] { if !list.is_empty() { tracing::info!(workspaces = ?list, "workspaces {label} from configuration"); } } Ok(report) } // ---- SSO accounts ------------------------------------------------------------------- #[derive(Debug, Clone)] pub struct SsoProfile { pub issuer: String, pub subject: String, pub email: Option, pub username_hint: Option, pub display_name: Option, pub groups: Vec, } #[derive(Debug)] pub enum SsoOutcome { /// The provider's groups map to nothing; no account is created or kept enabled. NoAccess, SignedIn { user: Box, token: String, csrf: String, }, } fn username_candidates(profile: &SsoProfile) -> Vec { let clean = |value: &str| -> String { value .trim() .to_lowercase() .chars() .map(|c| { if c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c) { c } else { '-' } }) .collect::() .trim_matches('-') .chars() .take(56) .collect() }; let mut bases: Vec = [ profile.username_hint.as_deref(), profile.email.as_deref().and_then(|e| e.split('@').next()), ] .into_iter() .flatten() .map(clean) .filter(|b| b.len() >= 3) .collect(); if bases.is_empty() { bases.push("user".into()); } let suffix = &service::token_hash(&format!("{}\n{}", profile.issuer, profile.subject))[..6]; let mut candidates = vec![]; for base in &bases { candidates.push(base.clone()); candidates.push(format!("{base}-{suffix}")); } candidates } async fn find_identity(db: &Store, profile: &SsoProfile) -> Result> { let (issuer, subject) = (profile.issuer.clone(), profile.subject.clone()); Ok( query!(ExternalIdentityRow,$issuer==issuer && $subject==subject) .get(&db.db) .await? .map(|i| i.user_id), ) } async fn create_sso_user(db: &Store, profile: &SsoProfile) -> Result { for username in username_candidates(profile) { let display = profile .display_name .as_deref() .map(str::trim) .filter(|n| !n.is_empty() && n.len() <= 120) .unwrap_or(&username) .to_owned(); let mut row = new_user_row(username, display, None); row.email = profile.email.clone(); if !insert_user(db, &row).await? { continue; } let mut identity = ExternalIdentityRow { id: Uuid::new_v4().to_string(), issuer: profile.issuer.clone(), subject: profile.subject.clone(), user_id: row.id.clone(), }; match identity.insert(&db.db).await { Ok(_) => return Ok(row.id), Err(DatabaseError::UniqueViolation) => { db.db .raw_with("DELETE FROM meguri__users WHERE id=$1", &[&row.id]) .await?; return find_identity(db, profile).await?.ok_or_else(|| { Error::Internal("SSO identity disappeared during sign-in".into()) }); } Err(e) => return Err(e.into()), } } Err(Error::Internal( "could not derive a unique username for the SSO account".into(), )) } /// Signs in a verified SSO identity. Groups are applied before anything else, so access that /// was revoked in the provider disappears at this login and an unmapped user stays out. pub async fn sso_sign_in(db: &Store, config: &Config, profile: SsoProfile) -> Result { let existing = find_identity(db, &profile).await?; let user_id = match existing { Some(id) => id, None => { if resolve_access(config, &profile.groups, false).is_empty() { return Ok(SsoOutcome::NoAccess); } let created = create_sso_user(db, &profile).await?; db.instance_event( None, "user.created", json!({"user_id":created,"sso":true,"subject":profile.subject,"email":profile.email,"groups":profile.groups}), ) .await?; created } }; let id = store::uuid(&user_id)?; let access = apply_access(db, config, id, Some(profile.groups.clone())).await?; if access.is_empty() { return Ok(SsoOutcome::NoAccess); } if let Some(email) = &profile.email { db.db .raw_with( "UPDATE meguri__users SET email=$2 WHERE id=$1 AND email IS DISTINCT FROM $2", &[&user_id, email], ) .await?; } if let Err(error) = link_contacts(db, id).await { tracing::warn!(user = %id, %error, "could not link the sign-in email to workspace contacts"); } let row = query!(UserRow,$id==user_id) .get(&db.db) .await? .ok_or(Error::NotFound)?; let user = store::user(row)?; let (token, csrf) = service::create_session(db, user.id).await?; Ok(SsoOutcome::SignedIn { user: Box::new(user), token, csrf, }) } // ---- contact linking --------------------------------------------------------------- /// In every workspace the user belongs to, binds the user's email to the scheduling person /// that carries it, so people planned by email are ready at first sign-in: /// * a contact with the same email becomes the user (the contact's old ID is kept as an alias /// and every reference is rewritten, preserving schedules, requests and variables); /// * otherwise the user's own person gains the email, or is added with it. /// /// Ambiguous or conflicting data is skipped, never guessed. Access is never granted by /// email: only group membership creates workspace access. pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> { let id = user.to_string(); let row = query!(UserRow,$id==id.clone()) .get(&db.db) .await? .ok_or(Error::NotFound)?; let Some(email) = row.email.as_deref().and_then(normalize_email) else { return Ok(()); }; for membership in query!(WorkspaceMemberRow,$user_id==id).all(&db.db).await? { let workspace = store::uuid(&membership.workspace_id)?; for _ in 0..3 { let Ok(mut record) = db.workspace(workspace).await else { break; }; let members = db.member_ids(workspace).await?; let Some((document, details)) = linked_document(&record.document, &row, user, &email) else { break; }; record.document = document; if record.document.validate(&members).is_err() { tracing::warn!(%workspace, %user, "contact linking skipped: the result would be invalid"); break; } match db .system_commit(&mut record, Some(user), "person.linked", details) .await { Ok(()) => break, Err(Error::Conflict(_)) => continue, Err(e) => return Err(e), } } } Ok(()) } /// Pure transformation for [`link_contacts`]. `None` means nothing to change. pub fn linked_document( document: &WorkspaceDocument, user: &UserRow, user_id: Uuid, email: &str, ) -> Option<(WorkspaceDocument, serde_json::Value)> { let contacts: Vec = document .people .iter() .enumerate() .filter(|(_, p)| p.email.as_deref() == Some(email) && p.id != user_id) .map(|(i, _)| i) .collect(); if contacts.len() > 1 { return None; } if let Some(&index) = contacts.first() { let contact = &document.people[index]; if contact.user_id.is_some() { return None; } if document .people .iter() .any(|p| p.id == user_id && p.email.as_deref().is_some_and(|e| e != email)) { return None; } let old = contact.id; let mut working = document.clone(); working.people.retain(|p| p.id != user_id); let text = serde_json::to_string(&working) .ok()? .replace(&old.to_string(), &user_id.to_string()); let mut linked: WorkspaceDocument = serde_json::from_str(&text).ok()?; let person = linked.people.iter_mut().find(|p| p.id == user_id)?; person.user_id = Some(user_id); if !person.aliases.contains(&old) { person.aliases.push(old); } return Some(( linked, json!({"user_id":user_id,"contact_id":old,"merged":true}), )); } match document.people.iter().position(|p| p.id == user_id) { Some(index) => { let person = &document.people[index]; if person.email.is_some() && person.user_id == Some(user_id) { return None; } if person.email.as_deref().is_some_and(|e| e != email) { return None; } let mut linked = document.clone(); linked.people[index].email = Some(email.to_owned()); linked.people[index].user_id = Some(user_id); Some((linked, json!({"user_id":user_id,"merged":false}))) } None => { let mut linked = document.clone(); linked.people.push(Person { id: user_id, user_id: Some(user_id), display_name: user.display_name.clone(), email: Some(email.to_owned()), country: Some(user.country.clone()), aliases: vec![], }); Some((linked, json!({"user_id":user_id,"merged":false}))) } } } #[cfg(test)] mod tests { use super::*; fn row() -> UserRow { new_user_row("dana".into(), "Dana".into(), None) } fn contact(email: &str) -> Person { Person { id: Uuid::new_v4(), user_id: None, display_name: "Contact".into(), email: Some(email.into()), country: Some("AM".into()), aliases: vec![], } } #[test] fn a_matching_contact_becomes_the_user_everywhere_without_duplicates() { let user = Uuid::new_v4(); let existing_self = Person { id: user, user_id: Some(user), display_name: "Dana".into(), email: None, country: Some("CY".into()), aliases: vec![], }; let mut doc = WorkspaceDocument::new("Team".into()); let c = contact("dana@corp.test"); let old = c.id; doc.people = vec![existing_self, c]; doc.generation.variables = vec![]; doc.absences.push(Absence { id: Uuid::new_v4(), user_id: old, start: "2026-11-01".parse().unwrap(), end: "2026-11-02".parse().unwrap(), }); let (linked, details) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap(); assert_eq!(linked.people.len(), 1, "no second entry for the same user"); let person = &linked.people[0]; assert_eq!((person.id, person.user_id), (user, Some(user))); assert_eq!(person.aliases, vec![old]); assert_eq!(person.display_name, "Contact"); assert_eq!(linked.absences[0].user_id, user); assert_eq!(details["contact_id"], json!(old)); assert_eq!( serde_json::to_string(&linked) .unwrap() .matches(&old.to_string()) .count(), 1, "the old id survives only as the alias" ); // Applying the result again is a no-op. assert!(linked_document(&linked, &row(), user, "dana@corp.test").is_none()); } #[test] fn ambiguous_or_conflicting_data_is_never_guessed() { let user = Uuid::new_v4(); let mut doc = WorkspaceDocument::new("Team".into()); // Two contacts with one email cannot validate, and must not be merged either. doc.people = vec![contact("dana@corp.test"), contact("dana@corp.test")]; assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); // A contact that already belongs to another login is not stolen. let mut taken = contact("dana@corp.test"); taken.user_id = Some(Uuid::new_v4()); doc.people = vec![taken]; assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); // The user's own person carries a different email: leave both alone. doc.people = vec![ Person { id: user, user_id: Some(user), display_name: "Dana".into(), email: Some("other@corp.test".into()), country: None, aliases: vec![], }, contact("dana@corp.test"), ]; assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); } #[test] fn users_without_a_contact_gain_a_person_with_their_email() { let user = Uuid::new_v4(); let doc = WorkspaceDocument::new("Team".into()); let (linked, _) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap(); assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test")); assert_eq!(linked.people[0].user_id, Some(user)); let mut with_person = doc.clone(); with_person.people.push(Person { id: user, user_id: Some(user), display_name: "Dana".into(), email: None, country: None, aliases: vec![], }); let (linked, _) = linked_document(&with_person, &row(), user, "dana@corp.test").unwrap(); assert_eq!(linked.people.len(), 1); assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test")); } #[test] fn usernames_are_clean_unique_candidates() { let profile = |hint: Option<&str>, email: Option<&str>| SsoProfile { issuer: "https://idp.test".into(), subject: "sub-1".into(), email: email.map(str::to_owned), username_hint: hint.map(str::to_owned), display_name: None, groups: vec![], }; let candidates = username_candidates(&profile(Some("Jane Doe!"), Some("jd@corp.test"))); assert_eq!(candidates[0], "jane-doe"); assert!( candidates[1].starts_with("jane-doe-") && candidates[1].len() == "jane-doe-".len() + 6 ); // Too-short hints fall back to the email name, and nothing at all to a neutral base. assert_eq!( username_candidates(&profile(Some("ab"), Some("Lena.Ivanova@corp.test")))[0], "lena.ivanova" ); assert_eq!(username_candidates(&profile(None, None))[0], "user"); assert!(candidates.iter().all(|c| crate::access::valid_username(c))); let long = "x".repeat(200); assert!( username_candidates(&profile(Some(&long), None)) .iter() .all(|c| crate::access::valid_username(c)) ); } }