Files
dockhand-upstream/src/lib/server/audit.ts
T
1ad36a83fb feat: Add pluggable secret providers (1Password, Infisical, HashiCorp Vault) (#1360)
* Add pluggable secret provider foundation

Introduces a provider-agnostic secret backend abstraction so stacks can
pull deploy-time secrets from 1Password, Infisical or HashiCorp Vault.

Providers implement the SecretProvider contract in secretproviders/shared.ts
and advertise which of the two resolution modes they support:

  - reference interpolation: a variable's value is an inline reference in
    the backend's own syntax (e.g. 1Password's op://vault/item/field)
  - bulk pull: every secret under a provider-defined selector, fetched as
    a flat key/value map

Adding a backend means dropping a file in secretproviders/ and registering
it in the index; no caller changes.

Four providers ship here: 1Password service account (references + bulk),
1Password Connect (references), Infisical (bulk) and HashiCorp Vault KV v2
(bulk). Provider config is persisted as an encrypted JSON blob in the new
secret_providers table, mirroring the existing notification_settings
type+config pattern, and stack_sources gains a nullable secret_provider_id.

Decrypted tokens stay in the provider modules for the duration of a call
and are never written to disk or returned to the client.

Co-authored-by: GooRoo <sergey.olendarenko@gmail.com>

* Add secret provider API routes and permissions

CRUD plus two test-connection endpoints under /api/secret-providers. The
unsaved variant validates a config posted from the provider form; the
stored variant tests a persisted provider by id.

Responses only ever carry provider summaries (id, type, name, timestamps).
The decrypted config never leaves the server.

Adds a "secrets" permission resource with view/create/edit/delete actions,
wired through the permission type, the role editor and the seeded roles,
and a secret_provider audit entity type so provider changes are logged.
Config rotations are audited as a boolean rather than a value.

* Resolve provider secrets at stack deploy time

Stacks can bind a secret provider; when one is bound, its secrets are
resolved and merged into the vars handed to docker compose.

Bulk pull is triggered by a selector variable: OP_ENVIRONMENT_ID, kept for
compatibility with the original 1Password integration, or the generic
DOCKHAND_SECRET_SELECTOR for any bulk-capable provider. The selector is
consumed by the provider and stripped before the vars reach the stack.
Values the bound provider recognises as inline references are resolved in
place, and references found only in the stack's .env file are promoted into
the secret set so they are never written back as literals.

Resolution is applied on deploy, on up, on recreate and on service update.
Explicitly configured secrets always win over bulk-pulled ones. A missing
or unsupported provider warns and leaves values untouched rather than
failing the deploy; a provider that errors mid-resolution fails it.

* Add secret provider settings UI and stack binding

A Secrets tab under settings lists configured providers and drives the
create/edit modal, which switches its fields on the selected provider type
and can test a connection before saving.

Both stack modals gain a provider selector so a stack can be bound to a
provider or left unbound. When a provider is bound, the bulk selector
variables are no longer reported as unused compose variables, since they
are consumed by the provider rather than the compose file.

---------

Co-authored-by: GooRoo <sergey.olendarenko@gmail.com>
Co-authored-by: Jarek Krochmalski <jotka@users.noreply.github.com>
2026-08-13 10:08:02 +02:00

530 lines
12 KiB
TypeScript

/**
* Audit Logging Helper
*
* Provides easy-to-use functions for logging audit events from API endpoints.
* This is an Enterprise-only feature.
*/
import type { RequestEvent } from '@sveltejs/kit';
import { isEnterprise } from './license';
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
import { authorize } from './authorize';
import { getRequestContext } from './request-context';
export interface AuditContext {
userId?: number | null;
username: string;
ipAddress?: string | null;
userAgent?: string | null;
}
/**
* Extract audit context from a request event
*/
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
const ctx = getRequestContext();
const user = ctx?.user ?? (await authorize(event.cookies)).user;
// Get IP address from various headers (proxied requests)
const forwardedFor = event.request.headers.get('x-forwarded-for');
const realIp = event.request.headers.get('x-real-ip');
let ipAddress = forwardedFor?.split(',')[0]?.trim() || realIp || event.getClientAddress?.() || null;
// Convert IPv6 loopback to more readable format
if (ipAddress === '::1' || ipAddress === '::ffff:127.0.0.1') {
ipAddress = '127.0.0.1';
} else if (ipAddress?.startsWith('::ffff:')) {
// Strip IPv6 prefix from IPv4-mapped addresses
ipAddress = ipAddress.substring(7);
}
// Get user agent
const userAgent = event.request.headers.get('user-agent') || null;
return {
userId: user?.id ?? null,
username: user?.username ?? 'anonymous',
ipAddress,
userAgent
};
}
/**
* Log an audit event (only logs if Enterprise license is active)
*/
export async function audit(
event: RequestEvent,
action: AuditAction,
entityType: AuditEntityType,
options: {
entityId?: string | null;
entityName?: string | null;
environmentId?: number | null;
description?: string | null;
details?: any | null;
} = {}
): Promise<void> {
// Only log if enterprise
if (!(await isEnterprise())) return;
const ctx = await getAuditContext(event);
const data: AuditLogCreateData = {
userId: ctx.userId,
username: ctx.username,
action,
entityType: entityType,
entityId: options.entityId ?? null,
entityName: options.entityName ?? null,
environmentId: options.environmentId ?? null,
description: options.description ?? null,
details: options.details ?? null,
ipAddress: ctx.ipAddress ?? null,
userAgent: ctx.userAgent ?? null
};
try {
await logAuditEvent(data);
} catch (error) {
// Don't let audit logging errors break the main operation
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Audit] Failed to log event:', errorMsg);
}
}
/**
* Helper for container actions
*/
export async function auditContainer(
event: RequestEvent,
action: AuditAction,
containerId: string,
containerName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'container', {
entityId: containerId,
entityName: containerName,
environmentId,
description: `Container ${containerName} ${action}`,
details
});
}
/**
* Helper for image actions
*/
export async function auditImage(
event: RequestEvent,
action: AuditAction,
imageId: string,
imageName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'image', {
entityId: imageId,
entityName: imageName,
environmentId,
description: `Image ${imageName} ${action}`,
details
});
}
/**
* Helper for stack actions
*/
export async function auditStack(
event: RequestEvent,
action: AuditAction,
stackName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'stack', {
entityId: stackName,
entityName: stackName,
environmentId,
description: `Stack ${stackName} ${action}`,
details
});
}
/**
* Helper for volume actions
*/
export async function auditVolume(
event: RequestEvent,
action: AuditAction,
volumeId: string,
volumeName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'volume', {
entityId: volumeId,
entityName: volumeName,
environmentId,
description: `Volume ${volumeName} ${action}`,
details
});
}
/**
* Helper for network actions
*/
export async function auditNetwork(
event: RequestEvent,
action: AuditAction,
networkId: string,
networkName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'network', {
entityId: networkId,
entityName: networkName,
environmentId,
description: `Network ${networkName} ${action}`,
details
});
}
/**
* Helper for user actions
*/
export async function auditUser(
event: RequestEvent,
action: AuditAction,
userId: number,
username: string,
details?: any
): Promise<void> {
await audit(event, action, 'user', {
entityId: String(userId),
entityName: username,
description: `User ${username} ${action}`,
details
});
}
/**
* Helper for role actions
*/
export async function auditRole(
event: RequestEvent,
action: AuditAction,
roleId: number,
roleName: string,
details?: any
): Promise<void> {
await audit(event, action, 'role', {
entityId: String(roleId),
entityName: roleName,
description: `Role ${roleName} ${action}`,
details
});
}
/**
* Helper for settings actions
*/
export async function auditSettings(
event: RequestEvent,
action: AuditAction,
settingName: string,
details?: any
): Promise<void> {
await audit(event, action, 'settings', {
entityId: settingName,
entityName: settingName,
description: `Settings ${settingName} ${action}`,
details
});
}
/**
* Helper for environment actions
*/
export async function auditEnvironment(
event: RequestEvent,
action: AuditAction,
environmentId: number,
environmentName: string,
details?: any
): Promise<void> {
await audit(event, action, 'environment', {
entityId: String(environmentId),
entityName: environmentName,
environmentId,
description: `Environment ${environmentName} ${action}`,
details
});
}
/**
* Helper for registry actions
*/
export async function auditRegistry(
event: RequestEvent,
action: AuditAction,
registryId: number,
registryName: string,
details?: any
): Promise<void> {
await audit(event, action, 'registry', {
entityId: String(registryId),
entityName: registryName,
description: `Registry ${registryName} ${action}`,
details
});
}
/**
* Helper for backup destination actions
*/
export async function auditBackupDestination(
event: RequestEvent,
action: AuditAction,
destinationId: number,
destinationName: string,
details?: any
): Promise<void> {
await audit(event, action, 'backup_destination', {
entityId: String(destinationId),
entityName: destinationName,
description: `Backup destination ${destinationName} ${action}`,
details
});
}
/**
* Helper for git repository actions
*/
export async function auditGitRepository(
event: RequestEvent,
action: AuditAction,
repositoryId: number,
repositoryName: string,
details?: any
): Promise<void> {
await audit(event, action, 'git_repository', {
entityId: String(repositoryId),
entityName: repositoryName,
description: `Git repository ${repositoryName} ${action}`,
details
});
}
/**
* Helper for git credential actions
*/
export async function auditGitCredential(
event: RequestEvent,
action: AuditAction,
credentialId: number,
credentialName: string,
details?: any
): Promise<void> {
await audit(event, action, 'git_credential', {
entityId: String(credentialId),
entityName: credentialName,
description: `Git credential ${credentialName} ${action}`,
details
});
}
/**
* Helper for secret provider actions
*/
export async function auditSecretProvider(
event: RequestEvent,
action: AuditAction,
providerId: number,
providerName: string,
details?: any
): Promise<void> {
await audit(event, action, 'secret_provider', {
entityId: String(providerId),
entityName: providerName,
description: `Secret provider ${providerName} ${action}`,
details
});
}
/**
* Helper for config set actions
*/
export async function auditConfigSet(
event: RequestEvent,
action: AuditAction,
configSetId: number,
configSetName: string,
details?: any
): Promise<void> {
await audit(event, action, 'config_set', {
entityId: String(configSetId),
entityName: configSetName,
description: `Config set ${configSetName} ${action}`,
details
});
}
/**
* Helper for notification channel actions
*/
export async function auditNotification(
event: RequestEvent,
action: AuditAction,
notificationId: number,
notificationName: string,
details?: any
): Promise<void> {
await audit(event, action, 'notification', {
entityId: String(notificationId),
entityName: notificationName,
description: `Notification channel ${notificationName} ${action}`,
details
});
}
/**
* Helper for OIDC provider actions
*/
export async function auditOidcProvider(
event: RequestEvent,
action: AuditAction,
providerId: number,
providerName: string,
details?: any
): Promise<void> {
await audit(event, action, 'oidc_provider', {
entityId: String(providerId),
entityName: providerName,
description: `OIDC provider ${providerName} ${action}`,
details
});
}
/**
* Helper for LDAP config actions
*/
export async function auditLdapConfig(
event: RequestEvent,
action: AuditAction,
configId: number,
configName: string,
details?: any
): Promise<void> {
await audit(event, action, 'ldap_config', {
entityId: String(configId),
entityName: configName,
description: `LDAP config ${configName} ${action}`,
details
});
}
/**
* Helper for git stack actions
*/
export async function auditGitStack(
event: RequestEvent,
action: AuditAction,
stackId: number,
stackName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'git_stack', {
entityId: String(stackId),
entityName: stackName,
environmentId,
description: `Git stack ${stackName} ${action}`,
details
});
}
/**
* Helper for auth actions (login/logout)
*/
export async function auditAuth(
event: RequestEvent,
action: 'login' | 'logout',
username: string,
details?: any
): Promise<void> {
// For login/logout, we want to log even without a session
if (!(await isEnterprise())) return;
const forwardedFor = event.request.headers.get('x-forwarded-for');
const realIp = event.request.headers.get('x-real-ip');
let ipAddress = forwardedFor?.split(',')[0]?.trim() || realIp || event.getClientAddress?.() || null;
// Convert IPv6 loopback to more readable format
if (ipAddress === '::1' || ipAddress === '::ffff:127.0.0.1') {
ipAddress = '127.0.0.1';
} else if (ipAddress?.startsWith('::ffff:')) {
ipAddress = ipAddress.substring(7);
}
const userAgent = event.request.headers.get('user-agent') || null;
const data: AuditLogCreateData = {
userId: null, // Will be set from details if available
username,
action,
entityType: 'user',
entityId: null,
entityName: username,
environmentId: null,
description: `User ${username} ${action}`,
details,
ipAddress: ipAddress,
userAgent: userAgent
};
try {
await logAuditEvent(data);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Audit] Failed to log event:', errorMsg);
}
}
/**
* Helper for backup actions (backup run, cancel, config create/update/delete)
*/
export async function auditBackup(
event: RequestEvent,
action: AuditAction,
targetName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'backup_config', {
entityName: targetName,
environmentId,
description: `Backup ${targetName} ${action}`,
details
});
}
/**
* Helper for restore actions
*/
export async function auditRestore(
event: RequestEvent,
targetName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, 'restore', 'backup_config', {
entityName: targetName,
environmentId,
description: `Restore ${targetName}`,
details
});
}