feat: support multiple secret variables during resource creation (#8386)

* feat: support multiple secret variables during resource creation

When creating a resource, users can now select multiple fields to be
stored as secret variables. If only one field is selected, behavior
is unchanged (single variable with same path as resource). If multiple
fields are selected, each gets its own variable with _field_name appended
to the resource path.

Closes #8384

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* fix: delete all linked secret variables when resource is deleted

When a resource with multiple secret fields is deleted, also delete
variables matching the {path}_{field_name} pattern in addition to
the exact path variable. Each deleted variable gets its own deployment
metadata update and webhook notification.

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update backend/windmill-store/src/resources.rs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* fix: only delete linked variables that are actually referenced in resource value

Instead of deleting variables purely based on path prefix matching
(which could accidentally delete unrelated variables), the deletion
now reads the resource value first, extracts all $var: references,
and only deletes variables that are actually used in the resource.

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
claude[bot]
2026-03-16 14:10:29 +00:00
committed by GitHub
parent 9fdea3e058
commit 54841b7549
4 changed files with 182 additions and 61 deletions
+65 -12
View File
@@ -883,6 +883,15 @@ async fn delete_resource(
}
let mut tx = user_db.begin(&authed).await?;
// Fetch the resource value before deleting, so we can find linked $var: references
let resource_value: Option<Option<serde_json::Value>> = sqlx::query_scalar(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let deleted_path = sqlx::query_scalar!(
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
@@ -891,13 +900,32 @@ async fn delete_resource(
.fetch_optional(&mut *tx)
.await?;
not_found_if_none(deleted_path, "Resource", &path)?;
let deleted_linked_variable = sqlx::query_scalar!(
"DELETE FROM variable WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
)
.fetch_optional(&mut *tx)
.await?;
// Collect all $var: paths referenced in the resource value
let mut linked_var_paths: Vec<String> = Vec::new();
if let Some(Some(value)) = resource_value {
collect_var_refs(&value, &mut linked_var_paths);
}
// Delete linked variables that are actually referenced in the resource value
let deleted_linked_variables: Vec<String> = if linked_var_paths.is_empty() {
Vec::new()
} else {
let placeholders: Vec<String> = linked_var_paths
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 2))
.collect();
let query = format!(
"DELETE FROM variable WHERE workspace_id = $1 AND path IN ({}) RETURNING path",
placeholders.join(", ")
);
let mut q = sqlx::query_scalar::<_, String>(&query).bind(&w_id);
for var_path in &linked_var_paths {
q = q.bind(var_path);
}
q.fetch_all(&mut *tx).await?
};
audit_log(
&mut *tx,
&authed,
@@ -927,19 +955,19 @@ async fn delete_resource(
WebhookMessage::DeleteResource { workspace: w_id.clone(), path: path.to_owned() },
);
if deleted_linked_variable.is_some() {
for var_path in &deleted_linked_variables {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Variable {
path: path.to_string(),
parent_path: Some(path.to_string()),
path: var_path.clone(),
parent_path: Some(var_path.clone()),
},
Some(format!(
"Variable '{}' deleted (linked resource deleted)",
path
var_path
)),
true,
None,
@@ -948,13 +976,38 @@ async fn delete_resource(
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteVariable { workspace: w_id, path: path.to_owned() },
WebhookMessage::DeleteVariable {
workspace: w_id.clone(),
path: var_path.clone(),
},
);
}
Ok(format!("resource {} deleted", path))
}
/// Recursively collect all `$var:path` references from a JSON value.
fn collect_var_refs(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::String(s) => {
if let Some(var_path) = s.strip_prefix("$var:") {
out.push(var_path.to_string());
}
}
serde_json::Value::Object(m) => {
for v in m.values() {
collect_var_refs(v, out);
}
}
serde_json::Value::Array(arr) => {
for v in arr {
collect_var_refs(v, out);
}
}
_ => {}
}
}
async fn delete_resources_bulk(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -21,7 +21,7 @@
resourceType: string
resourceTypeInfo: ResourceType | undefined
args?: Record<string, any> | any
linkedSecret?: string | undefined
linkedSecrets?: string[]
isValid?: boolean
linkedSecretCandidates?: string[] | undefined
description?: string | undefined
@@ -31,7 +31,7 @@
resourceType,
resourceTypeInfo,
args = $bindable({}),
linkedSecret = $bindable(undefined),
linkedSecrets = $bindable([]),
isValid = $bindable(true),
linkedSecretCandidates = undefined,
description = $bindable(undefined)
@@ -152,7 +152,7 @@
/>
<ResourceGen
bind:args
resourceType={resourceType}
{resourceType}
resourceSchema={notFound ? undefined : schema}
isFileset={resourceTypeInfo?.is_fileset ?? false}
/>
@@ -246,9 +246,7 @@
{/await}
</div>
{:else if resourceTypeInfo?.is_fileset}
<h5 class="mt-1 inline-flex items-center gap-4">
Fileset
</h5>
<h5 class="mt-1 inline-flex items-center gap-4"> Fileset </h5>
<FilesetEditor bind:args />
{:else if resourceTypeInfo?.format_extension}
<h5 class="mt-4 inline-flex items-center gap-4">
@@ -273,7 +271,7 @@
onlyMaskPassword
noDelete
{linkedSecretCandidates}
bind:linkedSecret
bind:linkedSecrets
isValid
{schema}
bind:args
@@ -84,14 +84,22 @@
)
}
let linkedSecret: string | undefined = $state(undefined)
let linkedSecrets: string[] = $state([])
let linkedSecretCandidates: string[] | undefined = $state(undefined)
function computeLinkedSecret(resourceType: string, argsKeys: string[], passwords: string[]) {
function computeDefaultLinkedSecrets(
resourceType: string,
argsKeys: string[],
passwords: string[]
): string[] {
linkedSecretCandidates = computeCandidates(resourceType, argsKeys, passwords)
return (
forceSecretValue(resourceType) ??
linkedSecretCandidates?.sort((ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua))?.[0]
)
const forced = forceSecretValue(resourceType)
if (forced) {
return [forced]
}
const best = linkedSecretCandidates?.sort(
(ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua)
)?.[0]
return best ? [best] : []
}
let scopes: string[] = $state([])
@@ -194,7 +202,7 @@
args['password'] == '' &&
args['api_key'] == '' &&
args['key'] == '' &&
linkedSecret != undefined
linkedSecrets.length > 0
: false)) ||
step == 3 ||
(step == 4 && pathError != '') ||
@@ -317,13 +325,13 @@
const passwords = newArgsKeys.filter((x) => {
return props?.[x]?.password
})
if (!linkedSecret) {
linkedSecret = computeLinkedSecret(resourceType, newArgsKeys, passwords)
if (linkedSecrets.length === 0) {
linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords)
}
}
export async function next() {
if (step == 1) {
linkedSecret = undefined
linkedSecrets = []
if (manual) {
getResourceTypeInfo()
args = {}
@@ -408,14 +416,30 @@
if (step == 2) return
throw Error('Path is not set')
}
let exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
path
})
if (exists) {
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
// Check if variable paths already exist
if (!manual || linkedSecrets.length <= 1) {
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
path
})
if (exists) {
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
}
} else {
for (const secretField of linkedSecrets) {
const varPath = `${path}_${secretField}`
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
path: varPath
})
if (exists) {
throw Error(
`Variable at path ${varPath} already exists. Delete it or pick another path`
)
}
}
}
exists = await ResourceService.existsResource({
let exists = await ResourceService.existsResource({
workspace: $workspaceStore!,
path
})
@@ -462,25 +486,65 @@
const resourceValue = args
let saveVariable = false
if (!manual || linkedSecret != undefined) {
let v = manual ? args[linkedSecret ?? ''] : value
let savedVariableCount = 0
if (!manual) {
// OAuth flow: single secret variable for the token
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value: value,
is_secret: true,
description: emptyString(description)
? `OAuth token for ${resourceType}`
: description,
is_oauth: true,
account: account
}
})
resourceValue['token'] = `$var:${path}`
}
} else if (linkedSecrets.length === 1) {
// Single secret: use the resource path as variable name (original behavior)
const secretField = linkedSecrets[0]
const v = args[secretField]
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
saveVariable = true
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value: v,
is_secret: true,
description: emptyString(description)
? `${manual ? 'Token' : 'OAuth token'} for ${resourceType}`
: description,
is_oauth: !manual,
account: account
description: emptyString(description) ? `Token for ${resourceType}` : description,
is_oauth: false
}
})
resourceValue[linkedSecret ?? 'token'] = `$var:${path}`
resourceValue[secretField] = `$var:${path}`
}
} else if (linkedSecrets.length > 1) {
// Multiple secrets: append _field_name to each variable path
for (const secretField of linkedSecrets) {
const v = args[secretField]
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
const varPath = `${path}_${secretField}`
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path: varPath,
value: v,
is_secret: true,
description: emptyString(description)
? `${secretField} for ${resourceType}`
: description,
is_oauth: false
}
})
resourceValue[secretField] = `$var:${varPath}`
}
}
}
@@ -495,7 +559,9 @@
})
dispatch('refresh', path)
dispatch('close')
sendUserToast(`Saved resource${saveVariable ? ' and variable' : ''} path: ${path}`)
sendUserToast(
`Saved resource${savedVariableCount > 0 ? ` and ${savedVariableCount} variable${savedVariableCount > 1 ? 's' : ''}` : ''} path: ${path}`
)
step = 1
resourceType = ''
}
@@ -738,7 +804,7 @@
{#key resourceTypeInfo}
<ApiConnectForm
bind:linkedSecret
bind:linkedSecrets
bind:description
{linkedSecretCandidates}
{resourceType}
+17 -13
View File
@@ -34,7 +34,7 @@
defaultValues?: Record<string, any>
shouldHideNoInputs?: boolean
compact?: boolean
linkedSecret?: string | undefined
linkedSecrets?: string[]
linkedSecretCandidates?: string[] | undefined
noVariablePicker?: boolean
flexWrap?: boolean
@@ -86,7 +86,7 @@
defaultValues = {},
shouldHideNoInputs = false,
compact = false,
linkedSecret = $bindable(undefined),
linkedSecrets = $bindable([]),
linkedSecretCandidates = undefined,
noVariablePicker = false,
flexWrap = false,
@@ -333,7 +333,7 @@
{variableEditor}
{itemPicker}
{pickForField}
password={linkedSecret == argName}
password={linkedSecrets.includes(argName)}
extra={formerProperty}
{showSchemaExplorer}
simpleTooltip={schemaFieldTooltip[argName]}
@@ -398,22 +398,24 @@
customErrorMessage={prop?.customErrorMessage}
bind:properties={
() => prop?.properties,
(v) => { if (prop) prop.properties = v }
(v) => {
if (prop) prop.properties = v
}
}
bind:order={
() => prop?.order,
(v) => { if (prop) prop.order = v }
(v) => {
if (prop) prop.order = v
}
}
nestedRequired={prop?.required}
itemsType={prop?.items}
disabled={disabledArgs.includes(argName) ||
disabled ||
prop?.disabled}
disabled={disabledArgs.includes(argName) || disabled || prop?.disabled}
{compact}
{variableEditor}
{itemPicker}
bind:pickForField
password={linkedSecret == argName}
password={linkedSecrets.includes(argName)}
extra={prop}
{showSchemaExplorer}
simpleTooltip={schemaFieldTooltip[argName]}
@@ -440,12 +442,14 @@
{#if linkedSecretCandidates?.includes(argName)}
<div class="relative">
<ToggleButtonGroup
selected={linkedSecret == argName ? 'secret' : 'inlined'}
selected={linkedSecrets.includes(argName) ? 'secret' : 'inlined'}
on:selected={(e) => {
if (e.detail === 'secret') {
linkedSecret = argName
} else if (linkedSecret == argName) {
linkedSecret = undefined
if (!linkedSecrets.includes(argName)) {
linkedSecrets = [...linkedSecrets, argName]
}
} else {
linkedSecrets = linkedSecrets.filter((s) => s !== argName)
}
}}
>