mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 00:03:57 +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>
229 lines
8.3 KiB
Rust
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);
|
|
}
|
|
}
|