diff --git a/drizzle-pg/0010_add_secret_providers.sql b/drizzle-pg/0010_add_secret_providers.sql new file mode 100644 index 0000000..5e3ad6f --- /dev/null +++ b/drizzle-pg/0010_add_secret_providers.sql @@ -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; diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index 70549cf..15d638d 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/drizzle/0010_add_secret_providers.sql b/drizzle/0010_add_secret_providers.sql new file mode 100644 index 0000000..15f7a11 --- /dev/null +++ b/drizzle/0010_add_secret_providers.sql @@ -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; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0984e55..5793388 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/package.json b/package.json index 6cdada0..5d5c0b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 8c00288..582c2bf 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -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", diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index d0abd48..a3098f4 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -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 { + await audit(event, action, 'secret_provider', { + entityId: String(providerId), + entityName: providerName, + description: `Secret provider ${providerName} ${action}`, + details + }); +} + /** * Helper for config set actions */ diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index cb68563..750b0a3 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -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 { 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 { + 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 { + 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 { + 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 { + const updateData: Record = { 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 { + 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 { 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; @@ -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; } @@ -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 { 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; diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 47a67cb..53f52ad 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -775,7 +775,8 @@ async function seedDatabase(): Promise { 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 { 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 { 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, diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 6ed478e..3ce171a 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -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; diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 1ea75b1..db48d4f 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -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) => ({ diff --git a/src/lib/server/secretproviders/connect.ts b/src/lib/server/secretproviders/connect.ts new file mode 100644 index 0000000..53a6ee2 --- /dev/null +++ b/src/lib/server/secretproviders/connect.ts @@ -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 { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/json' + }; +} + +/** + * Parses an `op:////` or + * `op:////
/` 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( + config: ConnectConfig, + path: string, + query?: Record +): Promise { + 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(); + + constructor(private readonly config: ConnectConfig) {} + + async listVaults(): Promise { + if (!this.vaults) { + this.vaults = (await connectGet(this.config, '/vaults')) ?? []; + } + return this.vaults; + } + + async listItems(vaultId: string): Promise { + const cached = this.itemsByVault.get(vaultId); + if (cached) { + return cached; + } + const items = + (await connectGet(this.config, `/vaults/${vaultId}/items`)) ?? []; + this.itemsByVault.set(vaultId, items); + return items; + } + + getItem(vaultId: string, itemId: string): Promise { + return connectGet(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 { + 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 = { + 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 { + 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> { + const result = new Map(); + 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> { + throw new UnsupportedOperationError( + '1Password Connect does not support environment bulk pull; use a service account.' + ); + } +}; diff --git a/src/lib/server/secretproviders/index.ts b/src/lib/server/secretproviders/index.ts new file mode 100644 index 0000000..8ac30dc --- /dev/null +++ b/src/lib/server/secretproviders/index.ts @@ -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 = { + [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 { + 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 }; diff --git a/src/lib/server/secretproviders/infisical.ts b/src/lib/server/secretproviders/infisical.ts new file mode 100644 index 0000000..786efd8 --- /dev/null +++ b/src/lib/server/secretproviders/infisical.ts @@ -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 `. 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 = { + type: 'infisical', + label: 'Infisical', + supportsReferences: false, + supportsBulk: true, + + isReference(_value: unknown): _value is string { + return false; + }, + + async testConnection(config: InfisicalConfig): Promise { + 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> { + throw new UnsupportedOperationError( + 'Infisical does not support inline references; use bulk pull (project/environment/path).' + ); + }, + + async resolveBulk(config: InfisicalConfig, selector: string): Promise> { + 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 = {}; + for (const secret of payload.secrets ?? []) { + if (secret?.secretKey !== undefined) { + result[secret.secretKey] = secret.secretValue; + } + } + return result; + } +}; diff --git a/src/lib/server/secretproviders/service-account.ts b/src/lib/server/secretproviders/service-account.ts new file mode 100644 index 0000000..aac37a2 --- /dev/null +++ b/src/lib/server/secretproviders/service-account.ts @@ -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 = { + 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 { + 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> { + const result = new Map(); + 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> { + const client = await makeClient(token); + const response = await client.environments.getVariables(selector); + const result: Record = {}; + for (const variable of response.variables) { + result[variable.name] = variable.value; + } + return result; + } +}; diff --git a/src/lib/server/secretproviders/shared.ts b/src/lib/server/secretproviders/shared.ts new file mode 100644 index 0000000..b92e8aa --- /dev/null +++ b/src/lib/server/secretproviders/shared.ts @@ -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 { + /** 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; + + /** + * 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>; + + /** + * 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>; +} diff --git a/src/lib/server/secretproviders/vault.ts b/src/lib/server/secretproviders/vault.ts new file mode 100644 index 0000000..27c2a86 --- /dev/null +++ b/src/lib/server/secretproviders/vault.ts @@ -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>['body']; + +/** Shape of a KV v2 read: the secret map lives at `data.data`. */ +interface KvV2ReadResponse { + data?: { + data?: Record; + 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 { + const headers: Record = { + '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 { + 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 = { + 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 { + 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> { + throw new UnsupportedOperationError( + 'HashiCorp Vault does not support inline references; use bulk pull (KV v2 path).' + ); + }, + + async resolveBulk(config: VaultConfig, selector: string): Promise> { + 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 = {}; + for (const [key, value] of Object.entries(data)) { + result[key] = typeof value === 'string' ? value : String(value); + } + return result; + } +}; diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index d494410..040ee49 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -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; + secretVars: Record; +} + +// 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, + secretVars: Record, + logPrefix: string, + secretProviderId?: number | null, + stackEnvFileContent?: string +): Promise { + 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(); + for (const [key, value] of Object.entries(envFileVars)) { + if (isRef(value)) { + envFileRefs.set(key, value.trim()); + } + } + + const refs = new Set(); + 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; + 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 { + 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 }; - diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index aa783a0..ed598f9 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -20,6 +20,7 @@ export interface Permissions { schedules: string[]; backups: string[]; templates: string[]; + secrets: string[]; } export interface AuthUser { diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 7e9ff53..5b469e4 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -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 diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index 7eba8c1..b55fb05 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -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); diff --git a/src/routes/api/secret-providers/+server.ts b/src/routes/api/secret-providers/+server.ts new file mode 100644 index 0000000..6433a42 --- /dev/null +++ b/src/routes/api/secret-providers/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/secret-providers/[id]/+server.ts b/src/routes/api/secret-providers/[id]/+server.ts new file mode 100644 index 0000000..80dc851 --- /dev/null +++ b/src/routes/api/secret-providers/[id]/+server.ts @@ -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 = {}; + 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 }); + } +}; diff --git a/src/routes/api/secret-providers/[id]/test/+server.ts b/src/routes/api/secret-providers/[id]/test/+server.ts new file mode 100644 index 0000000..c39b5aa --- /dev/null +++ b/src/routes/api/secret-providers/[id]/test/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/secret-providers/test/+server.ts b/src/routes/api/secret-providers/test/+server.ts new file mode 100644 index 0000000..ead545f --- /dev/null +++ b/src/routes/api/secret-providers/test/+server.ts @@ -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); +}; diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 4218033..22a5034 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -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 diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index d68666b..c051e4c 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -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) { diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts index a2f1a17..868c6d5 100644 --- a/src/routes/api/stacks/sources/+server.ts +++ b/src/routes/api/stacks/sources/+server.ts @@ -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 = {}; + 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, }; } diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 9380119..5f05fbf 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -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 @@ Git + + + Secrets + Config sets diff --git a/src/routes/settings/auth/roles/RoleModal.svelte b/src/routes/settings/auth/roles/RoleModal.svelte index d9aab93..dc20b75 100644 --- a/src/routes/settings/auth/roles/RoleModal.svelte +++ b/src/routes/settings/auth/roles/RoleModal.svelte @@ -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 = ''; diff --git a/src/routes/settings/secrets/ProviderModal.svelte b/src/routes/settings/secrets/ProviderModal.svelte new file mode 100644 index 0000000..c3bbbab --- /dev/null +++ b/src/routes/settings/secrets/ProviderModal.svelte @@ -0,0 +1,352 @@ + + + + + { + if (o) { + formError = ""; + focusFirstInput(); + } + }} +> + + + {isEditing ? "Edit" : "Add"} secret provider + +
+ {#if formError} +
+ {formError} +
+ {/if} +
+ + +
+
+ + + + {providerTypeLabel(formType)} + + + {#each PROVIDER_TYPES as t (t.value)} + + {t.label} + + {/each} + + +
+ {#each fields as field (field.key)} +
+ + + {#if field.hint} +

{field.hint}

+ {/if} +
+ {/each} +

+ Configuration is stored encrypted.{#if isEditing} + Leave secret fields blank to keep the existing values.{/if} +

+
+ + +
+ + +
+
+
diff --git a/src/routes/settings/secrets/SecretsTab.svelte b/src/routes/settings/secrets/SecretsTab.svelte new file mode 100644 index 0000000..345037d --- /dev/null +++ b/src/routes/settings/secrets/SecretsTab.svelte @@ -0,0 +1,215 @@ + + +
+
+
+ {providers.length} total +
+
+ {#if $canAccess("secrets", "create")} + + {/if} + +
+
+ + {#if loading && providers.length === 0} +

Loading secret providers...

+ {:else if providers.length === 0} + + {:else} +
+ {#each providers as provider (provider.id)} +
+ + +
+
+ + {provider.name} +
+ {providerTypeLabel(provider.type)} +
+
+ +
+ Added {new Date( + provider.createdAt, + ).toLocaleDateString()} +
+
+ {#if $canAccess("secrets", "view")} + + {/if} + {#if $canAccess("secrets", "edit")} + + {/if} + {#if $canAccess("secrets", "delete")} + + deleteProvider(provider.id)} + onOpenChange={(open) => + (confirmDeleteId = open + ? provider.id + : null)} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
+
+
+
+ {/each} +
+ {/if} +
+ + { + showModal = false; + editing = null; + }} + onSaved={fetchProviders} +/> diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index f67e6e4..9adc3bd 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -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([]); + let formSecretProviderId = $state(null); + // Environment variables state let formEnvFilePath = $state(null); let envFiles = $state([]); @@ -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 @@
+ +
+ + { formSecretProviderId = v ? parseInt(v) : null; }} + > + + {#if formSecretProviderId !== null} + {secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? 'Unknown provider'} + {:else} + None — disabled + {/if} + + + + None — disabled + + {#each secretProviders as provider (provider.id)} + + {provider.name} + + {/each} + + + + + + + + Inline references (e.g. op:// for 1Password) are still resolved. For bulk pull, set OP_ENVIRONMENT_ID (1Password Environment) or the generic DOCKHAND_SECRET_SELECTOR in env vars; the provider's secrets are loaded and injected. + + +
(undefined); + // Secret providers + type SecretProviderOption = { id: number; name: string }; + let secretProviders = $state([]); + let formSecretProviderId = $state(null); + // Environment variables state let envVars = $state([]); let rawEnvContent = $state(''); // Raw .env file content (comments preserved) let envValidation = $state(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(() => { + 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>(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 @@
+ +
+ + { + formSecretProviderId = v ? parseInt(v) : null; + markDirty(); + }} + > + + {#if formSecretProviderId !== null} + {secretProviders.find((p) => p.id === formSecretProviderId)?.name ?? 'Unknown provider'} + {:else} + None — disabled + {/if} + + + + None — disabled + + {#each secretProviders as provider (provider.id)} + + {provider.name} + + {/each} + + + + + + + + + Inline references (e.g. op:// for 1Password) are still resolved. For bulk pull, set OP_ENVIRONMENT_ID (1Password Environment) or the generic DOCKHAND_SECRET_SELECTOR in env vars; the provider's secrets are loaded and injected. + + + +
{ markDirty(); debouncedValidate(); }} diff --git a/tests/helpers/preload.ts b/tests/helpers/preload.ts new file mode 100644 index 0000000..556c8cd --- /dev/null +++ b/tests/helpers/preload.ts @@ -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 {}; diff --git a/tests/secret-provider-connect.test.ts b/tests/secret-provider-connect.test.ts new file mode 100644 index 0000000..d522428 --- /dev/null +++ b/tests/secret-provider-connect.test.ts @@ -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; +} + +let responses: MockResponse[]; +let calls: RecordedCall[]; + +const requestMock = mock(async (url: unknown, opts?: { method?: string; headers?: Record }) => { + 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/ + ); + }); +}); diff --git a/tests/secret-provider-infisical.test.ts b/tests/secret-provider-infisical.test.ts new file mode 100644 index 0000000..2368977 --- /dev/null +++ b/tests/secret-provider-infisical.test.ts @@ -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(); + }); +}); diff --git a/tests/secret-provider-vault.test.ts b/tests/secret-provider-vault.test.ts new file mode 100644 index 0000000..a118a7c --- /dev/null +++ b/tests/secret-provider-vault.test.ts @@ -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 }) => unknown; + +mock.module('undici', () => ({ + request: (url: string, opts: { method: string; headers: Record }) => + 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; 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 = {}; + 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 = {}; + 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/ + ); + }); +});