mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: support sensitive/secret fields for non-string types (#8635)
* feat: support sensitive/secret fields for non-string types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: restrict sensitive toggle to object type, move after showExpr Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show sensitive toggle in PropertyEditor at bottom, after children Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: gate sensitive toggle with showSensitiveToggle prop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: process secret args in flow test and script test paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: inline SecretArgInput into ArgInput, delete component Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CI review feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pass showSensitiveToggle to flow input schema editors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use explicit prop syntax to satisfy svelte-check Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: narrow try/catch to only processSecretArgs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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::<Value>(&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 {
|
||||
|
||||
@@ -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::<serde_json::Value>(&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();
|
||||
|
||||
|
||||
@@ -74,7 +74,15 @@
|
||||
<button
|
||||
class="text-xs text-accent"
|
||||
onclick={async () => {
|
||||
await getVariable(value.substring('$res:'.length))
|
||||
await getVariable(value.substring('$var:'.length))
|
||||
jsonViewer?.toggleDrawer()
|
||||
}}>{value}</button
|
||||
>
|
||||
{:else if isString(value) && value.startsWith('$jsonvar:')}
|
||||
<button
|
||||
class="text-xs text-accent"
|
||||
onclick={async () => {
|
||||
await getVariable(value.substring('$jsonvar:'.length))
|
||||
jsonViewer?.toggleDrawer()
|
||||
}}>{value}</button
|
||||
>
|
||||
|
||||
@@ -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)}</button
|
||||
>
|
||||
{:else if value && typeof value == 'string' && value?.startsWith('$jsonvar:')}
|
||||
Linked to variable <button
|
||||
class="text-accent underline font-normal"
|
||||
onclick={() => variableEditor?.editVariable?.(value.slice('$jsonvar:'.length))}
|
||||
>{value.slice('$jsonvar:'.length)}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1488,6 +1496,18 @@
|
||||
{@render actions?.()}
|
||||
</div>
|
||||
|
||||
{#if isNonStringSecret}
|
||||
{#if typeof value === 'string' && value.startsWith('$jsonvar:')}
|
||||
<div class="text-2xs text-tertiary">
|
||||
Sensitive — stored as secret: <code class="text-2xs">{value.slice('$jsonvar:'.length)}</code
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-2xs text-tertiary italic">Sensitive — will be stored as secret on submit</div
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !compact || (error && error != '')}
|
||||
<div class="text-right text-xs leading-3 text-red-600 dark:text-red-400 mb-2">
|
||||
{#if disabled || error === ''}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, any>
|
||||
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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { buildWsUrl } from '$lib/wsUrl'
|
||||
import { processSecretArgs } from './secretArgUtils'
|
||||
import type { Schema, SupportedLanguage } from '$lib/common'
|
||||
import {
|
||||
type CompletedJob,
|
||||
@@ -645,12 +646,14 @@
|
||||
|
||||
const testCode = activeModuleTab !== null ? editorCode : code
|
||||
const testLang = activeModuleTab !== null ? effectiveLang : lang
|
||||
const testArgs =
|
||||
const rawTestArgs =
|
||||
activeModuleTab !== null
|
||||
? testPanelArgs
|
||||
: selectedTab === 'preprocessor' || kind === 'preprocessor'
|
||||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) }
|
||||
: (args ?? {})
|
||||
const testSchema = activeModuleTab !== null ? testPanelSchema : schema
|
||||
const testArgs = await processSecretArgs(rawTestArgs, testSchema)
|
||||
|
||||
//@ts-ignore
|
||||
let job = await jobLoader.runPreview(
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
let { schema = $bindable(), customUi = undefined }: Props = $props()
|
||||
</script>
|
||||
|
||||
<EditableSchemaForm bind:schema uiOnly {customUi} editTab="inputEditor" />
|
||||
<EditableSchemaForm bind:schema uiOnly {customUi} editTab="inputEditor" showSensitiveToggle />
|
||||
|
||||
@@ -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])
|
||||
}}
|
||||
|
||||
@@ -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 @@
|
||||
</Alert>
|
||||
{:else if formatExtension && formatExtension !== ''}
|
||||
<Alert title={`Example: my_file.${formatExtension}`} type="info">
|
||||
The <span class="font-bold font-mono"> .{formatExtension} </span> 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 <span class="font-bold font-mono"> .{formatExtension} </span> 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.
|
||||
</Alert>
|
||||
<div></div>
|
||||
{/if}
|
||||
@@ -175,10 +177,7 @@
|
||||
path and contains text content. In the CLI, filesets are stored as directories.
|
||||
</Alert>
|
||||
{/if}
|
||||
<ToggleButtonGroup
|
||||
selected={resourceMode}
|
||||
onSelected={(mode) => switchResourceMode(mode)}
|
||||
>
|
||||
<ToggleButtonGroup selected={resourceMode} onSelected={(mode) => switchResourceMode(mode)}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="schema" label="JSON" {item} size="sm" />
|
||||
<ToggleButton value="file" label="File" {item} size="sm" />
|
||||
|
||||
@@ -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}
|
||||
<Toggle
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Is sensitive',
|
||||
rightTooltip:
|
||||
'The value will be stored as an ephemeral secret variable in the user space of the caller of the job, only viewable by him.'
|
||||
}}
|
||||
checked={extra['password'] ?? false}
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
extra['password'] = true
|
||||
} else {
|
||||
extra['password'] = undefined
|
||||
}
|
||||
dispatch('change')
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type EditableSchemaWrapperProps = {
|
||||
fullHeight?: boolean
|
||||
formatExtension?: string | undefined
|
||||
isFileset?: boolean | undefined
|
||||
showSensitiveToggle?: boolean
|
||||
customUi?: {
|
||||
noAddPopover?: boolean
|
||||
}
|
||||
|
||||
@@ -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<string, any>,
|
||||
schema: Schema | undefined
|
||||
): Promise<Record<string, any>> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user