Files
meguri/src/audit.rs
T
Aleksandr BogomiakovandClaude 4764f3545f 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>
2026-10-07 13:28:43 +01:00

112 lines
4.2 KiB
Rust

//! Read-only audit presentation; persisted event payloads are never rewritten.
use crate::domain::WorkspaceDocument;
use serde_json::{Value, json};
use std::collections::BTreeMap;
pub type Names = BTreeMap<String, String>;
pub fn remember(names: &mut Names, doc: &WorkspaceDocument) {
for person in &doc.people {
names.insert(person.id.to_string(), person.display_name.clone());
for alias in &person.aliases {
names.insert(alias.to_string(), person.display_name.clone());
}
if let Some(id) = person.user_id {
names.insert(id.to_string(), person.display_name.clone());
}
}
for (id, name) in doc
.schedules
.iter()
.map(|s| (s.id, &s.name))
.chain(doc.escalations.iter().map(|s| (s.id, &s.name)))
.chain(doc.routes.iter().map(|s| (s.id, &s.name)))
.chain(doc.generation.rules.iter().map(|s| (s.id, &s.name)))
.chain(doc.generation.variables.iter().map(|s| (s.id, &s.name)))
{
names.insert(id.to_string(), name.clone());
}
}
pub fn resolve(value: &Value, names: &Names) -> Value {
match value {
Value::String(id) => names
.get(id)
.map(|name| json!({"name":name,"id":id}))
.unwrap_or_else(|| value.clone()),
Value::Array(items) => Value::Array(items.iter().map(|v| resolve(v, names)).collect()),
Value::Object(fields) => Value::Object(
fields
.iter()
.map(|(key, value)| {
// Free text must remain verbatim, even if a comment happens to equal a UUID.
let reference = key == "id"
|| key.ends_with("_id")
|| matches!(
key.as_str(),
"from_user"
| "to_user"
| "requested_by"
| "forced_by"
| "approvals"
| "required_approvers"
| "participants"
| "primary"
| "secondary"
| "fallback"
| "user"
| "redirect_to"
| "escalation"
| "variable"
| "iteration"
| "output"
);
let resolved = if reference || value.is_object() || value.is_array() {
resolve(value, names)
} else {
value.clone()
};
(key.clone(), resolved)
})
.collect(),
),
_ => value.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn references_expand_without_changing_comments_unknown_ids_or_raw_details() {
let id = uuid::Uuid::new_v4().to_string();
let names = Names::from([(id.clone(), "Alice".into())]);
let raw = json!({"from_user":id,"approvals":[id],"reason":id,"unknown_id":"missing","legs":[{"to_user":id}]});
let resolved = resolve(&raw, &names);
assert_eq!(resolved["from_user"]["name"], "Alice");
assert_eq!(resolved["approvals"][0]["id"], id);
assert_eq!(resolved["legs"][0]["to_user"]["name"], "Alice");
assert_eq!(resolved["reason"], id);
assert_eq!(resolved["unknown_id"], "missing");
assert_eq!(raw["from_user"], id);
}
#[test]
fn merged_contact_ids_keep_resolving_to_the_person() {
let (old, new) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
let mut doc = WorkspaceDocument::new("Team".into());
doc.people.push(crate::domain::Person {
id: new,
user_id: Some(new),
display_name: "Dana".into(),
email: Some("dana@example.com".into()),
country: None,
aliases: vec![old],
});
let mut names = Names::new();
remember(&mut names, &doc);
assert_eq!(names[&old.to_string()], "Dana");
assert_eq!(names[&new.to_string()], "Dana");
}
}