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 <[email protected]>
* 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 <[email protected]>
Co-authored-by: Jarek Krochmalski <[email protected]>
This commit is contained in:
39 files changed
+3094
-31
No files matched your search
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "secret_providers" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"config" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "secret_providers_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stack_sources" ADD COLUMN "secret_provider_id" integer;--> 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;
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1781620382909,
|
||||
"tag": "0009_add_backup_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1783800723008,
|
||||
"tag": "0010_add_secret_providers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `secret_providers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
--> 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;
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1781620377161,
|
||||
"tag": "0009_add_backup_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1783800723008,
|
||||
"tag": "0010_add_secret_providers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"generate:legal": "node scripts/generate-legal-pages.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@1password/sdk": "0.4.1-beta.1",
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
[
|
||||
{
|
||||
"name": "@1password/sdk",
|
||||
"version": "0.4.1-beta.1",
|
||||
"license": "MIT",
|
||||
"repository": null
|
||||
},
|
||||
{
|
||||
"name": "@1password/sdk-core",
|
||||
"version": "0.4.1-beta.1",
|
||||
"license": "MIT",
|
||||
"repository": null
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/autocomplete",
|
||||
"version": "6.20.0",
|
||||
|
||||
@@ -335,6 +335,24 @@ export async function auditGitCredential(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for secret provider actions
|
||||
*/
|
||||
export async function auditSecretProvider(
|
||||
event: RequestEvent,
|
||||
action: AuditAction,
|
||||
providerId: number,
|
||||
providerName: string,
|
||||
details?: any
|
||||
): Promise<void> {
|
||||
await audit(event, action, 'secret_provider', {
|
||||
entityId: String(providerId),
|
||||
entityName: providerName,
|
||||
description: `Secret provider ${providerName} ${action}`,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for config set actions
|
||||
*/
|
||||
|
||||
@@ -69,6 +69,7 @@ const EMPTY_PERMISSIONS: Permissions = {
|
||||
audit_logs: [],
|
||||
activity: [],
|
||||
schedules: [],
|
||||
secrets: [],
|
||||
backups: []
|
||||
};
|
||||
|
||||
@@ -364,6 +365,7 @@ export async function getUserPermissionsById(userId: number): Promise<Permission
|
||||
audit_logs: [],
|
||||
activity: [],
|
||||
schedules: [],
|
||||
secrets: [],
|
||||
backups: []
|
||||
};
|
||||
|
||||
@@ -446,6 +448,7 @@ export async function getUserPermissionsForEnvironment(userId: number, environme
|
||||
audit_logs: [],
|
||||
activity: [],
|
||||
schedules: [],
|
||||
secrets: [],
|
||||
backups: []
|
||||
};
|
||||
|
||||
|
||||
+116
-4
@@ -5,6 +5,7 @@
|
||||
* Supports both SQLite and PostgreSQL.
|
||||
*/
|
||||
|
||||
import type { SecretProviderConfig, SecretProviderType } from './secretproviders/shared';
|
||||
import {
|
||||
db,
|
||||
isPostgres,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
gitCredentials,
|
||||
gitRepositories,
|
||||
gitStacks,
|
||||
secretProviders,
|
||||
stackSources,
|
||||
vulnerabilityScans,
|
||||
auditLogs,
|
||||
@@ -69,6 +71,7 @@ import {
|
||||
type GitCredential,
|
||||
type GitRepository,
|
||||
type GitStack,
|
||||
type SecretProviderRow,
|
||||
type StackSource,
|
||||
type VulnerabilityScan,
|
||||
type AuditLog,
|
||||
@@ -102,6 +105,7 @@ export type {
|
||||
GitCredential,
|
||||
GitRepository,
|
||||
GitStack,
|
||||
SecretProviderRow,
|
||||
StackSource,
|
||||
VulnerabilityScan,
|
||||
AuditLog,
|
||||
@@ -297,6 +301,107 @@ export async function setDefaultRegistry(id: number): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECRET PROVIDER OPERATIONS
|
||||
// =============================================================================
|
||||
// Pluggable secret providers (1Password, Infisical, HashiCorp Vault, ...). The
|
||||
// per-provider `config` object is stored as an encrypted JSON blob; the `token`
|
||||
// or `host`/`token` fields live inside it. See src/lib/server/secretproviders.
|
||||
|
||||
/** Row without the (secret) config, safe to return to the UI / list views. */
|
||||
export interface SecretProviderSummary {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** Row with its config decrypted and parsed. Server-side use only. */
|
||||
export interface SecretProviderWithConfig extends SecretProviderSummary {
|
||||
config: SecretProviderConfig;
|
||||
}
|
||||
|
||||
export async function getSecretProviders(): Promise<SecretProviderSummary[]> {
|
||||
const results = await db.select({
|
||||
id: secretProviders.id,
|
||||
type: secretProviders.type,
|
||||
name: secretProviders.name,
|
||||
createdAt: secretProviders.createdAt,
|
||||
updatedAt: secretProviders.updatedAt
|
||||
}).from(secretProviders).orderBy(asc(secretProviders.name));
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getSecretProviderById(id: number): Promise<SecretProviderWithConfig | undefined> {
|
||||
const results = await db.select().from(secretProviders).where(eq(secretProviders.id, id));
|
||||
if (!results[0]) return undefined;
|
||||
const row = results[0];
|
||||
const decrypted = decrypt(row.config);
|
||||
const config = decrypted
|
||||
? (JSON.parse(decrypted) as SecretProviderConfig)
|
||||
: ({} as SecretProviderConfig);
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
config
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSecretProvider(data: {
|
||||
type: SecretProviderType;
|
||||
name: string;
|
||||
config: SecretProviderConfig;
|
||||
}): Promise<SecretProviderSummary> {
|
||||
const encrypted = encrypt(JSON.stringify(data.config));
|
||||
if (!encrypted) throw new Error('Config is required');
|
||||
const result = await db.insert(secretProviders).values({
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
config: encrypted
|
||||
}).returning();
|
||||
const { config: _omit, ...safe } = result[0];
|
||||
return safe;
|
||||
}
|
||||
|
||||
export async function updateSecretProvider(
|
||||
id: number,
|
||||
data: { name?: string; type?: SecretProviderType; config?: SecretProviderConfig }
|
||||
): Promise<SecretProviderSummary | undefined> {
|
||||
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
|
||||
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));
|
||||
if (encrypted) updateData.config = encrypted;
|
||||
}
|
||||
await db.update(secretProviders).set(updateData).where(eq(secretProviders.id, id));
|
||||
const results = await db.select({
|
||||
id: secretProviders.id,
|
||||
type: secretProviders.type,
|
||||
name: secretProviders.name,
|
||||
createdAt: secretProviders.createdAt,
|
||||
updatedAt: secretProviders.updatedAt
|
||||
}).from(secretProviders).where(eq(secretProviders.id, id));
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function deleteSecretProvider(id: number): Promise<SecretProviderSummary | null> {
|
||||
const rows = await db.delete(secretProviders)
|
||||
.where(eq(secretProviders.id, id))
|
||||
.returning({
|
||||
id: secretProviders.id,
|
||||
type: secretProviders.type,
|
||||
name: secretProviders.name,
|
||||
createdAt: secretProviders.createdAt,
|
||||
updatedAt: secretProviders.updatedAt
|
||||
});
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STACK EVENT LOGGING
|
||||
// =============================================================================
|
||||
@@ -1203,6 +1308,7 @@ export interface Permissions {
|
||||
audit_logs: string[];
|
||||
activity: string[];
|
||||
schedules: string[];
|
||||
secrets: string[];
|
||||
backups: string[];
|
||||
}
|
||||
|
||||
@@ -2797,6 +2903,7 @@ export interface StackSourceData {
|
||||
gitStackId: number | null;
|
||||
composePath: string | null;
|
||||
envPath: string | null;
|
||||
secretProviderId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2904,6 +3011,7 @@ export async function upsertStackSource(data: {
|
||||
gitStackId?: number | null;
|
||||
composePath?: string | null;
|
||||
envPath?: string | null;
|
||||
secretProviderId?: number | null;
|
||||
}): Promise<StackSourceData> {
|
||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||
|
||||
@@ -2925,7 +3033,9 @@ export async function upsertStackSource(data: {
|
||||
gitStackId: newStackId,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
updatedAt: new Date().toISOString(),
|
||||
// Preserve existing binding when caller (like git) omits it
|
||||
...(data.secretProviderId !== undefined && { secretProviderId: data.secretProviderId })
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
@@ -2938,7 +3048,8 @@ export async function upsertStackSource(data: {
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null
|
||||
envPath: data.envPath ?? null,
|
||||
secretProviderId: data.secretProviderId ?? null
|
||||
});
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
}
|
||||
@@ -2947,7 +3058,7 @@ export async function upsertStackSource(data: {
|
||||
export async function updateStackSource(
|
||||
stackName: string,
|
||||
environmentId: number | null,
|
||||
updates: { composePath?: string | null; envPath?: string | null }
|
||||
updates: { composePath?: string | null; envPath?: string | null; secretProviderId?: number | null }
|
||||
): Promise<boolean> {
|
||||
const existing = await getStackSource(stackName, environmentId);
|
||||
if (!existing) return false;
|
||||
@@ -2956,6 +3067,7 @@ export async function updateStackSource(
|
||||
.set({
|
||||
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
|
||||
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
|
||||
secretProviderId: updates.secretProviderId !== undefined ? updates.secretProviderId : existing.secretProviderId,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
@@ -3304,7 +3416,7 @@ export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token'
|
||||
| 'backup_destination' | 'backup_config';
|
||||
| 'secret_provider' | 'backup_destination' | 'backup_config';
|
||||
|
||||
export interface AuditLogData {
|
||||
id: number;
|
||||
|
||||
@@ -775,7 +775,8 @@ async function seedDatabase(): Promise<void> {
|
||||
audit_logs: ['view'],
|
||||
activity: ['view'],
|
||||
schedules: ['view', 'edit', 'run'],
|
||||
templates: ['view', 'deploy', 'manage']
|
||||
templates: ['view', 'deploy', 'manage'],
|
||||
secrets: ['view', 'create', 'edit', 'delete']
|
||||
});
|
||||
|
||||
const operatorPermissions = JSON.stringify({
|
||||
@@ -795,7 +796,8 @@ async function seedDatabase(): Promise<void> {
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view', 'edit', 'run'],
|
||||
templates: ['view', 'deploy']
|
||||
templates: ['view', 'deploy'],
|
||||
secrets: ['view']
|
||||
});
|
||||
|
||||
const viewerPermissions = JSON.stringify({
|
||||
@@ -815,7 +817,8 @@ async function seedDatabase(): Promise<void> {
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view'],
|
||||
templates: ['view']
|
||||
templates: ['view'],
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
// Seed template sources if table is empty
|
||||
@@ -920,6 +923,7 @@ export const userRoles = schemaProxy.userRoles;
|
||||
export const gitCredentials = schemaProxy.gitCredentials;
|
||||
export const gitRepositories = schemaProxy.gitRepositories;
|
||||
export const gitStacks = schemaProxy.gitStacks;
|
||||
export const secretProviders = schemaProxy.secretProviders;
|
||||
export const stackSources = schemaProxy.stackSources;
|
||||
export const vulnerabilityScans = schemaProxy.vulnerabilityScans;
|
||||
export const auditLogs = schemaProxy.auditLogs;
|
||||
@@ -968,6 +972,8 @@ export type {
|
||||
NewGitRepository,
|
||||
GitStack,
|
||||
NewGitStack,
|
||||
SecretProviderRow,
|
||||
NewSecretProviderRow,
|
||||
StackSource,
|
||||
NewStackSource,
|
||||
VulnerabilityScan,
|
||||
|
||||
@@ -282,6 +282,19 @@ export const gitCredentials = sqliteTable('git_credentials', {
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
});
|
||||
|
||||
// Pluggable secret providers (1Password, Infisical, HashiCorp Vault, ...).
|
||||
// `type` selects the backend; `config` is an encrypted JSON blob whose shape is
|
||||
// provider-specific (see src/lib/server/secretproviders/shared.ts). Mirrors the
|
||||
// notification_settings type+config pattern.
|
||||
export const secretProviders = sqliteTable('secret_providers', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
type: text('type').notNull(),
|
||||
name: text('name').notNull().unique(),
|
||||
config: text('config').notNull(),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
});
|
||||
|
||||
export const gitRepositories = sqliteTable('git_repositories', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull().unique(),
|
||||
@@ -340,6 +353,7 @@ export const stackSources = sqliteTable('stack_sources', {
|
||||
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
|
||||
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' }),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
@@ -611,6 +625,9 @@ export type NewEnvironmentNotification = typeof environmentNotifications.$inferI
|
||||
export type GitCredential = typeof gitCredentials.$inferSelect;
|
||||
export type NewGitCredential = typeof gitCredentials.$inferInsert;
|
||||
|
||||
export type SecretProviderRow = typeof secretProviders.$inferSelect;
|
||||
export type NewSecretProviderRow = typeof secretProviders.$inferInsert;
|
||||
|
||||
export type GitRepository = typeof gitRepositories.$inferSelect;
|
||||
export type NewGitRepository = typeof gitRepositories.$inferInsert;
|
||||
|
||||
|
||||
@@ -285,6 +285,19 @@ export const gitCredentials = pgTable('git_credentials', {
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
});
|
||||
|
||||
// Pluggable secret providers (1Password, Infisical, HashiCorp Vault, ...).
|
||||
// `type` selects the backend; `config` is an encrypted JSON blob whose shape is
|
||||
// provider-specific (see src/lib/server/secretproviders/shared.ts). Mirrors the
|
||||
// notification_settings type+config pattern.
|
||||
export const secretProviders = pgTable('secret_providers', {
|
||||
id: serial('id').primaryKey(),
|
||||
type: text('type').notNull(),
|
||||
name: text('name').notNull().unique(),
|
||||
config: text('config').notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
});
|
||||
|
||||
export const gitRepositories = pgTable('git_repositories', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: text('name').notNull().unique(),
|
||||
@@ -343,6 +356,7 @@ export const stackSources = pgTable('stack_sources', {
|
||||
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
|
||||
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' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 1Password Connect provider.
|
||||
*
|
||||
* Talks to a self-hosted 1Password Connect server over its REST API (v1) using
|
||||
* a Connect access token. This is the Business path: teams run a Connect server
|
||||
* next to their vaults and Dockhand reads op:// references through it.
|
||||
*
|
||||
* Connect resolves inline references only. Unlike a service account it has no
|
||||
* concept of a 1Password Environment, so bulk pull is unsupported (callers get
|
||||
* an {@link UnsupportedOperationError} and should use a service account for that).
|
||||
*
|
||||
* Decrypted host + token 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 { ConnectConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
/** A vault as returned by GET /v1/vaults. */
|
||||
interface ConnectVault {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** An item summary as returned by GET /v1/vaults/{id}/items. */
|
||||
interface ConnectItemSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** A section reference on a field. */
|
||||
interface ConnectSection {
|
||||
id?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A single field on a full item. */
|
||||
interface ConnectField {
|
||||
id?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
section?: ConnectSection;
|
||||
}
|
||||
|
||||
/** A full item as returned by GET /v1/vaults/{id}/items/{id}. */
|
||||
interface ConnectItem {
|
||||
id: string;
|
||||
title: string;
|
||||
fields?: ConnectField[];
|
||||
}
|
||||
|
||||
/** A parsed op:// reference. `section` is optional. */
|
||||
interface ParsedReference {
|
||||
vault: string;
|
||||
item: string;
|
||||
section?: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown internally when a specific lookup (vault/item/field) has no match, so
|
||||
* the caller can skip+warn that one reference. Transport/auth failures are plain
|
||||
* Errors and propagate.
|
||||
*/
|
||||
class ReferenceNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ReferenceNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalises a host URL into the Connect v1 API base (no trailing slash). */
|
||||
function baseUrl(host: string): string {
|
||||
return `${host.replace(/\/$/, '')}/v1`;
|
||||
}
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an `op://<vault>/<item>/<field>` or
|
||||
* `op://<vault>/<item>/<section>/<field>` reference. Returns null for anything
|
||||
* that is not a well-formed reference (wrong scheme or wrong segment count).
|
||||
*/
|
||||
function parseReference(ref: string): ParsedReference | null {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed.startsWith('op://')) {
|
||||
return null;
|
||||
}
|
||||
const segments = trimmed.slice('op://'.length).split('/');
|
||||
if (segments.some((s) => s.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
if (segments.length === 3) {
|
||||
return { vault: segments[0], item: segments[1], field: segments[2] };
|
||||
}
|
||||
if (segments.length === 4) {
|
||||
return { vault: segments[0], item: segments[1], section: segments[2], field: segments[3] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a GET against the Connect API and returns the parsed JSON body.
|
||||
* Throws on any non-2xx status other than 404, which is signalled by returning
|
||||
* `undefined` so per-reference lookups can skip+warn instead of aborting the
|
||||
* whole batch. 401/403/5xx and transport errors propagate.
|
||||
*/
|
||||
async function connectGet<T>(
|
||||
config: ConnectConfig,
|
||||
path: string,
|
||||
query?: Record<string, string>
|
||||
): Promise<T | undefined> {
|
||||
const url = new URL(`${baseUrl(config.host)}${path}`);
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
const { statusCode, body } = await request(url, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(config.token)
|
||||
});
|
||||
if (statusCode === 404) {
|
||||
// Drain the body so the connection can be reused, then signal "not found".
|
||||
await body.text();
|
||||
return undefined;
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await body.text();
|
||||
throw new Error(`1Password Connect request failed (${statusCode}): ${detail || 'no body'}`);
|
||||
}
|
||||
return (await body.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-call cache that memoises the vault list and per-vault item lists so a
|
||||
* batch of references sharing vaults/items does not re-fetch. Full items are not
|
||||
* cached — each reference targets a distinct field on a possibly-distinct item.
|
||||
*/
|
||||
class ResolveCache {
|
||||
private vaults?: ConnectVault[];
|
||||
private itemsByVault = new Map<string, ConnectItemSummary[]>();
|
||||
|
||||
constructor(private readonly config: ConnectConfig) {}
|
||||
|
||||
async listVaults(): Promise<ConnectVault[]> {
|
||||
if (!this.vaults) {
|
||||
this.vaults = (await connectGet<ConnectVault[]>(this.config, '/vaults')) ?? [];
|
||||
}
|
||||
return this.vaults;
|
||||
}
|
||||
|
||||
async listItems(vaultId: string): Promise<ConnectItemSummary[]> {
|
||||
const cached = this.itemsByVault.get(vaultId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const items =
|
||||
(await connectGet<ConnectItemSummary[]>(this.config, `/vaults/${vaultId}/items`)) ?? [];
|
||||
this.itemsByVault.set(vaultId, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
getItem(vaultId: string, itemId: string): Promise<ConnectItem | undefined> {
|
||||
return connectGet<ConnectItem>(this.config, `/vaults/${vaultId}/items/${itemId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches a field to the parsed reference's field + optional section. */
|
||||
function fieldMatches(field: ConnectField, parsed: ParsedReference): boolean {
|
||||
const labelOrId = field.label ?? field.id;
|
||||
if (labelOrId !== parsed.field) {
|
||||
return false;
|
||||
}
|
||||
if (parsed.section === undefined) {
|
||||
return true;
|
||||
}
|
||||
const section = field.section;
|
||||
return section !== undefined && (section.label === parsed.section || section.id === parsed.section);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one parsed reference to a secret value, or throws
|
||||
* {@link ReferenceNotFoundError} if any hop has no match. Transport/auth errors
|
||||
* from the underlying requests propagate.
|
||||
*/
|
||||
async function resolveOne(cache: ResolveCache, parsed: ParsedReference): Promise<string> {
|
||||
const vaults = await cache.listVaults();
|
||||
const vault = vaults.find((v) => v.name === parsed.vault || v.id === parsed.vault);
|
||||
if (!vault) {
|
||||
throw new ReferenceNotFoundError(`vault "${parsed.vault}" not found`);
|
||||
}
|
||||
|
||||
const items = await cache.listItems(vault.id);
|
||||
const itemSummary = items.find((i) => i.title === parsed.item || i.id === parsed.item);
|
||||
if (!itemSummary) {
|
||||
throw new ReferenceNotFoundError(`item "${parsed.item}" not found in vault "${parsed.vault}"`);
|
||||
}
|
||||
|
||||
const item = await cache.getItem(vault.id, itemSummary.id);
|
||||
if (!item) {
|
||||
throw new ReferenceNotFoundError(`item "${parsed.item}" not found in vault "${parsed.vault}"`);
|
||||
}
|
||||
|
||||
const field = (item.fields ?? []).find((f) => fieldMatches(f, parsed));
|
||||
if (!field || field.value === undefined) {
|
||||
const where = parsed.section ? `${parsed.section}/${parsed.field}` : parsed.field;
|
||||
throw new ReferenceNotFoundError(`field "${where}" not found on item "${parsed.item}"`);
|
||||
}
|
||||
return field.value;
|
||||
}
|
||||
|
||||
export const connectProvider: SecretProvider<ConnectConfig> = {
|
||||
type: 'op-connect',
|
||||
label: '1Password Connect',
|
||||
supportsReferences: true,
|
||||
supportsBulk: false,
|
||||
|
||||
isReference(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().startsWith('op://');
|
||||
},
|
||||
|
||||
async testConnection(config: ConnectConfig): Promise<TestConnectionResult> {
|
||||
const host = config.host?.trim();
|
||||
const token = config.token?.trim();
|
||||
if (!host) {
|
||||
return { ok: false, error: 'Host is empty' };
|
||||
}
|
||||
if (!token) {
|
||||
return { ok: false, error: 'Token is empty' };
|
||||
}
|
||||
try {
|
||||
// GET /v1/vaults authenticates the token and confirms the host is a
|
||||
// Connect server. Connect has no /v1/health endpoint, and the root
|
||||
// /health is unauthenticated so it would not catch a bad token.
|
||||
const url = `${baseUrl(host)}/vaults`;
|
||||
const { statusCode, body } = await request(url, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(token)
|
||||
});
|
||||
const text = await body.text();
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return { ok: false, error: 'Invalid 1Password Connect token' };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `1Password Connect request failed (${statusCode}): ${text || 'no body'}`
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async resolveSecretReferences(
|
||||
config: ConnectConfig,
|
||||
refs: string[],
|
||||
logPrefix = '[1Password Connect]'
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
if (refs.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const cache = new ResolveCache(config);
|
||||
for (const ref of refs) {
|
||||
const parsed = parseReference(ref);
|
||||
if (!parsed) {
|
||||
console.warn(`${logPrefix} Skipping malformed op:// reference ${ref}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
result.set(ref, await resolveOne(cache, parsed));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ReferenceNotFoundError) {
|
||||
console.warn(`${logPrefix} Skipping op:// reference ${ref}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async resolveBulk(): Promise<Record<string, string>> {
|
||||
throw new UnsupportedOperationError(
|
||||
'1Password Connect does not support environment bulk pull; use a service account.'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Secret-provider registry.
|
||||
*
|
||||
* Maps a stored provider `type` to its implementation, and exposes the small
|
||||
* surface the deploy path (stacks.ts) and the API routes consume. Adding a new
|
||||
* backend means dropping a file in this directory and registering it here — no
|
||||
* caller needs to change.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SecretProvider,
|
||||
SecretProviderConfig,
|
||||
SecretProviderType,
|
||||
TestConnectionResult
|
||||
} from './shared';
|
||||
import { serviceAccountProvider } from './service-account';
|
||||
import { connectProvider } from './connect';
|
||||
import { infisicalProvider } from './infisical';
|
||||
import { vaultProvider } from './vault';
|
||||
|
||||
// Registered providers. Adding a new backend means dropping a file in this
|
||||
// directory and registering it here; each implements the SecretProvider
|
||||
// contract in ./shared.
|
||||
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
|
||||
};
|
||||
|
||||
/** Returns the provider for a stored type, or undefined if unknown. */
|
||||
export function getProvider(type: SecretProviderType): SecretProvider | undefined {
|
||||
return providers[type];
|
||||
}
|
||||
|
||||
/** True when a provider is registered for the given type. */
|
||||
export function hasProvider(type: SecretProviderType): boolean {
|
||||
return type in providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a provider config against its backend. Returns a clear error when
|
||||
* the type is not registered.
|
||||
*/
|
||||
export async function testProviderConnection(
|
||||
type: SecretProviderType,
|
||||
config: SecretProviderConfig
|
||||
): Promise<TestConnectionResult> {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ok: false, error: `Unknown secret provider type: ${type}` };
|
||||
}
|
||||
return provider.testConnection(config);
|
||||
}
|
||||
|
||||
export type { SecretProvider, SecretProviderConfig, SecretProviderType, TestConnectionResult };
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Infisical provider.
|
||||
*
|
||||
* Talks to an Infisical instance (Infisical Cloud or self-hosted) over its
|
||||
* public REST API to bulk-pull every secret under a project / environment /
|
||||
* path. Infisical has no inline compose-reference syntax (no op://-style
|
||||
* marker), so this provider is bulk-only: `resolveSecretReferences` is
|
||||
* unsupported and `isReference` is always false.
|
||||
*
|
||||
* The `token` is treated as a ready-to-use API access token / service token and
|
||||
* sent as `Authorization: Bearer <token>`. Universal Auth (clientId +
|
||||
* clientSecret exchanged via POST /api/v1/auth/universal-auth/login) is not
|
||||
* modelled by InfisicalConfig and would need a config field plus a login step.
|
||||
*
|
||||
* 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 { InfisicalConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
|
||||
/** Shape of a single secret in the /api/v3/secrets/raw response. */
|
||||
interface RawSecret {
|
||||
secretKey: string;
|
||||
secretValue: string;
|
||||
}
|
||||
|
||||
/** The parts of the /api/v3/secrets/raw response Dockhand consumes. */
|
||||
interface RawSecretsResponse {
|
||||
secrets?: RawSecret[];
|
||||
}
|
||||
|
||||
/** Strips a trailing slash so `${base}${path}` never doubles up. */
|
||||
function baseUrl(host: string): string {
|
||||
return host.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the /api/v3/secrets/raw URL for a workspace/environment/path. The
|
||||
* secret path defaults to `/` (Infisical's root) when nothing is configured.
|
||||
*/
|
||||
function rawSecretsUrl(
|
||||
host: string,
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
secretPath: string
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath: secretPath || '/'
|
||||
});
|
||||
return `${baseUrl(host)}/api/v3/secrets/raw?${params.toString()}`;
|
||||
}
|
||||
|
||||
export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
type: 'infisical',
|
||||
label: 'Infisical',
|
||||
supportsReferences: false,
|
||||
supportsBulk: true,
|
||||
|
||||
isReference(_value: unknown): _value is string {
|
||||
return false;
|
||||
},
|
||||
|
||||
async testConnection(config: InfisicalConfig): Promise<TestConnectionResult> {
|
||||
const host = config.host?.trim();
|
||||
const token = config.token?.trim();
|
||||
const projectId = config.projectId?.trim();
|
||||
const environment = config.environment?.trim();
|
||||
|
||||
if (!host) {
|
||||
return { ok: false, error: 'Host is empty' };
|
||||
}
|
||||
if (!token) {
|
||||
return { ok: false, error: 'Token is empty' };
|
||||
}
|
||||
if (!projectId) {
|
||||
return { ok: false, error: 'Project ID is empty' };
|
||||
}
|
||||
if (!environment) {
|
||||
return { ok: false, error: 'Environment is empty' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { statusCode, body } = await request(
|
||||
rawSecretsUrl(host, projectId, environment, '/'),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
// Always drain the body so undici can reuse the connection.
|
||||
await body.text().catch(() => '');
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}` };
|
||||
} 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(
|
||||
'Infisical does not support inline references; use bulk pull (project/environment/path).'
|
||||
);
|
||||
},
|
||||
|
||||
async resolveBulk(config: InfisicalConfig, selector: string): Promise<Record<string, string>> {
|
||||
const host = config.host?.trim();
|
||||
const token = config.token?.trim();
|
||||
const projectId = config.projectId?.trim();
|
||||
const environment = config.environment?.trim();
|
||||
const secretPath = selector?.trim() || config.path?.trim() || '/';
|
||||
|
||||
if (!host) {
|
||||
throw new Error('[Infisical] Host is required for a bulk pull');
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('[Infisical] Token is required for a bulk pull');
|
||||
}
|
||||
if (!projectId) {
|
||||
throw new Error('[Infisical] Project ID is required for a bulk pull');
|
||||
}
|
||||
if (!environment) {
|
||||
throw new Error('[Infisical] Environment is required for a bulk pull');
|
||||
}
|
||||
|
||||
const { statusCode, body } = await request(
|
||||
rawSecretsUrl(host, projectId, environment, secretPath),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
// Drain the body so the connection can be reused, then surface the status.
|
||||
const detail = await body.text().catch(() => '');
|
||||
throw new Error(
|
||||
`[Infisical] Bulk pull failed with HTTP ${statusCode}${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as RawSecretsResponse;
|
||||
const result: Record<string, string> = {};
|
||||
for (const secret of payload.secrets ?? []) {
|
||||
if (secret?.secretKey !== undefined) {
|
||||
result[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 1Password Service Account provider.
|
||||
*
|
||||
* Thin wrapper around the official @1password/sdk that exposes only what
|
||||
* Dockhand needs: validating a service account token, reading variables from a
|
||||
* 1Password Environment, and interpolating op:// references.
|
||||
*
|
||||
* Decrypted tokens stay inside this module and the in-memory SDK client.
|
||||
* Nothing here writes to disk or to the database.
|
||||
*
|
||||
* This is the homelab / Families path: service accounts are available on
|
||||
* Individual and Families plans. Business users who run a Connect server use
|
||||
* ./connect instead.
|
||||
*/
|
||||
|
||||
import { createClient } from '@1password/sdk';
|
||||
import type { SecretProvider, ServiceAccountConfig, TestConnectionResult } from './shared';
|
||||
|
||||
const INTEGRATION_NAME = 'Dockhand';
|
||||
const INTEGRATION_VERSION = '1.0.0';
|
||||
|
||||
async function makeClient(token: string) {
|
||||
return createClient({
|
||||
auth: token,
|
||||
integrationName: INTEGRATION_NAME,
|
||||
integrationVersion: INTEGRATION_VERSION
|
||||
});
|
||||
}
|
||||
|
||||
export const serviceAccountProvider: SecretProvider<ServiceAccountConfig> = {
|
||||
type: 'op-service-account',
|
||||
label: '1Password service account',
|
||||
supportsReferences: true,
|
||||
supportsBulk: true,
|
||||
|
||||
isReference(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().startsWith('op://');
|
||||
},
|
||||
|
||||
async testConnection({ token }: ServiceAccountConfig): Promise<TestConnectionResult> {
|
||||
const trimmed = token?.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: 'Token is empty' };
|
||||
}
|
||||
try {
|
||||
await makeClient(trimmed);
|
||||
return { ok: true };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Authentication failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async resolveSecretReferences(
|
||||
{ token }: ServiceAccountConfig,
|
||||
refs: string[],
|
||||
logPrefix = '[1Password]'
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
if (refs.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const client = await makeClient(token);
|
||||
const response = await client.secrets.resolveAll(refs);
|
||||
for (const [ref, item] of Object.entries(response.individualResponses)) {
|
||||
if (item.error) {
|
||||
const detail = item.error.message ?? item.error.type ?? 'unknown error';
|
||||
console.warn(`${logPrefix} Skipping op:// reference ${ref}: ${detail}`);
|
||||
continue;
|
||||
}
|
||||
if (item.content?.secret !== undefined) {
|
||||
result.set(ref, item.content.secret);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async resolveBulk(
|
||||
{ token }: ServiceAccountConfig,
|
||||
selector: string
|
||||
): Promise<Record<string, string>> {
|
||||
const client = await makeClient(token);
|
||||
const response = await client.environments.getVariables(selector);
|
||||
const result: Record<string, string> = {};
|
||||
for (const variable of response.variables) {
|
||||
result[variable.name] = variable.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Shared contract for secret providers.
|
||||
*
|
||||
* Dockhand is provider-agnostic about where secrets come from. A secret
|
||||
* provider knows how to authenticate against one backend (1Password, Infisical,
|
||||
* HashiCorp Vault, ...), and supports one or both resolution modes:
|
||||
*
|
||||
* 1. Reference interpolation — a variable's value is an inline reference in
|
||||
* that backend's own syntax (e.g. 1Password's `op://vault/item/field`),
|
||||
* which the provider replaces with the real secret. Detection of what
|
||||
* counts as a reference is provider-specific (see `isReference`).
|
||||
* 2. Bulk pull — fetch every secret under a provider-defined selector (a
|
||||
* 1Password Environment id, an Infisical project/environment/path, a Vault
|
||||
* kv path, ...) as a flat key/value map.
|
||||
*
|
||||
* Concrete providers live in sibling files and are registered in ./index.
|
||||
* Decrypted config (tokens, hosts) is passed in by the caller and never
|
||||
* persisted here — providers hold it only for the duration of a call.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Identifies which backend a stored provider row talks to. Matches the
|
||||
* `secret_providers.type` column and the API. Kept as a widenable string union
|
||||
* so new backends can be added without a breaking type change.
|
||||
*/
|
||||
export type SecretProviderType =
|
||||
| 'op-service-account'
|
||||
| 'op-connect'
|
||||
| 'infisical'
|
||||
| 'vault'
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
| (string & {});
|
||||
|
||||
/** 1Password service account: a single bearer token. */
|
||||
export interface ServiceAccountConfig {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** 1Password Connect: a self-hosted host URL plus an access token. */
|
||||
export interface ConnectConfig {
|
||||
host: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infisical: an API host (self-hosted or cloud), a machine-identity or service
|
||||
* token, and the project/environment coordinates a bulk pull targets. `path`
|
||||
* and `environment` may be overridden per stack via the bulk selector.
|
||||
*/
|
||||
export interface InfisicalConfig {
|
||||
host: string;
|
||||
token: string;
|
||||
projectId: string;
|
||||
environment?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HashiCorp Vault: a server address and token, plus the KV v2 mount to read
|
||||
* from (defaults to `secret`). `namespace` is only meaningful on Enterprise/HCP.
|
||||
*/
|
||||
export interface VaultConfig {
|
||||
address: string;
|
||||
token: string;
|
||||
namespace?: string;
|
||||
mount?: string;
|
||||
}
|
||||
|
||||
/** Persisted (encrypted) config, discriminated by the provider `type`. */
|
||||
export type SecretProviderConfig =
|
||||
| ServiceAccountConfig
|
||||
| ConnectConfig
|
||||
| InfisicalConfig
|
||||
| VaultConfig;
|
||||
|
||||
export interface TestConnectionResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by a provider when it is asked to do something its backend cannot do
|
||||
* (e.g. resolving a 1Password Environment over Connect, or inline reference
|
||||
* interpolation on a bulk-only backend). Callers can catch this to surface a
|
||||
* precise "not supported on this provider" message instead of a generic failure.
|
||||
*/
|
||||
export class UnsupportedOperationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UnsupportedOperationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The behaviour every provider implements. `C` is the provider's own config
|
||||
* shape; the registry in ./index narrows it per type.
|
||||
*
|
||||
* A provider that does not support a given mode advertises it via
|
||||
* `supportsReferences` / `supportsBulk` and throws
|
||||
* {@link UnsupportedOperationError} from the corresponding method.
|
||||
*/
|
||||
export interface SecretProvider<C extends SecretProviderConfig = SecretProviderConfig> {
|
||||
/** Stable type tag matching the `secret_providers.type` column. */
|
||||
readonly type: SecretProviderType;
|
||||
|
||||
/** Human-readable name for logs and UI (e.g. "1Password service account"). */
|
||||
readonly label: string;
|
||||
|
||||
/** Whether this backend supports inline reference interpolation. */
|
||||
readonly supportsReferences: boolean;
|
||||
|
||||
/** Whether this backend supports bulk pull of a selector. */
|
||||
readonly supportsBulk: boolean;
|
||||
|
||||
/**
|
||||
* Detects whether a variable value is an inline reference in this backend's
|
||||
* syntax. Bulk-only providers return false for everything.
|
||||
*/
|
||||
isReference(value: unknown): value is string;
|
||||
|
||||
/**
|
||||
* Validates the config against the backend. Returns `{ ok: true }` on
|
||||
* success, `{ ok: false, error }` otherwise. Must not throw for ordinary
|
||||
* auth failures.
|
||||
*/
|
||||
testConnection(config: C): Promise<TestConnectionResult>;
|
||||
|
||||
/**
|
||||
* Resolves a batch of inline references. Returns a map containing only the
|
||||
* references that resolved successfully; individual lookup failures are
|
||||
* logged and skipped so the caller can leave those values as literals.
|
||||
* Transport / auth failures propagate as thrown errors. Bulk-only providers
|
||||
* throw {@link UnsupportedOperationError}.
|
||||
*/
|
||||
resolveSecretReferences(
|
||||
config: C,
|
||||
refs: string[],
|
||||
logPrefix?: string
|
||||
): Promise<Map<string, string>>;
|
||||
|
||||
/**
|
||||
* Fetches every secret under a provider-defined selector as a flat key/value
|
||||
* map. The selector is opaque and provider-specific (a 1Password Environment
|
||||
* id, an Infisical path, a Vault kv path, ...). Providers without a bulk
|
||||
* concept throw {@link UnsupportedOperationError}.
|
||||
*/
|
||||
resolveBulk(config: C, selector: string): Promise<Record<string, string>>;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* HashiCorp Vault provider (KV v2).
|
||||
*
|
||||
* Talks to a Vault server's HTTP API to bulk-pull every key stored under a KV
|
||||
* v2 path. Vault has no universal inline reference syntax that fits arbitrary
|
||||
* Compose variables, so this provider is bulk-only: `supportsReferences` is
|
||||
* false and `resolveSecretReferences` throws {@link UnsupportedOperationError}.
|
||||
*
|
||||
* Auth is a Vault token sent as `X-Vault-Token`. On Vault Enterprise / HCP a
|
||||
* namespace may be supplied and is forwarded as `X-Vault-Namespace`. The KV v2
|
||||
* mount defaults to `secret` and can be overridden per provider.
|
||||
*
|
||||
* Decrypted tokens stay inside this module for the duration of a call. Nothing
|
||||
* here writes to disk or to the database.
|
||||
*
|
||||
* Vault OSS is integration-testable against a dev server (`vault server -dev`);
|
||||
* the unit tests mock undici's `request`.
|
||||
*/
|
||||
|
||||
import { request } from 'undici';
|
||||
import { UnsupportedOperationError } from './shared';
|
||||
import type { SecretProvider, TestConnectionResult, VaultConfig } from './shared';
|
||||
|
||||
/** Undici response body handle, derived so we don't import undici's types. */
|
||||
type ResponseBody = Awaited<ReturnType<typeof request>>['body'];
|
||||
|
||||
/** Shape of a KV v2 read: the secret map lives at `data.data`. */
|
||||
interface KvV2ReadResponse {
|
||||
data?: {
|
||||
data?: Record<string, unknown>;
|
||||
metadata?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** `${address}/v1`, with any trailing slash on the address removed. */
|
||||
function apiBase(config: VaultConfig): string {
|
||||
return `${config.address.replace(/\/$/, '')}/v1`;
|
||||
}
|
||||
|
||||
/** Auth headers, plus the namespace header on Enterprise / HCP. */
|
||||
function authHeaders(config: VaultConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Vault-Token': config.token
|
||||
};
|
||||
if (config.namespace) {
|
||||
headers['X-Vault-Namespace'] = config.namespace;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a Vault error body into a short human string. Vault returns
|
||||
* `{ "errors": [...] }`; falls back to raw (truncated) text otherwise. Never
|
||||
* throws — callers use it only to enrich an already-failing path.
|
||||
*/
|
||||
async function readErrorDetail(body: ResponseBody): Promise<string> {
|
||||
try {
|
||||
const text = await body.text();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
|
||||
return parsed.errors.join('; ');
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to the raw body.
|
||||
}
|
||||
return text.trim().slice(0, 500);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const vaultProvider: SecretProvider<VaultConfig> = {
|
||||
type: 'vault',
|
||||
label: 'HashiCorp Vault',
|
||||
supportsReferences: false,
|
||||
supportsBulk: true,
|
||||
|
||||
isReference(_value: unknown): _value is string {
|
||||
// Bulk-only backend: nothing is treated as an inline reference.
|
||||
return false;
|
||||
},
|
||||
|
||||
async testConnection(config: VaultConfig): Promise<TestConnectionResult> {
|
||||
if (!config.address?.trim()) {
|
||||
return { ok: false, error: 'Address is empty' };
|
||||
}
|
||||
if (!config.token?.trim()) {
|
||||
return { ok: false, error: 'Token is empty' };
|
||||
}
|
||||
try {
|
||||
const { statusCode, body } = await request(`${apiBase(config)}/auth/token/lookup-self`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(config)
|
||||
});
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
await body.dump();
|
||||
return { ok: true };
|
||||
}
|
||||
const detail = await readErrorDetail(body);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Vault returned ${statusCode}${detail ? `: ${detail}` : ''}`
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async resolveSecretReferences(
|
||||
_config: VaultConfig,
|
||||
_refs: string[],
|
||||
_logPrefix?: string
|
||||
): Promise<Map<string, string>> {
|
||||
throw new UnsupportedOperationError(
|
||||
'HashiCorp Vault does not support inline references; use bulk pull (KV v2 path).'
|
||||
);
|
||||
},
|
||||
|
||||
async resolveBulk(config: VaultConfig, selector: string): Promise<Record<string, string>> {
|
||||
const mount = config.mount || 'secret';
|
||||
const path = selector.replace(/^\/+/, '');
|
||||
const { statusCode, body } = await request(`${apiBase(config)}/${mount}/data/${path}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(config)
|
||||
});
|
||||
|
||||
// A missing bulk selector is a real failure (unlike a single skipped ref).
|
||||
if (statusCode === 404) {
|
||||
await body.dump();
|
||||
throw new Error(`HashiCorp Vault: KV v2 path not found: ${mount}/${path}`);
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const detail = await readErrorDetail(body);
|
||||
throw new Error(
|
||||
`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as KvV2ReadResponse;
|
||||
const data = payload.data?.data ?? {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result[key] = typeof value === 'string' ? value : String(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
+221
-11
@@ -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 };
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Permissions {
|
||||
schedules: string[];
|
||||
backups: string[];
|
||||
templates: string[];
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
Info,
|
||||
GitBranch,
|
||||
Tags,
|
||||
KeyRound,
|
||||
Archive
|
||||
} from 'lucide-svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -27,6 +28,7 @@
|
||||
import LabelsTab from './labels/LabelsTab.svelte';
|
||||
import RegistriesTab from './registries/RegistriesTab.svelte';
|
||||
import GitTab from './git/GitTab.svelte';
|
||||
import SecretsTab from './secrets/SecretsTab.svelte';
|
||||
import ConfigSetsTab from './config-sets/ConfigSetsTab.svelte';
|
||||
import NotificationsTab from './notifications/NotificationsTab.svelte';
|
||||
import BackupsTab from './backups/BackupsTab.svelte';
|
||||
@@ -71,6 +73,10 @@
|
||||
<GitBranch class="w-4 h-4" />
|
||||
Git
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="secrets" class="flex-1 flex items-center justify-center gap-1.5">
|
||||
<KeyRound class="w-4 h-4" />
|
||||
Secrets
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="config-sets" class="flex-1 flex items-center justify-center gap-1.5">
|
||||
<Layers class="w-4 h-4" />
|
||||
Config sets
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
audit_logs: string[];
|
||||
activity: string[];
|
||||
schedules: string[];
|
||||
secrets: string[];
|
||||
backups: string[];
|
||||
}>({
|
||||
containers: [],
|
||||
@@ -84,6 +85,7 @@
|
||||
audit_logs: [],
|
||||
activity: [],
|
||||
schedules: [],
|
||||
secrets: [],
|
||||
backups: []
|
||||
});
|
||||
|
||||
@@ -142,6 +144,12 @@
|
||||
{ key: 'edit', label: 'Edit schedules' },
|
||||
{ key: 'run', label: 'Run schedules' }
|
||||
],
|
||||
secrets: [
|
||||
{ key: 'view', label: 'View secrets' },
|
||||
{ key: 'create', label: 'Create secrets' },
|
||||
{ key: 'edit', label: 'Edit secrets' },
|
||||
{ key: 'delete', label: 'Delete secrets' }
|
||||
],
|
||||
backups: [
|
||||
{ key: 'view', label: 'View backups' },
|
||||
{ key: 'run', label: 'Run backups' },
|
||||
@@ -214,6 +222,7 @@
|
||||
audit_logs: ClipboardList,
|
||||
activity: Activity,
|
||||
schedules: Timer,
|
||||
secrets: KeyRound,
|
||||
backups: Archive
|
||||
};
|
||||
|
||||
@@ -234,6 +243,7 @@
|
||||
audit_logs: 'bg-stone-100 dark:bg-stone-950 text-stone-700 dark:text-stone-400 border-stone-300 dark:border-stone-800',
|
||||
activity: 'bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-400 border-emerald-300 dark:border-emerald-800',
|
||||
schedules: 'bg-sky-100 dark:bg-sky-950 text-sky-700 dark:text-sky-400 border-sky-300 dark:border-sky-800',
|
||||
secrets: 'bg-fuchsia-100 dark:bg-fuchsia-950 text-fuchsia-700 dark:text-fuchsia-400 border-fuchsia-300 dark:border-fuchsia-800',
|
||||
backups: 'bg-lime-100 dark:bg-lime-950 text-lime-700 dark:text-lime-400 border-lime-300 dark:border-lime-800'
|
||||
};
|
||||
|
||||
@@ -284,6 +294,7 @@
|
||||
audit_logs: [],
|
||||
activity: [],
|
||||
schedules: [],
|
||||
secrets: [],
|
||||
backups: []
|
||||
};
|
||||
}
|
||||
@@ -315,6 +326,7 @@
|
||||
audit_logs: [...(role.permissions.audit_logs || [])],
|
||||
activity: [...(role.permissions.activity || [])],
|
||||
schedules: [...(role.permissions.schedules || [])],
|
||||
secrets: [...(role.permissions.secrets || [])],
|
||||
backups: [...(role.permissions.backups || [])]
|
||||
};
|
||||
formError = '';
|
||||
@@ -344,6 +356,7 @@
|
||||
audit_logs: [...(copyFrom.permissions.audit_logs || [])],
|
||||
activity: [...(copyFrom.permissions.activity || [])],
|
||||
schedules: [...(copyFrom.permissions.schedules || [])],
|
||||
secrets: [...(copyFrom.permissions.secrets || [])],
|
||||
backups: [...(copyFrom.permissions.backups || [])]
|
||||
};
|
||||
formError = '';
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<script lang='ts' module>
|
||||
export interface SecretProvider {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface ProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
// Selectable provider types + their labels. Mirrors the registered providers
|
||||
// in src/lib/server/secretproviders (index.ts / shared.ts).
|
||||
export const PROVIDER_TYPES: { value: string; label: string }[] = [
|
||||
{ value: 'op-service-account', label: '1Password service account' },
|
||||
{ value: 'op-connect', label: '1Password Connect' },
|
||||
{ value: 'infisical', label: 'Infisical' },
|
||||
{ value: 'vault', label: 'HashiCorp Vault' },
|
||||
];
|
||||
|
||||
// Config fields per provider type, matching the config shapes in
|
||||
// secretproviders/shared.ts. Non-required fields are optional overrides.
|
||||
export const PROVIDER_FIELDS: Record<string, ProviderField[]> = {
|
||||
'op-service-account': [
|
||||
{ key: 'token', label: 'Service account token', type: 'password', required: true, placeholder: 'ops_eyJ...' },
|
||||
],
|
||||
'op-connect': [
|
||||
{ key: 'host', label: 'Connect host URL', type: 'text', required: true, placeholder: 'https://connect.example.com' },
|
||||
{ key: 'token', label: 'Connect token', type: 'password', required: true, placeholder: 'eyJ...' },
|
||||
],
|
||||
infisical: [
|
||||
{ key: 'host', label: 'API host', type: 'text', required: true, placeholder: 'https://app.infisical.com' },
|
||||
{ key: 'token', label: 'Access token', type: 'password', required: true, placeholder: 'st...' },
|
||||
{ key: 'projectId', label: 'Project ID', type: 'text', required: true, placeholder: 'workspace / project id' },
|
||||
{ key: 'environment', label: 'Environment', type: 'text', required: true, placeholder: 'prod' },
|
||||
{ key: 'path', label: 'Secret path', type: 'text', required: false, placeholder: '/' },
|
||||
],
|
||||
vault: [
|
||||
{ key: 'address', label: 'Vault address', type: 'text', required: true, placeholder: 'https://vault.example.com' },
|
||||
{ key: 'token', label: 'Vault token', type: 'password', required: true, placeholder: 'hvs...' },
|
||||
{ key: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin (Enterprise / HCP)' },
|
||||
{ key: 'mount', label: 'KV mount', type: 'text', required: false, placeholder: 'secret' },
|
||||
],
|
||||
};
|
||||
|
||||
export function providerTypeLabel(type: string): string {
|
||||
return PROVIDER_TYPES.find((t) => t.value === type)?.label ?? type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang='ts'>
|
||||
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 { Input } from '$lib/components/ui/input';
|
||||
import { Plus, Check, RefreshCw, PlugZap } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
provider?: SecretProvider | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
provider = null,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: Props = $props();
|
||||
|
||||
const isEditing = $derived(provider !== null);
|
||||
|
||||
let formName = $state('');
|
||||
let formType = $state('op-service-account');
|
||||
// One value per config field; blank means 'unset' (on edit: keep existing).
|
||||
let formConfig = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
let formTesting = $state(false);
|
||||
|
||||
const fields = $derived(PROVIDER_FIELDS[formType] ?? []);
|
||||
|
||||
function resetConfig() {
|
||||
formConfig = {};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formName = '';
|
||||
formType = 'op-service-account';
|
||||
resetConfig();
|
||||
formError = '';
|
||||
formSaving = false;
|
||||
formTesting = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (provider) {
|
||||
formName = provider.name;
|
||||
formType = provider.type;
|
||||
resetConfig();
|
||||
formError = '';
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The config never leaves the server, so on edit the fields start blank.
|
||||
// Collect only the fields the user actually filled in.
|
||||
function collectConfig(): Record<string, string> {
|
||||
const config: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const value = (formConfig[field.key] ?? '').trim();
|
||||
if (value) config[field.key] = value;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function missingRequired(config: Record<string, string>): string | null {
|
||||
for (const field of fields) {
|
||||
if (field.required && !config[field.key]) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onTypeChange(value: string) {
|
||||
formType = value;
|
||||
// Fields differ per type; drop any stale values.
|
||||
resetConfig();
|
||||
formError = '';
|
||||
}
|
||||
|
||||
async function testCurrent() {
|
||||
formTesting = true;
|
||||
formError = '';
|
||||
try {
|
||||
const config = collectConfig();
|
||||
const configProvided = Object.keys(config).length > 0;
|
||||
|
||||
let response: Response;
|
||||
if (isEditing && !configProvided) {
|
||||
// Test the stored (server-side) config.
|
||||
response = await fetch(`/api/secret-providers/${provider!.id}/test`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} else {
|
||||
const missing = missingRequired(config);
|
||||
if (missing) {
|
||||
formError = missing;
|
||||
return;
|
||||
}
|
||||
response = await fetch('/api/secret-providers/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: formType, config }),
|
||||
});
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
toast.success('Connection works');
|
||||
} else {
|
||||
toast.error(data.error || 'Connection failed');
|
||||
formError = data.error || 'Connection failed';
|
||||
}
|
||||
} catch {
|
||||
toast.error('Connection test failed');
|
||||
} finally {
|
||||
formTesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!formName.trim()) {
|
||||
formError = 'Name is required';
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
formSaving = true;
|
||||
formError = '';
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName.trim(),
|
||||
type: formType,
|
||||
};
|
||||
if (configProvided) body.config = config;
|
||||
|
||||
const url = isEditing
|
||||
? `/api/secret-providers/${provider!.id}`
|
||||
: '/api/secret-providers';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
open = false;
|
||||
onSaved();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
formError =
|
||||
data.error ||
|
||||
`Failed to ${isEditing ? 'update' : 'create'} secret provider`;
|
||||
}
|
||||
} catch {
|
||||
formError = `Failed to ${isEditing ? 'update' : 'create'} secret provider`;
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(o) => {
|
||||
if (o) {
|
||||
formError = "";
|
||||
focusFirstInput();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title
|
||||
>{isEditing ? "Edit" : "Add"} secret provider</Dialog.Title
|
||||
>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
{#if formError}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-2">
|
||||
<Label for="provider-name">Name</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
bind:value={formName}
|
||||
placeholder="Production secrets"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="provider-type">Provider</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formType}
|
||||
onValueChange={onTypeChange}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<Select.Trigger id="provider-type">
|
||||
{providerTypeLabel(formType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each PROVIDER_TYPES as t (t.value)}
|
||||
<Select.Item value={t.value} label={t.label}>
|
||||
{t.label}
|
||||
</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}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Configuration is stored encrypted.{#if isEditing}
|
||||
Leave secret fields blank to keep the existing values.{/if}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={testCurrent}
|
||||
disabled={formTesting || formSaving}
|
||||
>
|
||||
{#if formTesting}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<PlugZap class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Test connection
|
||||
</Button>
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="outline" onclick={handleClose}>Cancel</Button>
|
||||
<Button onclick={save} disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else if isEditing}
|
||||
<Check class="w-4 h-4" />
|
||||
{:else}
|
||||
<Plus class="w-4 h-4" />
|
||||
{/if}
|
||||
{isEditing ? "Save" : "Add"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang='ts'>
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
} from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { EmptyState } from '$lib/components/ui/empty-state';
|
||||
import ProviderModal, {
|
||||
type SecretProvider,
|
||||
providerTypeLabel,
|
||||
} from './ProviderModal.svelte';
|
||||
|
||||
let providers = $state<SecretProvider[]>([]);
|
||||
let loading = $state(true);
|
||||
let showModal = $state(false);
|
||||
let editing = $state<SecretProvider | null>(null);
|
||||
let confirmDeleteId = $state<number | null>(null);
|
||||
let testingId = $state<number | null>(null);
|
||||
|
||||
async function fetchProviders() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/secret-providers');
|
||||
providers = await response.json();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch secret providers:', e);
|
||||
toast.error('Failed to fetch secret providers');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(provider?: SecretProvider) {
|
||||
editing = provider || null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function deleteProvider(id: number) {
|
||||
try {
|
||||
const response = await fetch(`/api/secret-providers/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
await fetchProviders();
|
||||
toast.success('Secret provider deleted');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || 'Failed to delete secret provider');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete secret provider');
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(provider: SecretProvider) {
|
||||
testingId = provider.id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/secret-providers/${provider.id}/test`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
toast.success(`${provider.name}: connection works`);
|
||||
} else {
|
||||
toast.error(
|
||||
`${provider.name}: ${data.error || 'connection failed'}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Connection test failed');
|
||||
} finally {
|
||||
testingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchProviders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant="secondary" class="text-xs"
|
||||
>{providers.length} total</Badge
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if $canAccess("secrets", "create")}
|
||||
<Button size="sm" onclick={() => openModal()}>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add secret provider
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" onclick={fetchProviders}
|
||||
>Refresh</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading && providers.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Loading secret providers...</p>
|
||||
{:else if providers.length === 0}
|
||||
<EmptyState
|
||||
icon={KeyRound}
|
||||
title="No secret providers"
|
||||
description="Add a provider (1Password, Infisical, HashiCorp Vault, ...) to load secrets at deploy time"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each providers as provider (provider.id)}
|
||||
<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"
|
||||
/>
|
||||
<Card.Title class="text-base"
|
||||
>{provider.name}</Card.Title
|
||||
>
|
||||
</div>
|
||||
<Badge variant="secondary" class="text-xs"
|
||||
>{providerTypeLabel(provider.type)}</Badge
|
||||
>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Added {new Date(
|
||||
provider.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2 min-h-[32px]">
|
||||
{#if $canAccess("secrets", "view")}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => testProvider(provider)}
|
||||
disabled={testingId === provider.id}
|
||||
>
|
||||
{#if testingId === provider.id}
|
||||
<RefreshCw
|
||||
class="w-3 h-3 animate-spin"
|
||||
/>
|
||||
{:else}
|
||||
<PlugZap class="w-3 h-3" />
|
||||
{/if}
|
||||
Test
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $canAccess("secrets", "edit")}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openModal(provider)}
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $canAccess("secrets", "delete")}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === provider.id}
|
||||
action="Delete"
|
||||
itemType="secret provider"
|
||||
itemName={provider.name}
|
||||
title="Remove"
|
||||
position="left"
|
||||
onConfirm={() =>
|
||||
deleteProvider(provider.id)}
|
||||
onOpenChange={(open) =>
|
||||
(confirmDeleteId = open
|
||||
? provider.id
|
||||
: null)}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Trash2
|
||||
class="w-3 h-3 {open
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground hover:text-destructive'}"
|
||||
/>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ProviderModal
|
||||
bind:open={showModal}
|
||||
provider={editing}
|
||||
onClose={() => {
|
||||
showModal = false;
|
||||
editing = null;
|
||||
}}
|
||||
onSaved={fetchProviders}
|
||||
/>
|
||||
@@ -154,6 +154,11 @@
|
||||
let copiedWebhookUrl = $state<'ok' | 'error' | null>(null);
|
||||
let copiedWebhookSecret = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
// Secret providers
|
||||
type SecretProviderOption = { id: number; name: string };
|
||||
let secretProviders = $state<SecretProviderOption[]>([]);
|
||||
let formSecretProviderId = $state<number | null>(null);
|
||||
|
||||
// Environment variables state
|
||||
let formEnvFilePath = $state<string | null>(null);
|
||||
let envFiles = $state<string[]>([]);
|
||||
@@ -205,8 +210,21 @@
|
||||
// Add global mouse event listeners for split dragging
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
fetchSecretProviders();
|
||||
});
|
||||
|
||||
async function fetchSecretProviders() {
|
||||
try {
|
||||
const response = await fetch('/api/secret-providers');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
secretProviders = (data ?? []).map((p: any) => ({ id: p.id, name: p.name }));
|
||||
} catch (e) {
|
||||
console.warn('Failed to load secret providers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
@@ -426,6 +444,10 @@
|
||||
formRepullImages = gitStack.repullImages ?? false;
|
||||
formForceRedeploy = gitStack.forceRedeploy ?? false;
|
||||
formDeployNow = false;
|
||||
formSecretProviderId = null;
|
||||
|
||||
// Load secret provider binding
|
||||
loadSecretProviderBindingForStack(gitStack.stackName);
|
||||
|
||||
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
|
||||
// Wait for all loads to complete before allowing any other effect to run
|
||||
@@ -455,6 +477,20 @@
|
||||
formRepullImages = false;
|
||||
formForceRedeploy = false;
|
||||
formDeployNow = false;
|
||||
formSecretProviderId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSecretProviderBindingForStack(stackName: string) {
|
||||
try {
|
||||
const url = environmentId ? `/api/stacks/sources?env=${environmentId}` : '/api/stacks/sources';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return;
|
||||
const sourceMap = await response.json();
|
||||
const source = sourceMap?.[stackName];
|
||||
formSecretProviderId = source?.secretProviderId ?? null;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load secret provider binding for git stack:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +576,7 @@
|
||||
repullImages: formRepullImages,
|
||||
forceRedeploy: formForceRedeploy,
|
||||
deployNow: deployAfterSave,
|
||||
secretProviderId: formSecretProviderId,
|
||||
envVars: overrideVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
@@ -1151,6 +1188,41 @@
|
||||
|
||||
<!-- 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 -->
|
||||
<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
|
||||
type="single"
|
||||
value={formSecretProviderId !== null ? String(formSecretProviderId) : ''}
|
||||
onValueChange={(v) => { formSecretProviderId = v ? parseInt(v) : null; }}
|
||||
>
|
||||
<Select.Trigger id="secret-provider-select-git" class="h-7 text-xs">
|
||||
{#if formSecretProviderId !== null}
|
||||
{secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? 'Unknown provider'}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">None — disabled</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="" label="None">
|
||||
<span class="text-muted-foreground">None — disabled</span>
|
||||
</Select.Item>
|
||||
{#each secretProviders as provider (provider.id)}
|
||||
<Select.Item value={String(provider.id)} label={provider.name}>
|
||||
{provider.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-xs text-xs">
|
||||
Inline references (e.g. <code>op://</code> for 1Password) are still resolved. For bulk pull, set <code>OP_ENVIRONMENT_ID</code> (1Password Environment) or the generic <code>DOCKHAND_SECRET_SELECTOR</code> in env vars; the provider's secrets are loaded and injected.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
placeholder={{ key: 'MY_VAR', value: 'value' }}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, HelpCircle, Archive } from 'lucide-svelte';
|
||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||
@@ -70,11 +70,30 @@
|
||||
// Ref to the embedded backup panel so close can check its inline form for unsaved edits.
|
||||
let backupPanelRef = $state<BackupPanel | undefined>(undefined);
|
||||
|
||||
// Secret providers
|
||||
type SecretProviderOption = { id: number; name: string };
|
||||
let secretProviders = $state<SecretProviderOption[]>([]);
|
||||
let formSecretProviderId = $state<number | null>(null);
|
||||
|
||||
// Environment variables state
|
||||
let envVars = $state<EnvVar[]>([]);
|
||||
let rawEnvContent = $state(''); // Raw .env file content (comments preserved)
|
||||
let envValidation = $state<ValidationResult | null>(null);
|
||||
let validating = $state(false);
|
||||
|
||||
// The bulk-pull selector vars (OP_ENVIRONMENT_ID for 1Password, the generic
|
||||
// DOCKHAND_SECRET_SELECTOR for other providers) are consumed by the secret
|
||||
// provider, not the compose file, so they only count as "used" when a provider
|
||||
// is bound to the stack.
|
||||
const SELECTOR_VARS = ['OP_ENVIRONMENT_ID', 'DOCKHAND_SECRET_SELECTOR'];
|
||||
const effectiveValidation = $derived.by<ValidationResult | null>(() => {
|
||||
if (!envValidation || formSecretProviderId === null) return envValidation;
|
||||
if (!envValidation.unused.some((v) => SELECTOR_VARS.includes(v))) return envValidation;
|
||||
return {
|
||||
...envValidation,
|
||||
unused: envValidation.unused.filter((v) => !SELECTOR_VARS.includes(v))
|
||||
};
|
||||
});
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
let hadExistingDbVars = $state(false); // Track if DB had any vars on load (for proper cleanup)
|
||||
|
||||
@@ -694,8 +713,21 @@
|
||||
// Add global mouse event listeners for split dragging
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
fetchSecretProviders();
|
||||
});
|
||||
|
||||
async function fetchSecretProviders() {
|
||||
try {
|
||||
const response = await fetch('/api/secret-providers');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
secretProviders = (data ?? []).map((p: any) => ({ id: p.id, name: p.name }));
|
||||
} catch (e) {
|
||||
console.warn('Failed to load secret providers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
@@ -811,6 +843,18 @@
|
||||
originalComposePath = data.composePath || null;
|
||||
originalEnvPath = data.envPath || null;
|
||||
|
||||
// Load secret provider binding
|
||||
try {
|
||||
const sourcesRes = await fetch(appendEnvParam('/api/stacks/sources', envId));
|
||||
if (sourcesRes.ok) {
|
||||
const sourceMap = await sourcesRes.json();
|
||||
const source = sourceMap?.[stackName];
|
||||
formSecretProviderId = source?.secretProviderId ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load stack source for secret provider binding:', e);
|
||||
}
|
||||
|
||||
// Volumes/binds for the backup picker (managed/internal stack path).
|
||||
try {
|
||||
await loadStackVolumes(envId);
|
||||
@@ -968,6 +1012,8 @@
|
||||
requestBody.envPath = envPathToSave;
|
||||
}
|
||||
|
||||
requestBody.secretProviderId = formSecretProviderId;
|
||||
|
||||
// Create the stack
|
||||
response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
method: 'POST',
|
||||
@@ -1099,6 +1145,8 @@
|
||||
requestBody.moveFromDir = moveFromDir;
|
||||
}
|
||||
|
||||
requestBody.secretProviderId = formSecretProviderId;
|
||||
|
||||
// Save env files BEFORE compose to ensure deploy reads fresh values
|
||||
// Save raw content to .env file (non-secrets only, comments preserved)
|
||||
const rawEnvResponse = await fetch(
|
||||
@@ -1710,11 +1758,51 @@
|
||||
</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 -->
|
||||
<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
|
||||
type="single"
|
||||
value={formSecretProviderId !== null ? String(formSecretProviderId) : ''}
|
||||
onValueChange={(v) => {
|
||||
formSecretProviderId = v ? parseInt(v) : null;
|
||||
markDirty();
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="secret-provider-select" class="h-7 text-xs">
|
||||
{#if formSecretProviderId !== null}
|
||||
{secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? 'Unknown provider'}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">None — disabled</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="" label="None">
|
||||
<span class="text-muted-foreground">None — disabled</span>
|
||||
</Select.Item>
|
||||
{#each secretProviders as provider (provider.id)}
|
||||
<Select.Item value={String(provider.id)} label={provider.name}>
|
||||
{provider.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Tooltip.Provider delayDuration={150}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-xs text-xs">
|
||||
Inline references (e.g. <code>op://</code> for 1Password) are still resolved. For bulk pull, set <code>OP_ENVIRONMENT_ID</code> (1Password Environment) or the generic <code>DOCKHAND_SECRET_SELECTOR</code> in env vars; the provider's secrets are loaded and injected.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
<StackEnvVarsPanel
|
||||
bind:this={envVarsPanelRef}
|
||||
bind:variables={envVars}
|
||||
bind:rawContent={rawEnvContent}
|
||||
validation={envValidation}
|
||||
validation={effectiveValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
{readonly}
|
||||
onchange={() => { markDirty(); debouncedValidate(); }}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Test preload, referenced by `[test] preload` in bunfig.toml.
|
||||
//
|
||||
// The current suite is pure unit tests that mock their own transport, so there
|
||||
// is no global setup to do yet. This file exists so the path bunfig.toml points
|
||||
// at resolves; without it `bun test` fails before collecting any test.
|
||||
export {};
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Unit tests for the 1Password Connect secret provider.
|
||||
*
|
||||
* The Connect backend is a self-hosted REST server we have no Business account
|
||||
* for, so these tests mock undici's `request` and exercise the provider's HTTP
|
||||
* choreography directly: health check, op:// reference resolution (including the
|
||||
* optional section segment and the per-call vault/item cache), the not-found
|
||||
* skip+warn path, auth-failure propagation, and the unsupported bulk pull.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
/**
|
||||
* A queued response for the mocked undici `request`. `json`/`text` drive the
|
||||
* body helpers the provider calls; `status` is the HTTP status code.
|
||||
*/
|
||||
interface MockResponse {
|
||||
status: number;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Records one outgoing request so tests can assert URL/headers/count. */
|
||||
interface RecordedCall {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
let responses: MockResponse[];
|
||||
let calls: RecordedCall[];
|
||||
|
||||
const requestMock = mock(async (url: unknown, opts?: { method?: string; headers?: Record<string, string> }) => {
|
||||
calls.push({ url: String(url), method: opts?.method, headers: opts?.headers });
|
||||
const next = responses.shift();
|
||||
if (!next) {
|
||||
throw new Error(`Unexpected request to ${String(url)}: no mock response queued`);
|
||||
}
|
||||
return {
|
||||
statusCode: next.status,
|
||||
body: {
|
||||
async json() {
|
||||
return next.json;
|
||||
},
|
||||
async text() {
|
||||
return next.text ?? (next.json !== undefined ? JSON.stringify(next.json) : '');
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('undici', () => ({ request: requestMock }));
|
||||
|
||||
// Imported after the mock is registered so connect.ts binds the mocked request.
|
||||
const { connectProvider } = await import('../src/lib/server/secretproviders/connect');
|
||||
const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared');
|
||||
|
||||
const CONFIG = { host: 'https://connect.example.com/', token: 'test-token' };
|
||||
|
||||
/** Queue the responses a single-field resolution walks through. */
|
||||
function queueSuccessfulResolution() {
|
||||
responses = [
|
||||
{ status: 200, json: [{ id: 'vault-1', name: 'Prod' }] },
|
||||
{ status: 200, json: [{ id: 'item-1', title: 'Database' }] },
|
||||
{
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'item-1',
|
||||
title: 'Database',
|
||||
fields: [{ id: 'f1', label: 'password', value: 's3cret' }]
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
responses = [];
|
||||
calls = [];
|
||||
requestMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Fail loudly if a test queued responses it never consumed.
|
||||
expect(responses.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('connectProvider capabilities', () => {
|
||||
test('advertises the expected type, label, and capability flags', () => {
|
||||
expect(connectProvider.type).toBe('op-connect');
|
||||
expect(connectProvider.label).toBe('1Password Connect');
|
||||
expect(connectProvider.supportsReferences).toBe(true);
|
||||
expect(connectProvider.supportsBulk).toBe(false);
|
||||
});
|
||||
|
||||
test('isReference matches op:// strings only', () => {
|
||||
expect(connectProvider.isReference('op://Prod/Database/password')).toBe(true);
|
||||
expect(connectProvider.isReference(' op://Prod/Database/password')).toBe(true);
|
||||
expect(connectProvider.isReference('plain-value')).toBe(false);
|
||||
expect(connectProvider.isReference(42)).toBe(false);
|
||||
expect(connectProvider.isReference(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
test('rejects empty host or token without hitting the network', async () => {
|
||||
expect(await connectProvider.testConnection({ host: '', token: 't' })).toEqual({
|
||||
ok: false,
|
||||
error: 'Host is empty'
|
||||
});
|
||||
expect(await connectProvider.testConnection({ host: 'https://x', token: ' ' })).toEqual({
|
||||
ok: false,
|
||||
error: 'Token is empty'
|
||||
});
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ok on a 2xx /v1/vaults response and sends bearer auth', async () => {
|
||||
responses = [{ status: 200, json: [{ id: 'v1', name: 'Vault' }] }];
|
||||
const result = await connectProvider.testConnection(CONFIG);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(calls[0].url).toBe('https://connect.example.com/v1/vaults');
|
||||
expect(calls[0].headers?.Authorization).toBe('Bearer test-token');
|
||||
expect(calls[0].headers?.Accept).toBe('application/json');
|
||||
});
|
||||
|
||||
test('ok:false with a clear message on auth failure, without throwing', async () => {
|
||||
responses = [{ status: 401, text: 'invalid token' }];
|
||||
const result = await connectProvider.testConnection(CONFIG);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Invalid 1Password Connect token');
|
||||
});
|
||||
|
||||
test('ok:false when the request throws (transport error)', async () => {
|
||||
responses = [];
|
||||
requestMock.mockImplementationOnce(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const result = await connectProvider.testConnection(CONFIG);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('ECONNREFUSED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSecretReferences', () => {
|
||||
test('returns an empty map for no refs without any request', async () => {
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, []);
|
||||
expect(result.size).toBe(0);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolves a op://vault/item/field reference', async () => {
|
||||
queueSuccessfulResolution();
|
||||
const ref = 'op://Prod/Database/password';
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]);
|
||||
expect(result.get(ref)).toBe('s3cret');
|
||||
expect(result.size).toBe(1);
|
||||
// vaults, items, item detail — three GETs against the v1 base.
|
||||
expect(calls.map((c) => c.url)).toEqual([
|
||||
'https://connect.example.com/v1/vaults',
|
||||
'https://connect.example.com/v1/vaults/vault-1/items',
|
||||
'https://connect.example.com/v1/vaults/vault-1/items/item-1'
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves a reference with a section segment', async () => {
|
||||
responses = [
|
||||
{ status: 200, json: [{ id: 'vault-1', name: 'Prod' }] },
|
||||
{ status: 200, json: [{ id: 'item-1', title: 'Database' }] },
|
||||
{
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'item-1',
|
||||
title: 'Database',
|
||||
fields: [
|
||||
{ id: 'f1', label: 'password', value: 'wrong', section: { id: 's-other' } },
|
||||
{
|
||||
id: 'f2',
|
||||
label: 'password',
|
||||
value: 'right',
|
||||
section: { id: 's-1', label: 'replica' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
const ref = 'op://Prod/Database/replica/password';
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]);
|
||||
expect(result.get(ref)).toBe('right');
|
||||
});
|
||||
|
||||
test('matches vault by id and item by id', async () => {
|
||||
responses = [
|
||||
{ status: 200, json: [{ id: 'vault-1', name: 'Prod' }] },
|
||||
{ status: 200, json: [{ id: 'item-1', title: 'Database' }] },
|
||||
{
|
||||
status: 200,
|
||||
json: { id: 'item-1', title: 'Database', fields: [{ id: 'notes', value: 'byid' }] }
|
||||
}
|
||||
];
|
||||
const ref = 'op://vault-1/item-1/notes';
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]);
|
||||
expect(result.get(ref)).toBe('byid');
|
||||
});
|
||||
|
||||
test('caches vault and item lists across refs in one call', async () => {
|
||||
responses = [
|
||||
{ status: 200, json: [{ id: 'vault-1', name: 'Prod' }] },
|
||||
{ status: 200, json: [{ id: 'item-1', title: 'Database' }] },
|
||||
{
|
||||
status: 200,
|
||||
json: { id: 'item-1', title: 'Database', fields: [{ label: 'password', value: 'p1' }] }
|
||||
},
|
||||
// Second ref hits the same vault+item: only the item-detail GET repeats.
|
||||
{
|
||||
status: 200,
|
||||
json: { id: 'item-1', title: 'Database', fields: [{ label: 'username', value: 'u1' }] }
|
||||
}
|
||||
];
|
||||
const refs = ['op://Prod/Database/password', 'op://Prod/Database/username'];
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, refs);
|
||||
expect(result.get(refs[0])).toBe('p1');
|
||||
expect(result.get(refs[1])).toBe('u1');
|
||||
// 1 vaults + 1 items + 2 item-details = 4 requests (lists were cached).
|
||||
expect(calls.length).toBe(4);
|
||||
expect(calls.filter((c) => c.url.endsWith('/v1/vaults')).length).toBe(1);
|
||||
expect(calls.filter((c) => c.url.endsWith('/items')).length).toBe(1);
|
||||
});
|
||||
|
||||
test('skips and warns a ref whose field is missing, keeping the others', async () => {
|
||||
const warn = mock(() => {});
|
||||
const original = console.warn;
|
||||
console.warn = warn;
|
||||
try {
|
||||
responses = [
|
||||
{ status: 200, json: [{ id: 'vault-1', name: 'Prod' }] },
|
||||
{ status: 200, json: [{ id: 'item-1', title: 'Database' }] },
|
||||
// missing field -> skip
|
||||
{
|
||||
status: 200,
|
||||
json: { id: 'item-1', title: 'Database', fields: [{ label: 'password', value: 'p1' }] }
|
||||
},
|
||||
// second ref resolves fine
|
||||
{
|
||||
status: 200,
|
||||
json: { id: 'item-1', title: 'Database', fields: [{ label: 'password', value: 'p1' }] }
|
||||
}
|
||||
];
|
||||
const missing = 'op://Prod/Database/nope';
|
||||
const present = 'op://Prod/Database/password';
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, [missing, present]);
|
||||
expect(result.has(missing)).toBe(false);
|
||||
expect(result.get(present)).toBe('p1');
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('skips a ref whose vault 404s on the specific lookup', async () => {
|
||||
const warn = mock(() => {});
|
||||
const original = console.warn;
|
||||
console.warn = warn;
|
||||
try {
|
||||
// Empty vault list -> vault not found -> skip+warn, no throw.
|
||||
responses = [{ status: 200, json: [] }];
|
||||
const ref = 'op://Ghost/Database/password';
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, [ref]);
|
||||
expect(result.size).toBe(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('skips a malformed op:// reference', async () => {
|
||||
const warn = mock(() => {});
|
||||
const original = console.warn;
|
||||
console.warn = warn;
|
||||
try {
|
||||
responses = [];
|
||||
const bad = 'op://Prod/Database'; // too few segments
|
||||
const result = await connectProvider.resolveSecretReferences(CONFIG, [bad]);
|
||||
expect(result.size).toBe(0);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('throws on an auth failure (401) instead of skipping', async () => {
|
||||
responses = [{ status: 401, text: 'unauthorized' }];
|
||||
await expect(
|
||||
connectProvider.resolveSecretReferences(CONFIG, ['op://Prod/Database/password'])
|
||||
).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
test('throws on a server error (500)', async () => {
|
||||
responses = [{ status: 500, text: 'boom' }];
|
||||
await expect(
|
||||
connectProvider.resolveSecretReferences(CONFIG, ['op://Prod/Database/password'])
|
||||
).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBulk', () => {
|
||||
test('throws UnsupportedOperationError', async () => {
|
||||
await expect(connectProvider.resolveBulk(CONFIG, 'any-selector')).rejects.toBeInstanceOf(
|
||||
UnsupportedOperationError
|
||||
);
|
||||
});
|
||||
|
||||
test('the error message points at using a service account', async () => {
|
||||
await expect(connectProvider.resolveBulk(CONFIG, 'any-selector')).rejects.toThrow(
|
||||
/service account/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Unit tests for the Infisical secret provider.
|
||||
*
|
||||
* Infisical is open-source and self-hostable, so it is integration-testable
|
||||
* against a real instance later. Here we exercise the provider in isolation by
|
||||
* mocking undici's `request`, covering:
|
||||
* - a successful bulk pull mapping { secretKey, secretValue } → Record
|
||||
* - the selector / config.path / '/' precedence for the queried path
|
||||
* - an auth failure (HTTP 401) surfacing as a thrown error
|
||||
* - testConnection returning ok on 2xx and a helpful error otherwise
|
||||
* - resolveSecretReferences throwing UnsupportedOperationError (bulk-only)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
|
||||
/**
|
||||
* A single mock for undici's `request`. Registered before the provider is
|
||||
* imported so the provider's `import { request } from 'undici'` binds to it.
|
||||
* Each test sets its behaviour via `requestMock.mockImplementation(...)`.
|
||||
*/
|
||||
const requestMock = mock();
|
||||
mock.module('undici', () => ({ request: requestMock }));
|
||||
|
||||
const { infisicalProvider } = await import('../src/lib/server/secretproviders/infisical.ts');
|
||||
const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts');
|
||||
|
||||
/** Builds an undici-style response with json()/text() bodies. */
|
||||
function response(statusCode: number, json: unknown, text = '') {
|
||||
return {
|
||||
statusCode,
|
||||
body: {
|
||||
json: async () => json,
|
||||
text: async () => text
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
host: 'https://app.infisical.com',
|
||||
token: 'st.token.value',
|
||||
projectId: 'proj-123',
|
||||
environment: 'prod',
|
||||
path: '/'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset();
|
||||
});
|
||||
|
||||
describe('infisicalProvider capabilities', () => {
|
||||
it('advertises bulk-only, no references', () => {
|
||||
expect(infisicalProvider.type).toBe('infisical');
|
||||
expect(infisicalProvider.label).toBe('Infisical');
|
||||
expect(infisicalProvider.supportsBulk).toBe(true);
|
||||
expect(infisicalProvider.supportsReferences).toBe(false);
|
||||
});
|
||||
|
||||
it('isReference is always false', () => {
|
||||
expect(infisicalProvider.isReference('op://vault/item/field')).toBe(false);
|
||||
expect(infisicalProvider.isReference('anything')).toBe(false);
|
||||
expect(infisicalProvider.isReference(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('infisicalProvider.resolveBulk', () => {
|
||||
it('maps secretKey/secretValue pairs into a flat record', async () => {
|
||||
requestMock.mockImplementation(async () =>
|
||||
response(200, {
|
||||
secrets: [
|
||||
{ secretKey: 'DB_PASSWORD', secretValue: 's3cr3t' },
|
||||
{ secretKey: 'API_KEY', secretValue: 'abc123' }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const result = await infisicalProvider.resolveBulk(baseConfig, '');
|
||||
|
||||
expect(result).toEqual({ DB_PASSWORD: 's3cr3t', API_KEY: 'abc123' });
|
||||
|
||||
// Verify the request shape: raw secrets endpoint, bearer auth, and the
|
||||
// workspace/environment/path query.
|
||||
const [url, opts] = requestMock.mock.calls[0];
|
||||
expect(url).toContain('https://app.infisical.com/api/v3/secrets/raw');
|
||||
expect(url).toContain('workspaceId=proj-123');
|
||||
expect(url).toContain('environment=prod');
|
||||
expect(url).toContain('secretPath=%2F');
|
||||
expect(opts.method).toBe('GET');
|
||||
expect(opts.headers.authorization).toBe('Bearer st.token.value');
|
||||
});
|
||||
|
||||
it('tolerates an absent secrets array', async () => {
|
||||
requestMock.mockImplementation(async () => response(200, {}));
|
||||
const result = await infisicalProvider.resolveBulk(baseConfig, '');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('prefers the selector over config.path for secretPath', async () => {
|
||||
requestMock.mockImplementation(async () => response(200, { secrets: [] }));
|
||||
await infisicalProvider.resolveBulk({ ...baseConfig, path: '/config' }, '/services/web');
|
||||
const [url] = requestMock.mock.calls[0];
|
||||
expect(url).toContain('secretPath=%2Fservices%2Fweb');
|
||||
});
|
||||
|
||||
it('falls back to config.path when the selector is empty', async () => {
|
||||
requestMock.mockImplementation(async () => response(200, { secrets: [] }));
|
||||
await infisicalProvider.resolveBulk({ ...baseConfig, path: '/config' }, '');
|
||||
const [url] = requestMock.mock.calls[0];
|
||||
expect(url).toContain('secretPath=%2Fconfig');
|
||||
});
|
||||
|
||||
it('throws on an auth failure (HTTP 401)', async () => {
|
||||
requestMock.mockImplementation(async () =>
|
||||
response(401, {}, 'Unauthorized: invalid token')
|
||||
);
|
||||
await expect(infisicalProvider.resolveBulk(baseConfig, '')).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
it('throws a clear error when the environment is missing', async () => {
|
||||
await expect(
|
||||
infisicalProvider.resolveBulk({ ...baseConfig, environment: undefined }, '')
|
||||
).rejects.toThrow(/Environment is required/);
|
||||
// No HTTP call should have been attempted.
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('infisicalProvider.resolveSecretReferences', () => {
|
||||
it('throws UnsupportedOperationError (bulk-only backend)', async () => {
|
||||
await expect(
|
||||
infisicalProvider.resolveSecretReferences(baseConfig, ['ref'])
|
||||
).rejects.toBeInstanceOf(UnsupportedOperationError);
|
||||
await expect(
|
||||
infisicalProvider.resolveSecretReferences(baseConfig, ['ref'])
|
||||
).rejects.toThrow(/does not support inline references/);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('infisicalProvider.testConnection', () => {
|
||||
it('returns ok on a 2xx response', async () => {
|
||||
requestMock.mockImplementation(async () => response(200, { secrets: [] }));
|
||||
const result = await infisicalProvider.testConnection(baseConfig);
|
||||
expect(result.ok).toBe(true);
|
||||
const [url] = requestMock.mock.calls[0];
|
||||
expect(url).toContain('secretPath=%2F');
|
||||
});
|
||||
|
||||
it('returns ok:false with the status on an auth failure', async () => {
|
||||
requestMock.mockImplementation(async () => response(401, {}, 'Unauthorized'));
|
||||
const result = await infisicalProvider.testConnection(baseConfig);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('401');
|
||||
});
|
||||
|
||||
it('does not throw when the transport fails, returns the message', async () => {
|
||||
requestMock.mockImplementation(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const result = await infisicalProvider.testConnection(baseConfig);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('returns a helpful error when projectId is empty (no HTTP call)', async () => {
|
||||
const result = await infisicalProvider.testConnection({ ...baseConfig, projectId: '' });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('Project ID');
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a helpful error when environment is empty (no HTTP call)', async () => {
|
||||
const result = await infisicalProvider.testConnection({
|
||||
...baseConfig,
|
||||
environment: undefined
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('Environment');
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Unit tests for the HashiCorp Vault (KV v2) secret provider.
|
||||
*
|
||||
* undici's `request` is mocked so these run without a real Vault server. They
|
||||
* cover: a successful KV v2 read (mapping `data.data`, coercing non-strings),
|
||||
* the `X-Vault-Namespace` header being sent only when configured, the mount /
|
||||
* URL shape, auth failure (403) on both bulk read and testConnection, a missing
|
||||
* path (404) surfacing as an error, and the unsupported inline-reference mode.
|
||||
*
|
||||
* Vault OSS is separately integration-testable against `vault server -dev`.
|
||||
*/
|
||||
|
||||
// `svelte-check` runs over `tests/**` (per .svelte-kit/tsconfig.json) but this
|
||||
// repo ships no bun type declarations (`@types/bun`), so it cannot resolve the
|
||||
// `bun:test` built-in. Harmless at runtime — bun provides the module. Drop this
|
||||
// suppression once `@types/bun` is a dev dependency (see coordinator note).
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { test, expect, describe, mock, beforeEach } from 'bun:test';
|
||||
|
||||
/** Per-test undici stub; each test overrides this before exercising the provider. */
|
||||
let requestImpl: (url: string, opts: { method: string; headers: Record<string, string> }) => unknown;
|
||||
|
||||
mock.module('undici', () => ({
|
||||
request: (url: string, opts: { method: string; headers: Record<string, string> }) =>
|
||||
requestImpl(url, opts)
|
||||
}));
|
||||
|
||||
const { vaultProvider } = await import('../src/lib/server/secretproviders/vault.ts');
|
||||
const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts');
|
||||
|
||||
/** Fake undici response with a body exposing json()/text()/dump(). */
|
||||
function makeResponse(
|
||||
statusCode: number,
|
||||
opts: { json?: unknown; text?: string } = {}
|
||||
): { statusCode: number; headers: Record<string, string>; body: unknown } {
|
||||
return {
|
||||
statusCode,
|
||||
headers: {},
|
||||
body: {
|
||||
async json() {
|
||||
if ('json' in opts) return opts.json;
|
||||
return JSON.parse(opts.text ?? 'null');
|
||||
},
|
||||
async text() {
|
||||
if (opts.text !== undefined) return opts.text;
|
||||
return opts.json !== undefined ? JSON.stringify(opts.json) : '';
|
||||
},
|
||||
async dump() {
|
||||
/* drain — no-op in the mock */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const baseConfig = { address: 'https://vault.example.com', token: 's.rootToken' };
|
||||
|
||||
beforeEach(() => {
|
||||
requestImpl = () => {
|
||||
throw new Error('unexpected undici.request call');
|
||||
};
|
||||
});
|
||||
|
||||
describe('vaultProvider capabilities', () => {
|
||||
test('advertises bulk-only, no references', () => {
|
||||
expect(vaultProvider.type).toBe('vault');
|
||||
expect(vaultProvider.label).toBe('HashiCorp Vault');
|
||||
expect(vaultProvider.supportsBulk).toBe(true);
|
||||
expect(vaultProvider.supportsReferences).toBe(false);
|
||||
});
|
||||
|
||||
test('isReference returns false for anything', () => {
|
||||
expect(vaultProvider.isReference('vault://secret/foo#bar')).toBe(false);
|
||||
expect(vaultProvider.isReference('op://a/b/c')).toBe(false);
|
||||
expect(vaultProvider.isReference(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBulk', () => {
|
||||
test('reads KV v2 data.data and coerces non-string values', async () => {
|
||||
let capturedUrl = '';
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
requestImpl = (url, opts) => {
|
||||
capturedUrl = url;
|
||||
capturedHeaders = opts.headers;
|
||||
return makeResponse(200, {
|
||||
json: {
|
||||
data: {
|
||||
data: { DB_PASSWORD: 'hunter2', PORT: 5432, ENABLED: true },
|
||||
metadata: { version: 3 }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const out = await vaultProvider.resolveBulk(baseConfig, 'myapp/config');
|
||||
|
||||
expect(out).toEqual({ DB_PASSWORD: 'hunter2', PORT: '5432', ENABLED: 'true' });
|
||||
// mount defaults to 'secret'; address keeps no trailing slash.
|
||||
expect(capturedUrl).toBe('https://vault.example.com/v1/secret/data/myapp/config');
|
||||
expect(capturedHeaders['X-Vault-Token']).toBe('s.rootToken');
|
||||
expect(capturedHeaders['X-Vault-Namespace']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('honours a custom mount and strips trailing slash / leading path slash', async () => {
|
||||
let capturedUrl = '';
|
||||
requestImpl = (url) => {
|
||||
capturedUrl = url;
|
||||
return makeResponse(200, { json: { data: { data: { K: 'v' } } } });
|
||||
};
|
||||
|
||||
await vaultProvider.resolveBulk(
|
||||
{ address: 'https://vault.example.com/', token: 't', mount: 'kv' },
|
||||
'/team/app'
|
||||
);
|
||||
|
||||
expect(capturedUrl).toBe('https://vault.example.com/v1/kv/data/team/app');
|
||||
});
|
||||
|
||||
test('sends X-Vault-Namespace when configured', async () => {
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
requestImpl = (_url, opts) => {
|
||||
capturedHeaders = opts.headers;
|
||||
return makeResponse(200, { json: { data: { data: { K: 'v' } } } });
|
||||
};
|
||||
|
||||
await vaultProvider.resolveBulk({ ...baseConfig, namespace: 'admin/team' }, 'app');
|
||||
|
||||
expect(capturedHeaders['X-Vault-Namespace']).toBe('admin/team');
|
||||
expect(capturedHeaders['X-Vault-Token']).toBe('s.rootToken');
|
||||
});
|
||||
|
||||
test('returns empty map when data.data is absent', async () => {
|
||||
requestImpl = () => makeResponse(200, { json: { data: { metadata: {} } } });
|
||||
const out = await vaultProvider.resolveBulk(baseConfig, 'empty');
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
test('throws a clear "path not found" on 404', async () => {
|
||||
requestImpl = () => makeResponse(404, { json: { errors: [] } });
|
||||
await expect(vaultProvider.resolveBulk(baseConfig, 'missing/path')).rejects.toThrow(
|
||||
/path not found: secret\/missing\/path/
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on auth failure (403), including Vault error detail', async () => {
|
||||
requestImpl = () => makeResponse(403, { json: { errors: ['permission denied'] } });
|
||||
await expect(vaultProvider.resolveBulk(baseConfig, 'app')).rejects.toThrow(
|
||||
/status 403.*permission denied/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
test('ok:true on 2xx from lookup-self', async () => {
|
||||
let capturedUrl = '';
|
||||
requestImpl = (url) => {
|
||||
capturedUrl = url;
|
||||
return makeResponse(200, { json: { data: { display_name: 'token' } } });
|
||||
};
|
||||
|
||||
const res = await vaultProvider.testConnection(baseConfig);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(capturedUrl).toBe('https://vault.example.com/v1/auth/token/lookup-self');
|
||||
});
|
||||
|
||||
test('ok:false (no throw) on auth failure (403)', async () => {
|
||||
requestImpl = () => makeResponse(403, { json: { errors: ['permission denied'] } });
|
||||
const res = await vaultProvider.testConnection(baseConfig);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toContain('403');
|
||||
expect(res.error).toContain('permission denied');
|
||||
});
|
||||
|
||||
test('ok:false on transport error without throwing', async () => {
|
||||
requestImpl = () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
};
|
||||
const res = await vaultProvider.testConnection(baseConfig);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
test('rejects empty address / token before any request', async () => {
|
||||
expect((await vaultProvider.testConnection({ address: '', token: 't' })).ok).toBe(false);
|
||||
expect((await vaultProvider.testConnection({ address: 'x', token: '' })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSecretReferences', () => {
|
||||
test('throws UnsupportedOperationError (bulk-only backend)', async () => {
|
||||
await expect(vaultProvider.resolveSecretReferences(baseConfig, ['anything'])).rejects.toThrow(
|
||||
UnsupportedOperationError
|
||||
);
|
||||
await expect(vaultProvider.resolveSecretReferences(baseConfig, [])).rejects.toThrow(
|
||||
/does not support inline references/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user