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

+535
View File
@@ -0,0 +1,535 @@
//! Pure instance access model: declarative configuration, group-to-role resolution and
//! bootstrap passwords. No HTTP or database dependencies.
use crate::error::{Error, Result};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub oidc: Option<OidcConfig>,
#[serde(default)]
pub instance_admin_groups: Vec<String>,
#[serde(default)]
pub workspaces: Vec<WorkspaceConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
/// Allows every password account to sign in. Bootstrap accounts always may.
#[serde(default)]
pub password_login: bool,
/// Break-glass instance administrators with generated passwords. Omit in production.
#[serde(default)]
pub bootstrap_admin: Option<BootstrapConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BootstrapConfig {
pub usernames: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupSource {
IdToken,
Userinfo,
}
fn default_scopes() -> Vec<String> {
vec!["openid".into(), "profile".into(), "email".into()]
}
fn default_groups_claim() -> String {
"groups".into()
}
fn default_group_sources() -> Vec<GroupSource> {
vec![GroupSource::IdToken, GroupSource::Userinfo]
}
/// The client secret is a deployment secret (`MEGURI_OIDC_CLIENT_SECRET`), never configuration.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
#[serde(default = "default_scopes")]
pub scopes: Vec<String>,
/// Dotted path into the token claims, for example `groups` or `realm_access.roles`.
#[serde(default = "default_groups_claim")]
pub groups_claim: String,
#[serde(default = "default_group_sources")]
pub groups_sources: Vec<GroupSource>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
pub key: String,
pub name: String,
#[serde(default)]
pub admin_groups: Vec<String>,
#[serde(default)]
pub member_groups: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Employee,
Admin,
}
/// What a set of groups grants. Administrators win over employees in the same workspace.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Access {
pub instance_admin: bool,
pub workspaces: BTreeMap<String, Role>,
}
impl Access {
pub fn is_empty(&self) -> bool {
!self.instance_admin && self.workspaces.is_empty()
}
}
fn invalid(message: impl Into<String>) -> Error {
Error::Validation(format!("Invalid configuration: {}", message.into()))
}
pub fn valid_username(username: &str) -> bool {
(3..=64).contains(&username.len())
&& username
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c))
}
fn valid_key(key: &str) -> bool {
(3..=64).contains(&key.len())
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !key.starts_with('-')
&& !key.ends_with('-')
}
fn validate_groups(owner: &str, groups: &[String]) -> Result<()> {
let mut seen = BTreeSet::new();
for group in groups {
if group.trim().is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
return Err(invalid(format!(
"{owner} has an empty, oversized or unprintable group name"
)));
}
if !seen.insert(group) {
return Err(invalid(format!("{owner} lists group {group:?} twice")));
}
}
Ok(())
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let config: Self =
serde_yaml_ng::from_str(text).map_err(|e| invalid(format!("cannot parse: {e}")))?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if let Some(oidc) = &self.oidc {
let issuer = reqwest::Url::parse(&oidc.issuer)
.map_err(|_| invalid("oidc.issuer must be an absolute URL"))?;
let loopback = matches!(issuer.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"));
if issuer.scheme() != "https" && !(issuer.scheme() == "http" && loopback) {
return Err(invalid("oidc.issuer must use HTTPS outside localhost"));
}
if oidc.client_id.trim().is_empty() {
return Err(invalid("oidc.client_id is required"));
}
if !oidc.scopes.iter().any(|s| s == "openid") {
return Err(invalid("oidc.scopes must include openid"));
}
if oidc.groups_claim.trim().is_empty() {
return Err(invalid("oidc.groups_claim is required"));
}
if oidc.groups_sources.is_empty() {
return Err(invalid("oidc.groups_sources needs at least one source"));
}
}
if let Some(bootstrap) = &self.auth.bootstrap_admin {
if bootstrap.usernames.is_empty() {
return Err(invalid(
"auth.bootstrap_admin.usernames must not be empty; remove the block instead",
));
}
let mut seen = BTreeSet::new();
for name in &bootstrap.usernames {
if !valid_username(name) || !seen.insert(name) {
return Err(invalid(format!(
"bootstrap username {name:?} must be unique and use 3-64 lowercase letters, numbers, dots, underscores or hyphens"
)));
}
}
}
if self.oidc.is_none() && !self.auth.password_login && self.auth.bootstrap_admin.is_none() {
return Err(invalid(
"configure at least one way to sign in: oidc, auth.password_login or auth.bootstrap_admin",
));
}
validate_groups("instance_admin_groups", &self.instance_admin_groups)?;
let mut keys = BTreeSet::new();
let mut names = BTreeSet::new();
for workspace in &self.workspaces {
if !valid_key(&workspace.key) {
return Err(invalid(format!(
"workspace key {:?} must use 3-64 lowercase letters, numbers and hyphens, without leading/trailing hyphen",
workspace.key
)));
}
if !keys.insert(workspace.key.as_str()) {
return Err(invalid(format!(
"workspace key {:?} is used twice",
workspace.key
)));
}
if crate::domain::validate_name(&workspace.name).is_err() {
return Err(invalid(format!(
"workspace {:?} needs a name of 1-120 characters",
workspace.key
)));
}
if !names.insert(workspace.name.trim().to_lowercase()) {
return Err(invalid(format!(
"workspace name {:?} is used twice",
workspace.name
)));
}
if workspace.admin_groups.is_empty() {
return Err(invalid(format!(
"workspace {:?} needs at least one admin group",
workspace.key
)));
}
validate_groups(
&format!("workspace {:?} admin_groups", workspace.key),
&workspace.admin_groups,
)?;
validate_groups(
&format!("workspace {:?} member_groups", workspace.key),
&workspace.member_groups,
)?;
}
Ok(())
}
/// Every group that grants something; password-user assignments are chosen from this list.
pub fn known_groups(&self) -> Vec<String> {
let mut groups: BTreeSet<&String> = self.instance_admin_groups.iter().collect();
for workspace in &self.workspaces {
groups.extend(&workspace.admin_groups);
groups.extend(&workspace.member_groups);
}
groups.into_iter().cloned().collect()
}
pub fn password_login_enabled(&self) -> bool {
self.auth.password_login
}
pub fn bootstrap_usernames(&self) -> &[String] {
self.auth
.bootstrap_admin
.as_ref()
.map_or(&[], |b| b.usernames.as_slice())
}
pub fn password_form_available(&self) -> bool {
self.auth.password_login || !self.bootstrap_usernames().is_empty()
}
}
static CURRENT: std::sync::OnceLock<Config> = std::sync::OnceLock::new();
/// Installs the process-wide configuration read by request handlers.
pub fn install(config: Config) {
let _ = CURRENT.set(config);
}
pub fn current() -> Result<&'static Config> {
CURRENT
.get()
.ok_or_else(|| Error::Internal("the instance configuration is not loaded".into()))
}
/// Exact string matching of provider groups against the configured groups.
pub fn resolve_access(config: &Config, groups: &[String], bootstrap: bool) -> Access {
let held: BTreeSet<&str> = groups.iter().map(String::as_str).collect();
let any = |wanted: &[String]| wanted.iter().any(|g| held.contains(g.as_str()));
let mut access = Access {
instance_admin: bootstrap || any(&config.instance_admin_groups),
workspaces: BTreeMap::new(),
};
for workspace in &config.workspaces {
let role = if any(&workspace.admin_groups) {
Some(Role::Admin)
} else if any(&workspace.member_groups) {
Some(Role::Employee)
} else {
None
};
if let Some(role) = role {
access.workspaces.insert(workspace.key.clone(), role);
}
}
access
}
/// Reads a group list from a claims object. A literal key wins over a dotted path.
/// `None` means the claim is missing or has an unusable type, which is a provider
/// misconfiguration, not an empty membership.
pub fn claim_groups(claims: &Value, path: &str) -> Option<Vec<String>> {
let value = claims.get(path).or_else(|| {
path.split('.')
.try_fold(claims, |value, part| value.get(part))
})?;
match value {
Value::String(group) => Some(vec![group.clone()]),
Value::Array(items) => Some(
items
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect(),
),
_ => None,
}
}
const BASE32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
fn base32(bytes: &[u8]) -> String {
let mut output = String::new();
let (mut buffer, mut bits) = (0u32, 0u32);
for byte in bytes {
buffer = (buffer << 8) | u32::from(*byte);
bits += 8;
while bits >= 5 {
bits -= 5;
output.push(BASE32[((buffer >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
output.push(BASE32[((buffer << (5 - bits)) & 31) as usize] as char);
}
output
}
/// Replicas that start in the same UTC hour derive the same password, and it cannot be
/// guessed without the secret stored in the database.
pub fn bootstrap_password(secret: &[u8], username: &str, now: DateTime<Utc>) -> String {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret).expect("HMAC accepts any key");
mac.update(format!("meguri-bootstrap:{username}:{}", now.format("%Y%m%d%H")).as_bytes());
base32(&mac.finalize().into_bytes()[..20])
}
/// A fresh high-entropy password for single-process demonstration runs.
pub fn random_password() -> String {
let mut bytes = [0u8; 20];
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
bytes[16..].copy_from_slice(&uuid::Uuid::new_v4().as_bytes()[..4]);
base32(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn config() -> Config {
Config::parse(
r#"
auth: {password_login: true}
instance_admin_groups: ["/meguri/admins"]
workspaces:
- {key: payments, name: Payments, admin_groups: ["/payments/leads"], member_groups: ["/payments/team", "/everyone"]}
- {key: search, name: Search, admin_groups: ["/search/leads", "/everyone"], member_groups: ["/search/team"]}
"#,
)
.unwrap()
}
fn groups(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn groups_map_to_roles_by_exact_match_and_admin_wins() {
let c = config();
let access = resolve_access(&c, &groups(&["/payments/team", "/payments/leads"]), false);
assert_eq!(access.workspaces["payments"], Role::Admin);
assert!(!access.workspaces.contains_key("search"));
assert!(!access.instance_admin);
let access = resolve_access(&c, &groups(&["/everyone"]), false);
assert_eq!(access.workspaces["payments"], Role::Employee);
assert_eq!(access.workspaces["search"], Role::Admin);
for near_miss in [
"/payments/team/",
"payments/team",
"/Payments/Team",
"/payments",
] {
assert!(resolve_access(&c, &groups(&[near_miss]), false).is_empty());
}
assert!(resolve_access(&c, &[], false).is_empty());
}
#[test]
fn instance_admin_comes_from_a_group_or_the_bootstrap_flag_without_workspace_access() {
let c = config();
let access = resolve_access(&c, &groups(&["/meguri/admins"]), false);
assert!(access.instance_admin && access.workspaces.is_empty());
assert!(!access.is_empty());
assert!(resolve_access(&c, &[], true).instance_admin);
}
#[test]
fn claims_support_literal_and_dotted_paths_and_report_missing_claims() {
let claims = serde_json::json!({
"groups": ["/a", "/b", 7],
"realm_access": {"roles": ["admin"]},
"https://example.com/groups": "/single",
"bad": {"x": 1}
});
assert_eq!(claim_groups(&claims, "groups"), Some(groups(&["/a", "/b"])));
assert_eq!(
claim_groups(&claims, "realm_access.roles"),
Some(groups(&["admin"]))
);
assert_eq!(
claim_groups(&claims, "https://example.com/groups"),
Some(groups(&["/single"]))
);
assert_eq!(claim_groups(&claims, "missing"), None);
assert_eq!(claim_groups(&claims, "bad"), None);
assert_eq!(
claim_groups(&serde_json::json!({"groups": []}), "groups"),
Some(vec![])
);
}
#[test]
fn bootstrap_passwords_are_stable_within_an_hour_and_depend_on_secret_user_and_hour() {
let hour = Utc.with_ymd_and_hms(2026, 10, 7, 14, 5, 0).unwrap();
let same_hour = Utc.with_ymd_and_hms(2026, 10, 7, 14, 59, 59).unwrap();
let next_hour = Utc.with_ymd_and_hms(2026, 10, 7, 15, 0, 0).unwrap();
let password = bootstrap_password(b"secret-one", "admin", hour);
assert_eq!(
password,
bootstrap_password(b"secret-one", "admin", same_hour)
);
assert_ne!(
password,
bootstrap_password(b"secret-one", "admin", next_hour)
);
assert_ne!(password, bootstrap_password(b"secret-two", "admin", hour));
assert_ne!(password, bootstrap_password(b"secret-one", "other", hour));
assert!(password.len() >= 24 && password.chars().all(|c| c.is_ascii_alphanumeric()));
assert_ne!(random_password(), random_password());
}
#[test]
fn configuration_is_validated_strictly() {
let ok = "auth: {password_login: true}\nworkspaces: []\n";
assert!(Config::parse(ok).is_ok());
for (bad, why) in [
("workspaces: []\n", "no sign-in method"),
("auth: {password_login: true, typo: 1}\n", "unknown field"),
(
"auth: {password_login: true}\nworkspaces: [{key: Bad_Key, name: A, admin_groups: [g]}]\n",
"key format",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g]}, {key: aaa, name: B, admin_groups: [g]}]\n",
"duplicate key",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g]}, {key: bbb, name: a, admin_groups: [g]}]\n",
"duplicate name",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: []}]\n",
"no admin group",
),
(
"auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g, g]}]\n",
"duplicate group",
),
(
"auth: {bootstrap_admin: {usernames: []}}\n",
"empty bootstrap list",
),
(
"auth: {bootstrap_admin: {usernames: [Admin]}}\n",
"bad bootstrap name",
),
(
"oidc: {issuer: 'http://idp.example.com', client_id: x}\n",
"plain HTTP issuer",
),
(
"oidc: {issuer: 'https://idp.example.com', client_id: x, scopes: [email]}\n",
"scope without openid",
),
("{", "syntax"),
] {
assert!(Config::parse(bad).is_err(), "must reject: {why}");
}
let oidc =
Config::parse("oidc: {issuer: 'http://127.0.0.1:9000/realms/x', client_id: meguri}\n")
.unwrap();
let oidc = oidc.oidc.unwrap();
assert_eq!(oidc.groups_claim, "groups");
assert_eq!(oidc.scopes, ["openid", "profile", "email"]);
assert_eq!(
oidc.groups_sources,
[GroupSource::IdToken, GroupSource::Userinfo]
);
}
#[test]
fn the_shipped_example_configuration_is_valid() {
let config = Config::parse(include_str!("../deploy/instance-config.example.yaml")).unwrap();
assert!(config.oidc.is_some() && !config.workspaces.is_empty());
assert!(config.bootstrap_usernames().is_empty());
}
#[test]
fn known_groups_are_unique_and_sorted_and_the_form_flags_follow_the_configuration() {
let c = config();
assert_eq!(
c.known_groups(),
groups(&[
"/everyone",
"/meguri/admins",
"/payments/leads",
"/payments/team",
"/search/leads",
"/search/team"
])
);
assert!(c.password_form_available());
let sso_only =
Config::parse("oidc: {issuer: 'https://idp.example.com', client_id: x}\n").unwrap();
assert!(!sso_only.password_form_available());
let bootstrap = Config::parse(
"oidc: {issuer: 'https://idp.example.com', client_id: x}\nauth: {bootstrap_admin: {usernames: [admin]}}\n",
)
.unwrap();
assert!(bootstrap.password_form_available() && !bootstrap.password_login_enabled());
}
}
+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
}
+21
View File
@@ -8,6 +8,9 @@ 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());
}
@@ -87,4 +90,22 @@ mod tests {
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");
}
}
+24
View File
@@ -0,0 +1,24 @@
# Demonstration instance. It is described exactly like a production configuration; only the
# bootstrap administrator and the password sign-in switch differ.
auth:
password_login: true
bootstrap_admin:
usernames: [admin]
instance_admin_groups: [demo-instance-admins]
workspaces:
- key: acme-engineering
name: Acme Engineering
admin_groups: [acme-leads]
member_groups: [acme-staff]
- key: customer-support
name: Customer Support
admin_groups: [support-leads]
member_groups: [support-staff]
- key: regional-platform
name: Regional Platform
admin_groups: [regional-leads]
member_groups: [regional-staff]
- key: commerce-operations
name: Commerce Operations
admin_groups: [commerce-leads]
member_groups: [commerce-staff]
+207 -104
View File
@@ -4,12 +4,13 @@ use crate::{
error::Result,
generation::{Node, Rule, Variable},
generation_service::{self, Draft},
models::{UserRow, WorkspaceRow},
identity,
models::UserRow,
service,
store::{self, Store},
};
use chrono::{Datelike, Duration, NaiveDate, Utc};
use cot::db::{Model, query};
use cot::db::query;
use serde_json::{Value, json};
use uuid::Uuid;
@@ -226,148 +227,250 @@ pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variab
)
}
pub async fn seed(db: &Store) -> Result<()> {
let mut users = vec![];
for (username, display) in [
("alice", "Alice Morgan"),
("bob", "Bob Chen"),
("carol", "Carol Davis"),
("dave", "Dave Wilson"),
("erin", "Erin Patel"),
] {
let user = match query!(UserRow,$username==username).get(&db.db).await? {
Some(row) => store::user(row)?,
None => {
service::register(
db,
username.into(),
display.into(),
"meguri-demo-only".into(),
)
.await?
}
};
users.push(user);
/// Demonstration accounts: groups assigned exactly as an instance administrator would.
const ACCOUNTS: [(&str, &str, &str, &[&str]); 6] = [
("admin", "Instance Admin", "instance_admin", &[]),
(
"alice",
"Alice Morgan",
"workspace_admin",
&[
"acme-leads",
"support-leads",
"regional-leads",
"commerce-leads",
],
),
(
"bob",
"Bob Chen",
"employee",
&["acme-staff", "regional-staff"],
),
(
"carol",
"Carol Davis",
"employee",
&["acme-staff", "support-staff", "commerce-staff"],
),
("dave", "Dave Wilson", "employee", &["acme-staff"]),
("erin", "Erin Patel", "employee", &["acme-staff"]),
];
/// `MEGURI_DEMO_SANDBOXES=n` (0-20) adds empty "Sandbox 1..n" workspaces, each with its own
/// administrator group, so automated browser checks can mutate state without touching the
/// examples. Normal demonstrations do not set it.
fn sandboxes() -> usize {
std::env::var("MEGURI_DEMO_SANDBOXES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map_or(0, |n| n.min(20))
}
pub fn config() -> Result<crate::access::Config> {
let mut config = crate::access::Config::parse(include_str!("demo-config.yaml"))?;
for number in 1..=sandboxes() {
config.workspaces.push(crate::access::WorkspaceConfig {
key: format!("sandbox-{number}"),
name: format!("Sandbox {number}"),
admin_groups: vec![format!("sandbox-{number}-leads")],
member_groups: vec![format!("sandbox-{number}-staff")],
});
}
for user in &users {
config.validate()?;
Ok(config)
}
/// Accounts shown on the demonstration login screen.
pub fn accounts() -> Vec<crate::demo_runtime::DemoAccount> {
ACCOUNTS
.iter()
.map(|(username, _, role, _)| crate::demo_runtime::DemoAccount {
username: (*username).into(),
role: (*role).into(),
})
.collect()
}
async fn user_id(db: &Store, username: &str) -> Result<Uuid> {
let name = username.to_owned();
let row = query!(UserRow,$username==name)
.get(&db.db)
.await?
.ok_or(crate::error::Error::NotFound)?;
store::uuid(&row.id)
}
pub async fn seed(db: &Store) -> Result<()> {
let notice = crate::demo_runtime::notice()
.ok_or_else(|| crate::error::Error::Internal("demo mode is not active".into()))?;
let config = config()?;
identity::sync_config(
db,
&config,
identity::SyncOptions {
passwords: identity::BootstrapPasswords::Fixed(notice.password.clone()),
now: Utc::now(),
allow_disable_all: false,
},
)
.await?;
for (username, display, _, groups) in ACCOUNTS {
let name = username.to_owned();
if query!(UserRow,$username==name).exists(&db.db).await? {
continue;
}
crate::admin::create_password_user(
db,
&config,
None,
crate::admin::NewUser {
username: username.into(),
display_name: display.into(),
password: notice.password.clone(),
email: None,
groups: groups.iter().map(|g| (*g).to_owned()).collect(),
},
)
.await?;
}
let mut users = vec![];
for (username, ..) in ACCOUNTS {
let id = user_id(db, username).await?;
db.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING",
&[&user.id.to_string()],
&[&id.to_string()],
)
.await?;
users.push(id);
}
let alice = users[0].id;
// users[0] is the instance administrator; the five scheduling accounts follow.
let people: [Uuid; 5] = std::array::from_fn(|index| users[index + 1]);
let alice = people[0];
let today = Utc::now().date_naive();
let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday()));
for (index, workspace_name, timezone, start, end) in [
for (index, key, timezone, start, end) in [
(
1,
"Acme Engineering",
"acme-engineering",
"Asia/Nicosia",
"11:00:00",
"19:00:00",
),
(
2,
"Customer Support",
"customer-support",
"Europe/London",
"09:00:00",
"17:00:00",
),
(
3,
"Regional Platform",
"regional-platform",
"Asia/Nicosia",
"09:00:00",
"18:00:00",
),
(
4,
"Commerce Operations",
"commerce-operations",
"Europe/London",
"09:00:00",
"17:00:00",
),
] {
let mut existing = None;
for row in WorkspaceRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_some() {
continue;
}
let candidate = store::record(row)?;
if candidate.document.name == workspace_name
&& db.owner(candidate.id, alice).await.is_ok()
{
existing = Some(candidate);
break;
}
}
if existing.is_some() {
let Some(workspace) = identity::find_workspace(db, key).await? else {
continue;
}
let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?;
service::set_working_interval(
db,
alice,
workspace.id,
workspace.revision,
WorkingWindow {
timezone: timezone.into(),
weekdays: vec![1, 2, 3, 4, 5],
start: start.parse().unwrap(),
end: end.parse().unwrap(),
},
)
.await?;
workspace = db.workspace(workspace.id).await?;
let member_indices = if index == 1 {
vec![1, 2, 3, 4]
} else {
vec![if index % 2 == 1 { 1 } else { 2 }]
};
for member_index in member_indices {
workspace = service::set_member(
db,
alice,
workspace.id,
workspace.revision,
users[member_index].username.clone(),
Permission::member(),
)
.await?;
}
// Fixed, documented 2026 examples; use the normal scoped CSV import path.
let csv = include_str!("demo-holidays-2026.csv");
if !crate::holidays::compare(csv, &workspace.document.holidays)?
.additions
.is_empty()
{
workspace =
service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?;
}
let (variables, rules) = match index {
1 => engineering_matrix(monday, std::array::from_fn(|index| users[index].id)),
2 => support_queues(monday, alice, users[2].id),
3 => regional_platform(monday, alice, users[1].id),
_ => service_tiers(monday, alice, users[2].id),
};
let mut draft = Draft {
revision: workspace.revision,
variables,
rules,
route_position: 0,
review_id: None,
};
let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?;
draft.review_id = report["review_id"].as_str().map(str::to_owned);
generation_service::apply(db, alice, workspace.id, draft).await?;
db.db
let claimed = db
.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING",
&[&workspace.id.to_string()],
)
.await?;
.await?
.rows_affected()
.0
> 0;
if !claimed {
continue;
}
let result = seed_workspace(
db,
alice,
workspace,
index,
(timezone, start, end),
monday,
people,
)
.await;
if let Err(error) = result {
// Release the claim so the next maintenance pass retries the example.
let id = identity::find_workspace(db, key)
.await?
.map(|w| w.id.to_string());
db.db
.raw_with(
"DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id=$1",
&[&id],
)
.await?;
return Err(error);
}
}
Ok(())
}
async fn seed_workspace(
db: &Store,
alice: Uuid,
mut workspace: store::WorkspaceRecord,
index: usize,
(timezone, start, end): (&str, &str, &str),
monday: NaiveDate,
people: [Uuid; 5],
) -> Result<()> {
service::set_working_interval(
db,
alice,
workspace.id,
workspace.revision,
WorkingWindow {
timezone: timezone.into(),
weekdays: vec![1, 2, 3, 4, 5],
start: start.parse().unwrap(),
end: end.parse().unwrap(),
},
)
.await?;
workspace = db.workspace(workspace.id).await?;
// Fixed, documented 2026 examples; use the normal scoped CSV import path.
let csv = include_str!("demo-holidays-2026.csv");
if !crate::holidays::compare(csv, &workspace.document.holidays)?
.additions
.is_empty()
{
workspace =
service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?;
}
let [alice, bob, carol, ..] = people;
let (variables, rules) = match index {
1 => engineering_matrix(monday, people),
2 => support_queues(monday, alice, carol),
3 => regional_platform(monday, alice, bob),
_ => service_tiers(monday, alice, carol),
};
let mut draft = Draft {
revision: workspace.revision,
variables,
rules,
route_position: 0,
review_id: None,
};
let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?;
draft.review_id = report["review_id"].as_str().map(str::to_owned);
generation_service::apply(db, alice, workspace.id, draft).await?;
Ok(())
}
+10 -1
View File
@@ -53,9 +53,18 @@ impl Retention {
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DemoAccount {
pub username: String,
pub role: String,
}
/// Everything is shown on the login screen: demonstration runs are loopback-only and
/// disposable, and every restart generates a new password.
#[derive(Debug, Clone, Serialize)]
pub struct Notice {
pub isolated: bool,
pub retention: Retention,
pub password: String,
pub accounts: Vec<DemoAccount>,
}
static NOTICE: OnceLock<Notice> = OnceLock::new();
pub fn notice() -> Option<&'static Notice> {
@@ -191,7 +200,7 @@ pub async fn reset(db: &Database) -> Result<()> {
LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE;
IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF;
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL;
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins','meguri__instance_audit','meguri__instance_state','meguri__oidc_flows']) name WHERE to_regclass('public.'||name) IS NOT NULL;
IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF;
UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id;
END $$;").await?;
+22
View File
@@ -31,6 +31,14 @@ impl Permission {
pub fn member() -> Vec<Self> {
vec![Self::View, Self::RequestOverrides]
}
/// Default employee role: calendar access, schedule edits, and approved replacements.
pub fn employee_default() -> Vec<Self> {
vec![Self::View, Self::EditSchedules, Self::RequestOverrides]
}
/// Permissions an administrator may grant the employee role.
pub fn grantable_to_employees(self) -> bool {
!matches!(self, Self::ManageMembers | Self::ManageIntegration)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -43,12 +51,19 @@ pub struct User {
pub country: String,
/// None follows the browser timezone; this never changes scheduling rules.
pub timezone: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub instance_admin: bool,
#[serde(default)]
pub has_password: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub user: User,
pub permissions: Vec<Permission>,
pub is_owner: bool,
}
/// Scheduling metadata, not an authentication principal. Registered users retain their ID.
@@ -59,6 +74,9 @@ pub struct Person {
pub email: Option<String>,
pub country: Option<String>,
pub user_id: Option<Uuid>,
/// Former contact IDs that were merged into this person when a login was linked by email.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -85,6 +103,9 @@ pub struct WorkspaceDocument {
pub name: String,
pub timezone: String,
pub horizon_days: u16,
/// Grants held by the employee role; administrators always hold every permission.
#[serde(default = "Permission::employee_default")]
pub member_permissions: Vec<Permission>,
#[serde(default)]
pub people: Vec<Person>,
#[serde(default)]
@@ -113,6 +134,7 @@ impl WorkspaceDocument {
overwrite_drift: false,
timezone: "Europe/Bucharest".into(),
horizon_days: 90,
member_permissions: Permission::employee_default(),
people: vec![],
schedules: vec![],
holidays: vec![],
+1
View File
@@ -378,6 +378,7 @@ mod tests {
display_name: format!("Person {i}"),
email: None,
country: Some(if i == 0 { "AM" } else { "CY" }.into()),
aliases: vec![],
})
.collect();
let day = "2026-03-30".parse().unwrap();
+1
View File
@@ -624,6 +624,7 @@ fn recipient(
display_name: email.clone(),
email: Some(email),
country: Some("CY".into()),
aliases: vec![],
});
Ok(id)
}
+1
View File
@@ -67,6 +67,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res
display_name: user.display_name.clone(),
email: None,
country: Some(user.country.clone()),
aliases: vec![],
});
}
}
+975
View File
@@ -0,0 +1,975 @@
//! Instance identity: configuration sync, group-derived access, SSO accounts, bootstrap
//! accounts and contact linking. Authorization stays on the server; the browser only
//! displays what these functions decide.
use crate::{
access::{Access, Config, Role, WorkspaceConfig, bootstrap_password, resolve_access},
domain::*,
error::{Error, Result, ensure},
models::*,
service,
store::{self, Store, WorkspaceRecord},
};
use chrono::{DateTime, Utc};
use cot::db::{DatabaseError, Model, query};
use serde_json::json;
use std::collections::BTreeSet;
use uuid::Uuid;
fn config_error(message: impl Into<String>) -> Error {
Error::Validation(format!("Invalid configuration: {}", message.into()))
}
pub fn normalize_email(value: &str) -> Option<String> {
let email = value.trim().to_lowercase();
(email.len() <= 254 && email_address::EmailAddress::is_valid(&email)).then_some(email)
}
pub fn new_user_row(username: String, display_name: String, hash: Option<String>) -> UserRow {
UserRow {
language: None,
demo_deleted_at: None,
id: Uuid::new_v4().to_string(),
username,
display_name,
country: "CY".into(),
timezone: None,
password_hash: hash,
created_at: Utc::now(),
email: None,
instance_admin: false,
bootstrap: false,
groups: "[]".into(),
}
}
async fn insert_user(db: &Store, row: &UserRow) -> Result<bool> {
match row.clone().insert(&db.db).await {
Ok(_) => Ok(true),
Err(DatabaseError::UniqueViolation) => Ok(false),
Err(e) => Err(e.into()),
}
}
// ---- workspaces from configuration ---------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceChange {
Created,
Adopted,
Enabled,
Renamed,
}
#[derive(Debug, Clone)]
pub struct EnsuredWorkspace {
pub id: Uuid,
pub changes: Vec<WorkspaceChange>,
}
const CREATE_CONFIGURED_WORKSPACE: &str = r#"
WITH workspace AS (
INSERT INTO meguri__workspaces(id,key,document,revision,created_at) VALUES($1,$2,$3,1,now()) RETURNING *
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),NULL FROM workspace
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.created',$4,now() FROM workspace
"#;
const ENABLE_WORKSPACE: &str = r#"
WITH enabled AS (
UPDATE meguri__workspaces SET disabled_at=NULL WHERE id=$1 AND disabled_at IS NOT NULL AND demo_deleted_at IS NULL RETURNING id,revision
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,now() FROM enabled
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,NULL,'workspace.enabled','{}',now() FROM enabled
"#;
const DISABLE_WORKSPACE: &str = r#"
WITH disabled AS (
UPDATE meguri__workspaces SET disabled_at=now() WHERE id=$1 AND disabled_at IS NULL AND demo_deleted_at IS NULL RETURNING id
), jobs AS (
DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM disabled)
), members AS (
DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM disabled)
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.disabled','{}',now() FROM disabled
"#;
/// Active (not demo-expired) workspace rows, including disabled ones.
async fn live_rows(db: &Store) -> Result<Vec<WorkspaceRow>> {
Ok(WorkspaceRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|r| r.demo_deleted_at.is_none())
.collect())
}
fn record_of(row: &WorkspaceRow) -> Result<WorkspaceRecord> {
Ok(WorkspaceRecord {
id: store::uuid(&row.id)?,
revision: row.revision,
document: serde_json::from_str(&row.document)?,
})
}
fn ambiguous_adoption(config: &WorkspaceConfig, matches: usize) -> Error {
config_error(format!(
"workspace {:?} matches {matches} existing workspaces named {:?}; rename or delete the extras so the key can adopt exactly one",
config.key, config.name
))
}
/// Everything that can be refused from configuration alone is refused before the first write,
/// so a rejected configuration never leaves a half-applied instance behind.
async fn preflight(db: &Store, config: &Config, allow_disable_all: bool) -> Result<()> {
let rows = live_rows(db).await?;
if config.workspaces.is_empty() && !allow_disable_all {
let active = rows.iter().filter(|r| r.disabled_at.is_none()).count();
ensure(
active == 0,
format!(
"Refusing to disable {active} existing workspaces because the configuration lists none. Fix the configuration or set MEGURI_ALLOW_DISABLE_ALL=true to disable them all."
),
)?;
}
for workspace in &config.workspaces {
if rows
.iter()
.any(|r| r.key.as_deref() == Some(workspace.key.as_str()))
{
continue;
}
let mut matches = 0;
for row in rows.iter().filter(|r| r.key.is_none()) {
if record_of(row)?.document.name == workspace.name {
matches += 1;
}
}
if matches > 1 {
return Err(ambiguous_adoption(workspace, matches));
}
}
for username in config.bootstrap_usernames() {
let name = username.clone();
if let Some(row) = query!(UserRow,$username==name).get(&db.db).await? {
let id = row.id;
if query!(ExternalIdentityRow,$user_id==id)
.exists(&db.db)
.await?
{
return Err(config_error(format!(
"bootstrap username {username:?} belongs to an SSO account; choose another name"
)));
}
}
}
Ok(())
}
/// Creates, adopts (by exact name), enables or renames the workspace declared by `config`.
/// Every step is idempotent, so replicas that run it concurrently converge.
pub async fn ensure_workspace(db: &Store, config: &WorkspaceConfig) -> Result<EnsuredWorkspace> {
for _ in 0..4 {
let rows = live_rows(db).await?;
if let Some(row) = rows
.iter()
.find(|r| r.key.as_deref() == Some(config.key.as_str()))
{
let id = store::uuid(&row.id)?;
let mut changes = vec![];
if row.disabled_at.is_some() {
let result = db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?;
if result.rows_affected().0 > 0 {
changes.push(WorkspaceChange::Enabled);
}
}
let mut record = record_of(row)?;
if record.document.name != config.name {
let before = record.document.name.clone();
record.document.name = config.name.clone();
match db
.system_commit(
&mut record,
None,
"workspace.renamed",
json!({"from":before,"to":config.name}),
)
.await
{
Ok(()) => changes.push(WorkspaceChange::Renamed),
Err(Error::Conflict(_)) => continue,
Err(e) => return Err(e),
}
}
return Ok(EnsuredWorkspace { id, changes });
}
let mut candidates = vec![];
for row in rows.iter().filter(|r| r.key.is_none()) {
if record_of(row)?.document.name == config.name {
candidates.push(row);
}
}
if candidates.len() > 1 {
return Err(ambiguous_adoption(config, candidates.len()));
}
if let Some(row) = candidates.first() {
let adopted = db
.db
.raw_with(
"UPDATE meguri__workspaces SET key=$2 WHERE id=$1 AND key IS NULL",
&[&row.id, &config.key],
)
.await?;
if adopted.rows_affected().0 == 0 {
continue;
}
let mut changes = vec![WorkspaceChange::Adopted];
if row.disabled_at.is_some() {
db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?;
changes.push(WorkspaceChange::Enabled);
}
return Ok(EnsuredWorkspace {
id: store::uuid(&row.id)?,
changes,
});
}
let id = Uuid::new_v4();
let document = serde_json::to_string(&WorkspaceDocument::new(config.name.clone()))?;
let details = json!({"key":config.key}).to_string();
match db
.db
.raw_with(
CREATE_CONFIGURED_WORKSPACE,
&[&id.to_string(), &config.key, &document, &details],
)
.await
{
Ok(_) => {
return Ok(EnsuredWorkspace {
id,
changes: vec![WorkspaceChange::Created],
});
}
Err(DatabaseError::UniqueViolation) => continue,
Err(e) => return Err(e.into()),
}
}
Err(Error::Internal(format!(
"workspace {:?} kept changing while it was being created",
config.key
)))
}
/// The enabled workspace declared with `key`, if it exists.
pub async fn find_workspace(db: &Store, key: &str) -> Result<Option<WorkspaceRecord>> {
live_rows(db)
.await?
.iter()
.find(|r| r.key.as_deref() == Some(key) && r.disabled_at.is_none())
.map(record_of)
.transpose()
}
pub async fn disable_workspace(db: &Store, id: &str) -> Result<bool> {
Ok(db
.db
.raw_with(DISABLE_WORKSPACE, &[&id])
.await?
.rows_affected()
.0
> 0)
}
// ---- group-derived access -------------------------------------------------------------
const APPLY_ACCESS: &str = r#"
WITH changed AS (
UPDATE meguri__users SET groups=$2,instance_admin=$3 WHERE id=$1 RETURNING id
), desired AS (
SELECT w.id AS workspace_id,d.is_owner FROM jsonb_to_recordset($4::jsonb) AS d(key text,is_owner boolean)
JOIN meguri__workspaces w ON w.key=d.key AND w.demo_deleted_at IS NULL AND w.disabled_at IS NULL
), removed AS (
DELETE FROM meguri__workspace_members m WHERE m.user_id=$1 AND EXISTS(SELECT 1 FROM changed)
AND NOT EXISTS(SELECT 1 FROM desired d WHERE d.workspace_id=m.workspace_id)
)
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner)
SELECT d.workspace_id||':'||$1,d.workspace_id,$1,
CASE WHEN d.is_owner THEN $5 ELSE COALESCE((SELECT w.document::jsonb->'member_permissions' FROM meguri__workspaces w WHERE w.id=d.workspace_id)::text,$6) END,
d.is_owner
FROM desired d WHERE EXISTS(SELECT 1 FROM changed)
ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions,is_owner=EXCLUDED.is_owner
"#;
pub fn stored_groups(row: &UserRow) -> Result<Vec<String>> {
Ok(serde_json::from_str(&row.groups)?)
}
/// The one place that turns groups into memberships and the instance-admin flag. SSO login,
/// the administrator's password-user editor and startup reconciliation all end here.
/// `groups` replaces the stored list when given.
pub async fn apply_access(
db: &Store,
config: &Config,
user: Uuid,
groups: Option<Vec<String>>,
) -> Result<Access> {
let id = user.to_string();
let row = query!(UserRow,$id==id.clone())
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let mut groups = match groups {
Some(groups) => groups,
None => stored_groups(&row)?,
};
groups.sort();
groups.dedup();
let access = resolve_access(config, &groups, row.bootstrap);
let desired: Vec<_> = access
.workspaces
.iter()
.map(|(key, role)| json!({"key":key,"is_owner":*role == Role::Admin}))
.collect();
db.db
.raw_with(
APPLY_ACCESS,
&[
&id,
&serde_json::to_string(&groups)?,
&access.instance_admin,
&serde_json::to_string(&desired)?,
&serde_json::to_string(&Permission::all())?,
&serde_json::to_string(&Permission::employee_default())?,
],
)
.await?;
Ok(access)
}
pub async fn reconcile_all(db: &Store, config: &Config) -> Result<usize> {
let mut count = 0;
for row in UserRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_some() {
continue;
}
apply_access(db, config, store::uuid(&row.id)?, None).await?;
count += 1;
}
Ok(count)
}
// ---- bootstrap accounts ---------------------------------------------------------------
pub enum BootstrapPasswords {
/// HMAC of a database-held secret and the current UTC hour: identical across replicas.
Derived,
/// One generated password for a single-process demonstration run.
Fixed(String),
}
async fn instance_secret(db: &Store) -> Result<Vec<u8>> {
let candidate = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
db.db
.raw_with(
"INSERT INTO meguri__instance_state(key,value) VALUES('bootstrap_secret',$1) ON CONFLICT(key) DO NOTHING",
&[&candidate],
)
.await?;
let secret = query!(InstanceStateRow,$key=="bootstrap_secret".to_owned())
.get(&db.db)
.await?
.ok_or_else(|| Error::Internal("bootstrap secret is missing".into()))?;
Ok(secret.value.into_bytes())
}
/// Creates or refreshes one bootstrap account. Returns whether the password changed.
async fn ensure_bootstrap_account(db: &Store, username: &str, password: &str) -> Result<bool> {
for _ in 0..3 {
let name = username.to_owned();
match query!(UserRow,$username==name).get(&db.db).await? {
Some(row) => {
let id = row.id.clone();
if query!(ExternalIdentityRow,$user_id==id)
.exists(&db.db)
.await?
{
return Err(config_error(format!(
"bootstrap username {username:?} belongs to an SSO account; choose another name"
)));
}
if row.bootstrap
&& let Some(hash) = row.password_hash.clone()
&& service::password_matches(hash, password.to_owned()).await?
{
return Ok(false);
}
let hash = service::hash_password(password.to_owned()).await?;
db.db
.raw_with(
"UPDATE meguri__users SET password_hash=$2,bootstrap=true,demo_deleted_at=NULL WHERE id=$1",
&[&row.id, &hash],
)
.await?;
db.db
.raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id])
.await?;
return Ok(true);
}
None => {
let hash = service::hash_password(password.to_owned()).await?;
let mut row = new_user_row(username.to_owned(), username.to_owned(), Some(hash));
row.bootstrap = true;
if insert_user(db, &row).await? {
return Ok(true);
}
}
}
}
Err(Error::Internal(format!(
"could not create bootstrap account {username:?}"
)))
}
/// Accounts that stopped being bootstrap accounts lose their password and their sessions.
async fn release_bootstrap_accounts(db: &Store, keep: &[String]) -> Result<Vec<String>> {
let mut released = vec![];
for row in query!(UserRow,$bootstrap==true).all(&db.db).await? {
if keep.contains(&row.username) {
continue;
}
db.db
.raw_with(
"UPDATE meguri__users SET bootstrap=false,password_hash=NULL WHERE id=$1",
&[&row.id],
)
.await?;
db.db
.raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id])
.await?;
released.push(row.username);
}
Ok(released)
}
// ---- synchronization ----------------------------------------------------------------
#[derive(Debug, Default, Clone)]
pub struct SyncReport {
pub created: Vec<String>,
pub adopted: Vec<String>,
pub enabled: Vec<String>,
pub renamed: Vec<String>,
pub disabled: Vec<String>,
pub bootstrap_rotated: Vec<String>,
pub bootstrap_released: Vec<String>,
/// Current username/password pairs; the caller decides where to show them.
pub bootstrap_credentials: Vec<(String, String)>,
}
pub struct SyncOptions {
pub passwords: BootstrapPasswords,
pub now: DateTime<Utc>,
/// Allows a configuration without workspaces to disable every existing one.
pub allow_disable_all: bool,
}
/// Makes the database match the declarative configuration: configured workspaces exist and are
/// enabled, other workspaces are disabled (never deleted), bootstrap accounts match the
/// configured list, and every user's memberships follow their stored groups.
pub async fn sync_config(db: &Store, config: &Config, options: SyncOptions) -> Result<SyncReport> {
config.validate()?;
preflight(db, config, options.allow_disable_all).await?;
let mut report = SyncReport::default();
let mut keys = BTreeSet::new();
for workspace in &config.workspaces {
keys.insert(workspace.key.clone());
let ensured = ensure_workspace(db, workspace).await?;
for change in &ensured.changes {
let (list, action) = match change {
WorkspaceChange::Created => (&mut report.created, "workspace.created"),
WorkspaceChange::Adopted => (&mut report.adopted, "workspace.adopted"),
WorkspaceChange::Enabled => (&mut report.enabled, "workspace.enabled"),
WorkspaceChange::Renamed => (&mut report.renamed, "workspace.renamed"),
};
list.push(workspace.key.clone());
db.instance_event(
None,
action,
json!({"key":workspace.key,"id":ensured.id,"name":workspace.name}),
)
.await?;
}
}
for row in live_rows(db).await? {
let configured = row.key.as_ref().is_some_and(|k| keys.contains(k));
if !configured && row.disabled_at.is_none() && disable_workspace(db, &row.id).await? {
let name = record_of(&row)?.document.name;
report
.disabled
.push(row.key.clone().unwrap_or_else(|| name.clone()));
db.instance_event(
None,
"workspace.disabled",
json!({"key":row.key,"id":row.id,"name":name}),
)
.await?;
}
}
let usernames = config.bootstrap_usernames();
report.bootstrap_released = release_bootstrap_accounts(db, usernames).await?;
if !usernames.is_empty() {
let secret = match options.passwords {
BootstrapPasswords::Derived => Some(instance_secret(db).await?),
BootstrapPasswords::Fixed(_) => None,
};
for username in usernames {
let password = match (&options.passwords, &secret) {
(BootstrapPasswords::Fixed(password), _) => password.clone(),
(BootstrapPasswords::Derived, Some(secret)) => {
bootstrap_password(secret, username, options.now)
}
_ => unreachable!("derived passwords always load the instance secret"),
};
if ensure_bootstrap_account(db, username, &password).await? {
report.bootstrap_rotated.push(username.clone());
db.instance_event(None, "bootstrap.password_set", json!({"username":username}))
.await?;
}
report
.bootstrap_credentials
.push((username.clone(), password));
}
}
reconcile_all(db, config).await?;
for (list, label) in [
(&report.created, "created"),
(&report.adopted, "adopted"),
(&report.enabled, "enabled"),
(&report.renamed, "renamed"),
(&report.disabled, "disabled"),
] {
if !list.is_empty() {
tracing::info!(workspaces = ?list, "workspaces {label} from configuration");
}
}
Ok(report)
}
// ---- SSO accounts -------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct SsoProfile {
pub issuer: String,
pub subject: String,
pub email: Option<String>,
pub username_hint: Option<String>,
pub display_name: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug)]
pub enum SsoOutcome {
/// The provider's groups map to nothing; no account is created or kept enabled.
NoAccess,
SignedIn {
user: Box<User>,
token: String,
csrf: String,
},
}
fn username_candidates(profile: &SsoProfile) -> Vec<String> {
let clean = |value: &str| -> String {
value
.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c) {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.chars()
.take(56)
.collect()
};
let mut bases: Vec<String> = [
profile.username_hint.as_deref(),
profile.email.as_deref().and_then(|e| e.split('@').next()),
]
.into_iter()
.flatten()
.map(clean)
.filter(|b| b.len() >= 3)
.collect();
if bases.is_empty() {
bases.push("user".into());
}
let suffix = &service::token_hash(&format!("{}\n{}", profile.issuer, profile.subject))[..6];
let mut candidates = vec![];
for base in &bases {
candidates.push(base.clone());
candidates.push(format!("{base}-{suffix}"));
}
candidates
}
async fn find_identity(db: &Store, profile: &SsoProfile) -> Result<Option<String>> {
let (issuer, subject) = (profile.issuer.clone(), profile.subject.clone());
Ok(
query!(ExternalIdentityRow,$issuer==issuer && $subject==subject)
.get(&db.db)
.await?
.map(|i| i.user_id),
)
}
async fn create_sso_user(db: &Store, profile: &SsoProfile) -> Result<String> {
for username in username_candidates(profile) {
let display = profile
.display_name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty() && n.len() <= 120)
.unwrap_or(&username)
.to_owned();
let mut row = new_user_row(username, display, None);
row.email = profile.email.clone();
if !insert_user(db, &row).await? {
continue;
}
let mut identity = ExternalIdentityRow {
id: Uuid::new_v4().to_string(),
issuer: profile.issuer.clone(),
subject: profile.subject.clone(),
user_id: row.id.clone(),
};
match identity.insert(&db.db).await {
Ok(_) => return Ok(row.id),
Err(DatabaseError::UniqueViolation) => {
db.db
.raw_with("DELETE FROM meguri__users WHERE id=$1", &[&row.id])
.await?;
return find_identity(db, profile).await?.ok_or_else(|| {
Error::Internal("SSO identity disappeared during sign-in".into())
});
}
Err(e) => return Err(e.into()),
}
}
Err(Error::Internal(
"could not derive a unique username for the SSO account".into(),
))
}
/// Signs in a verified SSO identity. Groups are applied before anything else, so access that
/// was revoked in the provider disappears at this login and an unmapped user stays out.
pub async fn sso_sign_in(db: &Store, config: &Config, profile: SsoProfile) -> Result<SsoOutcome> {
let existing = find_identity(db, &profile).await?;
let user_id = match existing {
Some(id) => id,
None => {
if resolve_access(config, &profile.groups, false).is_empty() {
return Ok(SsoOutcome::NoAccess);
}
create_sso_user(db, &profile).await?
}
};
let id = store::uuid(&user_id)?;
let access = apply_access(db, config, id, Some(profile.groups.clone())).await?;
if access.is_empty() {
return Ok(SsoOutcome::NoAccess);
}
if let Some(email) = &profile.email {
db.db
.raw_with(
"UPDATE meguri__users SET email=$2 WHERE id=$1 AND email IS DISTINCT FROM $2",
&[&user_id, email],
)
.await?;
}
if let Err(error) = link_contacts(db, id).await {
tracing::warn!(user = %id, %error, "could not link the sign-in email to workspace contacts");
}
let row = query!(UserRow,$id==user_id)
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let user = store::user(row)?;
let (token, csrf) = service::create_session(db, user.id).await?;
Ok(SsoOutcome::SignedIn {
user: Box::new(user),
token,
csrf,
})
}
// ---- contact linking ---------------------------------------------------------------
/// In every workspace the user belongs to, binds the user's email to the scheduling person
/// that carries it, so people planned by email are ready at first sign-in:
/// * a contact with the same email becomes the user (the contact's old ID is kept as an alias
/// and every reference is rewritten, preserving schedules, requests and variables);
/// * otherwise the user's own person gains the email, or is added with it.
///
/// Ambiguous or conflicting data is skipped, never guessed. Access is never granted by
/// email: only group membership creates workspace access.
pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> {
let id = user.to_string();
let row = query!(UserRow,$id==id.clone())
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let Some(email) = row.email.as_deref().and_then(normalize_email) else {
return Ok(());
};
for membership in query!(WorkspaceMemberRow,$user_id==id).all(&db.db).await? {
let workspace = store::uuid(&membership.workspace_id)?;
for _ in 0..3 {
let Ok(mut record) = db.workspace(workspace).await else {
break;
};
let members = db.member_ids(workspace).await?;
let Some((document, details)) = linked_document(&record.document, &row, user, &email)
else {
break;
};
record.document = document;
if record.document.validate(&members).is_err() {
tracing::warn!(%workspace, %user, "contact linking skipped: the result would be invalid");
break;
}
match db
.system_commit(&mut record, Some(user), "person.linked", details)
.await
{
Ok(()) => break,
Err(Error::Conflict(_)) => continue,
Err(e) => return Err(e),
}
}
}
Ok(())
}
/// Pure transformation for [`link_contacts`]. `None` means nothing to change.
pub fn linked_document(
document: &WorkspaceDocument,
user: &UserRow,
user_id: Uuid,
email: &str,
) -> Option<(WorkspaceDocument, serde_json::Value)> {
let contacts: Vec<usize> = document
.people
.iter()
.enumerate()
.filter(|(_, p)| p.email.as_deref() == Some(email) && p.id != user_id)
.map(|(i, _)| i)
.collect();
if contacts.len() > 1 {
return None;
}
if let Some(&index) = contacts.first() {
let contact = &document.people[index];
if contact.user_id.is_some() {
return None;
}
if document
.people
.iter()
.any(|p| p.id == user_id && p.email.as_deref().is_some_and(|e| e != email))
{
return None;
}
let old = contact.id;
let mut working = document.clone();
working.people.retain(|p| p.id != user_id);
let text = serde_json::to_string(&working)
.ok()?
.replace(&old.to_string(), &user_id.to_string());
let mut linked: WorkspaceDocument = serde_json::from_str(&text).ok()?;
let person = linked.people.iter_mut().find(|p| p.id == user_id)?;
person.user_id = Some(user_id);
if !person.aliases.contains(&old) {
person.aliases.push(old);
}
return Some((
linked,
json!({"user_id":user_id,"contact_id":old,"merged":true}),
));
}
match document.people.iter().position(|p| p.id == user_id) {
Some(index) => {
let person = &document.people[index];
if person.email.is_some() && person.user_id == Some(user_id) {
return None;
}
if person.email.as_deref().is_some_and(|e| e != email) {
return None;
}
let mut linked = document.clone();
linked.people[index].email = Some(email.to_owned());
linked.people[index].user_id = Some(user_id);
Some((linked, json!({"user_id":user_id,"merged":false})))
}
None => {
let mut linked = document.clone();
linked.people.push(Person {
id: user_id,
user_id: Some(user_id),
display_name: user.display_name.clone(),
email: Some(email.to_owned()),
country: Some(user.country.clone()),
aliases: vec![],
});
Some((linked, json!({"user_id":user_id,"merged":false})))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row() -> UserRow {
new_user_row("dana".into(), "Dana".into(), None)
}
fn contact(email: &str) -> Person {
Person {
id: Uuid::new_v4(),
user_id: None,
display_name: "Contact".into(),
email: Some(email.into()),
country: Some("AM".into()),
aliases: vec![],
}
}
#[test]
fn a_matching_contact_becomes_the_user_everywhere_without_duplicates() {
let user = Uuid::new_v4();
let existing_self = Person {
id: user,
user_id: Some(user),
display_name: "Dana".into(),
email: None,
country: Some("CY".into()),
aliases: vec![],
};
let mut doc = WorkspaceDocument::new("Team".into());
let c = contact("dana@corp.test");
let old = c.id;
doc.people = vec![existing_self, c];
doc.generation.variables = vec![];
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: old,
start: "2026-11-01".parse().unwrap(),
end: "2026-11-02".parse().unwrap(),
});
let (linked, details) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people.len(), 1, "no second entry for the same user");
let person = &linked.people[0];
assert_eq!((person.id, person.user_id), (user, Some(user)));
assert_eq!(person.aliases, vec![old]);
assert_eq!(person.display_name, "Contact");
assert_eq!(linked.absences[0].user_id, user);
assert_eq!(details["contact_id"], json!(old));
assert_eq!(
serde_json::to_string(&linked)
.unwrap()
.matches(&old.to_string())
.count(),
1,
"the old id survives only as the alias"
);
// Applying the result again is a no-op.
assert!(linked_document(&linked, &row(), user, "dana@corp.test").is_none());
}
#[test]
fn ambiguous_or_conflicting_data_is_never_guessed() {
let user = Uuid::new_v4();
let mut doc = WorkspaceDocument::new("Team".into());
// Two contacts with one email cannot validate, and must not be merged either.
doc.people = vec![contact("dana@corp.test"), contact("dana@corp.test")];
assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none());
// A contact that already belongs to another login is not stolen.
let mut taken = contact("dana@corp.test");
taken.user_id = Some(Uuid::new_v4());
doc.people = vec![taken];
assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none());
// The user's own person carries a different email: leave both alone.
doc.people = vec![
Person {
id: user,
user_id: Some(user),
display_name: "Dana".into(),
email: Some("other@corp.test".into()),
country: None,
aliases: vec![],
},
contact("dana@corp.test"),
];
assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none());
}
#[test]
fn users_without_a_contact_gain_a_person_with_their_email() {
let user = Uuid::new_v4();
let doc = WorkspaceDocument::new("Team".into());
let (linked, _) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test"));
assert_eq!(linked.people[0].user_id, Some(user));
let mut with_person = doc.clone();
with_person.people.push(Person {
id: user,
user_id: Some(user),
display_name: "Dana".into(),
email: None,
country: None,
aliases: vec![],
});
let (linked, _) = linked_document(&with_person, &row(), user, "dana@corp.test").unwrap();
assert_eq!(linked.people.len(), 1);
assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test"));
}
#[test]
fn usernames_are_clean_unique_candidates() {
let profile = |hint: Option<&str>, email: Option<&str>| SsoProfile {
issuer: "https://idp.test".into(),
subject: "sub-1".into(),
email: email.map(str::to_owned),
username_hint: hint.map(str::to_owned),
display_name: None,
groups: vec![],
};
let candidates = username_candidates(&profile(Some("Jane Doe!"), Some("jd@corp.test")));
assert_eq!(candidates[0], "jane-doe");
assert!(
candidates[1].starts_with("jane-doe-") && candidates[1].len() == "jane-doe-".len() + 6
);
// Too-short hints fall back to the email name, and nothing at all to a neutral base.
assert_eq!(
username_candidates(&profile(Some("ab"), Some("Lena.Ivanova@corp.test")))[0],
"lena.ivanova"
);
assert_eq!(username_candidates(&profile(None, None))[0], "user");
assert!(candidates.iter().all(|c| crate::access::valid_username(c)));
let long = "x".repeat(200);
assert!(
username_candidates(&profile(Some(&long), None))
.iter()
.all(|c| crate::access::valid_username(c))
);
}
}
+4
View File
@@ -1,4 +1,6 @@
pub mod absence;
pub mod access;
pub mod admin;
pub mod audit;
pub mod configuration;
pub mod demo;
@@ -10,8 +12,10 @@ pub mod generation;
pub mod generation_service;
pub mod holidays;
pub mod i18n;
pub mod identity;
pub mod migrations;
pub mod models;
pub mod oidc;
pub mod providers;
pub mod service;
pub mod store;
+64 -2
View File
@@ -3,12 +3,30 @@ use cot::{
config::{DatabaseConfig, ProjectConfig},
};
use meguri::{
demo_runtime, service,
access::{self, Config},
demo, demo_runtime, identity, oidc, service,
store::Store,
web::{MeguriProject, enabled},
worker,
};
fn load_config(demo: bool) -> Result<Config, Box<dyn std::error::Error>> {
if demo {
if std::env::var_os("MEGURI_CONFIG_FILE").is_some() {
tracing::warn!(
"Demo mode uses its built-in configuration; MEGURI_CONFIG_FILE is ignored"
);
}
return Ok(demo::config()?);
}
let path = std::env::var("MEGURI_CONFIG_FILE").map_err(|_| {
"Set MEGURI_CONFIG_FILE to the instance configuration file (workspaces, group mappings and sign-in); see docs/deployment.md"
})?;
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("Cannot read MEGURI_CONFIG_FILE {path:?}: {e}"))?;
Ok(Config::parse(&text)?)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
@@ -22,6 +40,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if !database_url.starts_with("postgresql://") {
return Err("DATABASE_URL must use the postgresql:// scheme".into());
}
let demo_mode = enabled("MEGURI_SEED_DEMO");
let config = load_config(demo_mode)?;
let role = std::env::var("MEGURI_ROLE").unwrap_or_else(|_| "all".into());
let bind = std::env::var("MEGURI_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into());
let public_url =
@@ -31,6 +51,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if public.scheme() != "https" && !(public.scheme() == "http" && loopback) {
return Err("MEGURI_PUBLIC_URL must use HTTPS outside localhost".into());
}
if let Some(settings) = config.oidc.clone() {
let client_secret = std::env::var("MEGURI_OIDC_CLIENT_SECRET")
.ok()
.filter(|s| !s.is_empty())
.ok_or("Set MEGURI_OIDC_CLIENT_SECRET (a deployment secret) when oidc is configured")?;
oidc::install(oidc::Settings {
config: settings,
client_secret,
redirect_uri: format!("{}/api/oidc/callback", public_url.trim_end_matches('/')),
});
}
access::install(config.clone());
if enabled("MEGURI_SEED_DEMO") && (!loopback || !bind.starts_with("127.0.0.1:")) {
return Err("Demo accounts require a loopback bind and URL".into());
}
@@ -45,9 +77,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
database_url = selected;
let connection = cot::db::Database::new(&database_url).await?;
demo_runtime::reset(&connection).await?;
let password = access::random_password();
tracing::warn!(
"Demo accounts ({}) sign in with the password {password}; it changes on every start and is also shown on the login screen",
demo::accounts()
.iter()
.map(|a| a.username.clone())
.collect::<Vec<_>>()
.join(", ")
);
demo_runtime::install(demo_runtime::Notice {
isolated,
retention,
password,
accounts: demo::accounts(),
});
if isolated {
tracing::warn!(
@@ -65,8 +108,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} else {
Store::connect(&database_url).await?
};
// Whoever owns the migrations also applies the declarative configuration. Demo mode
// synchronizes while seeding. Replicas started with MEGURI_SKIP_MIGRATIONS only read.
if !demo_mode && role != "worker" && !enabled("MEGURI_SKIP_MIGRATIONS") {
let report = identity::sync_config(
&db,
&config,
identity::SyncOptions {
passwords: identity::BootstrapPasswords::Derived,
now: chrono::Utc::now(),
allow_disable_all: enabled("MEGURI_ALLOW_DISABLE_ALL"),
},
)
.await?;
for (username, password) in &report.bootstrap_credentials {
tracing::warn!(
"Bootstrap administrator enabled: sign in as {username} with password {password} (it stays valid until a later start in another UTC hour replaces it). Remove auth.bootstrap_admin from the configuration to disable it."
);
}
}
if role == "migrate" {
tracing::info!("Cot migrations completed");
tracing::info!("Cot migrations and configuration sync completed");
return Ok(());
}
if enabled("MEGURI_SEED_DEMO") {
+2
View File
@@ -6,6 +6,7 @@ pub mod m_0005_variables;
pub mod m_0006_demo_lifecycle;
pub mod m_0007_language;
pub mod m_0008_workspaces;
pub mod m_0009_access;
pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration,
&m_0002_constraints::Migration,
@@ -15,4 +16,5 @@ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0006_demo_lifecycle::Migration,
&m_0007_language::Migration,
&m_0008_workspaces::Migration,
&m_0009_access::Migration,
];
+27
View File
@@ -0,0 +1,27 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0009_access";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0008_workspaces",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw(r#"DO $$ BEGIN
ALTER TABLE meguri__users ALTER COLUMN password_hash DROP NOT NULL;
ALTER TABLE meguri__users ADD COLUMN email text, ADD COLUMN instance_admin boolean NOT NULL DEFAULT false, ADD COLUMN bootstrap boolean NOT NULL DEFAULT false, ADD COLUMN groups text NOT NULL DEFAULT '[]' CHECK(jsonb_typeof(groups::jsonb)='array');
ALTER TABLE meguri__workspaces ADD COLUMN key text, ADD COLUMN disabled_at timestamptz;
CREATE UNIQUE INDEX workspace_key_unique ON meguri__workspaces(key) WHERE key IS NOT NULL AND demo_deleted_at IS NULL;
CREATE UNIQUE INDEX external_identity_subject ON meguri__external_identities(issuer,subject);
CREATE INDEX external_identity_user ON meguri__external_identities(user_id);
CREATE TABLE meguri__instance_state(key text PRIMARY KEY,value text NOT NULL);
CREATE TABLE meguri__instance_audit(id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,actor_id text,action text NOT NULL,details text NOT NULL,created_at timestamptz NOT NULL);
CREATE TABLE meguri__oidc_flows(state_hash text PRIMARY KEY,nonce text NOT NULL,verifier text NOT NULL,expires_at timestamptz NOT NULL);
END $$;"#).await?;
Ok(())
}
+39 -1
View File
@@ -14,8 +14,14 @@ pub struct UserRow {
pub display_name: String,
pub country: String,
pub timezone: Option<String>,
pub password_hash: String,
/// None for accounts that sign in only through SSO or whose bootstrap access was removed.
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
pub email: Option<String>,
pub instance_admin: bool,
pub bootstrap: bool,
/// JSON array of group names that map to workspace roles through the instance configuration.
pub groups: String,
}
#[derive(Debug, Clone)]
@@ -47,6 +53,9 @@ pub struct WorkspaceRow {
pub document: String,
pub revision: i64,
pub created_at: DateTime<Utc>,
/// Stable identifier from the instance configuration; None for legacy workspaces.
pub key: Option<String>,
pub disabled_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
@@ -112,3 +121,32 @@ pub struct ProviderObjectRow {
pub content: String,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "oidc_flows")]
pub struct OidcFlowRow {
#[model(primary_key)]
pub state_hash: String,
pub nonce: String,
pub verifier: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "instance_state")]
pub struct InstanceStateRow {
#[model(primary_key)]
pub key: String,
pub value: String,
}
#[derive(Debug, Clone)]
#[model(table_name = "instance_audit")]
pub struct InstanceAuditRow {
#[model(primary_key)]
pub id: Auto<i64>,
pub actor_id: Option<String>,
pub action: String,
pub details: String,
pub created_at: DateTime<Utc>,
}
+348
View File
@@ -0,0 +1,348 @@
//! OpenID Connect authorization-code flow with PKCE for a confidential client. Tokens are
//! verified here (signature, issuer, audience, expiry, nonce); the rest of the application only
//! sees an already trusted profile.
use crate::{
access::{GroupSource, OidcConfig, claim_groups},
error::{Error, Result},
identity::{SsoProfile, normalize_email},
models::OidcFlowRow,
service,
store::Store,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use cot::db::{Model, query};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, jwk::JwkSet};
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
sync::OnceLock,
time::{Duration as StdDuration, Instant},
};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Settings {
pub config: OidcConfig,
pub client_secret: String,
pub redirect_uri: String,
}
static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub fn install(settings: Settings) {
let _ = SETTINGS.set(settings);
}
pub fn settings() -> Option<&'static Settings> {
SETTINGS.get()
}
/// How a sign-in attempt failed, in terms the browser can show.
#[derive(Debug)]
pub enum Failure {
/// The identity provider could not be reached or answered unusably.
Unavailable(String),
/// The callback or a token did not verify.
Invalid(String),
}
impl From<Failure> for Error {
fn from(failure: Failure) -> Self {
match failure {
Failure::Unavailable(m) | Failure::Invalid(m) => Error::Provider(m),
}
}
}
type Outcome<T> = std::result::Result<T, Failure>;
#[derive(Debug, Clone, Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
#[serde(default)]
userinfo_endpoint: Option<String>,
}
#[derive(Default)]
struct Cache {
discovery: Option<(Instant, Discovery)>,
jwks: Option<(Instant, JwkSet)>,
}
static CACHE: tokio::sync::Mutex<Cache> = tokio::sync::Mutex::const_new(Cache {
discovery: None,
jwks: None,
});
const DISCOVERY_TTL: StdDuration = StdDuration::from_secs(600);
const JWKS_MIN_REFRESH: StdDuration = StdDuration::from_secs(15);
fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(StdDuration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("HTTP client")
})
}
fn unavailable(what: &str, error: impl std::fmt::Display) -> Failure {
Failure::Unavailable(format!("{what}: {error}"))
}
async fn get_json<T: serde::de::DeserializeOwned>(
what: &str,
request: reqwest::RequestBuilder,
) -> Outcome<T> {
let response = request.send().await.map_err(|e| unavailable(what, e))?;
if !response.status().is_success() {
return Err(Failure::Unavailable(format!(
"{what}: HTTP {}",
response.status()
)));
}
response.json().await.map_err(|e| unavailable(what, e))
}
async fn discovery(settings: &Settings) -> Outcome<Discovery> {
let mut cache = CACHE.lock().await;
if let Some((at, found)) = &cache.discovery
&& at.elapsed() < DISCOVERY_TTL
{
return Ok(found.clone());
}
let issuer = settings.config.issuer.trim_end_matches('/');
let found: Discovery = get_json(
"OpenID discovery",
client().get(format!("{issuer}/.well-known/openid-configuration")),
)
.await?;
if found.issuer.trim_end_matches('/') != issuer {
return Err(Failure::Invalid(
"discovery document issuer differs from oidc.issuer".into(),
));
}
cache.discovery = Some((Instant::now(), found.clone()));
Ok(found)
}
async fn key_for(settings: &Settings, kid: Option<&str>) -> Outcome<DecodingKey> {
let found = discovery(settings).await?;
let mut cache = CACHE.lock().await;
for attempt in 0..2 {
let fresh = cache
.jwks
.as_ref()
.is_some_and(|(at, _)| at.elapsed() < JWKS_MIN_REFRESH);
let hit = cache.jwks.as_ref().and_then(|(_, set)| {
set.keys.iter().find(|k| match kid {
Some(kid) => k.common.key_id.as_deref() == Some(kid),
None => set.keys.len() == 1,
})
});
if let Some(jwk) = hit {
return DecodingKey::from_jwk(jwk).map_err(|e| Failure::Invalid(e.to_string()));
}
if attempt == 1 || fresh {
break;
}
let set: JwkSet = get_json("JWKS", client().get(&found.jwks_uri)).await?;
cache.jwks = Some((Instant::now(), set));
}
Err(Failure::Invalid("no signing key matches the token".into()))
}
fn random_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
/// Stores the one-time flow state and returns the provider URL plus the state that the
/// browser must present back through a cookie.
pub async fn begin(db: &Store) -> Result<(String, String)> {
let settings = settings().ok_or(Error::NotFound)?;
let found = discovery(settings).await?;
let (state, nonce, verifier) = (random_token(), random_token(), random_token());
db.db
.raw("DELETE FROM meguri__oidc_flows WHERE expires_at<now()")
.await?;
OidcFlowRow {
state_hash: service::token_hash(&state),
nonce: nonce.clone(),
verifier: verifier.clone(),
expires_at: Utc::now() + Duration::minutes(10),
}
.insert(&db.db)
.await?;
let mut url = reqwest::Url::parse(&found.authorization_endpoint)
.map_err(|e| Failure::Invalid(e.to_string()))?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &settings.config.client_id)
.append_pair("redirect_uri", &settings.redirect_uri)
.append_pair("scope", &settings.config.scopes.join(" "))
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair(
"code_challenge",
&URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())),
)
.append_pair("code_challenge_method", "S256");
Ok((url.to_string(), state))
}
#[derive(Deserialize)]
struct Tokens {
id_token: String,
access_token: Option<String>,
}
/// Verifies the callback and returns the profile of the authenticated person. The browser's
/// state cookie must equal the returned state, and the flow row can be used exactly once.
pub async fn complete(
db: &Store,
cookie_state: Option<&str>,
state: &str,
code: &str,
) -> std::result::Result<SsoProfile, Failure> {
let settings = settings().ok_or_else(|| Failure::Invalid("OIDC is not configured".into()))?;
if cookie_state != Some(state) || state.len() != 64 {
return Err(Failure::Invalid(
"state cookie is missing or differs from the callback state".into(),
));
}
let hash = service::token_hash(state);
let flow = query!(OidcFlowRow,$state_hash==hash.clone())
.get(&db.db)
.await
.map_err(|e| Failure::Unavailable(e.to_string()))?
.filter(|f| f.expires_at > Utc::now())
.ok_or_else(|| Failure::Invalid("unknown or expired login attempt".into()))?;
let used = db
.db
.raw_with(
"DELETE FROM meguri__oidc_flows WHERE state_hash=$1",
&[&hash],
)
.await
.map_err(|e| Failure::Unavailable(e.to_string()))?;
if used.rows_affected().0 != 1 {
return Err(Failure::Invalid("login attempt was already used".into()));
}
let found = discovery(settings).await?;
let response = client()
.post(&found.token_endpoint)
.basic_auth(&settings.config.client_id, Some(&settings.client_secret))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", settings.redirect_uri.as_str()),
("code_verifier", flow.verifier.as_str()),
])
.send()
.await
.map_err(|e| unavailable("token exchange", e))?;
if !response.status().is_success() {
return Err(Failure::Invalid(format!(
"token exchange: HTTP {}",
response.status()
)));
}
let tokens: Tokens = response
.json()
.await
.map_err(|e| unavailable("token response", e))?;
let claims = verify_id_token(settings, &tokens.id_token, &flow.nonce).await?;
profile(settings, &found, &claims, tokens.access_token.as_deref()).await
}
/// Signature (RS256 only), issuer, audience, expiry and nonce checks.
pub async fn verify_id_token(settings: &Settings, token: &str, nonce: &str) -> Outcome<Value> {
let header = jsonwebtoken::decode_header(token).map_err(|e| Failure::Invalid(e.to_string()))?;
let key = key_for(settings, header.kid.as_deref()).await?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_audience(&[&settings.config.client_id]);
validation.set_issuer(&[settings.config.issuer.trim_end_matches('/')]);
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
let claims = match jsonwebtoken::decode::<Value>(token, &key, &validation) {
Ok(data) => data.claims,
Err(first) => {
// Providers differ on a trailing slash in `iss`; accept only that single variation.
let mut alternate = validation.clone();
let issuer = settings.config.issuer.trim_end_matches('/');
alternate.set_issuer(&[format!("{issuer}/")]);
jsonwebtoken::decode::<Value>(token, &key, &alternate)
.map_err(|_| Failure::Invalid(first.to_string()))?
.claims
}
};
if claims.get("nonce").and_then(Value::as_str) != Some(nonce) {
return Err(Failure::Invalid("nonce mismatch".into()));
}
if let Some(audiences) = claims.get("aud").and_then(Value::as_array)
&& audiences.len() > 1
&& claims.get("azp").and_then(Value::as_str) != Some(settings.config.client_id.as_str())
{
return Err(Failure::Invalid("authorized party mismatch".into()));
}
Ok(claims)
}
async fn userinfo(found: &Discovery, access_token: Option<&str>) -> Option<Value> {
let (endpoint, token) = (found.userinfo_endpoint.as_ref()?, access_token?);
get_json::<Value>("userinfo", client().get(endpoint).bearer_auth(token))
.await
.map_err(|failure| tracing::warn!(?failure, "userinfo request failed"))
.ok()
}
async fn profile(
settings: &Settings,
found: &Discovery,
claims: &Value,
access_token: Option<&str>,
) -> Outcome<SsoProfile> {
let path = &settings.config.groups_claim;
let mut info: Option<Value> = None;
let mut groups = None;
for source in &settings.config.groups_sources {
match source {
GroupSource::IdToken => groups = claim_groups(claims, path),
GroupSource::Userinfo => {
if info.is_none() {
info = Some(userinfo(found, access_token).await.unwrap_or(Value::Null));
}
groups = info.as_ref().and_then(|i| claim_groups(i, path));
}
}
if groups.is_some() {
break;
}
}
if groups.is_none() {
tracing::warn!(
claim = %path,
"the identity provider returned no groups claim; check the group membership mapper of the client"
);
}
let text = |name: &str| -> Option<String> {
claims
.get(name)
.or_else(|| info.as_ref().and_then(|i| i.get(name)))
.and_then(Value::as_str)
.map(str::to_owned)
};
let subject = claims
.get("sub")
.and_then(Value::as_str)
.ok_or_else(|| Failure::Invalid("token has no subject".into()))?
.to_owned();
Ok(SsoProfile {
issuer: settings.config.issuer.trim_end_matches('/').to_owned(),
subject,
email: text("email").and_then(|e| normalize_email(&e)),
username_hint: text("preferred_username"),
display_name: text("name"),
groups: groups.unwrap_or_default(),
})
}
+67 -107
View File
@@ -38,57 +38,13 @@ pub async fn hash_password(password: String) -> Result<String> {
.map_err(|e| Error::Internal(e.to_string()))?
}
pub async fn register(
/// Checks a password against the stored hash. Unknown users and accounts without a
/// password still pay for one key derivation so response time does not reveal either.
pub async fn verify_password(
db: &Store,
username: String,
display_name: String,
username: &str,
password: String,
) -> Result<User> {
let username = username.trim().to_ascii_lowercase();
ensure(
(3..=64).contains(&username.len())
&& username
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)),
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
)?;
validate_name(&display_name)?;
let hash = hash_password(password).await?;
let user = User {
language: None,
id: Uuid::new_v4(),
username,
display_name,
country: "CY".into(),
timezone: None,
};
let result = UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
timezone: None,
password_hash: hash,
created_at: Utc::now(),
}
.insert(&db.db)
.await;
if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) {
return Err(Error::Conflict(
crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(),
));
}
result?;
Ok(user)
}
pub async fn login(
db: &Store,
username: String,
password: String,
) -> Result<(User, String, String)> {
) -> Result<Option<UserRow>> {
ensure(
password.len() <= 256 && username.len() <= 64,
crate::i18n::text("err_invalid_credentials_e41784"),
@@ -96,13 +52,17 @@ pub async fn login(
let username = username.trim().to_ascii_lowercase();
let row = query!(UserRow,$username==username).get(&db.db).await?;
let row = row.filter(|r| r.demo_deleted_at.is_none());
// Perform the expensive KDF for an unknown username too.
let fallback = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$DKjSa6guFpKhutOgiqYmtDXrWFFJEWEbCJ3JAlCSF3c";
let hash: String = row
.as_ref()
.map(|r| r.password_hash.clone())
.and_then(|r| r.password_hash.clone())
.unwrap_or(fallback.into());
let valid = tokio::task::spawn_blocking(move || {
let valid = password_matches(hash, password).await?;
Ok(row.filter(|r| valid && r.password_hash.is_some()))
}
pub async fn password_matches(hash: String, password: String) -> Result<bool> {
tokio::task::spawn_blocking(move || {
PasswordHash::new(&hash).is_ok_and(|h| {
Argon2::default()
.verify_password(password.as_bytes(), &h)
@@ -110,41 +70,38 @@ pub async fn login(
})
})
.await
.map_err(|e| Error::Internal(e.to_string()))?;
if !valid || row.is_none() {
return Err(Error::Unauthorized);
}
let row = row.unwrap();
let user = store::user(row)?;
.map_err(|e| Error::Internal(e.to_string()))
}
pub async fn create_session(db: &Store, user: Uuid) -> Result<(String, String)> {
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let csrf = Uuid::new_v4().to_string();
SessionRow {
token_hash: token_hash(&token),
user_id: user.id.to_string(),
user_id: user.to_string(),
csrf: csrf.clone(),
expires_at: Utc::now() + Duration::hours(12),
}
.insert(&db.db)
.await?;
Ok((user, token, csrf))
Ok((token, csrf))
}
pub async fn create_workspace(db: &Store, actor: Uuid, name: String) -> Result<WorkspaceRecord> {
validate_name(&name)?;
let id = Uuid::new_v4();
let doc = WorkspaceDocument::new(name);
db.db
.raw_with(
CREATE_WORKSPACE,
&[
&id.to_string(),
&serde_json::to_string(&doc)?,
&actor.to_string(),
&serde_json::to_string(&Permission::all())?,
],
)
.await?;
db.workspace(id).await
/// Password sign-in. Ordinary accounts need `password_login`; bootstrap accounts always may.
/// Every refusal is the same error as a wrong password.
pub async fn login(
db: &Store,
password_login: bool,
username: String,
password: String,
) -> Result<(User, String, String)> {
let row = verify_password(db, &username, password)
.await?
.filter(|r| password_login || r.bootstrap)
.ok_or(Error::Unauthorized)?;
let user = store::user(row)?;
let (token, csrf) = create_session(db, user.id).await?;
Ok((user, token, csrf))
}
pub async fn update_document(
@@ -375,36 +332,36 @@ pub async fn update_integration(
Ok(record)
}
pub async fn set_member(
/// Replaces the permission set of the employee role. Administrators always hold everything;
/// changing the role takes effect for every current and future employee in this workspace.
pub async fn set_member_permissions(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
username: String,
permissions: Vec<Permission>,
mut permissions: Vec<Permission>,
) -> Result<WorkspaceRecord> {
permissions.sort();
permissions.dedup();
ensure(
permissions.contains(&Permission::View),
crate::i18n::text("err_every_member_must_have_calendar_access_ca104e"),
)?;
ensure(
permissions.iter().all(|p| p.grantable_to_employees()),
crate::i18n::text("err_employees_cannot_manage_members_or_integrations_3c21aa"),
)?;
let mut record = db
.prepare(workspace, revision, actor, Permission::ManageMembers)
.await?;
let username = username.trim().to_ascii_lowercase();
let user = query!(UserRow,$username==username)
.get(&db.db)
.await?
.ok_or(Error::Validation(
crate::i18n::text("err_ask_this_person_to_create_an_account_first_897eb2").into(),
))?;
let user = store::uuid(&user.id)?;
record.document.member_permissions = permissions.clone();
db.commit(
&mut record,
actor,
Permission::ManageMembers,
"member.permissions_changed",
json!({"user_id":user,"permissions":permissions}),
Some((user, permissions)),
"member.role_permissions_changed",
json!({"permissions":permissions}),
Some(permissions),
)
.await?;
Ok(record)
@@ -921,6 +878,21 @@ pub async fn seed_demo(db: &Store) -> Result<()> {
pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> {
let actor = user.to_string();
let mut workspaces = vec![];
if db.is_instance_admin(user).await? {
// Instance administrators see every enabled workspace without belonging to it.
for row in WorkspaceRow::objects().all(&db.db).await? {
let key = row.key.clone();
match store::record(row) {
Ok(record) => workspaces.push(
json!({"id":record.id,"key":key,"name":record.document.name,"is_owner":true}),
),
Err(Error::NotFound) => {}
Err(e) => return Err(e),
}
}
workspaces.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
return Ok(json!({"workspaces":workspaces}));
}
for membership in query!(WorkspaceMemberRow,$user_id==actor)
.all(&db.db)
.await?
@@ -930,31 +902,19 @@ pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> {
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
if row.demo_deleted_at.is_some() {
let key = row.key.clone();
let Ok(record) = store::record(row) else {
continue;
}
let record = store::record(row)?;
};
if db.require(record.id, user, Permission::View).await.is_ok() {
workspaces.push(
json!({"id":record.id,"name":record.document.name,"is_owner":membership.is_owner}),
json!({"id":record.id,"key":key,"name":record.document.name,"is_owner":membership.is_owner}),
);
}
}
workspaces.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({"workspaces":workspaces}))
}
const CREATE_WORKSPACE: &str = r#"
WITH workspace AS (
INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now()) RETURNING *
), member AS (
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT id||':'||$3,id,$3,$4,true FROM workspace
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),$3 FROM workspace
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$3,'workspace.created','{}',now() FROM workspace
"#;
const SAVE_PROFILE: &str = r#"
WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KEY UPDATE), changed AS (
UPDATE meguri__users SET display_name=$2,country=$3,timezone=$4,password_hash=$5,language=$6 WHERE id IN(SELECT id FROM previous) RETURNING *
+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
"#;
+210 -56
View File
@@ -48,9 +48,15 @@ impl App for MeguriApp {
];
for path in [
"/api/session",
"/api/register",
"/api/login",
"/api/logout",
"/api/oidc/login",
"/api/oidc/callback",
"/api/admin/overview",
"/api/admin/users",
"/api/admin/users/{user}/groups",
"/api/admin/users/{user}/password",
"/api/admin/workspaces/{workspace}",
"/api/account",
"/api/workspaces",
"/api/workspaces/{workspace}",
@@ -64,7 +70,7 @@ impl App for MeguriApp {
"/api/workspaces/{workspace}/absences",
"/api/workspaces/{workspace}/generation-preview",
"/api/workspaces/{workspace}/generation",
"/api/workspaces/{workspace}/members",
"/api/workspaces/{workspace}/member-permissions",
"/api/workspaces/{workspace}/requests",
"/api/workspaces/{workspace}/request-preview",
"/api/workspaces/{workspace}/requests/{request}",
@@ -271,19 +277,28 @@ async fn dispatch(request: Request) -> Result<Response> {
crate::i18n::text("err_send_a_json_request_34adc9"),
)?;
}
let config = crate::access::current()?;
let identity = authenticate(&db, &request).await;
if path == "/api/session" && method == Method::GET {
let auth =
json!({"oidc":config.oidc.is_some(),"password":config.password_form_available()});
return match identity {
Ok(i) => response(
json!({"user":i.user,"csrf":i.csrf,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
json!({"user":i.user,"csrf":i.csrf,"auth":auth,"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
),
Err(Error::Unauthorized) => response(
json!({"user":null,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
json!({"user":null,"auth":auth,"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
),
Err(e) => Err(e),
};
}
let anonymous = path == "/api/login" || path == "/api/register";
if path == "/api/oidc/login" && method == Method::GET {
return oidc_login(&db).await;
}
if path == "/api/oidc/callback" && method == Method::GET {
return oidc_callback(&db, config, &request, &query_string).await;
}
let anonymous = path == "/api/login";
if mutation && !anonymous {
let i = identity.as_ref().map_err(|_| Error::Unauthorized)?;
if request
@@ -314,37 +329,19 @@ async fn dispatch(request: Request) -> Result<Response> {
struct Credentials {
username: String,
password: String,
#[serde(default)]
display_name: String,
}
let credentials: Credentials = decode(body)?;
check_login_rate(&credentials.username)?;
if path == "/api/register" {
if !enabled("MEGURI_ALLOW_SIGNUP") {
return Err(Error::Forbidden);
}
service::register(
&db,
credentials.username.clone(),
credentials.display_name,
credentials.password.clone(),
)
.await?;
}
let (user, token, csrf) =
service::login(&db, credentials.username, credentials.password).await?;
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
let (user, token, csrf) = service::login(
&db,
config.password_login_enabled(),
credentials.username.clone(),
credentials.password,
)
.await?;
forget_login_attempts(&credentials.username);
return response(json!({"user":user,"csrf":csrf}))?
.with_header(
"Set-Cookie",
format!(
"meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}"
),
)
.with_header("Set-Cookie", session_cookie(&token))
.into_response()
.map_err(|e| Error::Internal(e.to_string()));
}
@@ -373,14 +370,18 @@ async fn dispatch(request: Request) -> Result<Response> {
.await?
.ok_or(Error::NotFound)?;
if let Some(password) = body["password"].as_str().filter(|p| !p.is_empty()) {
ensure(
user.password_hash.is_some(),
crate::i18n::text("err_this_account_signs_in_with_sso_and_has_no_password_6d1f3b"),
)?;
let current = body["current_password"]
.as_str()
.unwrap_or_default()
.to_owned();
let (_, temporary, _) = service::login(&db, user.username.clone(), current).await?;
let hash = service::token_hash(&temporary);
query!(SessionRow,$token_hash==hash).delete(&db.db).await?;
user.password_hash = service::hash_password(password.to_owned()).await?;
service::verify_password(&db, &user.username, current)
.await?
.ok_or(Error::Unauthorized)?;
user.password_hash = Some(service::hash_password(password.to_owned()).await?);
}
user.display_name = name.into();
if let Some(language) = body.get("language") {
@@ -418,10 +419,8 @@ async fn dispatch(request: Request) -> Result<Response> {
return response(json!({"user":store::user(user)?}));
}
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
if parts == ["api", "workspaces"] && method == Method::POST {
return response(serde_json::to_value(
service::create_workspace(&db, actor, text(&body, "name")?).await?,
)?);
if parts.len() >= 3 && parts[1] == "admin" {
return admin_route(&db, config, actor, &parts, &method, body).await;
}
if parts.len() < 3 || parts[1] != "workspaces" {
return Err(Error::NotFound);
@@ -475,8 +474,16 @@ async fn dispatch(request: Request) -> Result<Response> {
} else {
json!({"total":0,"issues":[]})
};
let access = if grants.contains(&Permission::ManageMembers) {
db.workspace_key(workspace)
.await?
.and_then(|key| config.workspaces.iter().find(|w| w.key == key))
.map(|w| json!({"admin_groups":w.admin_groups,"member_groups":w.member_groups}))
} else {
None
};
return response(
json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}),
json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"instance_admin":identity.user.instance_admin,"access":access,"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}),
);
}
let action = parts.get(3).copied().unwrap_or_default();
@@ -622,14 +629,13 @@ async fn dispatch(request: Request) -> Result<Response> {
.await?,
)?);
}
if action == "members" && method == Method::PUT {
if action == "member-permissions" && method == Method::PUT {
return response(serde_json::to_value(
service::set_member(
service::set_member_permissions(
&db,
actor,
workspace,
revision(&body)?,
text(&body, "username")?,
decode(body["permissions"].clone())?,
)
.await?,
@@ -717,6 +723,149 @@ async fn dispatch(request: Request) -> Result<Response> {
Err(Error::NotFound)
}
fn session_cookie(token: &str) -> String {
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
format!("meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}")
}
fn flow_cookie(value: &str, max_age: u32) -> String {
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
format!(
"meguri_oidc={value}; Path=/api/oidc; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
)
}
fn redirect(location: &str) -> Result<Response> {
cot::response::Redirect::new(location.to_owned())
.into_response()
.map_err(|e| Error::Internal(e.to_string()))
}
fn login_failure(code: &str, detail: impl std::fmt::Display) -> Result<Response> {
let reference = Uuid::new_v4().simple().to_string()[..8].to_owned();
tracing::warn!(%reference, %code, %detail, "SSO sign-in was not completed");
redirect(&format!("/?login_error={code}&ref={reference}"))?
.with_header("Set-Cookie", flow_cookie("", 0))
.into_response()
.map_err(|e| Error::Internal(e.to_string()))
}
async fn oidc_login(db: &Store) -> Result<Response> {
if crate::oidc::settings().is_none() {
return Err(Error::NotFound);
}
match crate::oidc::begin(db).await {
Ok((url, state)) => redirect(&url)?
.with_header("Set-Cookie", flow_cookie(&state, 600))
.into_response()
.map_err(|e| Error::Internal(e.to_string())),
Err(error) => login_failure("unavailable", error),
}
}
async fn oidc_callback(
db: &Store,
config: &crate::access::Config,
request: &Request,
query_string: &str,
) -> Result<Response> {
use crate::{identity, oidc};
if oidc::settings().is_none() {
return Err(Error::NotFound);
}
let url = reqwest::Url::parse(&format!("http://localhost/?{query_string}"))
.map_err(|_| Error::NotFound)?;
let params: std::collections::BTreeMap<_, _> = url.query_pairs().into_owned().collect();
if let Some(error) = params.get("error") {
return login_failure("failed", format!("provider returned {error}"));
}
let (Some(state), Some(code)) = (params.get("state"), params.get("code")) else {
return login_failure("failed", "callback without state or code");
};
let cookie_state = request
.headers()
.get("cookie")
.and_then(|h| h.to_str().ok())
.unwrap_or_default()
.split(';')
.map(str::trim)
.find_map(|c| c.strip_prefix("meguri_oidc="))
.map(str::to_owned);
let profile = match oidc::complete(db, cookie_state.as_deref(), state, code).await {
Ok(profile) => profile,
Err(oidc::Failure::Unavailable(detail)) => return login_failure("unavailable", detail),
Err(oidc::Failure::Invalid(detail)) => return login_failure("failed", detail),
};
match identity::sso_sign_in(db, config, profile.clone()).await {
Ok(identity::SsoOutcome::SignedIn { token, .. }) => redirect("/")?
.with_header("Set-Cookie", session_cookie(&token))
.with_header("Set-Cookie", flow_cookie("", 0))
.into_response()
.map_err(|e| Error::Internal(e.to_string())),
Ok(identity::SsoOutcome::NoAccess) => login_failure(
"no_access",
format!(
"no workspace or instance role matches the groups {:?} of subject {}",
profile.groups, profile.subject
),
),
Err(error) => login_failure("failed", error),
}
}
async fn admin_route(
db: &Store,
config: &crate::access::Config,
actor: Uuid,
parts: &[&str],
method: &Method,
body: Value,
) -> Result<Response> {
use crate::admin;
match (parts, method) {
(["api", "admin", "overview"], &Method::GET) => {
response(admin::overview(db, config, actor).await?)
}
(["api", "admin", "users"], &Method::POST) => {
response(admin::create_user(db, config, actor, decode(body)?).await?)
}
(["api", "admin", "users", user, "groups"], &Method::PUT) => response(
admin::set_user_groups(
db,
config,
actor,
parse_id(user)?,
decode(body["groups"].clone())?,
)
.await?,
),
(["api", "admin", "users", user, "password"], &Method::POST) => {
admin::reset_password(db, actor, parse_id(user)?, text(&body, "password")?).await?;
response(json!({"ok":true}))
}
(["api", "admin", "workspaces", workspace], &Method::DELETE) => {
admin::delete_workspace(
db,
config,
actor,
parse_id(workspace)?,
&text(&body, "confirm")?,
)
.await?;
response(json!({"ok":true}))
}
_ => Err(Error::NotFound),
}
}
fn replacement_boundary(
doc: &WorkspaceDocument,
schedule: Uuid,
@@ -761,19 +910,24 @@ fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
})
}
type Attempts = std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, u16)>>;
fn attempts() -> &'static Attempts {
static ATTEMPTS: std::sync::OnceLock<Attempts> = std::sync::OnceLock::new();
ATTEMPTS.get_or_init(Default::default)
}
/// A successful sign-in clears the counter; only failed guessing accumulates.
fn forget_login_attempts(username: &str) {
if let Ok(mut attempts) = attempts().lock() {
attempts.remove(&username.trim().to_ascii_lowercase());
}
}
fn check_login_rate(username: &str) -> Result<()> {
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
static ATTEMPTS: OnceLock<Mutex<HashMap<String, (Instant, u16)>>> = OnceLock::new();
let mut attempts = ATTEMPTS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|_| {
Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into())
})?;
use std::time::{Duration, Instant};
let mut attempts = attempts().lock().map_err(|_| {
Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into())
})?;
attempts.retain(|_, (at, _)| at.elapsed() < Duration::from_secs(300));
if attempts.len() > 2000 {
return Err(Error::Validation(