315 lines
11 KiB
Rust
315 lines
11 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>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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;
|
||
|
|
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
|
||
|
|
}
|