diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index af963e1796..8fb77626e5 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -575,6 +575,16 @@ pub async fn transform_json_value( .await?; Ok(Value::String(v)) } + Value::String(y) if y.starts_with("$jsonvar:") => { + let path = y.strip_prefix("$jsonvar:").unwrap(); + + let v = + crate::variables::get_value_internal(&db_with_opt_authed, workspace, path, false) + .await?; + serde_json::from_str::(&v).map_err(|e| { + Error::internal_err(format!("Failed to parse $jsonvar value as JSON: {e}")) + }) + } Value::String(y) if y.starts_with("$res:") => { let path = y.strip_prefix("$res:").unwrap(); if path.split("/").count() < 2 { diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index c6d737becb..d303771cc8 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -145,7 +145,7 @@ pub async fn write_file_binary(dir: &str, path: &str, content: &[u8]) -> error:: } lazy_static::lazy_static! { - static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|res|encrypted)\:"#).unwrap(); + static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|jsonvar|res|encrypted)\:"#).unwrap(); } pub async fn transform_json<'a>( @@ -255,6 +255,15 @@ pub async fn transform_json_value( Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}")) }) } + Value::String(y) if y.starts_with("$jsonvar:") => { + let path = y.strip_prefix("$jsonvar:").unwrap(); + let v = client.get_variable_value(path).await.map_err(|e| { + Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}")) + })?; + serde_json::from_str::(&v).map_err(|e| { + Error::internal_err(format!("Failed to parse $jsonvar value as JSON: {e}")) + }) + } Value::String(y) if y.starts_with("$res:") => { let path = y.strip_prefix("$res:").unwrap(); diff --git a/frontend/src/lib/components/ArgInfo.svelte b/frontend/src/lib/components/ArgInfo.svelte index c99f994728..a1cfdf6276 100644 --- a/frontend/src/lib/components/ArgInfo.svelte +++ b/frontend/src/lib/components/ArgInfo.svelte @@ -74,7 +74,15 @@ +{:else if isString(value) && value.startsWith('$jsonvar:')} + diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 6712d2eaf7..56d0d4b0fa 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -495,6 +495,8 @@ let { debounced, clearDebounce } = debounce(() => compareValues(value), 50) let inputCat = $derived(computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)) + let isNonStringSecret = $derived((password || extra?.['password'] == true) && type === 'object') + let displayJsonToggleHeader = $derived( displayHeader && inputCat === 'list' && @@ -558,6 +560,12 @@ class="text-accent underline font-normal" onclick={() => variableEditor?.editVariable?.(value.slice(5))}>{value.slice(5)} + {:else if value && typeof value == 'string' && value?.startsWith('$jsonvar:')} + Linked to variable {/if} {/if} @@ -1488,6 +1496,18 @@ {@render actions?.()} + {#if isNonStringSecret} + {#if typeof value === 'string' && value.startsWith('$jsonvar:')} +
+ Sensitive — stored as secret: {value.slice('$jsonvar:'.length)} +
+ {:else} +
Sensitive — will be stored as secret on submit
+ {/if} + {/if} + {#if !compact || (error && error != '')}
{#if disabled || error === ''} diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index 989ce6a2fe..85b8abf753 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -51,6 +51,7 @@ noPreview?: boolean jsonEnabled?: boolean isAppInput?: boolean + showSensitiveToggle?: boolean displayWebhookWarning?: boolean onlyMaskPassword?: boolean editTab: @@ -95,6 +96,7 @@ noPreview = false, jsonEnabled = true, isAppInput = false, + showSensitiveToggle = false, displayWebhookWarning = false, onlyMaskPassword = false, editTab, @@ -297,7 +299,9 @@ } const editTabDefaultSize = untrack(() => noPreview) ? 100 : 50 - editPanelSize = untrack(() => editTab) ? (untrack(() => editPanelInitialSize) ?? editTabDefaultSize) : 0 + editPanelSize = untrack(() => editTab) + ? (untrack(() => editPanelInitialSize) ?? editTabDefaultSize) + : 0 let inputPanelSize = $state(100 - editPanelSize) let editPanelSizeSmooth = tweened(editPanelSize, { duration: 150 @@ -677,6 +681,7 @@ bind:order={schema.properties[argName].order} {isFlowInput} {isAppInput} + {showSensitiveToggle} > {#snippet typeeditor()} {#if isFlowInput || isAppInput} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 9c4ed4231e..01ae4c53fa 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -11,6 +11,7 @@ import { createEventDispatcher, getContext, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import { runFlowPreview } from './flows/utils.svelte' + import { processSecretArgs } from './secretArgUtils' import SchemaForm from './SchemaForm.svelte' import SchemaFormWithArgPicker from './SchemaFormWithArgPicker.svelte' import FlowStatusViewer from '../components/FlowStatusViewer.svelte' @@ -171,6 +172,7 @@ lastPreviewFlow = JSON.stringify(flowStore.val) flowProgressBar?.reset() const newFlow = extractFlow(previewMode) + args = await processSecretArgs(args, flowStore.val.schema as any) newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom, conversationId) jobId = newJobId isRunning = true diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index da0c87a15f..6c2ac09da0 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -3,7 +3,8 @@ computeSharableHash as computeSharableHash, defaultIfEmptyString, emptyString, - truncateHash + truncateHash, + sendUserToast } from '$lib/utils' import type { Schema } from '$lib/common' @@ -21,6 +22,7 @@ import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import InputSelectedBadge from './schema/InputSelectedBadge.svelte' import { untrack } from 'svelte' + import { processSecretArgs } from './secretArgUtils' let reloadArgs = $state(0) let jsonEditor: JsonInputs | undefined = $state(undefined) @@ -33,8 +35,20 @@ reloadArgs++ } - export function run() { - runAction(scheduledForStr, args ?? {}, invisible_to_owner, overrideTag) + export async function run(overrideScheduledForStr?: string | undefined | null) { + let processedArgs: Record + try { + processedArgs = await processSecretArgs(args ?? {}, runnable?.schema) + } catch (e) { + sendUserToast('Failed to process sensitive args: ' + e, true) + return + } + runAction( + overrideScheduledForStr === null ? undefined : (overrideScheduledForStr ?? scheduledForStr), + processedArgs, + invisible_to_owner, + overrideTag + ) } interface Props { @@ -276,7 +290,7 @@ unifiedSize="md" btnClasses="!inline-flex" disabled={!isValid && !jsonView} - on:click={() => runAction(scheduledForStr, args ?? {}, invisible_to_owner, overrideTag)} + on:click={() => run()} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > {scheduledForStr ? 'Schedule to run later' : buttonText} @@ -315,7 +329,7 @@ btnClasses="!px-6 !py-1 w-full" variant="accent" disabled={!isValid && !jsonView} - on:click={() => runAction(undefined, args ?? {}, invisible_to_owner, overrideTag)} + on:click={() => run(null)} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > {buttonText} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 622b057886..66101fc892 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -1,5 +1,6 @@ - + diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index a9eab8074c..5c7002f147 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -686,6 +686,7 @@ bind:schema={flowStore.val.schema} hiddenArgs={['user_message']} isFlowInput + showSensitiveToggle editTab={chatInputsEditTab ? 'inputEditor' : undefined} showDynOpt bind:dynCode @@ -741,6 +742,7 @@ bind:this={editableSchemaForm} bind:schema={flowStore.val.schema} isFlowInput + showSensitiveToggle on:delete={(e) => { addPropertyV2?.handleDeleteArgument([e.detail]) }} diff --git a/frontend/src/lib/components/schema/EditableSchemaWrapper.svelte b/frontend/src/lib/components/schema/EditableSchemaWrapper.svelte index 27f19928f1..65a865f1d9 100644 --- a/frontend/src/lib/components/schema/EditableSchemaWrapper.svelte +++ b/frontend/src/lib/components/schema/EditableSchemaWrapper.svelte @@ -20,6 +20,7 @@ fullHeight = true, formatExtension = $bindable(undefined), isFileset = $bindable(undefined), + showSensitiveToggle = false, customUi }: EditableSchemaWrapperProps = $props() @@ -113,6 +114,7 @@ bind:this={editableSchemaForm} bind:schema isFlowInput + {showSensitiveToggle} on:delete={(e) => { addPropertyComponent?.handleDeleteArgument([e.detail]) }} @@ -162,9 +164,9 @@ {:else if formatExtension && formatExtension !== ''} - The .{formatExtension} extension will be used to - infer the format when displaying the content and this is also how the resource will appear - when pulling via the CLI. + The .{formatExtension} extension will be used to infer + the format when displaying the content and this is also how the resource will appear when pulling + via the CLI.
{/if} @@ -175,10 +177,7 @@ path and contains text content. In the CLI, filesets are stored as directories. {/if} - switchResourceMode(mode)} -> + switchResourceMode(mode)}> {#snippet children({ item })} diff --git a/frontend/src/lib/components/schema/PropertyEditor.svelte b/frontend/src/lib/components/schema/PropertyEditor.svelte index 1dcf1a817d..3cd0ec5507 100644 --- a/frontend/src/lib/components/schema/PropertyEditor.svelte +++ b/frontend/src/lib/components/schema/PropertyEditor.svelte @@ -7,6 +7,7 @@ import NumberTypeNarrowing from '../NumberTypeNarrowing.svelte' import StringTypeNarrowing from '../StringTypeNarrowing.svelte' import Tooltip from '../Tooltip.svelte' + import Toggle from '../Toggle.svelte' import EditableSchemaForm from '../EditableSchemaForm.svelte' import { deepEqual } from 'fast-equals' @@ -35,6 +36,7 @@ nonEmpty?: boolean | undefined isFlowInput?: boolean isAppInput?: boolean + showSensitiveToggle?: boolean order?: string[] | undefined itemsType?: | { @@ -66,6 +68,7 @@ properties = $bindable(), isFlowInput = false, isAppInput = false, + showSensitiveToggle = false, order = $bindable(), itemsType = $bindable(undefined), typeeditor, @@ -290,5 +293,25 @@ {/if} {@render children?.()} + + {#if type == 'object' && showSensitiveToggle} + { + if (e.detail) { + extra['password'] = true + } else { + extra['password'] = undefined + } + dispatch('change') + }} + /> + {/if}
diff --git a/frontend/src/lib/components/schema/editable_schema_wrapper.ts b/frontend/src/lib/components/schema/editable_schema_wrapper.ts index 1ef4426613..bcc659b27e 100644 --- a/frontend/src/lib/components/schema/editable_schema_wrapper.ts +++ b/frontend/src/lib/components/schema/editable_schema_wrapper.ts @@ -7,6 +7,7 @@ export type EditableSchemaWrapperProps = { fullHeight?: boolean formatExtension?: string | undefined isFileset?: boolean | undefined + showSensitiveToggle?: boolean customUi?: { noAddPopover?: boolean } diff --git a/frontend/src/lib/components/secretArgUtils.ts b/frontend/src/lib/components/secretArgUtils.ts new file mode 100644 index 0000000000..82aee1f53d --- /dev/null +++ b/frontend/src/lib/components/secretArgUtils.ts @@ -0,0 +1,49 @@ +import type { Schema } from '$lib/common' +import { VariableService } from '$lib/gen' +import { get } from 'svelte/store' +import { userStore, workspaceStore } from '$lib/stores' +import { generateRandomString } from '$lib/utils' + +/** + * Process args before job submission: for non-string fields marked as password/sensitive, + * create ephemeral secret variables and replace values with $jsonvar:path references. + * String password fields are already handled by PasswordArgInput (uses $var:). + */ +export async function processSecretArgs( + args: Record, + schema: Schema | undefined +): Promise> { + if (!schema?.properties) return args + + const workspace = get(workspaceStore) + const user = get(userStore) + if (!workspace || !user) return args + + const username = (user.username ?? user.email)?.split('@')[0] + if (!username) return args + const userPrefix = `u/${username}/secret_arg/` + + const result = { ...args } + + for (const [key, prop] of Object.entries(schema.properties)) { + if (!prop.password) continue + if (prop.type !== 'object') continue // only object types; strings handled by PasswordArgInput + if (result[key] == null || result[key] === undefined) continue + if (typeof result[key] === 'string' && result[key].startsWith('$jsonvar:')) continue // already processed + + const path = userPrefix + generateRandomString(12) + await VariableService.createVariable({ + workspace, + requestBody: { + value: JSON.stringify(result[key]), + is_secret: true, + path, + description: 'Ephemeral secret variable', + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString() + } + }) + result[key] = '$jsonvar:' + path + } + + return result +}