mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 16:05:42 +00:00
* feat: add selfApproval option to WAC waitForApproval + inline approval buttons Add self-approval configuration to WAC workflows and inline approve/reject buttons in WorkflowTimeline. - TS SDK: add selfApproval option to waitForApproval() - Python SDK: add self_approval param to wait_for_approval() - Backend: store approval_conditions in flow_status for WAC, enforce self-approval checks on resume endpoints - Frontend: show Approve/Reject buttons in timeline with form support (EE), gated by user permissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert sqlx query change + regenerate system prompts - Revert get_suspended_flow_info to use original sqlx::query_as! with COALESCE to avoid sqlx offline cache mismatch in CI - Detect WAC by checking if FlowStatus parsing fails + suspend > 0 - Re-fetch flow_status column separately for WAC approval conditions - Regenerate auto-generated system prompt files for SDK changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: use resume URLs for WAC inline approval buttons - Backend generates HMAC-signed resume/cancel URLs when creating WAC approval, stores them in timeline entry and approval meta - Frontend uses anonymous resume endpoint (like classic flows) with fallback to resumeSuspendedFlowAsOwner for admins - Buttons show for everyone when URLs are present; server-side self_approval_disabled check enforces restrictions - Show warning for admins/owners when self-approval is disabled - selfApproval: false requires EE (errors at dispatch on CE) - self_approval_disabled check moved outside user_auth_required gate so it works independently - WAC detection no longer requires task import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add resume_suspended and approval_info endpoints - New approval_token DB table for token-based approval access - New POST /jobs_u/flow/resume_suspended/{job_id} endpoint: - OptAuthed: works with login or approval_token - Checks approval_conditions (self_approval, groups, auth) - Admins/owners bypass rules - New GET /jobs_u/flow/approval_info/{job_id} endpoint: - Returns form, rules, can_approve status - HMAC anonymous endpoint now bypasses all approval_conditions (secret = full capability) - getResumeUrls approvalPage URL now uses token format - WAC approval dispatch generates and stores approval tokens - Mark resumeSuspendedFlowAsOwner as legacy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: simplify frontend to use resume_suspended endpoint - OpenAPI spec updated with resume_suspended and approval_info endpoints - WorkflowTimeline: removed URL parsing, now calls single resumeSuspended endpoint for both approve and reject - Buttons show for any logged-in user viewing the job (backend enforces authorization rules) - Kept self-approval warning for admins Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update - Replace DB-stored approval tokens with stateless HMAC derivation: token = HMAC(workspace_key, job_id + "approval_token") Verifiable without DB lookup, not reversible to resume secret - Drop approval_token migration (no DB table needed) - FlowStatusWaitingForEvents: use resumeSuspended endpoint instead of URL parsing + resumeSuspendedFlowAsOwner - New approval page route /approve/{ws}/{job}?token= that uses approval_info and resume_suspended endpoints - Old approval page route kept for back-compat Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: match old approval page content in new approval page - Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult - Add approvers with tooltips, flow arguments section - Add admin self-approval bypass warning - Add "Open run details" link - Fetch full job alongside approval_info for all UI data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: filter _MODULES from args, show 'workflow' for WAC approvals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove deno template from approval/prompt SuspendDrawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: approval page form display + hide deno from approval script picker - Fix form schema rendering on new approval page by wrapping flat WAC form schemas in { properties, order } for SchemaForm - Hide deno from the approval step language picker in flow editor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove deno from canHaveApproval in script_helpers.ts The insert menu uses canHaveApproval() from script_helpers.ts via FlowInputsQuick, not the displayLang function in FlowInputs.svelte. Revert the unnecessary FlowInputs.svelte change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: return form schema and description in approval_info for classic flows The approval_info endpoint was returning None for form_schema on classic flows. Now fetches raw_flow to get suspend.resume_form schema, hide_cancel, and the step's completed result for description. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: inline Login component on approval page instead of redirect Show the Login component directly on the approval page when authentication is required. On successful login, reloads user and approval info without navigating away. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show resume buttons for all users, not just owners The resume_suspended endpoint handles authorization server-side, so the frontend should always show the buttons. Remove isOwner gate and the "cannot resume" message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent layout shift on resume by removing spinner from cancel button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent resume button expansion by using disabled instead of loading The loading prop adds a Loader2 spinner that expands the button width. Use disabled={loading} instead to prevent layout shift. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: approval page login redirects back with full page reload Set rd to the full URL (starts with http) so Login.redirectUser() uses window.location.href instead of goto(), triggering a full page reload after login. This ensures the approval page re-fetches data as an authenticated user. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fetch flow definition from flow_version when raw_flow is null Deployed flows don't store raw_flow on the job. Fall back to flow_version table using runnable_id to get suspend settings (form schema, hide_cancel) for the approval_info endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show specific reasons when user cannot approve Display whether denial is due to self-approval being disabled, required group membership, or both. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: support both nested and flat form schema in waitForApproval Users can now pass either: waitForApproval({ form: { schema: { name: { type: "string" } } } }) or: waitForApproval({ form: { name: { type: "string" } } }) Both WorkflowTimeline and approval page handle both formats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: convert sqlx query macros to non-macro for CI offline cache Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI. Also remove unused LogIn import from approval page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: suppress dead code warning + unused isOwner variable - Add #[allow(dead_code)] to without_flow method (CI -D warnings) - Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: security and robustness fixes from PR review - Add workspace_id verification in resume_suspended to prevent cross-workspace approval (#3) - Fix token leakage: use relative path for login redirect instead of full URL with token (#4) - Handle getJob failure independently from approval_info so the page works for unauthenticated users (#7) - Clear error state on successful data load (#13) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — shared token gen, rand resume_id, UX - Move generate_approval_token to windmill-common::variables (shared between windmill-api and windmill-worker, eliminates duplicate HMAC) - Use rand::random::<u32>() for resume_id instead of DefaultHasher - Stop polling after approve/reject on approval page - Add cancelLoading state to WorkflowTimeline Reject button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
496 lines
16 KiB
Rust
496 lines
16 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::error::{self, Error};
|
|
use crate::scripts::ScriptHash;
|
|
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();
|
|
}
|
|
|
|
#[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>>,
|
|
}
|
|
|
|
#[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>>,
|
|
}
|
|
|
|
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>>,
|
|
}
|
|
|
|
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()))
|
|
}
|
|
|
|
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() {
|
|
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>,
|
|
) -> 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 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_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.read().await.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. Only available when triggered from an app.".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 custom_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(),
|
|
};
|
|
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 {
|
|
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
|
|
)))
|
|
}
|
|
}
|