Files
windmill/backend/windmill-common/src/users.rs
T
Ruben FiszelandClaude Opus 4.8 76a9523009 feat: use derived username instead of email for non-member superadmins (#9857)
* 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>
2026-07-01 14:54:07 +00:00

229 lines
8.3 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.
*/
pub const SUPERADMIN_SECRET_EMAIL: &str = "superadmin_secret@windmill.dev";
pub const SUPERADMIN_NOTIFICATION_EMAIL: &str = "superadmin_notification@windmill.dev";
pub const SUPERADMIN_SYNC_EMAIL: &str = "superadmin_sync@windmill.dev";
pub const COOKIE_NAME: &str = "token";
/// Prefix for user-based permissioned_as values: "u/"
pub const PERMISSIONED_AS_USER_PREFIX: &str = "u/";
/// Prefix for group-based permissioned_as values: "g/"
pub const PERMISSIONED_AS_GROUP_PREFIX: &str = "g/";
/// Prefix for group-based usernames: "group-"
pub const USERNAME_GROUP_PREFIX: &str = "group-";
pub fn username_to_permissioned_as(user: &str) -> String {
if user.contains('@') {
user.to_string()
} else if let Some(group) = user.strip_prefix(USERNAME_GROUP_PREFIX) {
format!("{}{}", PERMISSIONED_AS_GROUP_PREFIX, group)
} else {
format!("{}{}", PERMISSIONED_AS_USER_PREFIX, user)
}
}
/// Borrowed key for zero-allocation cache lookups via `Equivalent<(String, String)>`.
#[derive(Hash)]
struct EmailCacheKey<'a>(&'a str, &'a str);
impl equivalent::Equivalent<(String, String)> for EmailCacheKey<'_> {
fn equivalent(&self, key: &(String, String)) -> bool {
self.0 == key.0 && self.1 == key.1
}
}
lazy_static::lazy_static! {
static ref EMAIL_CACHE: quick_cache::sync::Cache<(String, String), (String, std::time::Instant)> =
quick_cache::sync::Cache::new(500);
}
const EMAIL_CACHE_TTL_SECS: u64 = 60;
/// Resolve a workspace-scoped username to its email.
///
/// Members are found in `usr`. A superadmin acting in a workspace they are *not*
/// a member of has no `usr` row; they carry either their instance-derived
/// username (`password.username`, when `automate_username_creation` is enabled)
/// or their email (when it is disabled), so fall back to `password` on both,
/// gated on `super_admin` since only superadmins can act without membership.
/// Returns `None` when the username resolves to nobody.
pub async fn resolve_username_to_email<'c>(
workspace_id: &str,
username: &str,
db: impl sqlx::PgExecutor<'c>,
) -> crate::error::Result<Option<String>> {
Ok(sqlx::query_scalar!(
"SELECT COALESCE(
(SELECT email FROM usr WHERE workspace_id = $1 AND username = $2),
(SELECT email FROM password WHERE (username = $2 OR email = $2) AND super_admin = true)
)",
workspace_id,
username
)
.fetch_optional(db)
.await?
.flatten())
}
/// Get email from permissioned_as string.
/// - "u/{username}" → resolve via [`resolve_username_to_email`] (cached)
/// - "g/{group}" → "group-{group}@windmill.dev"
/// - raw email → return as-is
pub async fn get_email_from_permissioned_as<'c>(
permissioned_as: &str,
workspace_id: &str,
db: impl sqlx::PgExecutor<'c>,
) -> crate::error::Result<String> {
if let Some(username) = permissioned_as.strip_prefix(PERMISSIONED_AS_USER_PREFIX) {
let lookup = EmailCacheKey(workspace_id, username);
if let Some((email, cached_at)) = EMAIL_CACHE.get(&lookup) {
if cached_at.elapsed().as_secs() < EMAIL_CACHE_TTL_SECS {
return Ok(email);
}
}
let email = resolve_username_to_email(workspace_id, username, db)
.await?
.unwrap_or_else(|| format!("{}@unknown.windmill.dev", username));
let key = (workspace_id.to_string(), username.to_string());
EMAIL_CACHE.insert(key, (email.clone(), std::time::Instant::now()));
Ok(email)
} else if let Some(group) = permissioned_as.strip_prefix(PERMISSIONED_AS_GROUP_PREFIX) {
Ok(format!("{}{}@windmill.dev", USERNAME_GROUP_PREFIX, group))
} else {
// raw email
Ok(permissioned_as.to_string())
}
}
/// Compute the highest-precedence workspace role for a user across all their instance groups.
///
/// Precedence: admin (3) > developer (2) > operator (1).
/// Returns `(best_group_name, is_admin, is_operator)`.
pub fn compute_highest_workspace_role(
user_igroups: &[String],
ws_configured_groups: &[String],
ws_roles: &std::collections::HashMap<String, String>,
) -> (String, bool, bool) {
let mut best_group = String::new();
let mut best_precedence = 0u8;
for group in user_igroups {
if !ws_configured_groups.contains(group) {
continue;
}
let default_role = "developer".to_string();
let role = ws_roles.get(group).unwrap_or(&default_role);
let precedence = match role.as_str() {
"admin" => 3u8,
"operator" => 1,
_ => 2,
};
if precedence > best_precedence {
best_precedence = precedence;
best_group = group.clone();
}
}
let default_role = "developer".to_string();
let best_role_str = ws_roles.get(&best_group).unwrap_or(&default_role);
let (is_admin, is_operator) = match best_role_str.as_str() {
"admin" => (true, false),
"operator" => (false, true),
_ => (false, false),
};
(best_group, is_admin, is_operator)
}
pub fn truncate_token(token: &str) -> String {
if token.len() > 10 {
let mut s = token[..10].to_owned();
s.push_str("*****");
s
} else {
token.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_username_to_permissioned_as() {
assert_eq!(username_to_permissioned_as("alice"), "u/alice");
assert_eq!(
username_to_permissioned_as("alice@example.com"),
"alice@example.com"
);
assert_eq!(username_to_permissioned_as("group-all"), "g/all");
assert_eq!(username_to_permissioned_as("group-my-team"), "g/my-team");
}
#[test]
fn test_compute_highest_workspace_role_admin_wins() {
let user_groups = vec!["ops".to_string(), "admins".to_string()];
let ws_groups = vec!["ops".to_string(), "admins".to_string()];
let mut roles = std::collections::HashMap::new();
roles.insert("ops".to_string(), "operator".to_string());
roles.insert("admins".to_string(), "admin".to_string());
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
assert_eq!(group, "admins");
assert!(is_admin);
assert!(!is_operator);
}
#[test]
fn test_compute_highest_workspace_role_developer_over_operator() {
let user_groups = vec!["devs".to_string(), "ops".to_string()];
let ws_groups = vec!["devs".to_string(), "ops".to_string()];
let mut roles = std::collections::HashMap::new();
roles.insert("devs".to_string(), "developer".to_string());
roles.insert("ops".to_string(), "operator".to_string());
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
assert_eq!(group, "devs");
assert!(!is_admin);
assert!(!is_operator);
}
#[test]
fn test_compute_highest_workspace_role_skips_unconfigured_groups() {
let user_groups = vec!["admins".to_string(), "other".to_string()];
let ws_groups = vec!["ops".to_string()]; // admins not configured for this workspace
let mut roles = std::collections::HashMap::new();
roles.insert("admins".to_string(), "admin".to_string());
roles.insert("ops".to_string(), "operator".to_string());
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
// No user groups match ws_configured_groups, so best_group stays empty
assert_eq!(group, "");
assert!(!is_admin);
assert!(!is_operator);
}
#[test]
fn test_compute_highest_workspace_role_defaults_to_developer() {
let user_groups = vec!["team".to_string()];
let ws_groups = vec!["team".to_string()];
let roles = std::collections::HashMap::new(); // no role configured → developer
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
assert_eq!(group, "team");
assert!(!is_admin);
assert!(!is_operator);
}
}