Files
windmill/backend/windmill-common/src/users.rs
fb82748296 fix: make on_behalf_of control permissions for scripts and flows (#10438)
* fix: make on_behalf_of control permissions for scripts and flows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: inherit the recorded on-behalf-of identity when a preserving deploy omits it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep an omitted permissioned_as from re-versioning an unchanged script

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive the on-behalf-of principal from the email and reject mismatched pairs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop workspace deploys from carrying a source-workspace principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the onBehalfOfPermissionedAs param doc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that workspace deploys never carry a source-workspace principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the omitted-principal contract and refresh generated prompts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep external-superadmin principals on email-only redeploys

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope the recorded principal to its workspace and prefer real accounts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry the recorded principal correctly through drafts and set-permissioned-as

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sweep draft identity pairs on email change and offboarding

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: leave group identities alone when sweeping a user's email

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: treat only g/ without an email as a group, and match the offboard preview

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop the group guard from skipping rows with no recorded principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state the group guard once instead of restating it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: make the permissioned_as the only stored on-behalf-of identity

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: skip resolving the on-behalf-of address for sync clients that discard it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address the local review of the identity refactor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the on-behalf-of identity coherently across clones, offboarding and no-op deploys

* test: pin that a fork keeps only the on-behalf-of identities that resolve in it

* fix: decide a principal prefix-first everywhere and canonicalize bare addresses

* fix: prefix a slash-containing address so a reader cannot take it for a group

* fix: read an address as a username before the group- convention

* fix: rewrite the canonical principal when an account's address moves

* fix: keep the address form of a principal to accounts without a usr row

* fix: reject an identity a job row cannot carry and read it uncached at dispatch

* fix: count characters against the job identity width and cap the backfill

* refactor: name the script/flow principal on_behalf_of, as apps do

* docs: state the caller-must-authorize contract on the identity resolvers

* fix: keep writing on_behalf_of_email until every worker reads the principal

* fix: err high on the compatibility version and document the last resolver

* fix: keep the compatibility address current through identity mutations

* fix: carry the compatibility address with the principal on every copy path

* chore: re-pin the EE ref to the companion branch merged with EE main

* fix: key the dbt retry lookup on the stored principal

* fix: keep a mixed-version address recoverable through a fork

* fix: read a round-tripped address uncached so a redeploy is not rejected

* fix: refuse an email change that would make a principal unenqueueable

* chore: update ee-repo-ref to ac3d7d015296f041ae44ab6bc4953485f44d36e4

This commit updates the EE repository reference after PR #704 was merged in windmill-ee-private.

Previous ee-repo-ref: 219b0b03905a1a0028054b3a4985724e77d09036

New ee-repo-ref: ac3d7d015296f041ae44ab6bc4953485f44d36e4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-01 20:37:21 +02:00

432 lines
17 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();
}
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-";
/// 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;
/// 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.
///
/// The address is derived at dispatch and feeds the instance-superadmin check and
/// `email_to_igroup`, so serving a stale one would run jobs with the wrong authorization
/// for up to the cache TTL.
pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
EMAIL_CACHE.remove(&(workspace_id.to_string(), username.to_string()));
}
/// 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.
///
/// 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
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`] without the address cache. Nothing evicts that cache
/// across processes, so for a minute after an email change it still serves the old address —
/// fine where the address only labels something on screen, wrong where it decides whether a
/// write is accepted or is copied onto a job row that outlives the window.
///
/// Reads through the non-RLS pool and authorizes nothing, like the cached one: callers must
/// already be authorized for `workspace_id`.
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);
}
}