mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 16:03:43 +00:00
* feat: use derived username instead of email for non-member superadmins Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review - drop redundant username cache, guard whoami membership by email Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: use explicit non_member boolean instead of role string for superadmin banner Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: resolve email from password table for non-member superadmin permissioned_as Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: resolve non-member superadmin drafts via shared username->email resolver Adds resolve_username_to_email (usr, then super_admin password fallback for both derived-username and email modes) and uses it in get_email_from_permissioned_as and the drafts get/list endpoints, so a non-member superadmin's drafts resolve and no email leaks into the drafts payload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: superadmin-not-in-workspace schedule uses derived username as permissioned_as Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: resolve non-member superadmin identity in draft owner-circles, username_to_email, and home filter Applies the password-fallback username resolution to the script/flow/app/draft owner-circle subqueries and the username_to_email endpoint (was an admins-workspace 'username == email' hack), and switches the home items-list user-folder filter to the non_member flag instead of the now-broken username-contains-@ heuristic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: backfill non-member superadmin favorites from email to derived username Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: propagate DB errors in username resolution instead of leaking email (CI review) Addresses cubic-dev-ai P2: get_instance_username_or_fallback_to_email now returns Result and only falls back to the email for a genuine 'no derived username'; a query error propagates so callers fail closed rather than leaking the raw email as the acting username. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: clarify non-member superadmin popover (username used + admin permissions) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: keep username_to_email endpoint member-only to not disclose non-member superadmin email (CI review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: forbid disabling automate_username_creation once usernames assigned (CI review) Makes the setting effectively one-way once instance-wide usernames exist, so the global-uniqueness invariant that keeps stored u/<username> identities (schedules/triggers/drafts/superadmin ownership) unambiguous can never be dropped back to workspace-local uniqueness. Re-saving false on an already-disabled instance stays a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
6.1 KiB
Rust
167 lines
6.1 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();
|
|
}
|
|
|
|
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
|
|
)));
|
|
}
|
|
if i > 1 {
|
|
username = format!("{}{}", 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)
|
|
}
|
|
}
|
|
}
|