Files
meguri/src/admin.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

390 lines
14 KiB
Rust

//! 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<BTreeSet<String>> {
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<Value> {
require_instance_admin(db, actor).await?;
let mut members: BTreeMap<String, usize> = BTreeMap::new();
for member in WorkspaceMemberRow::objects().all(&db.db).await? {
*members.entry(member.workspace_id).or_default() += 1;
}
let mut jobs: BTreeMap<String, SyncJobRow> = 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::<crate::domain::WorkspaceDocument>(&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<String>) -> Result<Vec<String>> {
groups.sort();
groups.dedup();
let known: BTreeSet<String> = 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<String>,
#[serde(default)]
pub groups: Vec<String>,
/// Scheduling country (ISO 3166-1 alpha-2); defaults to CY like every new account.
#[serde(default)]
pub country: Option<String>,
}
/// 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<Uuid>,
input: NewUser,
) -> Result<Value> {
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<Value> {
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<UserRow> {
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<String>,
) -> Result<Value> {
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::<crate::domain::WorkspaceDocument>(&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<Value> {
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<String, String> = 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::<Value>(&row.details).unwrap_or(Value::Null),
"created_at":row.created_at,
}));
}
Ok(json!({"events":events,"more":more}))
}