//! Instance administration: read-only workspace overview, password-user management and the //! deliberate deletion of workspaces that configuration disabled. use crate::{ access::{Config, valid_username}, domain::validate_name, error::{Error, Result, ensure}, identity::{apply_access, new_user_row, normalize_email}, models::*, service, store::{self, Store}, }; use cot::db::{Model, query}; use serde::Deserialize; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; pub async fn require_instance_admin(db: &Store, actor: Uuid) -> Result<()> { if db.is_instance_admin(actor).await? { Ok(()) } else { Err(Error::Forbidden) } } async fn sso_user_ids(db: &Store) -> Result> { Ok(ExternalIdentityRow::objects() .all(&db.db) .await? .into_iter() .map(|i| i.user_id) .collect()) } /// Everything the instance administrator may see. Workspaces and group mappings are read-only /// here because configuration owns them. pub async fn overview(db: &Store, config: &Config, actor: Uuid) -> Result { require_instance_admin(db, actor).await?; let mut members: BTreeMap = BTreeMap::new(); for member in WorkspaceMemberRow::objects().all(&db.db).await? { *members.entry(member.workspace_id).or_default() += 1; } let mut jobs: BTreeMap = BTreeMap::new(); for job in SyncJobRow::objects().all(&db.db).await? { jobs.insert(job.workspace_id.clone(), job); } let mut workspaces = vec![]; for row in WorkspaceRow::objects().all(&db.db).await? { if row.demo_deleted_at.is_some() { continue; } let name = serde_json::from_str::(&row.document)?.name; let configured = row .key .as_ref() .and_then(|key| config.workspaces.iter().find(|w| &w.key == key)); let job = jobs.get(&row.id); workspaces.push(json!({ "id":row.id, "key":row.key, "name":name, "created_at":row.created_at, "disabled_at":row.disabled_at, "in_config":configured.is_some(), "admin_groups":configured.map(|w| w.admin_groups.clone()).unwrap_or_default(), "member_groups":configured.map(|w| w.member_groups.clone()).unwrap_or_default(), "member_count":members.get(&row.id).copied().unwrap_or(0), "sync_state":job.map(|j| j.state.clone()), "last_success":job.and_then(|j| j.last_success), "confirm":row.key.clone().unwrap_or_else(|| name.clone()), })); } workspaces.sort_by(|a, b| { (a["disabled_at"].is_null(), a["name"].as_str()) .cmp(&(b["disabled_at"].is_null(), b["name"].as_str())) }); let sso = sso_user_ids(db).await?; let mut users = vec![]; let mut rows = UserRow::objects().all(&db.db).await?; rows.sort_by(|a, b| a.username.cmp(&b.username)); for row in rows { if row.demo_deleted_at.is_some() { continue; } users.push(json!({ "id":row.id, "username":row.username, "display_name":row.display_name, "email":row.email, "sso":sso.contains(&row.id), "bootstrap":row.bootstrap, "instance_admin":row.instance_admin, "groups":crate::identity::stored_groups(&row)?, "created_at":row.created_at, })); } Ok(json!({ "config":{ "password_login":config.password_login_enabled(), "bootstrap_usernames":config.bootstrap_usernames(), "oidc_issuer":config.oidc.as_ref().map(|o| o.issuer.clone()), "instance_admin_groups":config.instance_admin_groups, }, "workspaces":workspaces, "users":users, "known_groups":config.known_groups(), })) } fn checked_groups(config: &Config, mut groups: Vec) -> Result> { groups.sort(); groups.dedup(); let known: BTreeSet = config.known_groups().into_iter().collect(); ensure( groups.iter().all(|g| known.contains(g)), crate::i18n::text("err_choose_groups_from_the_configured_list_5d0e71"), )?; Ok(groups) } #[derive(Debug, Deserialize)] pub struct NewUser { pub username: String, pub display_name: String, pub password: String, #[serde(default)] pub email: Option, #[serde(default)] pub groups: Vec, /// Scheduling country (ISO 3166-1 alpha-2); defaults to CY like every new account. #[serde(default)] pub country: Option, } /// Creates a password account. `actor` is None only for trusted startup seeding; the request /// handler always goes through [`create_user`]. pub async fn create_password_user( db: &Store, config: &Config, actor: Option, input: NewUser, ) -> Result { let username = input.username.trim().to_ascii_lowercase(); ensure( valid_username(&username), crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"), )?; validate_name(&input.display_name)?; let email = match input .email .as_deref() .map(str::trim) .filter(|e| !e.is_empty()) { None => None, Some(email) => Some(normalize_email(email).ok_or_else(|| { Error::Validation(crate::i18n::text("err_enter_a_valid_email_address_1577ea").into()) })?), }; let groups = checked_groups(config, input.groups)?; let hash = service::hash_password(input.password).await?; let mut row = new_user_row(username.clone(), input.display_name, Some(hash)); row.email = email; if let Some(country) = input .country .as_deref() .map(str::trim) .filter(|c| !c.is_empty()) { let country = country.to_ascii_uppercase(); ensure( crate::domain::valid_country(&country), crate::i18n::text("err_choose_a_valid_country_code_e79c72"), )?; row.country = country; } match row.clone().insert(&db.db).await { Ok(_) => {} Err(cot::db::DatabaseError::UniqueViolation) => { return Err(Error::Conflict( crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(), )); } Err(e) => return Err(e.into()), } let id = store::uuid(&row.id)?; apply_access(db, config, id, Some(groups.clone())).await?; db.instance_event( actor, "user.created", json!({"user_id":id,"username":username,"groups":groups}), ) .await?; let created = query!(UserRow,$id==row.id.clone()) .get(&db.db) .await? .ok_or(Error::NotFound)?; Ok(json!({"user":store::user(created)?})) } pub async fn create_user( db: &Store, config: &Config, actor: Uuid, input: NewUser, ) -> Result { require_instance_admin(db, actor).await?; create_password_user(db, config, Some(actor), input).await } /// Password accounts only: SSO groups come from the provider and bootstrap accounts from /// configuration. Administrators cannot edit their own groups, so the last one cannot vanish. async fn editable_target(db: &Store, actor: Uuid, target: Uuid) -> Result { ensure( actor != target, crate::i18n::text("err_you_cannot_change_your_own_access_9b44c0"), )?; let id = target.to_string(); let row = query!(UserRow,$id==id) .get(&db.db) .await? .filter(|r| r.demo_deleted_at.is_none()) .ok_or(Error::NotFound)?; ensure( !row.bootstrap && !sso_user_ids(db).await?.contains(&row.id), crate::i18n::text("err_this_account_is_managed_by_sso_or_configuration_a3c8e2"), )?; Ok(row) } pub async fn set_user_groups( db: &Store, config: &Config, actor: Uuid, target: Uuid, groups: Vec, ) -> Result { require_instance_admin(db, actor).await?; let before = editable_target(db, actor, target).await?; let groups = checked_groups(config, groups)?; apply_access(db, config, target, Some(groups.clone())).await?; db.instance_event( Some(actor), "user.groups_changed", json!({"user_id":target,"username":before.username,"before":crate::identity::stored_groups(&before)?,"after":groups}), ) .await?; let id = target.to_string(); let row = query!(UserRow,$id==id) .get(&db.db) .await? .ok_or(Error::NotFound)?; Ok(json!({"user":store::user(row)?})) } pub async fn reset_password(db: &Store, actor: Uuid, target: Uuid, password: String) -> Result<()> { require_instance_admin(db, actor).await?; let row = editable_target(db, actor, target).await?; let hash = service::hash_password(password).await?; db.db .raw_with( "UPDATE meguri__users SET password_hash=$2 WHERE id=$1", &[&row.id, &hash], ) .await?; db.db .raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id]) .await?; db.instance_event( Some(actor), "user.password_reset", json!({"user_id":target,"username":row.username}), ) .await } const DELETE_WORKSPACE: &str = r#" WITH target AS MATERIALIZED ( SELECT id FROM meguri__workspaces w WHERE w.id=$1 AND w.disabled_at IS NOT NULL AND w.demo_deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM meguri__sync_jobs j WHERE j.workspace_id=w.id AND j.lease_until>now()) ), members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM target)), history AS (DELETE FROM meguri__workspace_history WHERE workspace_id IN(SELECT id FROM target)), audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM target)), jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM target)), objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM target)), pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM target)) DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM target) "#; /// Permanently removes a workspace that is disabled and absent from configuration. The caller /// must type its key (or name when it has none). Remote provider resources are not touched. pub async fn delete_workspace( db: &Store, config: &Config, actor: Uuid, workspace: Uuid, confirm: &str, ) -> Result<()> { require_instance_admin(db, actor).await?; let id = workspace.to_string(); let row = query!(WorkspaceRow,$id==id) .get(&db.db) .await? .filter(|r| r.demo_deleted_at.is_none()) .ok_or(Error::NotFound)?; let name = serde_json::from_str::(&row.document)?.name; ensure( row.disabled_at.is_some() && !row .key .as_ref() .is_some_and(|key| config.workspaces.iter().any(|w| &w.key == key)), crate::i18n::text("err_only_disabled_workspaces_can_be_deleted_8f27d1"), )?; let expected = row.key.clone().unwrap_or_else(|| name.clone()); ensure( confirm == expected, crate::i18n::text("err_type_the_workspace_key_to_confirm_deletion_4be9a0"), )?; let result = db.db.raw_with(DELETE_WORKSPACE, &[&row.id]).await?; ensure( result.rows_affected().0 > 0, crate::i18n::text("err_the_workspace_is_publishing_try_again_shortly_2a7d94"), )?; db.instance_event( Some(actor), "workspace.deleted", json!({"id":row.id,"key":row.key,"name":name}), ) .await } const EVENT_LIMIT: usize = 300; /// The newest instance events, newest first, optionally filtered by a case-insensitive text that /// is matched against the action, the actor and the details. A support reference from a failed /// sign-in (the `ref` shown on the login screen) is found this way. pub async fn events(db: &Store, actor: Uuid, search: &str) -> Result { require_instance_admin(db, actor).await?; let needle = search.trim().to_lowercase(); ensure( needle.len() <= 200, crate::i18n::text("err_the_search_text_is_too_long_7c3d58"), )?; let mut rows = InstanceAuditRow::objects().all(&db.db).await?; rows.sort_by_key(|row| std::cmp::Reverse(row.id.unwrap())); let mut names: BTreeMap = BTreeMap::new(); let mut events = vec![]; let mut more = false; for row in rows { let actor_name = match &row.actor_id { None => None, Some(id) => { if !names.contains_key(id) { let key = id.clone(); let name = query!(UserRow,$id==key) .get(&db.db) .await? .map_or_else(|| "unknown".to_owned(), |u| u.username); names.insert(id.clone(), name); } names.get(id).cloned() } }; if !needle.is_empty() && ![ row.action.as_str(), actor_name.as_deref().unwrap_or(""), row.details.as_str(), ] .iter() .any(|text| text.to_lowercase().contains(&needle)) { continue; } if events.len() == EVENT_LIMIT { more = true; break; } events.push(json!({ "id":row.id.unwrap(), "action":row.action, "actor":actor_name, "details":serde_json::from_str::(&row.details).unwrap_or(Value::Null), "created_at":row.created_at, })); } Ok(json!({"events":events,"more":more})) }