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

+103 -14
View File
@@ -35,10 +35,13 @@ pub fn user(row: UserRow) -> Result<User> {
display_name: row.display_name,
country: row.country,
timezone: row.timezone,
email: row.email,
instance_admin: row.instance_admin,
has_password: row.password_hash.is_some(),
})
}
pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> {
if row.demo_deleted_at.is_some() {
if row.demo_deleted_at.is_some() || row.disabled_at.is_some() {
return Err(Error::NotFound);
}
Ok(WorkspaceRecord {
@@ -66,8 +69,24 @@ impl Store {
.ok_or(Error::NotFound)?,
)
}
pub async fn is_instance_admin(&self, user: Uuid) -> Result<bool> {
let id = user.to_string();
Ok(query!(UserRow,$id==id && $instance_admin==true)
.exists(&self.db)
.await?)
}
pub async fn workspace_key(&self, workspace: Uuid) -> Result<Option<String>> {
let id = workspace.to_string();
Ok(query!(WorkspaceRow,$id==id)
.get(&self.db)
.await?
.and_then(|row| row.key))
}
pub async fn permissions(&self, workspace: Uuid, actor: Uuid) -> Result<Vec<Permission>> {
self.workspace(workspace).await?;
if self.is_instance_admin(actor).await? {
return Ok(Permission::all());
}
let workspace = workspace.to_string();
let actor = actor.to_string();
let row = query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor)
@@ -95,6 +114,9 @@ impl Store {
}
pub async fn owner(&self, workspace: Uuid, actor: Uuid) -> Result<()> {
self.workspace(workspace).await?;
if self.is_instance_admin(actor).await? {
return Ok(());
}
let workspace = workspace.to_string();
let actor = actor.to_string();
if query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor && $is_owner==true)
@@ -125,6 +147,7 @@ impl Store {
} else {
serde_json::from_str(&m.permissions)?
},
is_owner: m.is_owner,
});
}
members.sort_by(|a, b| a.user.display_name.cmp(&b.user.display_name));
@@ -166,8 +189,8 @@ impl Store {
Ok(record)
}
/// Cot 0.7 has no transaction API. A single parameterized CTE commits the CAS,
/// membership (if any), history, audit, and durable job as one PostgreSQL statement.
#[allow(clippy::too_many_arguments)]
/// employee-role grants (if changed), history, audit, and durable job as one
/// PostgreSQL statement. Instance administrators pass the permission check.
pub async fn commit(
&self,
record: &mut WorkspaceRecord,
@@ -175,15 +198,16 @@ impl Store {
permission: Permission,
action: &str,
details: Value,
member: Option<(Uuid, Vec<Permission>)>,
role_permissions: Option<Vec<Permission>>,
) -> Result<()> {
let workspace = record.id.to_string();
let actor = actor.to_string();
let document = serde_json::to_string(&record.document)?;
let details = details.to_string();
let required = serde_json::to_string(&vec![permission])?;
let member_id = member.as_ref().map(|(id, _)| id.to_string());
let grants = serde_json::to_string(&member.map(|(_, g)| g).unwrap_or_default())?;
let role_permissions = role_permissions
.map(|grants| serde_json::to_string(&grants))
.transpose()?;
let result = self
.db
.raw_with(
@@ -196,8 +220,7 @@ impl Store {
&action,
&details,
&required,
&member_id,
&grants,
&role_permissions,
],
)
.await?;
@@ -210,6 +233,57 @@ impl Store {
record.revision += 1;
Ok(())
}
/// Revision-checked change made by the system (configuration sync or identity linking)
/// rather than by a person with a workspace grant. Never expose this to request input.
pub async fn system_commit(
&self,
record: &mut WorkspaceRecord,
actor: Option<Uuid>,
action: &str,
details: Value,
) -> Result<()> {
let workspace = record.id.to_string();
let actor = actor.map(|a| a.to_string());
let document = serde_json::to_string(&record.document)?;
let details = details.to_string();
let result = self
.db
.raw_with(
SYSTEM_COMMIT_WORKSPACE,
&[
&workspace,
&record.revision,
&document,
&actor,
&action,
&details,
],
)
.await?;
if result.rows_affected().0 == 0 {
return Err(Error::Conflict(
crate::i18n::text("err_this_team_changed_refresh_before_saving_7f93ad").into(),
));
}
record.revision += 1;
Ok(())
}
pub async fn instance_event(
&self,
actor: Option<Uuid>,
action: &str,
details: Value,
) -> Result<()> {
let actor = actor.map(|a| a.to_string());
let details = details.to_string();
self.db
.raw_with(
"INSERT INTO meguri__instance_audit(actor_id,action,details,created_at) VALUES($1,$2,$3,now())",
&[&actor, &action, &details],
)
.await?;
Ok(())
}
pub async fn audit(&self, workspace: Uuid) -> Result<Vec<Value>> {
let mut names = crate::audit::Names::new();
for person in self.directory(workspace).await? {
@@ -379,12 +453,11 @@ impl Store {
const COMMIT_WORKSPACE: &str = r#"
WITH updated AS (
UPDATE meguri__workspaces SET document=$3,revision=revision+1
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL
AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb)) RETURNING *
), member AS (
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner)
SELECT $1||':'||$8,$1,$8,$9,false FROM updated WHERE $8::text IS NOT NULL
ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL
AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$4 AND instance_admin)
OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb))) RETURNING *
), roles AS (
UPDATE meguri__workspace_members SET permissions=$8 WHERE $8::text IS NOT NULL AND NOT is_owner AND workspace_id IN(SELECT id FROM updated)
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4 FROM updated RETURNING effective_at
@@ -396,3 +469,19 @@ WITH updated AS (
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at)
SELECT id,$4,$5,$6,(SELECT effective_at FROM history) FROM updated
"#;
const SYSTEM_COMMIT_WORKSPACE: &str = r#"
WITH updated AS (
UPDATE meguri__workspaces SET document=$3,revision=revision+1
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL RETURNING *
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4::text FROM updated RETURNING effective_at
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt)
SELECT id,revision,'pending',0,now() FROM updated
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at)
SELECT id,$4::text,$5,$6,(SELECT effective_at FROM history) FROM updated
"#;