Files
windmill/backend/windmill-common/src/folders.rs
T
Ruben FiszelandClaude Opus 5 c90d1d95c2 refactor: make the app policy's principal the authority for its identity (#10440)
* refactor: make the app policy's principal the authority for its identity

* fix: align the app backfill with the sibling migration and audit the uncached address

* chore: refresh the sqlx cache after rebasing onto the merged base

* fix: resolve the app execution address uncached, it decides the job's authorization

* chore: cache the EE queries at the ref this branch pins

* chore: cache the EE queries at the ref this branch pins

* fix: derive the app draft's on-behalf-of address on read

* chore: cache the query the draft derivation test added

* fix: derive the app identity on the draft-table and version reads too

* docs: state the draft resolver's authorization contract

* fix: resolve a draft's principal against workspace membership only

* chore: cache the membership lookup the draft resolver added

* fix: drop an unresolvable draft's address instead of leaving it stale

* perf: evict the address cache on change so app dispatch can read it

* fix: evict on superadmin role changes, not only address changes

* refactor: make the app policy's address optional instead of derived on read

* fix: follow an external superadmin's rename into the apps that name them

* docs: state the removal gate once, and correctly

* refactor: drop the app-policy version constant that gated nothing

* docs: drop the last reference to the removed constant

* perf: read the address cache everywhere now that eviction reaches every replica

* fix: keep persisted addresses off the cache the poller evicts asynchronously

* docs: state where the cached address is accepted and where it is not

* docs: keep the cache rule in one place and drop the stale premise

* docs: sort the two lookups by how long a wrong answer lives

* fix: resolve the schedule address uncached where it is written to the row

* docs: name the release this actually ships in

* perf: evict a superadmin's key per workspace instead of the whole cache

* fix: evict every alias a superadmin principal can be spelled as

* docs: describe the trigger as it is

* docs: cover the round-tripped read in the cache rule

* docs: record why a stale dispatch address cannot escalate

* fix: validate a dispatch address against the principal's live binding

* fix: carry the validated address through to the job row and token

* fix: record the validated address on the job row, not the one handed in

* test: run the substep tag check as the non-superadmin it means to test

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: rewrite a stored app address that disagrees with its principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the accepted staleness window of the cached dispatch address

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: record the validated address on the job's audit row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the accepted rename race of pre-transaction identity resolution

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: separate the app's stored address from the derived one in the resolver doc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: describe the job identity fast path the push comments skipped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: backfill a legacy group-prefixed username as the group it names

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: resolve a schedule edit's identity before opening its transaction

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: never resolve a disabled member to a same-named superadmin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: state what the email-change notify buys, and rewrap two comment lines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: keep a group's runnables when offboarding a legacy group-prefixed member

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: read the app author from the stored address, as execution does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the rename race's full consequence as a known, accepted limitation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the keep-target group address case as a known, accepted limitation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:10:15 +02:00

145 lines
5.1 KiB
Rust

use globset::Glob;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderDefaultPermissionedAsRule {
pub path_glob: String,
pub permissioned_as: String,
}
/// Returns the first matching default `permissioned_as` for `path` in its folder, if any.
///
/// `path` is the full item path (e.g. `f/prod/jobs/run_a`). The glob is matched against
/// the path *relative* to the folder root (`jobs/run_a` in the example) so rules can
/// never match outside their own folder. Returns `None` if the path is not under a
/// folder, if the folder has no rules, or if no rule matches.
///
/// If a rule matches but its `permissioned_as` resolves to a user or group that no
/// longer exists, returns `Error::BadRequest` so the caller surfaces a clean error
/// rather than silently creating an item owned by nobody.
pub async fn resolve_folder_default_permissioned_as(
db: &Pool<Postgres>,
w_id: &str,
path: &str,
) -> Result<Option<String>> {
let Some(rest) = path.strip_prefix("f/") else {
return Ok(None);
};
let Some((folder_name, relative_path)) = rest.split_once('/') else {
return Ok(None);
};
if relative_path.is_empty() {
return Ok(None);
}
let rules_value = sqlx::query_scalar!(
"SELECT default_permissioned_as FROM folder WHERE workspace_id = $1 AND name = $2",
w_id,
folder_name
)
.fetch_optional(db)
.await?;
let Some(rules_value) = rules_value else {
return Ok(None);
};
let rules: Vec<FolderDefaultPermissionedAsRule> =
serde_json::from_value(rules_value).unwrap_or_default();
for rule in rules {
let Ok(glob) = Glob::new(&rule.path_glob) else {
continue;
};
if !glob.compile_matcher().is_match(relative_path) {
continue;
}
ensure_permissioned_as_exists(db, w_id, folder_name, &rule).await?;
return Ok(Some(rule.permissioned_as));
}
Ok(None)
}
/// On-behalf-of variant of [`resolve_folder_default_permissioned_as`], for flows and
/// scripts which store the identity as an `(email, permissioned_as)` pair.
///
/// Reads folder rules through the non-RLS pool and authorizes nothing: callers must already
/// be authorized for `w_id`.
pub async fn resolve_folder_default_on_behalf_of(
db: &Pool<Postgres>,
w_id: &str,
path: &str,
) -> Result<Option<(String, String)>> {
let Some(permissioned_as) = resolve_folder_default_permissioned_as(db, w_id, path).await?
else {
return Ok(None);
};
let email =
crate::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db).await?;
Ok(Some((email, permissioned_as)))
}
async fn ensure_permissioned_as_exists(
db: &Pool<Postgres>,
w_id: &str,
folder_name: &str,
rule: &FolderDefaultPermissionedAsRule,
) -> Result<()> {
if let Some(username) = rule.permissioned_as.strip_prefix("u/") {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)",
w_id,
username
)
.fetch_one(db)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::BadRequest(format!(
"Folder '{folder_name}' default_permissioned_as rule '{}' resolves to user '{}' which does not exist in this workspace. Fix the folder rule and try again.",
rule.path_glob, rule.permissioned_as
)));
}
} else if let Some(group) = rule.permissioned_as.strip_prefix("g/") {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)",
w_id,
group
)
.fetch_one(db)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::BadRequest(format!(
"Folder '{folder_name}' default_permissioned_as rule '{}' resolves to group '{}' which does not exist in this workspace. Fix the folder rule and try again.",
rule.path_glob, rule.permissioned_as
)));
}
} else if rule.permissioned_as.contains('@') {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)",
w_id,
&rule.permissioned_as
)
.fetch_one(db)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::BadRequest(format!(
"Folder '{folder_name}' default_permissioned_as rule '{}' resolves to email '{}' which does not exist in this workspace. Fix the folder rule and try again.",
rule.path_glob, rule.permissioned_as
)));
}
} else {
return Err(Error::BadRequest(format!(
"Folder '{folder_name}' default_permissioned_as rule '{}' has an unrecognised permissioned_as format '{}'. Expected u/<username>, g/<groupname>, or an email.",
rule.path_glob, rule.permissioned_as
)));
}
Ok(())
}