Files
meguri/src/identity.rs
T

982 lines
36 KiB
Rust
Raw Normal View History

//! 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<String>) -> Error {
Error::Validation(format!("Invalid configuration: {}", message.into()))
}
pub fn normalize_email(value: &str) -> Option<String> {
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<String>) -> 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<bool> {
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<WorkspaceChange>,
}
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<Vec<WorkspaceRow>> {
Ok(WorkspaceRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|r| r.demo_deleted_at.is_none())
.collect())
}
fn record_of(row: &WorkspaceRow) -> Result<WorkspaceRecord> {
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<EnsuredWorkspace> {
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<Option<WorkspaceRecord>> {
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<bool> {
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<Vec<String>> {
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<Vec<String>>,
) -> Result<Access> {
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<usize> {
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<Vec<u8>> {
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<bool> {
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<Vec<String>> {
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<String>,
pub adopted: Vec<String>,
pub enabled: Vec<String>,
pub renamed: Vec<String>,
pub disabled: Vec<String>,
pub bootstrap_rotated: Vec<String>,
pub bootstrap_released: Vec<String>,
/// 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<Utc>,
/// 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<SyncReport> {
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<String>,
pub username_hint: Option<String>,
pub display_name: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug)]
pub enum SsoOutcome {
/// The provider's groups map to nothing; no account is created or kept enabled.
NoAccess,
SignedIn {
user: Box<User>,
token: String,
csrf: String,
},
}
fn username_candidates(profile: &SsoProfile) -> Vec<String> {
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::<String>()
.trim_matches('-')
.chars()
.take(56)
.collect()
};
let mut bases: Vec<String> = [
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<Option<String>> {
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<String> {
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<SsoOutcome> {
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<usize> = 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))
);
}
}