mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
* feat: add WM_ROOT_WORKSPACE, the closest dev or prod workspace of a job Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBMJwogo6jJ1P55uvB3YpF * fix: do not cache a failed root-workspace lookup, and sweep on fork create Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBMJwogo6jJ1P55uvB3YpF * fix: shorten the agent-worker root-workspace TTL and pin the sweep wiring Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBMJwogo6jJ1P55uvB3YpF * chore: update ee-repo-ref to a2fa58e5301d3865dd06ad73519e20ba7a5af0f0 This commit updates the EE repository reference after PR #736 was merged in windmill-ee-private. Previous ee-repo-ref: 07a9d26a79a403ae27c48abd508a6699f2c87c49 New ee-repo-ref: a2fa58e5301d3865dd06ad73519e20ba7a5af0f0 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>
812 lines
28 KiB
Rust
812 lines
28 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.
|
|
*/
|
|
|
|
use crate::db::{Authable, UserDB};
|
|
use crate::error::{self, Error};
|
|
use crate::scripts::ScriptHash;
|
|
use crate::secret_backend::{get_secret_value, is_external_stored_value};
|
|
use crate::utils::WarnAfterExt;
|
|
use crate::worker::Connection;
|
|
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
|
|
use chrono::{SecondsFormat, Utc};
|
|
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
|
|
use quick_cache::sync::Cache;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref SECRET_SALT: Option<String> = std::env::var("SECRET_SALT").ok();
|
|
static ref RESERVED_WM_VAR_NAME: regex::Regex = regex::Regex::new(r"^WM_[A-Z_]+$").unwrap();
|
|
}
|
|
|
|
/// Escape a string so it can be safely embedded inside a single-quoted JS
|
|
/// string literal in a generated NativeTS/Bun prologue.
|
|
pub fn escape_js_single_quoted(s: &str) -> String {
|
|
s.replace('\\', "\\\\")
|
|
.replace('\'', "\\'")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
}
|
|
|
|
/// True when `name` is a plain ASCII JS identifier and can therefore sit in a
|
|
/// single-quoted string literal or identifier context of a generated prologue
|
|
/// without smuggling statement terminators, quotes, or comments. Custom
|
|
/// workspace env-var names are attacker-controllable (any workspace admin sets
|
|
/// them), so a non-identifier name must never reach an identifier position in
|
|
/// generated JS.
|
|
pub fn is_valid_js_identifier(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
match chars.next() {
|
|
Some(c) if c == '_' || c == '$' || c.is_ascii_alphabetic() => {}
|
|
_ => return false,
|
|
}
|
|
chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
|
|
}
|
|
|
|
/// Names that cannot be used as a binding — `const {word} = ...` is a
|
|
/// SyntaxError. The generated prologue runs as an ES module (always strict), so
|
|
/// this includes the strict-mode reserved words and the two names that strict
|
|
/// mode additionally forbids as bindings: `eval` and `arguments`.
|
|
fn is_js_reserved_word(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"arguments"
|
|
| "eval"
|
|
| "break"
|
|
| "case"
|
|
| "catch"
|
|
| "class"
|
|
| "const"
|
|
| "continue"
|
|
| "debugger"
|
|
| "default"
|
|
| "delete"
|
|
| "do"
|
|
| "else"
|
|
| "enum"
|
|
| "export"
|
|
| "extends"
|
|
| "false"
|
|
| "finally"
|
|
| "for"
|
|
| "function"
|
|
| "if"
|
|
| "import"
|
|
| "in"
|
|
| "instanceof"
|
|
| "new"
|
|
| "null"
|
|
| "return"
|
|
| "super"
|
|
| "switch"
|
|
| "this"
|
|
| "throw"
|
|
| "true"
|
|
| "try"
|
|
| "typeof"
|
|
| "var"
|
|
| "void"
|
|
| "while"
|
|
| "with"
|
|
| "yield"
|
|
| "let"
|
|
| "static"
|
|
| "await"
|
|
| "implements"
|
|
| "interface"
|
|
| "package"
|
|
| "private"
|
|
| "protected"
|
|
| "public"
|
|
)
|
|
}
|
|
|
|
/// Identifiers the generated prologue already binds; a second `const {name}`
|
|
/// for them would be a redeclaration SyntaxError. Keep in sync with the prologue
|
|
/// head emitted in worker.rs and bun_executor.rs (`build_nativets_env_code`).
|
|
const PROLOGUE_RESERVED_BINDINGS: &[&str] = &["process", "BASE_URL", "BASE_INTERNAL_URL"];
|
|
|
|
/// True when `name` can be emitted as a `const {name}` binding in the NativeTS/
|
|
/// Bun prologue: a valid identifier that is neither a JS reserved word nor a
|
|
/// name the prologue already binds. Names failing this are still exposed via
|
|
/// `process.env['{name}']` (with the name escaped), so nothing is lost — a
|
|
/// `const` for such a name would only ever be a SyntaxError that breaks every
|
|
/// NativeTS run in the workspace.
|
|
pub fn can_bind_as_prologue_const(name: &str) -> bool {
|
|
is_valid_js_identifier(name)
|
|
&& !is_js_reserved_word(name)
|
|
&& !PROLOGUE_RESERVED_BINDINGS.contains(&name)
|
|
}
|
|
|
|
#[derive(Serialize, Clone)]
|
|
|
|
pub struct ContextualVariable {
|
|
pub name: String,
|
|
pub value: String,
|
|
pub description: String,
|
|
pub is_custom: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone)]
|
|
|
|
pub struct ListableVariable {
|
|
pub workspace_id: String,
|
|
pub path: String,
|
|
pub value: Option<String>,
|
|
pub is_secret: bool,
|
|
pub description: String,
|
|
pub extra_perms: serde_json::Value,
|
|
pub account: Option<i32>,
|
|
pub is_oauth: Option<bool>,
|
|
pub is_expired: Option<bool>,
|
|
pub is_refreshed: Option<bool>,
|
|
pub refresh_error: Option<String>,
|
|
pub is_linked: Option<bool>,
|
|
pub expires_at: Option<chrono::DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub labels: Option<Vec<String>>,
|
|
/// Labels inherited from the parent folder, computed at read time.
|
|
#[sqlx(default)]
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub inherited_labels: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub ws_specific: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub edited_at: Option<chrono::DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub edited_by: Option<String>,
|
|
/// True when this row is a per-user draft with no deployed variable
|
|
/// at the same path. Surfaced by `include_draft_only` so the frontend
|
|
/// can render a "Draft" badge and the editor can open from the draft
|
|
/// alone. `None`/omitted on rows fetched from the `variable` table.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[sqlx(default)]
|
|
pub draft_only: Option<bool>,
|
|
/// True when the authed user has a per-user draft at this path —
|
|
/// layered over a deployed variable or a synthesized draft-only row.
|
|
/// Drives the `*` suffix on the variables page.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[sqlx(default)]
|
|
pub is_draft: Option<bool>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct ExportableListableVariable {
|
|
pub workspace_id: String,
|
|
pub path: String,
|
|
pub value: Option<String>,
|
|
pub is_secret: bool,
|
|
pub description: String,
|
|
pub extra_perms: serde_json::Value,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub account: Option<i32>,
|
|
#[serde(skip_serializing_if = "is_none_or_false")]
|
|
pub is_oauth: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires_at: Option<chrono::DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub labels: Option<Vec<String>>,
|
|
}
|
|
|
|
fn is_none_or_false(b: &Option<bool>) -> bool {
|
|
b.is_none() || !b.unwrap()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateVariable {
|
|
pub path: String,
|
|
pub value: String,
|
|
pub is_secret: bool,
|
|
pub description: String,
|
|
pub account: Option<i32>,
|
|
pub is_oauth: Option<bool>,
|
|
pub expires_at: Option<chrono::DateTime<Utc>>,
|
|
#[serde(default)]
|
|
pub labels: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub ws_specific: Option<bool>,
|
|
}
|
|
|
|
pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result<MagicCrypt256> {
|
|
// Check cache first (300-second staleness)
|
|
let cached_key_o = WORKSPACE_CRYPT_CACHE.get(w_id).and_then(|(ts, key)| {
|
|
if ts > chrono::Utc::now().timestamp() - 300 {
|
|
Some(key)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let crypt = if let Some(cached_key) = cached_key_o {
|
|
cached_key
|
|
} else {
|
|
let key = get_workspace_key(w_id, db).await?;
|
|
tracing::info!(
|
|
"crypt for workspace {} with key {}*** expired, refetching",
|
|
w_id,
|
|
&key[..key.len().min(8)]
|
|
);
|
|
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
|
|
format!("{}{}", key, salt)
|
|
} else {
|
|
key
|
|
};
|
|
let ncrypt = magic_crypt::new_magic_crypt!(crypt_key, 256);
|
|
WORKSPACE_CRYPT_CACHE.insert(
|
|
w_id.to_string(),
|
|
(chrono::Utc::now().timestamp(), ncrypt.clone()),
|
|
);
|
|
ncrypt
|
|
};
|
|
|
|
Ok(crypt)
|
|
}
|
|
|
|
pub async fn build_crypt_with_key_suffix(
|
|
db: &DB,
|
|
w_id: &str,
|
|
key_suffix: &str,
|
|
) -> crate::error::Result<MagicCrypt256> {
|
|
let key = get_workspace_key(w_id, db).await?;
|
|
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
|
|
format!("{}{}{}", key, salt, key_suffix)
|
|
} else {
|
|
format!("{}{}", key, key_suffix)
|
|
};
|
|
Ok(magic_crypt::new_magic_crypt!(crypt_key, 256))
|
|
}
|
|
|
|
pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<String> {
|
|
let key = sqlx::query_scalar!(
|
|
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'",
|
|
w_id
|
|
)
|
|
.fetch_one(db)
|
|
.warn_after_seconds(5)
|
|
.await
|
|
.map_err(|e| crate::Error::internal_err(format!("fetching workspace key: {e:#}")))?;
|
|
|
|
Ok(key)
|
|
}
|
|
|
|
/// Generate a stateless approval token from workspace key + job_id.
|
|
/// This token grants access to view approval info and attempt to resume,
|
|
/// but cannot be reversed to obtain the HMAC resume secret.
|
|
pub async fn generate_approval_token(
|
|
w_id: &str,
|
|
job_id: uuid::Uuid,
|
|
db: &DB,
|
|
) -> crate::error::Result<String> {
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
let key = get_workspace_key(w_id, db).await?;
|
|
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
|
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
|
|
mac.update(job_id.as_bytes());
|
|
mac.update(b"approval_token");
|
|
Ok(hex::encode(mac.finalize().into_bytes()))
|
|
}
|
|
|
|
/// Domain separator of the member-audience view token (see [`generate_view_token`]).
|
|
pub const VIEW_TOKEN_DOMAIN: &[u8] = b"view_token";
|
|
|
|
/// Domain separator of the public-audience view token (see [`generate_view_token`]).
|
|
/// Distinct from [`VIEW_TOKEN_DOMAIN`] so that already-shared member links keep granting
|
|
/// only what they were minted for: going public is always an explicit new mint.
|
|
pub const PUBLIC_VIEW_TOKEN_DOMAIN: &[u8] = b"public_view_token";
|
|
|
|
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || domain)`.
|
|
/// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can
|
|
/// never be used as a view token (or vice-versa). Used to build a "share read link" that
|
|
/// grants read access to a job (and its flow subtree) to someone who otherwise lacks ACL
|
|
/// on it: an authenticated workspace member under [`VIEW_TOKEN_DOMAIN`], anyone holding
|
|
/// the link (logged in or not) under [`PUBLIC_VIEW_TOKEN_DOMAIN`]. No expiry/revocation
|
|
/// (stateless), like the approval token.
|
|
pub async fn generate_view_token(
|
|
w_id: &str,
|
|
job_id: uuid::Uuid,
|
|
domain: &[u8],
|
|
db: &DB,
|
|
) -> crate::error::Result<String> {
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
let key = get_workspace_key(w_id, db).await?;
|
|
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
|
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
|
|
mac.update(job_id.as_bytes());
|
|
mac.update(domain);
|
|
Ok(hex::encode(mac.finalize().into_bytes()))
|
|
}
|
|
|
|
pub async fn get_secret_value_as_admin(
|
|
db: &DB,
|
|
w_id: &str,
|
|
path: &str,
|
|
) -> crate::error::Result<String> {
|
|
let variable_o = sqlx::query!(
|
|
"SELECT value, is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2", path, w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let variable = if let Some(variable) = variable_o {
|
|
variable
|
|
} else {
|
|
return Err(crate::Error::NotFound(format!(
|
|
"variable {} not found in workspace {}",
|
|
path, w_id
|
|
)));
|
|
};
|
|
|
|
let r = if variable.is_secret {
|
|
let value = variable.value;
|
|
if !value.is_empty() {
|
|
if is_external_stored_value(&value) {
|
|
get_secret_value(db, w_id, &variable.path, &value).await?
|
|
} else {
|
|
let mc = build_crypt(db, w_id).await?;
|
|
decrypt(&mc, value).map_err(|e| {
|
|
crate::error::Error::internal_err(format!(
|
|
"Error decrypting variable {}: {}",
|
|
variable.path, e
|
|
))
|
|
})?
|
|
}
|
|
} else {
|
|
"".to_string()
|
|
}
|
|
} else {
|
|
variable.value
|
|
};
|
|
|
|
Ok(r)
|
|
}
|
|
|
|
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
|
mc.encrypt_str_to_base64(value)
|
|
}
|
|
|
|
pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
|
|
mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
|
MagicCryptError::DecryptError(_) => error::Error::internal_err(
|
|
"Could not decrypt value. The value may have been encrypted with a different key."
|
|
.to_string(),
|
|
),
|
|
_ => error::Error::internal_err(e.to_string()),
|
|
})
|
|
}
|
|
|
|
pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR";
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref CUSTOM_ENVS_CACHE: Cache<String, (i64, Vec<(String, String)>)> = Cache::new(100);
|
|
pub static ref WORKSPACE_CRYPT_CACHE: Cache<String, (i64, MagicCrypt256)> = Cache::new(1000);
|
|
|
|
}
|
|
|
|
pub async fn get_reserved_variables(
|
|
conn: &Connection,
|
|
w_id: &str,
|
|
token: &str,
|
|
email: &str,
|
|
username: &str,
|
|
job_id: &str,
|
|
permissioned_as: &str,
|
|
path: Option<String>,
|
|
flow_id: Option<String>,
|
|
flow_path: Option<String>,
|
|
schedule_path: Option<String>,
|
|
step_id: Option<String>,
|
|
flow_innermost_root_job: Option<String>,
|
|
root_job_id: Option<String>,
|
|
scheduled_for: Option<chrono::DateTime<Utc>>,
|
|
runnable_id: Option<ScriptHash>,
|
|
end_user_email: Option<String>,
|
|
tested_runnable: Option<String>,
|
|
) -> Vec<ContextualVariable> {
|
|
let state_path = {
|
|
let trigger = if schedule_path.is_some() {
|
|
username.to_string()
|
|
} else {
|
|
"user".to_string()
|
|
};
|
|
|
|
if let Some(flow_path) = flow_path.clone() {
|
|
format!(
|
|
"{flow_path}/{}_{trigger}",
|
|
step_id.clone().unwrap_or_else(|| "nostep".to_string())
|
|
)
|
|
} else if let Some(script_path) = path.clone() {
|
|
let script_path = if script_path.ends_with("/") {
|
|
format!("{script_path}state")
|
|
} else {
|
|
script_path
|
|
};
|
|
format!("{script_path}/{trigger}")
|
|
} else {
|
|
format!("u/{username}/tmp_state")
|
|
}
|
|
};
|
|
|
|
let custom_envs = get_cached_workspace_envs(conn, w_id).await;
|
|
let root_workspace = crate::workspaces::root_workspace_id(conn, w_id).await;
|
|
|
|
let joined_schedule_path = schedule_path
|
|
.clone()
|
|
.unwrap_or("manual".to_string())
|
|
.split("/")
|
|
.collect::<Vec<&str>>()
|
|
.join("_");
|
|
let ts = chrono::Utc::now().timestamp_millis();
|
|
let object_path = if let Some(flow_path) = flow_path.clone() {
|
|
let flow_path = flow_path.split("/").collect::<Vec<&str>>().join("_");
|
|
let step_id = step_id.clone().unwrap_or("".to_string());
|
|
format!("{flow_path}/{joined_schedule_path}/{step_id}/{ts}_{job_id}")
|
|
} else {
|
|
let joined_script_path = path
|
|
.clone()
|
|
.unwrap_or("".to_string())
|
|
.split("/")
|
|
.collect::<Vec<&str>>()
|
|
.join("_");
|
|
format!("{joined_script_path}/{joined_schedule_path}/{ts}_{job_id}")
|
|
};
|
|
|
|
vec![
|
|
ContextualVariable {
|
|
name: "WM_WORKSPACE".to_string(),
|
|
value: w_id.to_string(),
|
|
description: "Workspace id of the current script".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_ROOT_WORKSPACE".to_string(),
|
|
value: root_workspace,
|
|
description: "Workspace id of the nearest dev or prod workspace at or above the current \
|
|
one. Equal to WM_WORKSPACE unless the script runs in a fork, in which case \
|
|
it is the closest dev or prod workspace the fork descends from - not \
|
|
necessarily its direct parent, since a fork of a fork skips past it".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_TOKEN".to_string(),
|
|
value: token.to_string(),
|
|
description: "Token ephemeral to the current script with equal permission to the \
|
|
permission of the run (Usable as a bearer token)"
|
|
.to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_EMAIL".to_string(),
|
|
value: email.to_string(),
|
|
description: "Email of the user that executed the current script".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_USERNAME".to_string(),
|
|
value: username.to_string(),
|
|
description: "Username of the user that executed the current script".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_BASE_URL".to_string(),
|
|
value: (**BASE_URL.load()).clone(),
|
|
description: "base url of this instance".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_JOB_ID".to_string(),
|
|
value: job_id.to_string(),
|
|
description: "Job id of the current script".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: WM_SCHEDULED_FOR.to_string(),
|
|
value: scheduled_for
|
|
.map(|ts| ts.to_rfc3339_opts(SecondsFormat::Secs, true))
|
|
.unwrap_or_else(|| "".to_string()),
|
|
description: "date-time in UTC (e.g: 2014-11-28T12:45:59.324310806Z) of when the job was scheduled".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_JOB_PATH".to_string(),
|
|
value: path.unwrap_or_else(|| "".to_string()),
|
|
description: "Path of the script or flow being run if any".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_FLOW_JOB_ID".to_string(),
|
|
value: flow_id.unwrap_or_else(|| "".to_string()),
|
|
description: "Job id of the encapsulating flow if the job is a flow step".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_ROOT_FLOW_JOB_ID".to_string(),
|
|
value: flow_innermost_root_job.unwrap_or_else(|| "".to_string()),
|
|
description: "Job id of the innermost root flow if the job is a flow step".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_ROOT_JOB_ID".to_string(),
|
|
value: root_job_id.unwrap_or_else(|| "".to_string()),
|
|
description: "Job id of the root job".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_FLOW_PATH".to_string(),
|
|
value: flow_path.unwrap_or_else(|| "".to_string()),
|
|
description: "Path of the encapsulating flow if the job is a flow step".to_string(),
|
|
is_custom: false,
|
|
},
|
|
|
|
ContextualVariable {
|
|
name: "WM_SCHEDULE_PATH".to_string(),
|
|
value: schedule_path.unwrap_or_else(|| "".to_string()),
|
|
description: "Path of the schedule if the job of the step or encapsulating step has \
|
|
been triggered by a schedule"
|
|
.to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_PERMISSIONED_AS".to_string(),
|
|
value: permissioned_as.to_string(),
|
|
description: "Fully Qualified (u/g) owner name of executor of the job".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_STATE_PATH".to_string(),
|
|
value: state_path.clone(),
|
|
description: "State resource path unique to a script and its trigger".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_FLOW_STEP_ID".to_string(),
|
|
value: step_id.unwrap_or_else(|| "".to_string()),
|
|
description: "The node id in a flow (like 'a', 'b', or 'f')".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_OBJECT_PATH".to_string(),
|
|
value: object_path,
|
|
description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_WORKER_GROUP".to_string(),
|
|
value: WORKER_GROUP.clone(),
|
|
description: "Name of the worker group the job is running on".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_RUNNABLE_ID".to_string(),
|
|
value: runnable_id.map(|x| x.to_string()).unwrap_or_else(|| "".to_string()),
|
|
description: "Hash of the script. Useful as cache key for cache that should be runnable specific.".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_END_USER_EMAIL".to_string(),
|
|
value: end_user_email.unwrap_or_else(|| "".to_string()),
|
|
description: "Email of the end user that executed the current script, propagated to flow steps. Only set when the run was triggered from an app (empty otherwise).".to_string(),
|
|
is_custom: false,
|
|
},
|
|
ContextualVariable {
|
|
name: "WM_TESTED_RUNNABLE".to_string(),
|
|
value: tested_runnable.unwrap_or_else(|| "".to_string()),
|
|
description: "Qualified path ({kind}/{path}) of the runnable that triggered this CI test. Empty string unless the job was triggered by a CI test annotation.".to_string(),
|
|
is_custom: false,
|
|
},
|
|
].into_iter().chain(custom_envs.into_iter().map(|(name, value)| ContextualVariable {
|
|
name,
|
|
value,
|
|
description: "Custom workspace environment variable".to_string(),
|
|
is_custom: true,
|
|
})).collect()
|
|
}
|
|
|
|
async fn get_cached_workspace_envs(conn: &Connection, w_id: &str) -> Vec<(String, String)> {
|
|
let cached_envs_o = CUSTOM_ENVS_CACHE.get(w_id).and_then(|(ts, envs)| {
|
|
if ts > chrono::Utc::now().timestamp() - (60 * 15) {
|
|
Some(envs)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let custom_envs = if let Some(cached_envs) = cached_envs_o {
|
|
cached_envs
|
|
} else {
|
|
let raw_envs = match conn {
|
|
Connection::Sql(db) => sqlx::query_as::<_, (String, String)>(
|
|
"SELECT name, value FROM workspace_env WHERE workspace_id = $1",
|
|
)
|
|
.bind(w_id)
|
|
.fetch_all(db)
|
|
.await
|
|
.unwrap_or_default(),
|
|
Connection::Http(client) => client
|
|
.get(&format!("/api/w/{w_id}/agent_workers/custom_envs"))
|
|
.await
|
|
.unwrap_or_default(),
|
|
};
|
|
// Applied here (not in the SQL branch alone) so agent workers going
|
|
// through `Connection::Http` are covered too — drop any name that
|
|
// would shadow a built-in `%%WM_*%%` contextual var.
|
|
let custom_envs: Vec<(String, String)> = raw_envs
|
|
.into_iter()
|
|
.filter(|(name, _)| !RESERVED_WM_VAR_NAME.is_match(name))
|
|
.collect();
|
|
CUSTOM_ENVS_CACHE.insert(
|
|
w_id.to_string(),
|
|
(chrono::Utc::now().timestamp(), custom_envs.clone()),
|
|
);
|
|
custom_envs
|
|
};
|
|
custom_envs
|
|
}
|
|
|
|
pub async fn get_variable_or_self(
|
|
path: String,
|
|
db: &DB,
|
|
w_id: &str,
|
|
) -> crate::error::Result<String> {
|
|
if !path.starts_with("$var:") {
|
|
return Ok(path);
|
|
}
|
|
let path = path.strip_prefix("$var:").unwrap().to_string();
|
|
|
|
let record = sqlx::query!(
|
|
"SELECT value, is_secret
|
|
FROM variable
|
|
WHERE path = $1 AND workspace_id = $2",
|
|
&path,
|
|
&w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
if let Some(record) = record {
|
|
let mut value = record.value;
|
|
if record.is_secret {
|
|
if is_external_stored_value(&value) {
|
|
value = get_secret_value(db, w_id, &path, &value).await?;
|
|
} else {
|
|
let mc = build_crypt(db, w_id).await?;
|
|
value = decrypt(&mc, value).map_err(|e| {
|
|
Error::internal_err(format!("Error decrypting variable {}: {}", path, e))
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(value)
|
|
} else {
|
|
Err(Error::NotFound(format!(
|
|
"Variable not found when resolving `$var:{}`",
|
|
path
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Like `get_variable_or_self`, but uses an RLS-scoped connection to enforce
|
|
/// that the caller has read access to the referenced variable.
|
|
pub async fn get_variable_or_self_as<T: Authable + Sync>(
|
|
path: String,
|
|
db: &DB,
|
|
user_db: &UserDB,
|
|
authed: &T,
|
|
w_id: &str,
|
|
) -> crate::error::Result<String> {
|
|
if !path.starts_with("$var:") {
|
|
return Ok(path);
|
|
}
|
|
let var_path = path.strip_prefix("$var:").unwrap().to_string();
|
|
|
|
// Use an RLS-scoped transaction so the query respects row-level security
|
|
let mut tx = user_db.clone().begin(authed).await?;
|
|
let record = sqlx::query!(
|
|
"SELECT value, is_secret
|
|
FROM variable
|
|
WHERE path = $1 AND workspace_id = $2",
|
|
&var_path,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
if let Some(record) = record {
|
|
let mut value = record.value;
|
|
if record.is_secret {
|
|
if is_external_stored_value(&value) {
|
|
value = get_secret_value(db, w_id, &var_path, &value).await?;
|
|
} else {
|
|
let mc = build_crypt(db, w_id).await?;
|
|
value = decrypt(&mc, value).map_err(|e| {
|
|
Error::internal_err(format!("Error decrypting variable {}: {}", var_path, e))
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(value)
|
|
} else {
|
|
Err(Error::NotFound(format!(
|
|
"Variable not found when resolving `$var:{}`",
|
|
var_path
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{can_bind_as_prologue_const, escape_js_single_quoted, is_valid_js_identifier};
|
|
|
|
#[test]
|
|
fn is_valid_js_identifier_gates_prologue_injection() {
|
|
for ok in ["FOO", "_bar", "$x", "a1_2", "WM_CUSTOM"] {
|
|
assert!(
|
|
is_valid_js_identifier(ok),
|
|
"{ok} should be a valid identifier"
|
|
);
|
|
}
|
|
// Custom workspace env-var names are attacker-controllable; none of these
|
|
// may reach `const {name}` position in the NativeTS/Bun prologue.
|
|
for bad in [
|
|
"",
|
|
"1BAD",
|
|
"has space",
|
|
"dotted.name",
|
|
"kebab-case",
|
|
"_x=1;globalThis.PWNED=1//",
|
|
"x'];globalThis.PWNED=1;process.env['y",
|
|
] {
|
|
assert!(
|
|
!is_valid_js_identifier(bad),
|
|
"{bad:?} must not be a valid identifier"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn can_bind_as_prologue_const_excludes_reserved_and_owned_names() {
|
|
// `async` is a contextual keyword, not reserved — `const async` is valid.
|
|
for ok in ["FOO", "_bar", "$x", "myVar", "async"] {
|
|
assert!(can_bind_as_prologue_const(ok), "{ok} should be bindable");
|
|
}
|
|
// Valid identifiers that would still break `const {name}`: reserved words
|
|
// (SyntaxError), the two names strict mode forbids as bindings (`eval`,
|
|
// `arguments`), and names the prologue already binds (redeclaration).
|
|
for bad in [
|
|
"class",
|
|
"const",
|
|
"await",
|
|
"return",
|
|
"eval",
|
|
"arguments",
|
|
"process",
|
|
"BASE_URL",
|
|
"BASE_INTERNAL_URL",
|
|
] {
|
|
assert!(is_valid_js_identifier(bad), "{bad} is a valid identifier");
|
|
assert!(
|
|
!can_bind_as_prologue_const(bad),
|
|
"{bad} must not bind as a prologue const"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn escape_js_single_quoted_neutralizes_string_breakout() {
|
|
assert_eq!(escape_js_single_quoted("a'b"), "a\\'b");
|
|
assert_eq!(escape_js_single_quoted("a\\b"), "a\\\\b");
|
|
assert_eq!(escape_js_single_quoted("a\nb\rc"), "a\\nb\\rc");
|
|
// A key crafted to break out of process.env['...'] is fully contained.
|
|
assert_eq!(
|
|
escape_js_single_quoted("x'];danger();['y"),
|
|
"x\\'];danger();[\\'y"
|
|
);
|
|
}
|
|
}
|