mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 00:03:19 +00:00
* 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>
145 lines
5.1 KiB
Rust
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(())
|
|
}
|