feat: Add pluggable secret providers (1Password, Infisical, HashiCorp Vault) (#1360)
* Add pluggable secret provider foundation
Introduces a provider-agnostic secret backend abstraction so stacks can
pull deploy-time secrets from 1Password, Infisical or HashiCorp Vault.
Providers implement the SecretProvider contract in secretproviders/shared.ts
and advertise which of the two resolution modes they support:
- reference interpolation: a variable's value is an inline reference in
the backend's own syntax (e.g. 1Password's op://vault/item/field)
- bulk pull: every secret under a provider-defined selector, fetched as
a flat key/value map
Adding a backend means dropping a file in secretproviders/ and registering
it in the index; no caller changes.
Four providers ship here: 1Password service account (references + bulk),
1Password Connect (references), Infisical (bulk) and HashiCorp Vault KV v2
(bulk). Provider config is persisted as an encrypted JSON blob in the new
secret_providers table, mirroring the existing notification_settings
type+config pattern, and stack_sources gains a nullable secret_provider_id.
Decrypted tokens stay in the provider modules for the duration of a call
and are never written to disk or returned to the client.
Co-authored-by: GooRoo <sergey.olendarenko@gmail.com>
* Add secret provider API routes and permissions
CRUD plus two test-connection endpoints under /api/secret-providers. The
unsaved variant validates a config posted from the provider form; the
stored variant tests a persisted provider by id.
Responses only ever carry provider summaries (id, type, name, timestamps).
The decrypted config never leaves the server.
Adds a "secrets" permission resource with view/create/edit/delete actions,
wired through the permission type, the role editor and the seeded roles,
and a secret_provider audit entity type so provider changes are logged.
Config rotations are audited as a boolean rather than a value.
* Resolve provider secrets at stack deploy time
Stacks can bind a secret provider; when one is bound, its secrets are
resolved and merged into the vars handed to docker compose.
Bulk pull is triggered by a selector variable: OP_ENVIRONMENT_ID, kept for
compatibility with the original 1Password integration, or the generic
DOCKHAND_SECRET_SELECTOR for any bulk-capable provider. The selector is
consumed by the provider and stripped before the vars reach the stack.
Values the bound provider recognises as inline references are resolved in
place, and references found only in the stack's .env file are promoted into
the secret set so they are never written back as literals.
Resolution is applied on deploy, on up, on recreate and on service update.
Explicitly configured secrets always win over bulk-pulled ones. A missing
or unsupported provider warns and leaves values untouched rather than
failing the deploy; a provider that errors mid-resolution fails it.
* Add secret provider settings UI and stack binding
A Secrets tab under settings lists configured providers and drives the
create/edit modal, which switches its fields on the selected provider type
and can test a connection before saving.
Both stack modals gain a provider selector so a stack can be bound to a
provider or left unbound. When a provider is bound, the bulk selector
variables are no longer reported as unused compose variables, since they
are consumed by the provider rather than the compose file.
---------
Co-authored-by: GooRoo <sergey.olendarenko@gmail.com>
Co-authored-by: Jarek Krochmalski <jotka@users.noreply.github.com>
This commit is contained in:
39 files changed
+3094
-31
No files matched your search
@@ -62,6 +62,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
'secretProviderId' in data &&
|
||||
data.secretProviderId !== null &&
|
||||
typeof data.secretProviderId !== 'number'
|
||||
) {
|
||||
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for name conflicts with existing stacks (regular/external/git)
|
||||
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
|
||||
if (existing) {
|
||||
@@ -139,7 +147,8 @@ export const POST: RequestHandler = async (event) => {
|
||||
environmentId: data.environmentId || null,
|
||||
sourceType: 'git',
|
||||
gitRepositoryId: repositoryId,
|
||||
gitStackId: gitStack.id
|
||||
gitStackId: gitStack.id,
|
||||
secretProviderId: data.secretProviderId ?? null
|
||||
});
|
||||
|
||||
// Register schedule with croner if auto-update is enabled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars } from '$lib/server/db';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars, updateStackSource } from '$lib/server/db';
|
||||
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
@@ -51,6 +51,14 @@ export const PUT: RequestHandler = async (event) => {
|
||||
|
||||
const data = await request.json();
|
||||
|
||||
if (
|
||||
'secretProviderId' in data &&
|
||||
data.secretProviderId !== null &&
|
||||
typeof data.secretProviderId !== 'number'
|
||||
) {
|
||||
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate stack name if it's being changed
|
||||
if (data.stackName !== undefined) {
|
||||
const trimmedStackName = data.stackName.trim();
|
||||
@@ -94,6 +102,13 @@ export const PUT: RequestHandler = async (event) => {
|
||||
await updateStackEnvVarsName(oldStackName, data.stackName, existing.environmentId);
|
||||
}
|
||||
|
||||
// Update secret provider binding
|
||||
if ('secretProviderId' in data) {
|
||||
await updateStackSource(updated.stackName, existing.environmentId, {
|
||||
secretProviderId: data.secretProviderId ?? null
|
||||
});
|
||||
}
|
||||
|
||||
// Register or unregister schedule with croner
|
||||
if (updated.autoUpdate && updated.autoUpdateCron) {
|
||||
await registerSchedule(id, 'git_stack_sync', updated.environmentId);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSecretProviders, createSecretProvider } from '$lib/server/db';
|
||||
import { hasProvider } from '$lib/server/secretproviders';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditSecretProvider } from '$lib/server/audit';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Summaries never include the decrypted config.
|
||||
const providers = await getSecretProviders();
|
||||
return json(providers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching secret providers:', error);
|
||||
return json({ error: 'Failed to fetch secret providers' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'create'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request.json();
|
||||
const name = typeof data.name === 'string' ? data.name.trim() : '';
|
||||
const type = typeof data.type === 'string' ? data.type.trim() : '';
|
||||
const config = data.config;
|
||||
|
||||
if (!name || !type) {
|
||||
return json({ error: 'Name and type are required' }, { status: 400 });
|
||||
}
|
||||
if (!hasProvider(type)) {
|
||||
return json({ error: `Unknown secret provider type: ${type}` }, { status: 400 });
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return json({ error: 'Config is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = await createSecretProvider({ type, name, config });
|
||||
await auditSecretProvider(event, 'create', provider.id, provider.name, { type });
|
||||
// Never return the decrypted config.
|
||||
return json(provider, { status: 201 });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating secret provider:', error);
|
||||
if (error.message?.includes('UNIQUE') || error.message?.includes('unique')) {
|
||||
return json({ error: 'A secret provider with this name already exists' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'Failed to create secret provider' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getSecretProviders,
|
||||
getSecretProviderById,
|
||||
updateSecretProvider,
|
||||
deleteSecretProvider
|
||||
} from '$lib/server/db';
|
||||
import { hasProvider } from '$lib/server/secretproviders';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditSecretProvider } from '$lib/server/audit';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = Number.parseInt(params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return json({ error: 'Invalid secret provider ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const providers = await getSecretProviders();
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
if (!provider) {
|
||||
return json({ error: 'Secret provider not found' }, { status: 404 });
|
||||
}
|
||||
// Summary only, never the decrypted config.
|
||||
return json(provider);
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { params, request, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'edit'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = Number.parseInt(params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return json({ error: 'Invalid secret provider ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await getSecretProviderById(id);
|
||||
if (!existing) {
|
||||
return json({ error: 'Secret provider not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const name = typeof data.name === 'string' ? data.name.trim() : undefined;
|
||||
const type = typeof data.type === 'string' ? data.type.trim() : undefined;
|
||||
const config = 'config' in data ? data.config : undefined;
|
||||
|
||||
if (name !== undefined && !name) {
|
||||
return json({ error: 'Name cannot be empty' }, { status: 400 });
|
||||
}
|
||||
if (type !== undefined && !hasProvider(type)) {
|
||||
return json({ error: `Unknown secret provider type: ${type}` }, { status: 400 });
|
||||
}
|
||||
if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
|
||||
return json({ error: 'Config must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateSecretProvider(id, { name, type, config });
|
||||
if (!updated) {
|
||||
return json({ error: 'Secret provider not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const details: Record<string, any> = {};
|
||||
if (name && name !== existing.name) details.name = { from: existing.name, to: name };
|
||||
if (type && type !== existing.type) details.type = { from: existing.type, to: type };
|
||||
if (config !== undefined) details.configRotated = true;
|
||||
|
||||
await auditSecretProvider(event, 'update', id, updated.name, details);
|
||||
// Never return the decrypted config.
|
||||
return json(updated);
|
||||
} catch (error: any) {
|
||||
console.error('Error updating secret provider:', error);
|
||||
if (error.message?.includes('UNIQUE') || error.message?.includes('unique')) {
|
||||
return json({ error: 'A secret provider with this name already exists' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'Failed to update secret provider' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'delete'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = Number.parseInt(params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return json({ error: 'Invalid secret provider ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = await deleteSecretProvider(id);
|
||||
if (!deleted) {
|
||||
return json({ error: 'Secret provider not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await auditSecretProvider(event, 'delete', id, deleted.name);
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting secret provider:', error);
|
||||
return json({ error: 'Failed to delete secret provider' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSecretProviderById } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { testProviderConnection } from '$lib/server/secretproviders';
|
||||
|
||||
/** Test a stored provider using its persisted (decrypted) config. */
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = Number.parseInt(params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return json({ error: 'Invalid secret provider ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = await getSecretProviderById(id);
|
||||
if (!provider) {
|
||||
return json({ error: 'Secret provider not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await testProviderConnection(provider.type, provider.config);
|
||||
if (!result.ok) {
|
||||
return json({ ok: false, error: result.error }, { status: 200 });
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasProvider, testProviderConnection } from '$lib/server/secretproviders';
|
||||
|
||||
/** Test a provider config before it's persisted to the database. */
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('secrets', 'create'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
let type = '';
|
||||
let config: unknown;
|
||||
try {
|
||||
const data = await request.json();
|
||||
type = typeof data.type === 'string' ? data.type.trim() : '';
|
||||
config = data.config;
|
||||
} catch {
|
||||
return json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!type || !hasProvider(type)) {
|
||||
return json({ ok: false, error: 'A valid provider type is required' }, { status: 200 });
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return json({ ok: false, error: 'Config is required' }, { status: 200 });
|
||||
}
|
||||
|
||||
const result = await testProviderConnection(type, config as any);
|
||||
return json(result);
|
||||
};
|
||||
@@ -91,7 +91,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, compose, start, envVars, rawEnvContent, composePath, envPath } = body;
|
||||
const { name, compose, start, envVars, rawEnvContent, composePath, envPath, secretProviderId } = body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
@@ -101,6 +101,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Compose file content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
'secretProviderId' in body &&
|
||||
secretProviderId !== null &&
|
||||
typeof secretProviderId !== 'number'
|
||||
) {
|
||||
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
|
||||
}
|
||||
|
||||
// If start is false, only create the compose file without deploying
|
||||
if (start === false) {
|
||||
const result = await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
@@ -134,7 +142,8 @@ export const POST: RequestHandler = async (event) => {
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
envPath: envPath || undefined,
|
||||
secretProviderId,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
@@ -175,7 +184,8 @@ export const POST: RequestHandler = async (event) => {
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
envPath: envPath || undefined,
|
||||
secretProviderId
|
||||
});
|
||||
|
||||
// Deploy via SSE to keep connection alive during long operations
|
||||
|
||||
@@ -56,15 +56,23 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } = body;
|
||||
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId } = body;
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
return json({ error: 'Compose file content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build options object for custom paths, move operation, and file renames
|
||||
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
|
||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
|
||||
if (
|
||||
'secretProviderId' in body &&
|
||||
secretProviderId !== null &&
|
||||
typeof secretProviderId !== 'number'
|
||||
) {
|
||||
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build options object for custom paths, move operation, file renames, and secret provider binding
|
||||
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined)
|
||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId }
|
||||
: undefined;
|
||||
|
||||
if (restart) {
|
||||
|
||||
@@ -18,12 +18,21 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const sources = await getStackSources(envIdNum);
|
||||
|
||||
// Convert to a map for easier lookup in the frontend
|
||||
const sourceMap: Record<string, { sourceType: string; composePath?: string | null; repository?: any }> = {};
|
||||
const sourceMap: Record<
|
||||
string,
|
||||
{
|
||||
sourceType: string;
|
||||
composePath?: string | null;
|
||||
repository?: any;
|
||||
secretProviderId?: number | null;
|
||||
}
|
||||
> = {};
|
||||
for (const source of sources) {
|
||||
sourceMap[source.stackName] = {
|
||||
sourceType: source.sourceType,
|
||||
composePath: source.composePath,
|
||||
repository: source.repository
|
||||
repository: source.repository,
|
||||
secretProviderId: source.secretProviderId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user