Files
windmill/backend/windmill-common/src/users.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

540 lines
24 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.
*/
lazy_static::lazy_static! {
/// Matches the non-quoted, non-IP-literal subset of the `proper_email` CHECK constraint
/// carried by `usr` and `workspace_invite`, so anything accepted here is accepted by those
/// tables too.
pub static ref VALID_EMAIL: regex::Regex = regex::Regex::new(
r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$"
).unwrap();
}
/// Width of the `email` columns of `usr`, `workspace_invite` and `email_to_igroup`.
pub const EMAIL_COLUMN_MAX_LEN: usize = 255;
/// The regex of the `proper_email` CHECK constraint on `usr` and `workspace_invite`
/// (`20220620210708_regex_fix`), verbatim, for [`usr_accepts_email`]. Evaluated by the
/// database and never by a Rust engine: `~*` folds case under the database collation, so a
/// fixed mirror accepts addresses the constraint rejects, or rejects ones it holds, on some
/// locale. `windmill-common/tests/usr_accepts_email.rs` pins the text to the constraint.
pub const PROPER_EMAIL_PATTERN: &str = r#"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$"#;
/// Whether `usr` (and `workspace_invite`) will store `email`: the `proper_email` regex as the
/// database evaluates it, plus the column width. Unlike [`VALID_EMAIL`] this admits every
/// address those tables already hold, which matters wherever an existing member is judged.
pub async fn usr_accepts_email<'c, E>(db: E, email: &str) -> crate::error::Result<bool>
where
E: sqlx::Executor<'c, Database = sqlx::Postgres>,
{
if email.contains('\0') || email.chars().count() > EMAIL_COLUMN_MAX_LEN {
return Ok(false);
}
let accepted: bool = sqlx::query_scalar("SELECT $1::text ~* $2::text")
.bind(email)
.bind(PROPER_EMAIL_PATTERN)
.fetch_one(db)
.await?;
Ok(accepted)
}
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";
/// `password.login_type` of an account created for someone before they have signed in:
/// no credential of its own (password login and reset require `'password'`), reachable
/// only through a superadmin-minted login link until either `set_password` turns it into
/// a password account or the first OAuth login proving the same address adopts it and
/// rewrites `login_type` to the provider.
pub const PENDING_OAUTH_LOGIN_TYPE: &str = "pending_oauth";
/// 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-";
/// Widest principal a job row can carry (`v2_job.permissioned_as`), which is narrower than the
/// columns runnables and triggers store one in.
pub const PERMISSIONED_AS_MAX_LEN: usize = 55;
/// Whether any account exists for `email`: a `password` row (deactivated ones
/// included, since the sign-in path filters `disabled = false` and a re-enabled
/// account must not read as absent) or a `usr` row in any workspace (what a service
/// account has instead of a password). A guest is someone with none: the single rule
/// that keeps an account holder from ever holding a cheaper guest identity.
///
/// The address is lowercased before the lookup: accounts are stored lowercased, so a
/// mixed-case address would otherwise miss an existing account and be let through. The
/// comparison stays a plain equality (not `lower(email)`), so it uses the email index.
pub async fn has_any_account<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
executor: E,
email: &str,
) -> crate::error::Result<bool> {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
)
.bind(email.to_lowercase())
.fetch_one(executor)
.await
.map_err(|e| crate::error::Error::internal_err(format!("checking account for {email}: {e:#}")))
}
/// An email-shaped username is its own principal, which is how a superadmin acting without a
/// `usr` row is named (`usr.username` is constrained to `[\w-]+`, so a member never is). It is
/// decided before the group convention — an address is never a group's username — and one
/// containing `/` is prefixed, since readers split on the first `/` and would otherwise take
/// `g/alice@example.com` for a group.
pub fn username_to_permissioned_as(user: &str) -> String {
if user.contains('@') {
return if user.contains('/') {
format!("{}{}", PERMISSIONED_AS_USER_PREFIX, user)
} else {
user.to_string()
};
}
if let Some(group) = user.strip_prefix(USERNAME_GROUP_PREFIX) {
return format!("{}{}", PERMISSIONED_AS_GROUP_PREFIX, group);
}
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())
}
/// Whether a permissioned_as names something that exists in this workspace.
///
/// An existence probe over the non-RLS pool, no authorization of its own: callers must
/// already be authorized for `w_id`. Same contract for [`permissioned_as_from_email`] and
/// [`resolve_username_to_email`].
///
/// Accepts the three forms `username_to_permissioned_as` can produce: `u/{username}`,
/// `g/{group}`, and a bare address when the username is itself email-shaped. Anything else
/// is malformed — `fetch_authed_from_permissioned_as` would take its least-privileged
/// branch rather than fail, so callers reject instead of storing it.
///
/// Decided prefix first, like `fetch_authed_from_permissioned_as` and
/// [`get_email_from_permissioned_as`]: a group name may contain `@` while a username never
/// carries a `u/`/`g/` prefix, so a bare address is only ever what is left over. Validating
/// by a different rule than dispatch reads by is what lets a stored identity run as someone
/// else.
pub async fn permissioned_as_exists(
workspace_id: &str,
permissioned_as: &str,
db: &sqlx::Pool<sqlx::Postgres>,
) -> crate::error::Result<bool> {
if let Some(username) = permissioned_as.strip_prefix(PERMISSIONED_AS_USER_PREFIX) {
return Ok(resolve_username_to_email(workspace_id, username, db)
.await?
.is_some());
}
if let Some(group) = permissioned_as.strip_prefix(PERMISSIONED_AS_GROUP_PREFIX) {
return Ok(sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)",
workspace_id,
group
)
.fetch_one(db)
.await?
.unwrap_or(false));
}
// The bare form names an account acting without a `usr` row, not anyone who merely holds
// that address: `u/{username}` is the canonical principal for a member, and the bare branch
// of `fetch_authed_from_permissioned_as` grants neither their groups nor their folders.
// Anything unprefixed that is not an address at all is malformed.
if !permissioned_as.contains('@') {
return Ok(false);
}
Ok(sqlx::query_scalar!(
"SELECT EXISTS(
SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2
UNION ALL
SELECT 1 FROM password WHERE email = $2 AND super_admin
)",
workspace_id,
permissioned_as
)
.fetch_one(db)
.await?
.unwrap_or(false))
}
/// Drop a cached address so a transactional email change is visible immediately.
///
/// Not the thing that keeps authorization correct — `fetch_authed_from_permissioned_as`
/// re-resolves the address before granting anything. This keeps the cache from serving an
/// address that is merely wrong for the TTL, on reads and on what is shown.
pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
EMAIL_CACHE.remove(&(workspace_id.to_string(), username.to_string()));
}
/// Drop this name's entry in every workspace, for the changes that know the name but not the
/// workspace: a superadmin resolves through `password`, whose row names no workspace of its own.
pub fn invalidate_email_cache_for_username(username: &str) {
EMAIL_CACHE.retain(|(_workspace_id, cached_username), _| cached_username != username);
}
/// Inverse of [`get_email_from_permissioned_as`]: the principal an on-behalf-of email
/// names in this workspace, for callers that supply the email alone.
///
/// Mirrors [`resolve_username_to_email`] branch for branch, including its fallback to
/// `password` — a superadmin acting in a workspace they are not a member of has no `usr`
/// row, and dropping them here would hand their runnables back to the deployer while
/// keeping their superadmin email.
///
/// `None` when the email names nobody at all — an address outside the workspace that is
/// not a superadmin's, or a group that no longer exists. Callers then leave the identity
/// unrecorded rather than storing a principal that cannot authenticate.
///
/// Known, accepted consequence of a real account winning the synthetic `group-*@windmill.dev`
/// namespace: a group identity sent as its address alone, as a "keep target identity" workspace
/// deploy sends it for scripts, flows and apps, comes back as the account holding that address
/// when one exists, not as `g/*`. Such an account takes an admin to exist: a superadmin or an
/// admin-configured identity provider to create it (the public OAuth providers only assert a
/// `@windmill.dev` address to that domain's owner) and an admin of the target workspace to admit
/// it, so no member can steer a group's runnables to themselves this way.
///
/// Reads through the non-RLS pool and authorizes nothing: callers must already be authorized
/// for `workspace_id`.
pub async fn permissioned_as_from_email(
workspace_id: &str,
email: &str,
db: &sqlx::Pool<sqlx::Postgres>,
) -> crate::error::Result<Option<String>> {
let mut conn = db.acquire().await?;
// A real account always wins: the synthetic group namespace below is not reserved,
// so a user may legitimately hold a `group-*@windmill.dev` address, and resolving it
// to the like-named group would hand their runnables that group's folder access.
if let Some(username) = sqlx::query_scalar!(
"SELECT COALESCE(
(SELECT username FROM usr WHERE workspace_id = $1 AND email = $2),
(SELECT COALESCE(username, email) FROM password WHERE email = $2 AND super_admin = true)
)",
workspace_id,
email
)
.fetch_optional(&mut *conn)
.await?
.flatten()
{
return Ok(Some(username_to_permissioned_as(&username)));
}
// Groups have no address of their own; `get_email_from_permissioned_as` mints this
// synthetic one, so it is the only form that can be read back as a group.
let Some(group) = email
.strip_prefix(USERNAME_GROUP_PREFIX)
.and_then(|rest| rest.strip_suffix("@windmill.dev"))
else {
return Ok(None);
};
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)",
workspace_id,
group
)
.fetch_one(&mut *conn)
.await?
.unwrap_or(false);
Ok(exists.then(|| format!("{}{}", PERMISSIONED_AS_GROUP_PREFIX, group)))
}
/// 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
///
/// `notify_user_email_change` evicts the key on every process for each change that can move it,
/// at that process's next notify-event poll (`LISTEN_NEW_EVENTS_INTERVAL_SEC`, 10s by default),
/// so a hit can still be the old address for up to one poll. The TTL caps it if an eviction is
/// ever missed.
///
/// Which of the two to use is a question of how long a wrong answer lives, not of whether it is
/// stored — both of these get stored and read back. A config row (an app policy, a schedule, a
/// runnable) is the authority for every run that follows it, so a stale address there is
/// permanent and invisible: those use [`get_email_from_permissioned_as_uncached`]. Job dispatch
/// also stores its answer, and the worker reads it back to build that run's authed, but it
/// governs one job and dies with it, so it stays here.
///
/// The job's own authorization does not trust the address as given:
/// `fetch_authed_from_permissioned_as` re-resolves it from the principal's live binding, and that
/// corrected address is what the job row and its token carry. Route an address into an `Authed`,
/// a job row or a token without going through that function, and this cache stops being safe to
/// read at dispatch.
///
/// What reads the dispatch address before that re-resolution (the quota and superadmin-exemption
/// checks at the top of `push_inner`, a flow step's tag check) or when the principal has no live
/// binding can act on the old address for up to one poll after a username reuse, an email change
/// or a superadmin change. That window is accepted as the cost of keeping dispatch off the
/// database; a consumer that cannot tolerate it must re-resolve first.
///
/// Reads through the non-RLS pool and authorizes nothing — callers must already be authorized
/// for `workspace_id`.
pub async fn get_email_from_permissioned_as<'c>(
permissioned_as: &str,
workspace_id: &str,
db: impl sqlx::PgExecutor<'c>,
) -> crate::error::Result<String> {
get_email_from_permissioned_as_inner(permissioned_as, workspace_id, db, true).await
}
/// [`get_email_from_permissioned_as`] for a value about to be **persisted**.
///
/// The eviction is delivered by the `notify_event` poller, not synchronously, so for a few
/// seconds after a change a replica can still serve the old address. In a config row that is
/// permanent: the row outlives the eviction, every later run trusts it, and nothing re-derives
/// it, so a principal and an address that name different accounts stay that way.
///
/// Use this for three cases, all of which end in a stored pair:
/// - writing the address into a row;
/// - the lookup that validates a pair before it is stored;
/// - **reads whose result the client sends back** — a script or a workspace export hands over a
/// principal and address together, and a redeploy validates that pair against a fresh
/// resolution, so a stale one comes back as a rejected deploy rather than a stale display.
///
/// See [`get_email_from_permissioned_as`] for the dispatch case that deliberately does not.
pub async fn get_email_from_permissioned_as_uncached<'c>(
permissioned_as: &str,
workspace_id: &str,
db: impl sqlx::PgExecutor<'c>,
) -> crate::error::Result<String> {
get_email_from_permissioned_as_inner(permissioned_as, workspace_id, db, false).await
}
async fn get_email_from_permissioned_as_inner<'c>(
permissioned_as: &str,
workspace_id: &str,
db: impl sqlx::PgExecutor<'c>,
use_cache: bool,
) -> crate::error::Result<String> {
if let Some(username) = permissioned_as.strip_prefix(PERMISSIONED_AS_USER_PREFIX) {
if use_cache {
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("g/alice@example.com"),
"u/g/alice@example.com"
);
// The `group-` convention is for usernames, and an address is never one.
assert_eq!(
username_to_permissioned_as("group-ops/alice@example.com"),
"u/group-ops/alice@example.com"
);
assert_eq!(
username_to_permissioned_as("group-ops@example.com"),
"group-ops@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_valid_email() {
for email in [
"alice@example.com",
"alice.bob+tag@sub.example.co.uk",
"a_b-c!#$%&'*+/=?^_`{|}~@example.com",
] {
assert!(VALID_EMAIL.is_match(email), "{email} should be valid");
}
for email in [
"alice",
"alice@example",
"alice@@example.com",
"alice @example.com",
"alice@example.com\nbob@example.com",
"",
] {
assert!(!VALID_EMAIL.is_match(email), "{email} should be invalid");
}
}
#[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);
}
}