Files
meguri/src/identity.rs
T
Aleksandr BogomiakovandClaude 548494f12e Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log
Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:12 +01:00

983 lines
36 KiB
Rust

//! 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))
);
}
}