mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 00:02:55 +00:00
* fix: skip instance group members that are not email addresses * fix: keep provisioned members whose address only proper_email accepts * fix: judge instance group members by a mirror of the usr email constraint * fix: fold ascii only in the proper_email mirror, like the constraint * fix: let the database judge which instance group members usr will store * fix: cut a derived username to the column width so a long local part can be provisioned * chore: move the ee pin to the scim member doc fix * chore: update ee-repo-ref to 0780955effb657807d14f0eb503cba1d49cee007 This commit updates the EE repository reference after PR #801 was merged in windmill-ee-private. Previous ee-repo-ref: ee6452d489563204a98df883703f78d5e74cdd69 New ee-repo-ref: 0780955effb657807d14f0eb503cba1d49cee007 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
198 lines
7.2 KiB
Rust
198 lines
7.2 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use regex::Regex;
|
|
use sqlx::{Postgres, Transaction};
|
|
|
|
use crate::error::{self, Error};
|
|
use crate::DB;
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref INVALID_USERNAME_CHARS: Regex = Regex::new(r"[^A-Za-z0-9_]").unwrap();
|
|
pub static ref VALID_USERNAME: Regex = Regex::new(r#"^[a-zA-Z][a-zA-Z_0-9]*$"#).unwrap();
|
|
}
|
|
|
|
/// Width of the `username` columns of `usr`, `password` and `pending_user`.
|
|
pub const USERNAME_MAX_LEN: usize = 50;
|
|
|
|
/// `base` with the collision suffix of `attempt` appended (none for the first attempt), cut
|
|
/// to `USERNAME_MAX_LEN`. A local part longer than the column is a valid email, and an
|
|
/// insert that fails on the derived username rolls back everything around it.
|
|
pub fn fit_username(base: &str, attempt: u32) -> String {
|
|
let suffix = if attempt > 1 {
|
|
attempt.to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
let mut username: String = base.chars().take(USERNAME_MAX_LEN - suffix.len()).collect();
|
|
username.push_str(&suffix);
|
|
username
|
|
}
|
|
|
|
pub async fn generate_instance_wide_unique_username<'c>(
|
|
tx: &mut Transaction<'c, Postgres>,
|
|
email: &str,
|
|
) -> error::Result<String> {
|
|
let mut username = email.split('@').next().unwrap().to_string();
|
|
|
|
username = INVALID_USERNAME_CHARS
|
|
.replace_all(&mut username, "")
|
|
.to_string();
|
|
|
|
if username.is_empty() {
|
|
username = "user".to_string()
|
|
}
|
|
|
|
let base_username = username.clone();
|
|
let mut username_conflict = true;
|
|
let mut i = 1;
|
|
while username_conflict {
|
|
if i > 1000 {
|
|
return Err(Error::internal_err(format!(
|
|
"too many username conflicts for {}",
|
|
email
|
|
)));
|
|
}
|
|
username = fit_username(&base_username, i);
|
|
username_conflict = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 and email != $2 UNION SELECT 1 FROM password WHERE username = $1 UNION SELECT 1 FROM pending_user WHERE username = $1)",
|
|
&username,
|
|
&email
|
|
)
|
|
.fetch_one(&mut **tx)
|
|
.await?
|
|
.unwrap_or(false);
|
|
i += 1;
|
|
}
|
|
|
|
Ok(username)
|
|
}
|
|
|
|
pub async fn generate_instance_username_for_all_users(db: &DB) -> error::Result<()> {
|
|
let mut tx = db.begin().await?;
|
|
let users = sqlx::query!(r#"SELECT p.email as "email!", u.username as "username?" FROM password p LEFT JOIN usr u ON p.email = u.email WHERE p.username IS NULL AND (SELECT COUNT(DISTINCT username) FROM usr WHERE email = p.email) <= 1"#)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for user in users {
|
|
let username = if let Some(username) = user.username {
|
|
let username_conflict = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 and email != $2 UNION SELECT 1 FROM password WHERE username = $1 UNION SELECT 1 FROM pending_user WHERE username = $1)",
|
|
&username,
|
|
&user.email
|
|
).fetch_one(&mut *tx).await?.unwrap_or(false);
|
|
|
|
if !username_conflict {
|
|
username
|
|
} else {
|
|
generate_instance_wide_unique_username(&mut tx, &user.email).await?
|
|
}
|
|
} else {
|
|
generate_instance_wide_unique_username(&mut tx, &user.email).await?
|
|
};
|
|
|
|
sqlx::query!(
|
|
"UPDATE password SET username = $1 WHERE email = $2",
|
|
&username,
|
|
&user.email
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the username to use for a user (typically a superadmin) accessing a
|
|
/// workspace they are not a member of. When instance-wide username derivation is
|
|
/// enabled (`automate_username_creation`, the default on almost all instances),
|
|
/// `password.username` is populated, so we return that derived username instead
|
|
/// of leaking the raw email address downstream (as job/script author, audit
|
|
/// actor, etc.). Falls back to the email when no derived username exists (the few
|
|
/// instances that keep `automate_username_creation` disabled).
|
|
///
|
|
/// The hot callers (token auth, `fetch_api_authed`) are already behind their own
|
|
/// 120s auth caches, so this deliberately does not add another email->username
|
|
/// cache (which would only help the cold JWT-mint / job-perms paths while risking
|
|
/// cross-DB contamination in the shared-process integration tests).
|
|
///
|
|
/// A DB error is propagated rather than swallowed into the email fallback: falling
|
|
/// back to the email on a transient failure would reintroduce the very email leak
|
|
/// this path exists to prevent, so callers fail closed instead.
|
|
pub async fn get_instance_username_or_fallback_to_email<'e, E>(
|
|
db: E,
|
|
email: &str,
|
|
) -> error::Result<String>
|
|
where
|
|
E: sqlx::PgExecutor<'e>,
|
|
{
|
|
let derived = sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", email)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.flatten();
|
|
// No derived username (automate_username_creation disabled) → fall back to the
|
|
// email. This is the only legitimate fallback; a query error propagates above.
|
|
Ok(derived.unwrap_or_else(|| email.to_string()))
|
|
}
|
|
|
|
pub async fn get_instance_username_or_create_pending<'c>(
|
|
tx: &mut Transaction<'c, Postgres>,
|
|
email: &str,
|
|
) -> error::Result<String> {
|
|
let user = sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", email)
|
|
.fetch_optional(&mut **tx)
|
|
.await?;
|
|
|
|
if let Some(opt_username) = user {
|
|
if let Some(username) = opt_username {
|
|
Ok(username)
|
|
} else {
|
|
Err(Error::BadRequest(format!("No instance-wide username found for {email}. The user has different usernames for different workspaces. Ask the instance administrator to solve the conflict in the instance settings.")))
|
|
}
|
|
} else {
|
|
let pending_username =
|
|
sqlx::query_scalar!("SELECT username FROM pending_user WHERE email = $1", email)
|
|
.fetch_optional(&mut **tx)
|
|
.await?;
|
|
|
|
if let Some(username) = pending_username {
|
|
Ok(username)
|
|
} else {
|
|
let username = generate_instance_wide_unique_username(&mut *tx, email).await?;
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO pending_user (email, username) VALUES ($1, $2)",
|
|
email,
|
|
username
|
|
)
|
|
.execute(&mut **tx)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("creating pending user: {e:#}")))?;
|
|
|
|
Ok(username)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_fit_username_keeps_the_column_width() {
|
|
assert_eq!(fit_username("alice", 1), "alice");
|
|
assert_eq!(fit_username("alice", 2), "alice2");
|
|
let base = "a".repeat(60);
|
|
assert_eq!(fit_username(&base, 1), "a".repeat(USERNAME_MAX_LEN));
|
|
let with_suffix = fit_username(&base, 1000);
|
|
assert_eq!(with_suffix.len(), USERNAME_MAX_LEN);
|
|
assert!(with_suffix.ends_with("1000"));
|
|
}
|
|
}
|