Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
+975
@@ -0,0 +1,975 @@
|
||||
//! 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);
|
||||
}
|
||||
create_sso_user(db, &profile).await?
|
||||
}
|
||||
};
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user