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:
authored and GitHub committed 2026-08-13 10:08:02 +02:00
1 parent 905c4a004d
commit 1ad36a83fb
39 files changed
+3094 -31

No files matched your search

+221 -11
View File
@@ -36,8 +36,10 @@ import {
getPendingContainerUpdates,
deleteAutoUpdateSchedule,
getAutoUpdateSetting,
getStackSourceByComposePath
getStackSourceByComposePath,
getSecretProviderById
} from './db';
import { getProvider } from './secretproviders';
import { unregisterSchedule } from './scheduler';
import { sendEventNotification } from './notifications';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
@@ -609,6 +611,7 @@ export async function saveStackComposeFile(
moveFromDir?: string; // Old directory to move all files from when path changes
oldComposePath?: string; // Old compose file path for renaming
oldEnvPath?: string; // Old env file path for renaming
secretProviderId?: number | null; // secret provider binding (undefined = unchanged)
}
): Promise<{ success: boolean; error?: string }> {
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
@@ -767,14 +770,22 @@ export async function saveStackComposeFile(
}
}
// If a custom composePath is being set (new or update), save it to the database
if (options?.composePath || options?.envPath !== undefined) {
// If a custom composePath, envPath, or 1Password binding is being set (new or update), save it to the database
if (
options?.composePath ||
options?.envPath !== undefined ||
options?.secretProviderId !== undefined
) {
await upsertStackSource({
stackName: name,
environmentId: envId ?? null,
sourceType: 'internal',
composePath: options?.composePath || source?.composePath || null,
envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null)
envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null),
secretProviderId:
options?.secretProviderId !== undefined
? options.secretProviderId
: (source?.secretProviderId ?? null),
});
}
@@ -2244,6 +2255,10 @@ export async function startStack(
const containers = await getStackContainers(stackName, envId);
const operation = containers.length > 0 ? 'start' : 'up';
if (operation === 'up') {
await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`);
}
const startResult = await executeComposeCommand(
operation,
opts,
@@ -2328,6 +2343,7 @@ export async function restartStack(
if (mode === 'recreate') {
// Stop first, then bring up with --force-recreate to ensure new container IDs
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars);
await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`);
composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars);
} else {
composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars);
@@ -2859,11 +2875,13 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
stackFiles[composeFilename] = compose;
console.log(`${logPrefix} Added ${composeFilename} to stackFiles for Hawser (${compose.length} chars)`);
}
if (actualEnvPath && existsSync(actualEnvPath) && !stackFiles['.env']) {
let envFileContent: string | undefined = stackFiles['.env'];
if (!envFileContent && actualEnvPath && existsSync(actualEnvPath)) {
try {
const envContent = readFileSync(actualEnvPath, 'utf-8');
stackFiles['.env'] = envContent;
console.log(`${logPrefix} Added .env to stackFiles for Hawser (${envContent.length} chars)`);
envFileContent = readFileSync(actualEnvPath, 'utf-8');
stackFiles['.env'] = envFileContent;
console.log(`${logPrefix} Added .env to stackFiles for Hawser (${envFileContent.length} chars)`);
} catch (err) {
console.warn(`${logPrefix} Failed to read .env file at ${actualEnvPath}:`, err);
}
@@ -2874,8 +2892,18 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(compose);
// Fetch overrides and secrets from DB
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(name, envId);
const secretVars = await getSecretEnvVarsAsRecord(name, envId);
const initialDbNonSecretVars = await getNonSecretEnvVarsAsRecord(name, envId);
const initialSecretVars = await getSecretEnvVarsAsRecord(name, envId);
// Add environment variables from 1Password
const source = await getStackSource(name, envId);
const { dbNonSecretVars, secretVars } = await resolveProviderEnvVars(
initialDbNonSecretVars,
initialSecretVars,
logPrefix,
source?.secretProviderId,
envFileContent
);
console.log(`${logPrefix} DB non-secret override vars:`, Object.keys(dbNonSecretVars).length);
console.log(`${logPrefix} DB secret vars:`, Object.keys(secretVars).length);
@@ -3034,6 +3062,8 @@ export async function updateStackService(
};
}
await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`);
// Don't use forceRecreate - Docker Compose will detect the image change
// naturally since the image was already pulled before this function is called.
// Using forceRecreate can cause permission issues on bind mounts.
@@ -3185,6 +3215,187 @@ export async function saveStackEnvVars(
await writeStackEnvFile(stackName, variables, envId, customEnvPath);
}
// =============================================================================
// SECRET PROVIDER INJECTION (deploy-time)
// =============================================================================
// Resolves secrets from the stack's bound secret provider at deploy time and
// merges them into the vars handed to `docker compose`. Two modes, depending on
// what the provider supports:
// - bulk pull: a whole environment / path of secrets, triggered by a selector
// variable (OP_ENVIRONMENT_ID for 1Password back-compat, or the generic
// DOCKHAND_SECRET_SELECTOR for any bulk-capable provider).
// - inline references: values the provider recognises as references (e.g.
// 1Password op://...), resolved in place.
// The bound provider decides what a reference is and how to resolve it; nothing
// here is 1Password-specific. Non-secret behaviour is untouched.
interface EnrichedEnvVars {
dbNonSecretVars: Record<string, string>;
secretVars: Record<string, string>;
}
// Variable names that trigger a bulk pull. OP_ENVIRONMENT_ID is retained for
// backward compatibility with the original 1Password integration.
const BULK_SELECTOR_VARS = ['DOCKHAND_SECRET_SELECTOR', 'OP_ENVIRONMENT_ID'];
async function resolveProviderEnvVars(
dbNonSecretVars: Record<string, string>,
secretVars: Record<string, string>,
logPrefix: string,
secretProviderId?: number | null,
stackEnvFileContent?: string
): Promise<EnrichedEnvVars> {
const envFileVars = stackEnvFileContent ? parseEnvFileContent(stackEnvFileContent) : {};
const providerRow = secretProviderId ? await getSecretProviderById(secretProviderId) : undefined;
const provider = providerRow ? getProvider(providerRow.type) : undefined;
// --- Bulk pull (environment / path) --------------------------------------
// Priority: secrets > DB non-secrets > .env file (each overrides the previous)
let selector: string | undefined;
let selectorVar: string | undefined;
for (const name of BULK_SELECTOR_VARS) {
const v = secretVars[name] ?? dbNonSecretVars[name] ?? envFileVars[name];
if (v) {
selector = v;
selectorVar = name;
break;
}
}
if (selector && selectorVar) {
try {
if (providerRow && provider?.supportsBulk) {
// Strip the selector var from the values passed to the stack
delete secretVars[selectorVar];
delete dbNonSecretVars[selectorVar];
console.log(`${logPrefix} Resolving bulk selector via "${providerRow.name}" (${provider.label})`);
const bulkVars = await provider.resolveBulk(providerRow.config, selector);
console.log(`${logPrefix} ${provider.label} injected ${Object.keys(bulkVars).length} secret(s)`);
// Bulk values merged underneath, with explicit DB secrets keeping priority
secretVars = Object.assign(bulkVars, secretVars);
} else if (!providerRow) {
console.warn(`${logPrefix} ${selectorVar} is set but no secret provider is bound to this stack`);
} else if (!provider) {
console.warn(`${logPrefix} ${selectorVar} is set but bound provider type "${providerRow.type}" is not registered`);
} else {
console.warn(`${logPrefix} ${selectorVar} is set but provider "${providerRow.name}" (${provider.label}) does not support bulk pull`);
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`Failed to load secrets from provider: ${msg}`);
}
}
// --- Inline references ----------------------------------------------------
// Only providers that support inline references detect any here.
const isRef = (value: unknown): value is string =>
provider?.supportsReferences ? provider.isReference(value) : false;
const envFileRefs = new Map<string, string>();
for (const [key, value] of Object.entries(envFileVars)) {
if (isRef(value)) {
envFileRefs.set(key, value.trim());
}
}
const refs = new Set<string>();
for (const value of Object.values(dbNonSecretVars)) {
if (isRef(value)) refs.add(value.trim());
}
for (const value of Object.values(secretVars)) {
if (isRef(value)) refs.add(value.trim());
}
for (const ref of envFileRefs.values()) refs.add(ref);
if (refs.size === 0) {
return { dbNonSecretVars, secretVars };
}
if (!providerRow || !provider) {
console.warn(`${logPrefix} Found ${refs.size} reference(s) but no usable secret provider is bound to this stack; leaving them as literals`);
return { dbNonSecretVars, secretVars };
}
let refMap: Map<string, string>;
try {
refMap = await provider.resolveSecretReferences(providerRow.config, Array.from(refs), logPrefix);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`Failed to resolve secret references: ${msg}`);
}
let promotedFromDb = 0;
for (const [key, value] of Object.entries(dbNonSecretVars)) {
if (isRef(value)) {
const resolved = refMap.get(value.trim());
if (resolved !== undefined) {
delete dbNonSecretVars[key];
secretVars[key] = resolved;
promotedFromDb++;
}
}
}
for (const [key, value] of Object.entries(secretVars)) {
if (isRef(value)) {
const resolved = refMap.get(value.trim());
if (resolved !== undefined) {
secretVars[key] = resolved;
}
}
}
let promotedFromEnvFile = 0;
for (const [key, ref] of envFileRefs) {
if (key in secretVars || key in dbNonSecretVars) continue;
const resolved = refMap.get(ref);
if (resolved !== undefined) {
secretVars[key] = resolved;
promotedFromEnvFile++;
}
}
console.log(`${logPrefix} ${provider.label} resolved ${refMap.size}/${refs.size} reference(s) (promoted from DB: ${promotedFromDb}, from .env: ${promotedFromEnvFile})`);
return { dbNonSecretVars, secretVars };
}
/**
* Resolve the bound provider's secrets for a compose result produced by
* requireComposeFile(). Mutates result.secretVars / result.nonSecretVars in
* place so callers can pass the result through to executeComposeCommand
* without further plumbing.
*/
async function applyProviderSecretsToComposeResult(
result: RequireComposeResult,
stackName: string,
envId: number | null | undefined,
logPrefix: string
): Promise<void> {
if (!result.success || !result.secretVars || !result.nonSecretVars) return;
let envFileContent: string | undefined;
if (result.envPath && existsSync(result.envPath)) {
try {
envFileContent = readFileSync(result.envPath, 'utf-8');
} catch (err) {
console.warn(`${logPrefix} Failed to read .env at ${result.envPath}:`, err);
}
}
const source = await getStackSource(stackName, envId ?? undefined);
const { dbNonSecretVars, secretVars } = await resolveProviderEnvVars(
{ ...result.nonSecretVars },
{ ...result.secretVars },
logPrefix,
source?.secretProviderId,
envFileContent
);
result.nonSecretVars = dbNonSecretVars;
result.secretVars = secretVars;
}
// =============================================================================
// RE-EXPORTS FOR BACKWARDS COMPATIBILITY
// =============================================================================
@@ -3193,4 +3404,3 @@ export async function saveStackEnvVars(
// They can be removed once all imports are updated
export type { StackOperationResult as CreateStackResult };