Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
+535
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -624,6 +624,7 @@ fn recipient(
|
||||
display_name: email.clone(),
|
||||
email: Some(email),
|
||||
country: Some("CY".into()),
|
||||
aliases: vec![],
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user