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:
Aleksandr BogomiakovandClaude committed 2026-10-07 13:28:43 +01:00
1 parent e37d583179
commit 4764f3545f
68 files changed
+7220 -964

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
//! 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
}