deleting a secret provider warns which stacks it unbinds

This commit is contained in:
Jarek Krochmalski committed 2026-09-06 17:03:47 +05:30
1 parent 3a5dce99e2
commit 07f29f5afc
9 files changed
+186 -10

No files matched your search

+9 -1
View File
@@ -8,7 +8,8 @@ import {
createGitRepository,
upsertStackSource,
setStackEnvVars,
getStackSource
getStackSource,
secretProviderExists
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
@@ -98,6 +99,13 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// A stale provider id (e.g. the provider was deleted/recreated while the editor
// held the old list) would otherwise hit a raw foreign-key error on save. Reject
// it cleanly so the user knows to reselect a provider (#1522).
if (typeof data.secretProviderId === 'number' && !(await secretProviderExists(data.secretProviderId))) {
return json({ error: 'The selected secret provider no longer exists. Reopen the stack and pick a current provider.' }, { status: 400 });
}
// Check for name conflicts with existing stacks (regular/external/git)
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
if (existing) {
+7 -1
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars, updateStackSource } from '$lib/server/db';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars, updateStackSource, secretProviderExists } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { normalizeStackBranchUpdate } from '$lib/git-stack-branch';
import { authorize } from '$lib/server/authorize';
@@ -90,6 +90,12 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// A stale provider id (provider deleted/recreated while the editor held the old
// list) would otherwise hit a raw foreign-key error on save (#1522).
if (typeof data.secretProviderId === 'number' && !(await secretProviderExists(data.secretProviderId))) {
return json({ error: 'The selected secret provider no longer exists. Reopen the stack and pick a current provider.' }, { status: 400 });
}
// Validate stack name if it's being changed
if (data.stackName !== undefined) {
const trimmedStackName = data.stackName.trim();
@@ -4,7 +4,8 @@ import {
getSecretProviders,
getSecretProviderById,
updateSecretProvider,
deleteSecretProvider
deleteSecretProvider,
getStacksUsingSecretProvider
} from '$lib/server/db';
import { hasProvider } from '$lib/server/secretproviders';
import { redactProviderConfig } from '$lib/server/secretproviders/shared';
@@ -15,7 +16,7 @@ import { auditSecretProvider } from '$lib/server/audit';
* @openapi
* summary: Get one secret provider with its non-secret config coordinates (the token is redacted out)
* path: id:integer The secret provider id
* resp-200: {id:integer!, name:string!, type:string!, config:object!}
* resp-200: {id:integer!, name:string!, type:string!, config:object!, stacksUsing:array<{stackName:string!, environmentId:integer}>!}
* resp-400: Invalid secret provider ID
* resp-403: Permission denied (needs secrets:view)
* resp-404: Secret provider not found
@@ -39,7 +40,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Secret provider not found' }, { status: 404 });
}
const { config, ...summary } = full;
return json({ ...summary, config: redactProviderConfig(config) });
// Name the stacks currently bound to this provider so the delete confirmation can
// warn the user that deleting it unbinds them (#1522). A provider is global, but its
// bound stacks live in environments - so env-scope the list to what the caller may
// see (Enterprise RBAC), or a restricted user would learn stack names in envs they
// cannot reach. null = all-environments access (admin/free/disabled) -> no filter.
const allStacksUsing = await getStacksUsingSecretProvider(id);
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
const stacksUsing = accessibleEnvIds === null
? allStacksUsing
: allStacksUsing.filter((s) => s.environmentId === null || accessibleEnvIds.includes(s.environmentId));
return json({ ...summary, config: redactProviderConfig(config), stacksUsing });
};
/**
+40 -4
View File
@@ -30,6 +30,11 @@
let showModal = $state(false);
let editing = $state<SecretProvider | null>(null);
let confirmDeleteId = $state<number | null>(null);
// Stacks bound to the provider whose delete popover is open, so we can warn the
// user that deleting it unbinds them (#1522). Keyed by nothing - only one popover is
// open at a time.
let deleteAffectedStacks = $state<Array<{ stackName: string; environmentId: number | null }>>([]);
let loadingAffected = $state(false);
let testingId = $state<number | null>(null);
// Brief green tick on the tile's Test button right after a successful test.
let testOkId = $state<number | null>(null);
@@ -53,6 +58,22 @@
showModal = true;
}
async function loadAffectedStacks(id: number) {
loadingAffected = true;
deleteAffectedStacks = [];
try {
const res = await fetch(`/api/secret-providers/${id}`);
if (res.ok) {
const data = await res.json();
deleteAffectedStacks = data.stacksUsing ?? [];
}
} catch {
// Non-fatal: the confirmation still works, just without the stack list.
} finally {
loadingAffected = false;
}
}
async function deleteProvider(id: number) {
try {
const response = await fetch(`/api/secret-providers/${id}`, {
@@ -195,10 +216,11 @@
position="left"
onConfirm={() =>
deleteProvider(provider.id)}
onOpenChange={(open) =>
(confirmDeleteId = open
? provider.id
: null)}
onOpenChange={(open) => {
confirmDeleteId = open ? provider.id : null;
if (open) loadAffectedStacks(provider.id);
else deleteAffectedStacks = [];
}}
>
{#snippet children({ open })}
<Trash2
@@ -207,6 +229,20 @@
: 'text-muted-foreground hover:text-destructive'}"
/>
{/snippet}
{#snippet extraContent()}
{#if loadingAffected}
<p class="text-xs text-muted-foreground">Checking which stacks use this provider...</p>
{:else if deleteAffectedStacks.length > 0}
<p class="text-xs text-amber-600 dark:text-amber-500">
This unbinds {deleteAffectedStacks.length} stack{deleteAffectedStacks.length === 1 ? '' : 's'}; their next deploy drops the injected secrets:
</p>
<ul class="mt-1 text-xs text-muted-foreground list-disc list-inside max-h-24 overflow-y-auto">
{#each deleteAffectedStacks as s}
<li>{s.stackName}</li>
{/each}
</ul>
{/if}
{/snippet}
</ConfirmPopover>
{/if}
</div>
+1
View File
@@ -1365,6 +1365,7 @@
injectedSecretKeys={gitStack !== null ? injectedSecretKeys : []}
providerType={secretProviders.find((p) => p.id === formSecretProviderId)?.type ?? null}
providerName={secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? null}
providerBound={formSecretProviderId != null && secretProviders.some((p) => p.id === formSecretProviderId)}
placeholder={{ key: 'MY_VAR', value: 'value' }}
infoText="Override variables from your repository env files. Non-secrets are saved to <code class='bg-muted px-1 rounded'>.env.dockhand</code> in the stack directory. Secrets are stored in the database and injected via shell environment at deploy time.<br/><br/>Variables are available for <strong>compose file interpolation</strong> using <code class='bg-muted px-1 rounded'>${'{VAR_NAME}'}</code> syntax. They are not automatically injected into containers — use <code class='bg-muted px-1 rounded'>environment:</code> or reference <code class='bg-muted px-1 rounded'>.env.dockhand</code> in <code class='bg-muted px-1 rounded'>env_file:</code> to pass them through."
existingSecretKeys={gitStack !== null ? existingSecretKeys : new Set()}