stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)
This commit is contained in:
28 files changed
+667
-100
No files matched your search
@@ -9,4 +9,5 @@ CREATE TABLE "secret_providers" (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stack_sources" ADD COLUMN "secret_provider_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "stack_sources" ADD COLUMN "injected_secret_keys" text;--> statement-breakpoint
|
||||
ALTER TABLE "stack_sources" ADD CONSTRAINT "stack_sources_secret_provider_id_secret_providers_id_fk" FOREIGN KEY ("secret_provider_id") REFERENCES "public"."secret_providers"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -8,4 +8,5 @@ CREATE TABLE `secret_providers` (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `secret_providers_name_unique` ON `secret_providers` (`name`);--> statement-breakpoint
|
||||
ALTER TABLE `stack_sources` ADD `secret_provider_id` integer REFERENCES secret_providers(id) ON DELETE SET NULL;
|
||||
ALTER TABLE `stack_sources` ADD `secret_provider_id` integer REFERENCES secret_providers(id) ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE `stack_sources` ADD `injected_secret_keys` text;
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="1 0 99.6 99.7" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
|
||||
<path d="M64.3.3A7.5 7.5 0 0 0 55 6l-4 19.3a32 32 0 0 1-25 25l-19 4a7.5 7.5 0 1 0 4 14.5l18.3-6c12.3-4.1 25.8-.5 34.3 9.1l13 14.5a7.5 7.5 0 1 0 10.6-10.6l-14.6-13a32 32 0 0 1-9.2-34.4l6.1-18.5c1.4-4.1-1-8.5-5.2-9.6"/>
|
||||
<path d="M14.2 24.4A8 8 0 1 1 25.6 13l9.8 10.8A7.4 7.4 0 0 1 25 34z"/>
|
||||
<path d="M90 30.4A8 8 0 1 1 94.2 46L80 49a7.4 7.4 0 0 1-3.8-14.2z"/>
|
||||
<path d="M46.6 93.3A8 8 0 1 1 31 89.2l4.4-13.9a7.4 7.4 0 0 1 14.2 3.8z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
|
||||
<path d="M0 0l7.971 15.516L16 0H0zm6.732 6.16h-1.27V4.89h1.27v1.27zm0-1.906h-1.27V2.985h1.27v1.269zm1.904 3.81h-1.27v-1.27h1.27v1.27zm0-1.905h-1.27V4.89h1.27v1.27zm0-1.905h-1.27V2.985h1.27v1.269zm1.894 1.905H9.26V4.89h1.27v1.27zM9.26 4.254V2.985h1.27v1.269H9.26z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
|
||||
<path d="M477.8 173.6c-22.1-22.2-51.5-34.5-82.7-34.5-29.5 0-59.5 12.8-89.4 38.1-21.2 17.9-38 38.6-49.7 55.2-10.2-13.7-26.2-33.2-46.6-51.3-14.9-13.4-29.8-23.9-44.2-31.2-16.7-8.5-32.9-12.8-48-12.8-31.3 0-60.8 12.2-82.9 34.5S0 223.5 0 255s12.2 61.2 34.3 83.5c22.2 22.2 51.6 34.5 82.9 34.5 35.4 0 70.7-25.2 94.1-46.3 20.5-18.6 35.9-37.5 45-49.7 16.2 22.3 35 46.1 55.4 63.9 26.7 23.3 53.2 34.1 83.4 34.1 31.2 0 60.5-12.3 82.7-34.6 22.1-22.2 34.3-51.9 34.3-83.4s-12.2-61.1-34.3-83.4M180.8 293c-24.4 22-47.5 34.6-63.6 34.6-39.6 0-71.8-32.6-71.8-72.6s32.2-72.6 71.8-72.6c16.5 0 38.5 11.6 62 32.5 12.6 11.2 25.5 25.3 36.9 40-10.5 13.4-23 27-35.3 38.1M395 329.5c-18.8 0-35.4-7-53.6-22.9C328 295 314.1 279 296 254.5c12.1-16.6 25.4-31.2 39-42.9 21.1-17.8 41.9-27.3 60-27.3 39.5 0 71.6 32.6 71.6 72.6s-32.1 72.6-71.6 72.6"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor" role="img">
|
||||
<path d="M10.51,10.013V7.299c0-0.306,0.248-0.554,0.554-0.554h1.911c0.306,0,0.554,0.248,0.554,0.554v4.968 c0,0.076-0.038,0.134-0.076,0.191l-0.478,0.478c-0.115,0.115-0.115,0.287,0,0.382l0.478,0.478c0.057,0.057,0.076,0.115,0.076,0.191 v2.713c0,0.306-0.248,0.554-0.554,0.554h-1.911c-0.306,0-0.554-0.248-0.554-0.554v-4.968c0-0.076,0.038-0.134,0.076-0.191 l0.478-0.478c0.115-0.115,0.115-0.287,0-0.382l-0.478-0.478C10.529,10.146,10.51,10.089,10.51,10.013z M19.127,12 c0-3.936-3.191-7.127-7.127-7.127S4.873,8.064,4.873,12S8.064,19.127,12,19.127S19.127,15.936,19.127,12z M21.382,12 c0,5.178-4.204,9.363-9.363,9.363c-5.178,0-9.363-4.204-9.363-9.363c0-5.178,4.204-9.363,9.363-9.363 C17.178,2.637,21.382,6.822,21.382,12z M0.764,12c0,6.21,5.025,11.236,11.236,11.236S23.236,18.21,23.236,12S18.21,0.764,12,0.764 S0.764,5.79,0.764,12z M0,12C0,5.369,5.369,0,12,0c6.631,0,12,5.369,12,12s-5.369,12-12,12S0,18.631,0,12z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Component } from 'svelte';
|
||||
import { KeyRound } from 'lucide-svelte';
|
||||
import OnePassword from './OnePassword.svelte';
|
||||
import Infisical from './Infisical.svelte';
|
||||
import HashiCorpVault from './HashiCorpVault.svelte';
|
||||
import Doppler from './Doppler.svelte';
|
||||
|
||||
/** Brand icon for a secret-provider type. Falls back to a generic key icon. */
|
||||
export function getProviderIcon(type: string): Component {
|
||||
switch (type) {
|
||||
case 'op-service-account':
|
||||
case 'op-connect':
|
||||
return OnePassword;
|
||||
case 'infisical':
|
||||
return Infisical;
|
||||
case 'vault':
|
||||
return HashiCorpVault;
|
||||
case 'doppler':
|
||||
return Doppler;
|
||||
default:
|
||||
return KeyRound; // any future provider until it has a brand icon
|
||||
}
|
||||
}
|
||||
+61
-1
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { SecretProviderConfig, SecretProviderType } from './secretproviders/shared';
|
||||
import { SECRET_CONFIG_KEYS } from './secretproviders/shared';
|
||||
import {
|
||||
db,
|
||||
isPostgres,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
import { parseInjectedSecretKeys, serializeInjectedSecretKeys } from './stack-secret-keys';
|
||||
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@@ -375,7 +377,25 @@ export async function updateSecretProvider(
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.type !== undefined) updateData.type = data.type;
|
||||
if (data.config !== undefined) {
|
||||
const encrypted = encrypt(JSON.stringify(data.config));
|
||||
// The edit form pre-fills non-secret fields but leaves the token blank to mean
|
||||
// "keep the stored secret". Merge the incoming config OVER the existing one so a
|
||||
// blank/absent secret keeps its stored value instead of being wiped. Only applies
|
||||
// when the provider type is unchanged (a type change is a full re-config).
|
||||
let merged: Record<string, unknown> = { ...(data.config as Record<string, unknown>) };
|
||||
const typeUnchanged = data.type === undefined;
|
||||
if (typeUnchanged) {
|
||||
const existing = await getSecretProviderById(id);
|
||||
if (existing) {
|
||||
for (const key of SECRET_CONFIG_KEYS) {
|
||||
const incoming = (data.config as Record<string, unknown>)[key];
|
||||
if (incoming === undefined || incoming === '') {
|
||||
const stored = (existing.config as Record<string, unknown>)[key];
|
||||
if (stored !== undefined) merged[key] = stored;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const encrypted = encrypt(JSON.stringify(merged));
|
||||
if (encrypted) updateData.config = encrypted;
|
||||
}
|
||||
await db.update(secretProviders).set(updateData).where(eq(secretProviders.id, id));
|
||||
@@ -3075,6 +3095,40 @@ export async function updateStackSource(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the names (no values) of secret keys injected from the bound provider on
|
||||
* the last deploy, so container inspect can mask them without a live provider call.
|
||||
* Stored as a JSON array on stack_sources; null clears it. No-op if the stack has
|
||||
* no source row.
|
||||
*/
|
||||
export async function setStackInjectedSecretKeys(
|
||||
stackName: string,
|
||||
environmentId: number | null | undefined,
|
||||
keys: string[]
|
||||
): Promise<void> {
|
||||
const existing = await getStackSource(stackName, environmentId ?? null);
|
||||
if (!existing) return;
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
injectedSecretKeys: serializeInjectedSecretKeys(keys),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read back the provider-injected secret key names for a stack (empty if none).
|
||||
* Tolerates a malformed/legacy value by returning an empty set.
|
||||
*/
|
||||
export async function getStackInjectedSecretKeys(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Set<string>> {
|
||||
const source = await getStackSource(stackName, environmentId ?? null);
|
||||
const raw = (source as { injectedSecretKeys?: string | null } | null)?.injectedSecretKeys;
|
||||
return parseInjectedSecretKeys(raw);
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
@@ -5241,6 +5295,12 @@ export async function getSecretKeysToMask(
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
|
||||
// Provider-injected secrets (Vault/Doppler bulk keys, promoted op:// refs) live
|
||||
// only in the provider, never in stack_env_vars, so add the names persisted on
|
||||
// the last deploy. Without this they leak plaintext in container inspect.
|
||||
const injected = await getStackInjectedSecretKeys(stackName, environmentId);
|
||||
for (const key of injected) secretKeyNames.add(key);
|
||||
|
||||
if (secretKeyNames.size === 0) return secretKeyNames;
|
||||
|
||||
// If we have compose content, parse interpolation references to find
|
||||
|
||||
@@ -354,6 +354,9 @@ export const stackSources = sqliteTable('stack_sources', {
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
secretProviderId: integer('secret_provider_id').references(() => secretProviders.id, { onDelete: 'set null' }),
|
||||
// Names (no values) of secret keys injected from the bound provider on the last
|
||||
// deploy, so container inspect can mask them without a live provider call.
|
||||
injectedSecretKeys: text('injected_secret_keys'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -357,6 +357,9 @@ export const stackSources = pgTable('stack_sources', {
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
secretProviderId: integer('secret_provider_id').references(() => secretProviders.id, { onDelete: 'set null' }),
|
||||
// Names (no values) of secret keys injected from the bound provider on the last
|
||||
// deploy, so container inspect can mask them without a live provider call.
|
||||
injectedSecretKeys: text('injected_secret_keys'),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
@@ -365,7 +365,8 @@ export async function migrateCredentials(): Promise<void> {
|
||||
ldapConfig,
|
||||
notificationSettings,
|
||||
stackEnvironmentVariables,
|
||||
backupDestinations
|
||||
backupDestinations,
|
||||
secretProviders
|
||||
} = await import('./db/drizzle.js');
|
||||
|
||||
let migrated = 0;
|
||||
@@ -461,6 +462,13 @@ export async function migrateCredentials(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const providers = await db.select().from(secretProviders);
|
||||
for (const p of providers) {
|
||||
if (p.config && isEncrypted(p.config)) {
|
||||
allEncrypted.push({ table: 'secretProviders', id: p.id, field: 'config', value: p.config });
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt all values with old key (still cached at this point)
|
||||
const decryptedValues: Map<string, string> = new Map();
|
||||
for (const item of allEncrypted) {
|
||||
@@ -510,6 +518,8 @@ export async function migrateCredentials(): Promise<void> {
|
||||
} else if (item.table === 'backupDestinations') {
|
||||
// Both password and envVars are plain encrypted columns.
|
||||
await tx.update(backupDestinations).set({ [item.field]: reEncrypted }).where(eq(backupDestinations.id, item.id));
|
||||
} else if (item.table === 'secretProviders') {
|
||||
await tx.update(secretProviders).set({ config: reEncrypted }).where(eq(secretProviders.id, item.id));
|
||||
}
|
||||
|
||||
migrated++;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { ConnectConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
/** A vault as returned by GET /v1/vaults. */
|
||||
@@ -72,6 +73,7 @@ class ReferenceNotFoundError extends Error {
|
||||
|
||||
/** Normalises a host URL into the Connect v1 API base (no trailing slash). */
|
||||
function baseUrl(host: string): string {
|
||||
assertSafeProviderHost(host, '1Password Connect');
|
||||
return `${host.replace(/\/$/, '')}/v1`;
|
||||
}
|
||||
|
||||
@@ -132,8 +134,11 @@ async function connectGet<T>(
|
||||
return undefined;
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await body.text();
|
||||
throw new Error(`1Password Connect request failed (${statusCode}): ${detail || 'no body'}`);
|
||||
// Log the raw body server-side only; the thrown message (which reaches the
|
||||
// deploy job / client) carries just the status, never arbitrary upstream bytes.
|
||||
const detail = await body.text().catch(() => '');
|
||||
if (detail) console.warn(`[1Password Connect] request ${statusCode}: ${detail}`);
|
||||
throw new Error(`1Password Connect request failed (${statusCode})`);
|
||||
}
|
||||
return (await body.json()) as T;
|
||||
}
|
||||
@@ -251,9 +256,13 @@ export const connectProvider: SecretProvider<ConnectConfig> = {
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return { ok: false, error: 'Invalid 1Password Connect token' };
|
||||
}
|
||||
// Log the raw body server-side; show the client only a message parsed from
|
||||
// Connect's own {message}/{messages} error shape (never arbitrary bytes).
|
||||
if (text) console.warn(`[1Password Connect] testConnection ${statusCode}: ${text}`);
|
||||
const safe = parseProviderError(text);
|
||||
return {
|
||||
ok: false,
|
||||
error: `1Password Connect request failed (${statusCode}): ${text || 'no body'}`
|
||||
error: `1Password Connect request failed (${statusCode})${safe ? `: ${safe}` : ''}`
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Doppler provider.
|
||||
*
|
||||
* Talks to Doppler's REST API to bulk-pull every secret in a config. Doppler has
|
||||
* no inline compose-reference syntax, so this provider is bulk-only:
|
||||
* `resolveSecretReferences` is unsupported and `isReference` is always false. A
|
||||
* SERVICE token (dp.st.) already identifies the project + config; a PERSONAL token
|
||||
* (dp.pt.) is account-wide and needs project/config supplied in the config. Either
|
||||
* way the DOCKHAND_SECRET_SELECTOR is ignored (the config, not the selector, is
|
||||
* the scope).
|
||||
*
|
||||
* Auth is a Doppler service token sent as `Authorization: Bearer <token>`. The
|
||||
* download endpoint returns a flat `{ KEY: value }` JSON map when asked for
|
||||
* `format=json`. `host` defaults to Doppler's SaaS API and is overridable only
|
||||
* for testing against a stub (Doppler is not self-hostable).
|
||||
*
|
||||
* Decrypted tokens stay inside this module for the duration of a call. Nothing
|
||||
* here writes to disk or to the database.
|
||||
*/
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { DopplerConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost, parseProviderError } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
const DEFAULT_HOST = 'https://api.doppler.com';
|
||||
|
||||
/**
|
||||
* Keys Doppler injects into EVERY config's download - its own metadata about the
|
||||
* project/config the token is scoped to, not the user's secrets. We drop them so
|
||||
* they never leak into the stack's environment. (Confirmed against the live API:
|
||||
* every config returns exactly these three alongside the real secrets.)
|
||||
*/
|
||||
export const DOPPLER_META_KEYS = new Set(['DOPPLER_PROJECT', 'DOPPLER_CONFIG', 'DOPPLER_ENVIRONMENT']);
|
||||
|
||||
/** Strips a trailing slash so `${base}${path}` never doubles up. */
|
||||
function baseUrl(host: string | undefined): string {
|
||||
const resolved = (host?.trim() || DEFAULT_HOST).replace(/\/$/, '');
|
||||
assertSafeProviderHost(resolved, 'Doppler');
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The download URL. `include_dynamic_secrets=false` keeps the response a plain
|
||||
* flat map (dynamic secrets would nest). A service token already implies the
|
||||
* project/config; a personal token needs them as query params (else the API
|
||||
* returns 400). We append whichever were provided and let Doppler decide.
|
||||
*/
|
||||
function downloadUrl(config: DopplerConfig): string {
|
||||
let url = `${baseUrl(config.host)}/v3/configs/config/secrets/download?format=json&include_dynamic_secrets=false`;
|
||||
if (config.project?.trim()) url += `&project=${encodeURIComponent(config.project.trim())}`;
|
||||
if (config.config?.trim()) url += `&config=${encodeURIComponent(config.config.trim())}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export const dopplerProvider: SecretProvider<DopplerConfig> = {
|
||||
type: 'doppler',
|
||||
label: 'Doppler',
|
||||
supportsReferences: false,
|
||||
supportsBulk: true,
|
||||
|
||||
isReference(_value: unknown): _value is string {
|
||||
return false;
|
||||
},
|
||||
|
||||
async testConnection(config: DopplerConfig): Promise<TestConnectionResult> {
|
||||
const token = config.token?.trim();
|
||||
if (!token) {
|
||||
return { ok: false, error: 'Token is empty' };
|
||||
}
|
||||
try {
|
||||
const { statusCode, body } = await request(downloadUrl(config), {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
// Drain the body; on failure log it server-side and show the client only a
|
||||
// message parsed from Doppler's own {messages:[...]} shape.
|
||||
const rawBody = await body.text().catch(() => '');
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (rawBody) console.warn(`[Doppler] testConnection ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return { ok: false, error: `Doppler returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async resolveSecretReferences(): Promise<Map<string, string>> {
|
||||
throw new UnsupportedOperationError(
|
||||
'Doppler does not support inline references; a service token bulk-pulls its whole config.'
|
||||
);
|
||||
},
|
||||
|
||||
async resolveBulk(config: DopplerConfig, _selector: string): Promise<Record<string, string>> {
|
||||
const token = config.token?.trim();
|
||||
if (!token) {
|
||||
throw new Error('[Doppler] Token is required for a bulk pull');
|
||||
}
|
||||
|
||||
const { statusCode, body } = await request(downloadUrl(config), {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await body.text().catch(() => '');
|
||||
throw new Error(`[Doppler] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
// format=json returns a flat { KEY: value } object; coerce non-string values
|
||||
// and drop Doppler's own metadata keys (DOPPLER_META_KEYS).
|
||||
const payload = (await body.json()) as Record<string, unknown>;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(payload ?? {})) {
|
||||
if (DOPPLER_META_KEYS.has(key)) continue;
|
||||
if (value !== null && value !== undefined) {
|
||||
result[key] = typeof value === 'string' ? value : String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { serviceAccountProvider } from './service-account';
|
||||
import { connectProvider } from './connect';
|
||||
import { infisicalProvider } from './infisical';
|
||||
import { vaultProvider } from './vault';
|
||||
import { dopplerProvider } from './doppler';
|
||||
|
||||
// Registered providers. Adding a new backend means dropping a file in this
|
||||
// directory and registering it here; each implements the SecretProvider
|
||||
@@ -25,7 +26,8 @@ const providers: Record<string, SecretProvider> = {
|
||||
[serviceAccountProvider.type]: serviceAccountProvider as SecretProvider,
|
||||
[connectProvider.type]: connectProvider as SecretProvider,
|
||||
[infisicalProvider.type]: infisicalProvider as SecretProvider,
|
||||
[vaultProvider.type]: vaultProvider as SecretProvider
|
||||
[vaultProvider.type]: vaultProvider as SecretProvider,
|
||||
[dopplerProvider.type]: dopplerProvider as SecretProvider
|
||||
};
|
||||
|
||||
/** Returns the provider for a stored type, or undefined if unknown. */
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { InfisicalConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost, parseProviderError } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
/** Shape of a single secret in the /api/v3/secrets/raw response. */
|
||||
@@ -33,6 +34,7 @@ interface RawSecretsResponse {
|
||||
|
||||
/** Strips a trailing slash so `${base}${path}` never doubles up. */
|
||||
function baseUrl(host: string): string {
|
||||
assertSafeProviderHost(host, 'Infisical');
|
||||
return host.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
@@ -91,12 +93,15 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
// Always drain the body so undici can reuse the connection.
|
||||
await body.text().catch(() => '');
|
||||
// Drain the body; on failure log it server-side and show the client only a
|
||||
// message parsed from Infisical's own {message}/{messages} shape.
|
||||
const rawBody = await body.text().catch(() => '');
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}` };
|
||||
if (rawBody) console.warn(`[Infisical] testConnection ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
@@ -138,11 +143,10 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
// Drain the body so the connection can be reused, then surface the status.
|
||||
// Drain the body, log it server-side, but never reflect it to the client.
|
||||
const detail = await body.text().catch(() => '');
|
||||
throw new Error(
|
||||
`[Infisical] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
if (detail) console.warn(`[Infisical] bulk pull ${statusCode}: ${detail}`);
|
||||
throw new Error(`[Infisical] Bulk pull failed with HTTP ${statusCode}`);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as RawSecretsResponse;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* persisted here — providers hold it only for the duration of a call.
|
||||
*/
|
||||
|
||||
import { isSafeNotificationUrl } from '../url-safety';
|
||||
|
||||
/**
|
||||
* Identifies which backend a stored provider row talks to. Matches the
|
||||
* `secret_providers.type` column and the API. Kept as a widenable string union
|
||||
@@ -28,9 +30,78 @@ export type SecretProviderType =
|
||||
| 'op-connect'
|
||||
| 'infisical'
|
||||
| 'vault'
|
||||
| 'doppler'
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
| (string & {});
|
||||
|
||||
/**
|
||||
* SSRF guard for a provider's user-supplied host/address. A provider makes
|
||||
* server-side HTTP requests to whatever host the user configured, so an
|
||||
* unchecked value lets an authenticated user pivot at Dockhand's network
|
||||
* (cloud-metadata 169.254.169.254, loopback, internal services) and read the
|
||||
* response back through testConnection / the bulk pull. Same policy as
|
||||
* notifications: block loopback + cloud metadata, allow ordinary LAN ranges so a
|
||||
* self-hosted Vault/Infisical/Connect on the local network still works. Throws on
|
||||
* an unsafe host; call it before the first request in every REST provider.
|
||||
*/
|
||||
export function assertSafeProviderHost(rawUrl: string, label: string): void {
|
||||
const safe = isSafeNotificationUrl(rawUrl);
|
||||
if (!safe.ok) {
|
||||
throw new Error(`${label}: host not allowed (${safe.reason})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a user-supplied selector/path before it is concatenated into a
|
||||
* provider's API URL (Vault KV path, Infisical secretPath). The selector comes
|
||||
* from a stack env var an operator controls, so an unencoded value could inject
|
||||
* `..` traversal or `?`/`#`/scheme characters and forge a request to a different
|
||||
* endpoint on the same server. Rejects `..` segments and percent-encodes each
|
||||
* path segment; a leading slash is stripped by the caller as before.
|
||||
*/
|
||||
export function sanitizeSelectorPath(selector: string, label: string): string {
|
||||
const trimmed = selector.trim().replace(/^\/+/, '');
|
||||
const segments = trimmed.split('/');
|
||||
for (const seg of segments) {
|
||||
if (seg === '..' || seg === '.') {
|
||||
throw new Error(`${label}: selector must not contain path traversal ("${selector}")`);
|
||||
}
|
||||
}
|
||||
return segments.map((s) => encodeURIComponent(s)).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a short, human error message from a provider's error RESPONSE BODY, but
|
||||
* only when the body parses as that provider's own documented error JSON shape.
|
||||
* Used by testConnection (interactive setup) to show an actionable message like
|
||||
* "permission denied" or "Invalid Auth token" WITHOUT reflecting arbitrary upstream
|
||||
* bytes: a non-provider host (SSRF probe of a LAN service) returns HTML/other text
|
||||
* that does not match the shape, so nothing leaks. Returns null when the body is
|
||||
* absent, unparseable, or not the expected shape - the caller then shows status only.
|
||||
*
|
||||
* Shapes: Vault `{errors:[...]}`, Doppler/Infisical `{messages:[...]}` / `{message}`.
|
||||
*/
|
||||
export function parseProviderError(rawBody: string | undefined | null): string | null {
|
||||
if (!rawBody) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return null; // not JSON -> not a known provider error shape
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const pick = (v: unknown): string | null => {
|
||||
if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 200);
|
||||
if (Array.isArray(v)) {
|
||||
const strs = v.filter((x) => typeof x === 'string' && x.trim()) as string[];
|
||||
if (strs.length) return strs.join('; ').slice(0, 200);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return pick(obj.errors) ?? pick(obj.messages) ?? pick(obj.message) ?? null;
|
||||
}
|
||||
|
||||
/** 1Password service account: a single bearer token. */
|
||||
export interface ServiceAccountConfig {
|
||||
token: string;
|
||||
@@ -66,12 +137,52 @@ export interface VaultConfig {
|
||||
mount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Doppler: bulk-only. A SERVICE token (dp.st.) is scoped to one config, so the
|
||||
* token alone identifies it and `project`/`config` are not needed. A PERSONAL
|
||||
* token (dp.pt.) is account-wide, so it MUST be told which project + config to
|
||||
* download (the API returns 400 "You must specify a project" otherwise). `host`
|
||||
* defaults to Doppler's SaaS API; it is overridable only for testing against a
|
||||
* stub (Doppler has no self-hosted server).
|
||||
*/
|
||||
export interface DopplerConfig {
|
||||
token: string;
|
||||
host?: string;
|
||||
/** Required for a personal token (dp.pt.); ignored for a service token. */
|
||||
project?: string;
|
||||
config?: string;
|
||||
}
|
||||
|
||||
/** Persisted (encrypted) config, discriminated by the provider `type`. */
|
||||
export type SecretProviderConfig =
|
||||
| ServiceAccountConfig
|
||||
| ConnectConfig
|
||||
| InfisicalConfig
|
||||
| VaultConfig;
|
||||
| VaultConfig
|
||||
| DopplerConfig;
|
||||
|
||||
/**
|
||||
* Config keys that hold a SECRET across every provider type. Only these are stripped
|
||||
* when a provider config is sent to the client (the edit form); everything else
|
||||
* (host, address, projectId, environment, path, namespace, mount, project, config) is
|
||||
* a non-secret coordinate the user needs to see and edit. Keep in sync with the
|
||||
* `type: 'password'` fields in ProviderModal.svelte's PROVIDER_FIELDS.
|
||||
*/
|
||||
export const SECRET_CONFIG_KEYS = new Set(['token']);
|
||||
|
||||
/**
|
||||
* Returns a copy of a provider config with secret values removed, so the edit form
|
||||
* can pre-fill the non-secret coordinates without the token ever leaving the server.
|
||||
* A stripped secret is simply absent (the form treats a blank secret field as
|
||||
* "keep the stored value").
|
||||
*/
|
||||
export function redactProviderConfig(config: SecretProviderConfig): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (!SECRET_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
ok: boolean;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { request } from 'undici';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
import { UnsupportedOperationError, assertSafeProviderHost, sanitizeSelectorPath, parseProviderError } from './shared';
|
||||
import type { SecretProvider, TestConnectionResult, VaultConfig } from './shared';
|
||||
|
||||
/** Undici response body handle, derived so we don't import undici's types. */
|
||||
@@ -34,6 +34,7 @@ interface KvV2ReadResponse {
|
||||
|
||||
/** `${address}/v1`, with any trailing slash on the address removed. */
|
||||
function apiBase(config: VaultConfig): string {
|
||||
assertSafeProviderHost(config.address, 'HashiCorp Vault');
|
||||
return `${config.address.replace(/\/$/, '')}/v1`;
|
||||
}
|
||||
|
||||
@@ -97,13 +98,18 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
headers: authHeaders(config)
|
||||
});
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
await body.dump();
|
||||
await body.text().catch(() => '');
|
||||
return { ok: true };
|
||||
}
|
||||
const detail = await readErrorDetail(body);
|
||||
const rawBody = await body.text().catch(() => '');
|
||||
// Log the full upstream body server-side; show the client only a message
|
||||
// parsed from Vault's own {errors:[...]} shape (a non-Vault host probed via
|
||||
// SSRF returns other text that won't parse, so nothing leaks).
|
||||
if (rawBody) console.warn(`[HashiCorp Vault] token lookup ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Vault returned ${statusCode}${detail ? `: ${detail}` : ''}`
|
||||
error: `Vault returned ${statusCode}${safe ? `: ${safe}` : ''}`
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
@@ -122,8 +128,8 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
},
|
||||
|
||||
async resolveBulk(config: VaultConfig, selector: string): Promise<Record<string, string>> {
|
||||
const mount = config.mount || 'secret';
|
||||
const path = selector.replace(/^\/+/, '');
|
||||
const mount = encodeURIComponent(config.mount || 'secret');
|
||||
const path = sanitizeSelectorPath(selector, 'HashiCorp Vault');
|
||||
const { statusCode, body } = await request(`${apiBase(config)}/${mount}/data/${path}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(config)
|
||||
@@ -136,9 +142,8 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await readErrorDetail(body);
|
||||
throw new Error(
|
||||
`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
if (detail) console.warn(`[HashiCorp Vault] read ${mount}/${path} ${statusCode}: ${detail}`);
|
||||
throw new Error(`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})`);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as KvV2ReadResponse;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Pure helpers for the `stack_sources.injected_secret_keys` column - the names (no
|
||||
* values) of provider-injected secrets recorded on the last deploy so container
|
||||
* inspect can mask them. Kept dependency-free (no DB import) so it unit-tests without
|
||||
* spinning up the database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses the stored JSON string[] of key names into a Set. Tolerates
|
||||
* null/empty/malformed/legacy values by returning an empty set (never throws), so a
|
||||
* bad row degrades to "nothing extra masked" rather than a 500. Non-string array
|
||||
* members are dropped.
|
||||
*/
|
||||
export function parseInjectedSecretKeys(raw: string | null | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? new Set(parsed.filter((k) => typeof k === 'string')) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises the injected key names for storage: a JSON array, or null when empty so
|
||||
* an unbound / provider-less stack clears the column instead of storing `[]`.
|
||||
*/
|
||||
export function serializeInjectedSecretKeys(keys: string[]): string | null {
|
||||
return keys.length > 0 ? JSON.stringify(keys) : null;
|
||||
}
|
||||
@@ -58,14 +58,46 @@ export function isSafeNotificationUrl(raw: string): { ok: boolean; reason?: stri
|
||||
*/
|
||||
export type IpCategory = 'loopback' | 'metadata' | 'private' | 'reserved';
|
||||
|
||||
/**
|
||||
* Extract the embedded IPv4 dotted-quad from an IPv4-mapped/compatible IPv6 host,
|
||||
* or null. `new URL()` normalizes `::ffff:1.2.3.4` to the HEX form `::ffff:102:304`,
|
||||
* so a regex on the dotted form alone is dead on the real code path — every mapped
|
||||
* address must be canonicalized from its hextets. Handles the compressed
|
||||
* `::ffff:HHHH:HHHH`, the fully-expanded `0:0:0:0:0:ffff:HHHH:HHHH`, the
|
||||
* `::ffff:0:HHHH:HHHH` (v4-translated), and the deprecated IPv4-compatible
|
||||
* `::HHHH:HHHH` / `::a.b.c.d`. The last two 16-bit hextets carry the 32-bit v4.
|
||||
*/
|
||||
function embeddedV4(h: string): string | null {
|
||||
// Trailing dotted quad after a v4-mapped/compatible prefix, in either the
|
||||
// compressed (::ffff:1.2.3.4) or fully-expanded (0:0:0:0:0:ffff:1.2.3.4) form.
|
||||
const dotted = h.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (dotted && /^(::ffff:|::ffff:0:|::|(0:){5}ffff:|(0:){4}0:ffff:)/.test(h)) {
|
||||
return dotted[1];
|
||||
}
|
||||
// Hex-hextet v4-mapped/compatible forms. Take the last two colon-separated
|
||||
// groups as the 32-bit v4 IFF the address is one of the embedded-v4 shapes.
|
||||
const isMapped =
|
||||
/^::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
|
||||
/^::ffff:0:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
|
||||
/^(0:){5}ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h) ||
|
||||
/^::[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(h); // deprecated v4-compatible ::a:b
|
||||
if (!isMapped) return null;
|
||||
const groups = h.split(':').filter((g) => g.length > 0);
|
||||
if (groups.length < 2) return null;
|
||||
const hi = parseInt(groups[groups.length - 2], 16);
|
||||
const lo = parseInt(groups[groups.length - 1], 16);
|
||||
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
|
||||
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
||||
}
|
||||
|
||||
export function ipCategory(host: string): IpCategory | null {
|
||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
// IPv6 loopback, then link-local / unique-local (treated as private).
|
||||
// IPv6 loopback / unspecified, then link-local / unique-local (private).
|
||||
if (h === '::1') return 'loopback';
|
||||
if (h === '::' || h === '::0') return 'reserved'; // unspecified / all-interfaces
|
||||
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return 'private';
|
||||
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — judge the embedded v4.
|
||||
const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
const v4 = mapped ? mapped[1] : h;
|
||||
// IPv4-mapped/compatible IPv6 — judge the embedded v4 (see embeddedV4).
|
||||
const v4 = embeddedV4(h) ?? h;
|
||||
const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (m) {
|
||||
const [a, b] = [Number(m[1]), Number(m[2])];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -126,6 +126,10 @@
|
||||
{#if activeTab === 'git'}<GitTab />{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="secrets" class="flex-1 min-h-0 overflow-y-auto pr-5">
|
||||
{#if activeTab === 'secrets'}<SecretsTab />{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="config-sets" class="flex-1 min-h-0 overflow-y-auto pr-5">
|
||||
{#if activeTab === 'config-sets'}<ConfigSetsTab />{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{ value: 'op-connect', label: '1Password Connect' },
|
||||
{ value: 'infisical', label: 'Infisical' },
|
||||
{ value: 'vault', label: 'HashiCorp Vault' },
|
||||
{ value: 'doppler', label: 'Doppler' },
|
||||
];
|
||||
|
||||
// Config fields per provider type, matching the config shapes in
|
||||
@@ -48,6 +49,11 @@
|
||||
{ key: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin (Enterprise / HCP)' },
|
||||
{ key: 'mount', label: 'KV mount', type: 'text', required: false, placeholder: 'secret' },
|
||||
],
|
||||
doppler: [
|
||||
{ key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'dp.st.... or dp.pt....', hint: 'A service token (dp.st.) already targets one config. A personal token (dp.pt.) also needs the project and config below.' },
|
||||
{ key: 'project', label: 'Project', type: 'text', required: false, placeholder: 'only for a personal token (dp.pt.)' },
|
||||
{ key: 'config', label: 'Config', type: 'text', required: false, placeholder: 'e.g. prd' },
|
||||
],
|
||||
};
|
||||
|
||||
export function providerTypeLabel(type: string): string {
|
||||
@@ -59,9 +65,12 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { FieldLabel } from '$lib/components/ui/field-label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Plus, Check, RefreshCw, PlugZap } from 'lucide-svelte';
|
||||
import { Plus, Check, RefreshCw, PlugZap, KeyRound } from 'lucide-svelte';
|
||||
import { scale } from 'svelte/transition';
|
||||
import { backOut, cubicIn } from 'svelte/easing';
|
||||
import { getProviderIcon } from '$lib/components/provider-icons';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
@@ -88,6 +97,9 @@
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
let formTesting = $state(false);
|
||||
// Brief green tick on the Test connection button right after a successful test.
|
||||
let testOk = $state(false);
|
||||
let testOkTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const fields = $derived(PROVIDER_FIELDS[formType] ?? []);
|
||||
|
||||
@@ -111,14 +123,34 @@
|
||||
formType = provider.type;
|
||||
resetConfig();
|
||||
formError = '';
|
||||
// Pre-fill the NON-secret config fields (host, projectId, mount, ...) from
|
||||
// the server; the token stays blank ('keep existing'). The list only has a
|
||||
// summary, so fetch the single provider which returns the redacted config.
|
||||
void loadProviderConfig(provider.id);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The config never leaves the server, so on edit the fields start blank.
|
||||
// Collect only the fields the user actually filled in.
|
||||
async function loadProviderConfig(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/secret-providers/${id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const cfg = (data?.config ?? {}) as Record<string, unknown>;
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(cfg)) {
|
||||
if (value != null) next[key] = String(value);
|
||||
}
|
||||
formConfig = next; // secret fields (token) are absent -> stay blank
|
||||
} catch {
|
||||
// leave fields blank on failure - the user can re-enter them
|
||||
}
|
||||
}
|
||||
|
||||
// A blank secret field on edit means "keep the stored value"; non-secret fields are
|
||||
// pre-filled (loadProviderConfig). Collect only the fields the user actually filled.
|
||||
function collectConfig(): Record<string, string> {
|
||||
const config: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
@@ -128,8 +160,11 @@
|
||||
return config;
|
||||
}
|
||||
|
||||
function missingRequired(config: Record<string, string>): string | null {
|
||||
function missingRequired(config: Record<string, string>, editing = false): string | null {
|
||||
for (const field of fields) {
|
||||
// On edit a blank secret (password) field keeps the stored value, so it is
|
||||
// allowed to be empty; non-secret required fields still must be present.
|
||||
if (editing && field.type === 'password') continue;
|
||||
if (field.required && !config[field.key]) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
@@ -149,16 +184,19 @@
|
||||
formError = '';
|
||||
try {
|
||||
const config = collectConfig();
|
||||
const configProvided = Object.keys(config).length > 0;
|
||||
// On edit the token is server-side (blank field = keep stored), so if the
|
||||
// user hasn't typed a new one, test the STORED config; the pre-filled
|
||||
// non-secret fields aren't enough to re-test from the client.
|
||||
const hasNewSecret = fields.some((f) => f.type === 'password' && (config[f.key] ?? '') !== '');
|
||||
|
||||
let response: Response;
|
||||
if (isEditing && !configProvided) {
|
||||
if (isEditing && !hasNewSecret) {
|
||||
// Test the stored (server-side) config.
|
||||
response = await fetch(`/api/secret-providers/${provider!.id}/test`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} else {
|
||||
const missing = missingRequired(config);
|
||||
const missing = missingRequired(config, isEditing);
|
||||
if (missing) {
|
||||
formError = missing;
|
||||
return;
|
||||
@@ -172,6 +210,9 @@
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
toast.success('Connection works');
|
||||
clearTimeout(testOkTimer);
|
||||
testOk = true;
|
||||
testOkTimer = setTimeout(() => (testOk = false), 2000);
|
||||
} else {
|
||||
toast.error(data.error || 'Connection failed');
|
||||
formError = data.error || 'Connection failed';
|
||||
@@ -190,17 +231,15 @@
|
||||
}
|
||||
|
||||
const config = collectConfig();
|
||||
const configProvided = Object.keys(config).length > 0;
|
||||
|
||||
// On create, a complete config is required. On edit, config is optional
|
||||
// (blank keeps the stored config); if the user touches any field they
|
||||
// must supply the whole config, since it is replaced wholesale.
|
||||
if (!isEditing || configProvided) {
|
||||
const missing = missingRequired(config);
|
||||
if (missing) {
|
||||
formError = missing;
|
||||
return;
|
||||
}
|
||||
// On create, every required field must be present. On EDIT, a blank SECRET
|
||||
// field (token) means "keep the stored value", so a required secret is allowed
|
||||
// to be blank; non-secret required fields (host, projectId, ...) are pre-filled
|
||||
// and still validated. The backend merges the stored secret over the blank.
|
||||
const missing = missingRequired(config, isEditing);
|
||||
if (missing) {
|
||||
formError = missing;
|
||||
return;
|
||||
}
|
||||
|
||||
formSaving = true;
|
||||
@@ -210,8 +249,11 @@
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName.trim(),
|
||||
type: formType,
|
||||
// Always send config; on edit the backend keeps the stored secret when a
|
||||
// secret field is blank (updateSecretProvider merges), and the non-secret
|
||||
// fields are pre-filled, so `config` is the full intended coordinates.
|
||||
config,
|
||||
};
|
||||
if (configProvided) body.config = config;
|
||||
|
||||
const url = isEditing
|
||||
? `/api/secret-providers/${provider!.id}`
|
||||
@@ -241,6 +283,8 @@
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
clearTimeout(testOkTimer);
|
||||
testOk = false;
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
@@ -255,11 +299,12 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title
|
||||
>{isEditing ? "Edit" : "Add"} secret provider</Dialog.Title
|
||||
>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<KeyRound class="w-5 h-5 text-muted-foreground" />
|
||||
{isEditing ? "Edit" : "Add"} secret provider
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
{#if formError}
|
||||
@@ -268,55 +313,60 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-2">
|
||||
<Label for="provider-name">Name</Label>
|
||||
<FieldLabel label="Name" forId="provider-name" required showOptional={false} />
|
||||
<Input
|
||||
id="provider-name"
|
||||
bind:value={formName}
|
||||
placeholder="Production secrets"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="provider-type">Provider</Label>
|
||||
<div class="space-y-2 sm:w-[calc(50%-0.5rem)]">
|
||||
<FieldLabel label="Provider" forId="provider-type" required showOptional={false} />
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formType}
|
||||
onValueChange={onTypeChange}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<Select.Trigger id="provider-type">
|
||||
<Select.Trigger id="provider-type" class="flex w-full items-center gap-2">
|
||||
{@const TriggerIcon = getProviderIcon(formType)}
|
||||
<TriggerIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{providerTypeLabel(formType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each PROVIDER_TYPES as t (t.value)}
|
||||
{@const ItemIcon = getProviderIcon(t.value)}
|
||||
<Select.Item value={t.value} label={t.label}>
|
||||
{t.label}
|
||||
<span class="flex items-center gap-2">
|
||||
<ItemIcon class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{t.label}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#each fields as field (field.key)}
|
||||
<div class="space-y-2">
|
||||
<Label for={`provider-${field.key}`}>
|
||||
{field.label}{#if !field.required}<span
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
(optional)</span
|
||||
>{/if}
|
||||
</Label>
|
||||
<Input
|
||||
id={`provider-${field.key}`}
|
||||
type={field.type}
|
||||
bind:value={formConfig[field.key]}
|
||||
placeholder={isEditing && field.type === "password"
|
||||
? "leave blank to keep existing"
|
||||
: field.placeholder}
|
||||
/>
|
||||
{#if field.hint}
|
||||
<p class="text-xs text-muted-foreground">{field.hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Provider config fields in a 2-column grid. min-height fits the tallest
|
||||
provider (5 fields = 3 rows) so the modal keeps a fixed height and does
|
||||
not jump when switching providers. -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3" style="min-height: 13.5rem;">
|
||||
{#each fields as field (field.key)}
|
||||
<div class="space-y-2">
|
||||
<FieldLabel label={field.label} forId={`provider-${field.key}`} required={field.required} />
|
||||
<Input
|
||||
id={`provider-${field.key}`}
|
||||
type={field.type}
|
||||
bind:value={formConfig[field.key]}
|
||||
placeholder={isEditing && field.type === "password"
|
||||
? "leave blank to keep existing"
|
||||
: field.placeholder}
|
||||
/>
|
||||
{#if field.hint}
|
||||
<p class="text-xs text-muted-foreground">{field.hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Configuration is stored encrypted.{#if isEditing}
|
||||
Leave secret fields blank to keep the existing values.{/if}
|
||||
@@ -327,12 +377,19 @@
|
||||
variant="outline"
|
||||
onclick={testCurrent}
|
||||
disabled={formTesting || formSaving}
|
||||
class={`transition-colors duration-300 ${testOk ? 'border-green-500/60 text-green-600 dark:text-green-400' : ''}`}
|
||||
>
|
||||
{#if formTesting}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<PlugZap class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
<span class="inline-flex w-4 h-4 mr-1 items-center justify-center shrink-0">
|
||||
{#if formTesting}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else if testOk}
|
||||
<span in:scale={{ duration: 260, start: 0.4, easing: backOut }} out:scale={{ duration: 150, start: 0.6, easing: cubicIn }}>
|
||||
<Check class="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</span>
|
||||
{:else}
|
||||
<PlugZap class="w-4 h-4" />
|
||||
{/if}
|
||||
</span>
|
||||
Test connection
|
||||
</Button>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
Check,
|
||||
} from 'lucide-svelte';
|
||||
import { scale } from 'svelte/transition';
|
||||
import { backOut, cubicIn } from 'svelte/easing';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { EmptyState } from '$lib/components/ui/empty-state';
|
||||
@@ -20,6 +23,7 @@
|
||||
type SecretProvider,
|
||||
providerTypeLabel,
|
||||
} from './ProviderModal.svelte';
|
||||
import { getProviderIcon } from '$lib/components/provider-icons';
|
||||
|
||||
let providers = $state<SecretProvider[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -27,6 +31,9 @@
|
||||
let editing = $state<SecretProvider | null>(null);
|
||||
let confirmDeleteId = $state<number | null>(null);
|
||||
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);
|
||||
let testOkTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function fetchProviders() {
|
||||
loading = true;
|
||||
@@ -73,6 +80,9 @@
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
toast.success(`${provider.name}: connection works`);
|
||||
clearTimeout(testOkTimer);
|
||||
testOkId = provider.id;
|
||||
testOkTimer = setTimeout(() => (testOkId = null), 2000);
|
||||
} else {
|
||||
toast.error(
|
||||
`${provider.name}: ${data.error || 'connection failed'}`,
|
||||
@@ -121,14 +131,13 @@
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each providers as provider (provider.id)}
|
||||
{@const ProviderIcon = getProviderIcon(provider.type)}
|
||||
<div out:fade={{ duration: 200 }}>
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyRound
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<ProviderIcon class="w-5 h-5 text-muted-foreground" />
|
||||
<Card.Title class="text-base"
|
||||
>{provider.name}</Card.Title
|
||||
>
|
||||
@@ -151,14 +160,19 @@
|
||||
size="sm"
|
||||
onclick={() => testProvider(provider)}
|
||||
disabled={testingId === provider.id}
|
||||
class={`transition-colors duration-300 ${testOkId === provider.id ? 'border-green-500/60 text-green-600 dark:text-green-400' : ''}`}
|
||||
>
|
||||
{#if testingId === provider.id}
|
||||
<RefreshCw
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
{:else}
|
||||
<PlugZap class="w-3 h-3" />
|
||||
{/if}
|
||||
<span class="inline-flex w-3 h-3 mr-1 items-center justify-center shrink-0">
|
||||
{#if testingId === provider.id}
|
||||
<RefreshCw class="w-3 h-3 animate-spin" />
|
||||
{:else if testOkId === provider.id}
|
||||
<span in:scale={{ duration: 260, start: 0.4, easing: backOut }} out:scale={{ duration: 150, start: 0.6, easing: cubicIn }}>
|
||||
<Check class="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||
</span>
|
||||
{:else}
|
||||
<PlugZap class="w-3 h-3" />
|
||||
{/if}
|
||||
</span>
|
||||
Test
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -1188,7 +1188,9 @@
|
||||
|
||||
<!-- Right column: Environment Variables -->
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<!-- Secret provider selector -->
|
||||
<!-- Secret provider selector - hidden when no external provider is configured
|
||||
(still shown if this stack already has one bound, so it can be cleared). -->
|
||||
{#if secretProviders.length > 0 || formSecretProviderId !== null}
|
||||
<div class="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-100/60 dark:bg-zinc-800/60 flex items-center gap-2 text-xs">
|
||||
<Label for="secret-provider-select-git" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
|
||||
<Select.Root
|
||||
@@ -1223,6 +1225,7 @@
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
placeholder={{ key: 'MY_VAR', value: 'value' }}
|
||||
|
||||
@@ -1758,7 +1758,9 @@
|
||||
</div>
|
||||
<!-- Environment variables panel -->
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<!-- Secret provider selector -->
|
||||
<!-- Secret provider selector - hidden when no external provider is configured
|
||||
(still shown if this stack already has one bound, so it can be cleared). -->
|
||||
{#if secretProviders.length > 0 || formSecretProviderId !== null}
|
||||
<div class="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-100/60 dark:bg-zinc-800/60 flex items-center gap-2 text-xs">
|
||||
<Label for="secret-provider-select" class="text-xs text-muted-foreground shrink-0">Secret provider</Label>
|
||||
<Select.Root
|
||||
@@ -1798,6 +1800,7 @@
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
{/if}
|
||||
<StackEnvVarsPanel
|
||||
bind:this={envVarsPanelRef}
|
||||
bind:variables={envVars}
|
||||
|
||||
Reference in new issue
Block a user