Files
windmill/backend/windmill-common/src/usernames.rs
T
Ruben Fiszelandwindmill-internal-app[bot] e3e638f7f5 fix: skip instance group members that are not email addresses (#11128)
* 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>
2026-09-15 09:45:10 +02:00

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"));
}
}