mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
* fix: stop the AI chat destroying secret variables on edit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clear stale staged secret values and state the draft-staging rule Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: condense the pending-secret invariant to its field Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse empty and oauth-managed secret values, keep drawer-staged ones in the draft Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve a variable deploy's secret from one draft snapshot Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: make the variable draft the single source of a staged secret Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: drop stale in-memory secret invariants from comments and the eval Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop null account/expires_at leaking into variable drafts and diffs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: report when a variable deploy leaves the secret value unchanged Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: scope the variable-value readability claims to the chat Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the secret-draft invariant in the diff masking comment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: record why a non-secret value is resent on a partial update Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop "Load secret value" discarding a staged secret The audit-logged load writes the deployed secret into the draft row the variable drawer shares with the AI chat, so offering it while that row already stages a value silently replaces it — and the deploy that follows carries the old value with no sign the staged one was lost. The gate that hid the action already existed but keyed on `isEncryptedDraftValue`, which only holds once a draft has round-tripped through the server. A value staged in the same tab is still plaintext, so it slipped through. Key on "anything staged" instead; clearing stays explicit via Reset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: extend the variable draft's empty-value sentinel past secrets Two gaps in the chat's variable write path, both from treating "the draft cannot carry this value" as meaning only "the value is secret". `variableToDraftState` drops the value of an OAuth-managed variable so a refreshed live token is never pinned into a draft, leaving '' behind. The deploy body resent that '' verbatim for a non-secret one, wiping the token the refresh flow owns. The sentinel now covers every value the draft is not allowed to hold, which also removes the divergence from `VariableEditor.save` and the shared deployer. Making a variable secret when it holds no value produced a secret draft staging '', a deploy body with no `value`, and the backend's "cannot change is_secret without updating value too" — the sibling create path already answers that case with guidance, so answer it here too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate the Secret toggle's secret load on the staged value too The toggle calls `onLoadSecret` on every change so an is_secret flip has a value to send, but that load overwrites the shared draft row — the same discard the button gate just closed, reached by a different control. It now loads only when the row stages nothing, which is exactly when the flip needs a value fetched. With a value already staged there is one to send, and it is the one the user or the chat put there. Blocking the load costs the side effect that used to mask a worse bug: for a deployed variable, the load replaced an `$encrypted:` marker with real plaintext before save. Without it, un-securing a marker would store the marker string as the value, since the deploy endpoints only decrypt it while is_secret stays true. So the toggle is disabled outright while a marker is staged — Reset first. That closes the marker case for draft-only variables as well, where no load could ever have masked it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
6.7 KiB
Svelte
207 lines
6.7 KiB
Svelte
<script lang="ts">
|
|
import Path from './Path.svelte'
|
|
import LabelsInput from './LabelsInput.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import Alert from './common/alert/Alert.svelte'
|
|
import { Button } from './common'
|
|
import Tooltip from './Tooltip.svelte'
|
|
import Label from './Label.svelte'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import { Loader2, RotateCcw } from 'lucide-svelte'
|
|
import autosize from '$lib/autosize'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { isOwner } from '$lib/utils'
|
|
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
|
import EncryptedDraftField from './EncryptedDraftField.svelte'
|
|
|
|
interface Variable {
|
|
value: string
|
|
is_secret: boolean
|
|
description: string
|
|
}
|
|
|
|
interface Props {
|
|
path: string
|
|
initialPath: string
|
|
pathError: string
|
|
variable: Variable
|
|
labels: string[] | undefined
|
|
wsSpecific: boolean
|
|
deployTo: string | undefined
|
|
can_write: boolean
|
|
edit: boolean
|
|
onLoadSecret?: () => void
|
|
/** Workspace the path is validated against; defaults to the nav workspace. */
|
|
workspace?: string | undefined
|
|
}
|
|
|
|
let {
|
|
path = $bindable(),
|
|
initialPath,
|
|
pathError = $bindable(),
|
|
variable = $bindable(),
|
|
labels = $bindable(),
|
|
wsSpecific = $bindable(),
|
|
deployTo,
|
|
can_write,
|
|
edit,
|
|
onLoadSecret,
|
|
workspace = undefined
|
|
}: Props = $props()
|
|
|
|
let ws = $derived(workspace ?? $workspaceStore)
|
|
|
|
// Loading the deployed secret overwrites the draft row this form shares with the AI
|
|
// chat, so every path that would trigger it has to be blocked while that row stages a
|
|
// value — otherwise the staged one is replaced and the next deploy carries the old one.
|
|
// '' is the sentinel for "stages nothing", matching the deploy bodies.
|
|
let hasStagedValue = $derived(variable.value !== '')
|
|
|
|
const MAX_VARIABLE_LENGTH = 10000
|
|
|
|
let editorKind: 'plain' | 'json' | 'yaml' = $state('plain')
|
|
let editor: any = $state(undefined)
|
|
|
|
export function setCode(value: string) {
|
|
editor?.setCode(value)
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label for="path" class="text-xs font-semibold text-emphasis">Path</label>
|
|
<Path
|
|
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
|
|
bind:error={pathError}
|
|
bind:path
|
|
{initialPath}
|
|
namePlaceholder="variable"
|
|
kind="variable"
|
|
workspaceOverride={workspace}
|
|
/>
|
|
<LabelsInput bind:labels />
|
|
</div>
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Secret</span>
|
|
<!-- An `$encrypted:` value is only redeemable while the variable stays secret — the
|
|
deploy endpoints decrypt the marker inside their `is_secret` branch and store it
|
|
verbatim otherwise — so un-securing one has to be unreachable until it is Reset. -->
|
|
<Toggle
|
|
on:change={() => edit && !hasStagedValue && onLoadSecret?.()}
|
|
bind:checked={variable.is_secret}
|
|
disabled={edit && ($userStore?.operator || isEncryptedDraftValue(variable.value))}
|
|
/>
|
|
{#if variable.is_secret}
|
|
<Alert type="info" title="Audit log for each access">
|
|
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
|
|
addition, any read of a secret variable generates an audit log whose operation name is:
|
|
variables.decrypt_secret
|
|
</Alert>
|
|
{/if}
|
|
</label>
|
|
|
|
{#if deployTo}
|
|
<Label
|
|
label="Workspace specific"
|
|
tooltip="Prevents this variable from being deployed to prod/staging. May have been enabled automatically because a workspace-specific resource references this variable via $var:. Disabling this toggle does not retroactively un-mark the resource that referenced it."
|
|
>
|
|
<Toggle bind:checked={wsSpecific} />
|
|
</Label>
|
|
{/if}
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label for="variable-value" class="flex flex-row justify-left items-center">
|
|
<span class="text-xs font-semibold text-emphasis">Variable value </span>
|
|
{#if !isEncryptedDraftValue(variable.value)}
|
|
<span class="text-xs text-secondary font-normal">
|
|
({variable.value.length}/{MAX_VARIABLE_LENGTH} characters)
|
|
</span>
|
|
{/if}
|
|
{#if edit && variable.is_secret}
|
|
<div class="ml-3"></div>
|
|
{#if hasStagedValue}
|
|
<!-- Clearing the staged value is the only way back to loading the deployed one;
|
|
see `hasStagedValue`. An `$encrypted:` value additionally cannot be displayed. -->
|
|
<Button
|
|
size="xs"
|
|
variant="default"
|
|
startIcon={{ icon: RotateCcw }}
|
|
disabled={!can_write}
|
|
on:click={() => (variable.value = '')}
|
|
>
|
|
Reset
|
|
</Button>
|
|
{:else if $userStore?.operator}
|
|
<div class="p-2 border">Operators cannot load secret value</div>
|
|
{:else}
|
|
<Button size="xs" variant="default" on:click={() => onLoadSecret?.()}>
|
|
Load secret value<Tooltip>Will generate an audit log</Tooltip>
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
</label>
|
|
<div>
|
|
{#if isEncryptedDraftValue(variable.value)}
|
|
<!-- The draft's secret value was encrypted server-side and can't be
|
|
loaded back. Saving deploys the last saved value as-is; Reset clears
|
|
it so a new secret can be typed. -->
|
|
<EncryptedDraftField disabled={!can_write} onReset={() => (variable.value = '')} />
|
|
{:else}
|
|
<div class="flex flex-col gap-2">
|
|
<ToggleButtonGroup bind:selected={editorKind}>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="plain" label="Plain" {item} />
|
|
<ToggleButton value="json" label="Json" {item} />
|
|
<ToggleButton value="yaml" label="YAML" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
{#if editorKind == 'plain'}
|
|
<textarea
|
|
disabled={!can_write}
|
|
rows="4"
|
|
use:autosize
|
|
bind:value={variable.value}
|
|
placeholder="Update variable value"
|
|
id="variable-value"
|
|
></textarea>
|
|
{:else if editorKind == 'json'}
|
|
<div class="border rounded mb-4 w-full">
|
|
{#await import('$lib/components/SimpleEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:this={editor}
|
|
autoHeight
|
|
lang="json"
|
|
bind:code={variable.value}
|
|
fixedOverflowWidgets={false}
|
|
class="bg-surface-tertiary"
|
|
/>
|
|
{/await}
|
|
</div>
|
|
{:else if editorKind == 'yaml'}
|
|
<div class="border rounded mb-4 w-full">
|
|
{#await import('$lib/components/SimpleEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:this={editor}
|
|
autoHeight
|
|
lang="yaml"
|
|
bind:code={variable.value}
|
|
fixedOverflowWidgets={false}
|
|
class="bg-surface-tertiary"
|
|
/>
|
|
{/await}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Description</span>
|
|
<textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X"
|
|
></textarea>
|
|
</label>
|