stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent d7f435dd5f
commit f43736d94a
28 files changed
+667 -100

No files matched your search

+11
View File
@@ -70,6 +70,17 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
}
// Binding a secret provider resolves its secrets into the container at deploy;
// require the secrets permission so a stacks-only user can't exfiltrate a
// provider's secrets by binding it and reading the container env.
if (
typeof data.secretProviderId === 'number' &&
auth.authEnabled &&
!(await auth.can('secrets', 'view', data.environmentId || undefined))
) {
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// Check for name conflicts with existing stacks (regular/external/git)
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
if (existing) {
+11
View File
@@ -59,6 +59,17 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
}
// Binding a secret provider resolves its secrets into the container at deploy;
// require the secrets permission so a stacks-only user can't exfiltrate a
// provider's secrets by binding it and reading the container env.
if (
typeof data.secretProviderId === 'number' &&
auth.authEnabled &&
!(await auth.can('secrets', 'view', existing.environmentId || undefined))
) {
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// Validate stack name if it's being changed
if (data.stackName !== undefined) {
const trimmedStackName = data.stackName.trim();
@@ -7,6 +7,7 @@ import {
deleteSecretProvider
} from '$lib/server/db';
import { hasProvider } from '$lib/server/secretproviders';
import { redactProviderConfig } from '$lib/server/secretproviders/shared';
import { authorize } from '$lib/server/authorize';
import { auditSecretProvider } from '$lib/server/audit';
@@ -21,13 +22,15 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid secret provider ID' }, { status: 400 });
}
const providers = await getSecretProviders();
const provider = providers.find((p) => p.id === id);
if (!provider) {
// Load the full (decrypted) row so the edit form can pre-fill the NON-secret
// coordinates (host, projectId, mount, ...). redactProviderConfig strips the
// token before it leaves the server, so the secret never reaches the client.
const full = await getSecretProviderById(id);
if (!full) {
return json({ error: 'Secret provider not found' }, { status: 404 });
}
// Summary only, never the decrypted config.
return json(provider);
const { config, ...summary } = full;
return json({ ...summary, config: redactProviderConfig(config) });
};
export const PUT: RequestHandler = async (event) => {
+11
View File
@@ -109,6 +109,17 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
}
// Binding a secret provider resolves its secrets into the container at deploy;
// require the secrets permission so a stacks-only user can't exfiltrate a
// provider's secrets by binding it and reading the container env.
if (
typeof secretProviderId === 'number' &&
auth.authEnabled &&
!(await auth.can('secrets', 'view', envIdNum))
) {
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
}
// If start is false, only create the compose file without deploying
if (start === false) {
const result = await saveStackComposeFile(name, compose, true, envIdNum, {