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
+315
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user