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
+103
-14
@@ -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
|
||||
"#;
|
||||
Reference in new issue
Block a user