From 8c6dc572e046727485968daa009e17f7622f0296 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 4 Sep 2026 15:13:09 +0200 Subject: [PATCH 01/14] footer version --- src/lib/components/Footer.svelte | 71 +++++++++++++++++++++++++++++--- src/lib/i18n/locales/en.json | 5 ++- src/lib/i18n/locales/es.json | 5 ++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index 0bd9b07..64018a9 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -1,8 +1,9 @@
diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte index 228b6e3..bd9098f 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte @@ -67,7 +67,6 @@ label: 'Vault', icon: Shield, href: `/org/${orgSlug}/projects/${project.slug}/vault`, - soon: true, }, { key: 'codereport', @@ -117,11 +116,6 @@ editModules = { ...editModules, [key]: !editModules[key] }; } - function moduleValueForSubmit(key: keyof ProjectModules) { - const option = moduleOptions.find((moduleOption) => moduleOption.key === key); - return editModules[key] && !option?.soon ? 'on' : ''; - } - async function copySlug() { try { await navigator.clipboard.writeText(project.slug); @@ -244,9 +238,9 @@ class="overflow-hidden rounded-md border border-slate-200 bg-white" > - - - + + +
{#if isArchived} - import { Shield, KeyRound, Lock, Sparkles } from '@lucide/svelte'; - import { _ } from '$lib/i18n'; + import { enhance } from '$app/forms'; + import { goto } from '$app/navigation'; + import { page } from '$app/stores'; + import type { SubmitFunction } from '@sveltejs/kit'; + import { + Check, + CheckCircle, + ChevronDown, + ChevronRight, + Download, + Edit3, + Eye, + EyeOff, + Folder, + FolderInput, + Link2, + Plus, + Search, + Shield, + Upload, + } from '@lucide/svelte'; + + type VaultEnvironment = { + id: string; + slug: string; + name: string; + description?: string | null; + }; + + type VaultFolder = { + id: string; + parentFolderId: string | null; + linkedFolderId: string | null; + name: string; + path: string; + description?: string | null; + }; + + type VaultSecret = { + id: string; + folderId: string | null; + key: string; + description?: string | null; + values: Record; + }; + + export let data: { + environments: VaultEnvironment[]; + folders: VaultFolder[]; + secrets: VaultSecret[]; + currentEnvironment?: VaultEnvironment | null; + currentFolder?: VaultFolder | null; + currentPath?: string; + canManageSecrets: boolean; + project?: { name?: string | null; slug?: string | null } | null; + }; + + let selectedEnvironmentSlug = ''; + let environmentMenuOpen = false; + let environmentMenuEl: HTMLDivElement | null = null; + let actionsMenuOpen = false; + let actionsMenuEl: HTMLDivElement | null = null; + let searchQuery = ''; + let importOpen = false; + let importContent = ''; + let createSecretOpen = false; + let createFolderOpen = false; + let linkFolderOpen = false; + let editingSecret: VaultSecret | null = null; + let revealed: Record = {}; + let formError = ''; + let success = ''; + let submitting = false; + + let newSecretKey = ''; + let newSecretValue = ''; + + let editSecretKey = ''; + let editSecretDescription = ''; + let editSecretFolderId = ''; + let editSecretValues: Record = {}; + + let newFolderName = ''; + + let linkedFolderPath = ''; + + $: orgSlug = $page.params.org; + $: projectSlug = $page.params.slug; + $: currentFolderId = data.currentFolder?.id ?? null; + $: currentPath = data.currentPath ?? '/'; + $: if (!selectedEnvironmentSlug && data.environments[0]) { + selectedEnvironmentSlug = data.currentEnvironment?.slug ?? data.environments[0].slug; + } + + $: if ( + data.currentEnvironment?.slug && + selectedEnvironmentSlug !== data.currentEnvironment.slug + ) { + selectedEnvironmentSlug = data.currentEnvironment.slug; + } + + $: selectedEnvironment = + data.environments.find((environment) => environment.slug === selectedEnvironmentSlug) ?? + data.environments[0] ?? + null; + $: selectedFolder = data.folders.find((folder) => folder.id === currentFolderId) ?? null; + $: childFolders = data.folders.filter((folder) => folder.parentFolderId === currentFolderId); + $: visibleSecretFolderIds = selectedFolder ? inheritedFolderIds(selectedFolder) : [null]; + $: visibleSecrets = data.secrets.filter((secret) => + visibleSecretFolderIds.includes(secret.folderId), + ); + $: filteredFolders = childFolders.filter((folder) => { + const query = searchQuery.trim().toLowerCase(); + return !query || folder.name.toLowerCase().includes(query); + }); + $: filteredSecrets = visibleSecrets.filter((secret) => { + const query = searchQuery.trim().toLowerCase(); + return !query || secret.key.toLowerCase().includes(query); + }); + $: allRevealed = + visibleSecrets.length > 0 && + visibleSecrets.every((secret) => revealed[`${secret.id}:${selectedEnvironmentSlug}`]); + $: parentFolder = selectedFolder?.parentFolderId + ? (data.folders.find((folder) => folder.id === selectedFolder?.parentFolderId) ?? null) + : null; + + function flashSuccess(message: string) { + success = message; + setTimeout(() => { + success = ''; + }, 2500); + } + + function inheritedFolderIds(folder: VaultFolder) { + const ids: Array = []; + let cursor: VaultFolder | undefined = folder; + + while (cursor) { + ids.push(cursor.id); + cursor = cursor.parentFolderId + ? data.folders.find((candidate) => candidate.id === cursor?.parentFolderId) + : undefined; + } + + if (folder.linkedFolderId) { + const linkedFolder = data.folders.find((candidate) => candidate.id === folder.linkedFolderId); + if (linkedFolder) ids.push(...inheritedFolderIds(linkedFolder)); + } + + return [...new Set(ids)]; + } + + function pathToUrl(path: string) { + if (path === '/') + return `/org/${orgSlug}/projects/${projectSlug}/vault/${selectedEnvironmentSlug}`; + const encodedPath = path + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); + return `/org/${orgSlug}/projects/${projectSlug}/vault/${selectedEnvironmentSlug}/${encodedPath}`; + } + + function goToFolder(folder: VaultFolder | null) { + void goto(pathToUrl(folder?.path ?? '/')); + } + + function changeEnvironment() { + void goto(pathToUrl(currentPath)); + } + + function selectEnvironment(slug: string) { + environmentMenuOpen = false; + if (slug === selectedEnvironmentSlug) return; + selectedEnvironmentSlug = slug; + changeEnvironment(); + } + + function handleWindowClick(event: MouseEvent) { + const target = event.target as Node; + if (environmentMenuOpen && environmentMenuEl && !environmentMenuEl.contains(target)) { + environmentMenuOpen = false; + } + if (actionsMenuOpen && actionsMenuEl && !actionsMenuEl.contains(target)) { + actionsMenuOpen = false; + } + } + + function toggleReveal(secret: VaultSecret) { + const key = `${secret.id}:${selectedEnvironmentSlug}`; + revealed = { ...revealed, [key]: !revealed[key] }; + } + + function toggleRevealAll() { + revealed = allRevealed + ? {} + : Object.fromEntries( + visibleSecrets.map((secret) => [`${secret.id}:${selectedEnvironmentSlug}`, true]), + ); + } + + function downloadEnvFile() { + const content = filteredSecrets + .map((secret) => `${secret.key}=${secret.values?.[selectedEnvironmentSlug] ?? ''}`) + .join('\n'); + const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = `${projectSlug}-${selectedEnvironmentSlug}.env`; + link.click(); + URL.revokeObjectURL(url); + } + + function folderLabel(folder: VaultFolder) { + const parent = folder.parentFolderId + ? data.folders.find((candidate) => candidate.id === folder.parentFolderId) + : null; + return parent ? `${parent.name} / ${folder.name}` : folder.name; + } + + function resetSecretForm() { + newSecretKey = ''; + newSecretValue = ''; + } + + function openCreateSecret() { + formError = ''; + resetSecretForm(); + createSecretOpen = true; + } + + function openEditSecret(secret: VaultSecret) { + formError = ''; + editingSecret = secret; + editSecretKey = secret.key; + editSecretDescription = secret.description ?? ''; + editSecretFolderId = secret.folderId ?? ''; + editSecretValues = Object.fromEntries( + data.environments.map((environment) => [ + environment.slug, + secret.values?.[environment.slug] ?? '', + ]), + ); + } + + function openCreateFolder() { + formError = ''; + newFolderName = ''; + createFolderOpen = true; + } + + function openLinkFolder() { + formError = ''; + linkedFolderPath = ''; + linkFolderOpen = true; + } + + function openImportSecrets() { + formError = ''; + importContent = ''; + importOpen = true; + } + + const handleSubmit: SubmitFunction = () => { + formError = ''; + submitting = true; + return async ({ result, update }) => { + await update(); + submitting = false; + + if (result.type === 'success') { + createSecretOpen = false; + createFolderOpen = false; + linkFolderOpen = false; + importOpen = false; + editingSecret = null; + flashSuccess('Cambios guardados'); + return; + } + + formError = + result.type === 'failure' && result.data?.error + ? String(result.data.error) + : 'No se pudieron guardar los cambios'; + }; + }; + + function maskValue(value: string | undefined) { + if (!value) return 'Sin valor'; + return '************'; + } Vault - GitOps + +
-
-
-
-
-

{$_('project.vault.sectionLabel')}

-

Vault

-

{$_('project.vault.description')}

-
- - - {$_('common.comingSoon')} - -
+ {#if success} +
+ {success}
+ {/if} + +
+
+ + + + + + {data.project?.name ?? projectSlug} + -
-
- -

{$_('project.vault.policies')}

-

{$_('project.vault.policiesDescription')}

+ / + +
+ + + {#if environmentMenuOpen} +
    + {#each data.environments as environment (environment.id)} +
  • + +
  • + {/each} +
+ {/if}
-
- -

{$_('project.vault.secrets')}

-

{$_('project.vault.secretsDescription')}

+ + {#if currentPath !== '/'} + / + {currentPath.replace(/^\//, '')} + {/if} +
+ +
+
+ +
-
- -

{$_('project.vault.access')}

-

{$_('project.vault.accessDescription')}

+ +
+ + + + {#if data.canManageSecrets} +
+ + + + {#if actionsMenuOpen} + + {/if} +
+ {/if}
-
-
-
- +
+
+ + + + + + + + + + {#if filteredFolders.length === 0 && filteredSecrets.length === 0} + + + + {:else} + {#if currentFolderId} + goToFolder(parentFolder)} + > + + + + + {/if} + {#each filteredFolders as folder (folder.id)} + goToFolder(folder)} + > + + + + + {/each} + {#each filteredSecrets as secret (secret.id)} + + + + + + {/each} + {/if} + +
KeyValue
+ No hay carpetas ni secretos en esta vista. +
+ + + .. + + + +
+ + {#if folder.linkedFolderId} + + {:else} + + {/if} + {folder.name} + + + +
+

{secret.key}

+
+ + + {#if data.canManageSecrets} + + {/if} +
+
-

{$_('project.vault.workingTitle')}

-

{$_('project.vault.workingDescription')}

+ +{#if createSecretOpen} +
+
+

Crear secreto

+ {#if formError}

+ {formError} +

{/if} + +
+ + +
+
+ +
+
+
+{/if} + +{#if editingSecret} +
+
+ +

Detalle del secreto

+ {#if formError}

+ {formError} +

{/if} +
+ + + + {#each data.environments as environment (environment.id)} + + {/each} +
+
+ +
+
+
+{/if} + +{#if createFolderOpen} +
+
+

Crear carpeta

+ {#if formError}

+ {formError} +

{/if} + +
+ +

+ Se creara en {currentPath} +

+
+
+ +
+
+
+{/if} + +{#if linkFolderOpen} +
+
+

Enlazar carpeta

+ {#if formError}

+ {formError} +

{/if} + +
+ +
+
+ +
+
+
+{/if} + +{#if importOpen} +
+
+

Importar secretos

+

+ Pega el contenido de un archivo .env para el entorno {selectedEnvironment?.name ?? + selectedEnvironmentSlug}. +

+ {#if formError}

+ {formError} +

{/if} + + + +
+ +
+
+
+{/if} diff --git a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts new file mode 100644 index 0000000..9ba60d3 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts @@ -0,0 +1,221 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; +import { vaultService } from '$modules/vault'; + +export async function load({ params, locals }) { + let project; + try { + project = await projectService.getProjectBySlug(params.slug); + } catch { + throw error(404, 'Project not found'); + } + + const organizationId = project.organization?.id; + const [canReadSecrets, canReadEnvironments] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:vault:environments:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + ]); + + if (!canReadSecrets && !canReadEnvironments) { + throw error(403, 'Forbidden'); + } + + const vault = await vaultService.getProjectVault(project.id); + const currentEnvironment = vault.environments.find( + (environment) => environment.slug === params.env, + ); + if (!currentEnvironment) { + throw redirect( + 302, + `/org/${params.org}/projects/${params.slug}/vault/${vault.environments[0]?.slug ?? 'dev'}`, + ); + } + + const currentPath = routePath(params.path); + let currentFolder; + try { + currentFolder = await vaultService.getFolderByPath(project.id, currentPath); + } catch { + throw error(404, 'Folder not found'); + } + + return { + ...vault, + currentEnvironment, + currentFolder, + currentPath, + canManageSecrets: await cancanService.canSessionUser( + locals.user, + 'project:vault:secrets:update', + { + scope: 'project', + projectId: project.id, + organizationId, + }, + ), + }; +} + +async function getAuthorizedProject( + params: { slug: string }, + locals: RequestEvent['locals'], + permission: string, +) { + const project = await projectService.getProjectBySlug(params.slug); + const allowed = await cancanService.canSessionUser(locals.user, permission, { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!allowed) throw new Error('Forbidden'); + return project; +} + +function routePath(path: string | undefined) { + if (!path) return '/'; + return `/${path + .split('/') + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)) + .join('/')}`; +} + +function normalizeFolderPath(path: string) { + const segments = path.trim().split('/').filter(Boolean); + return segments.length === 0 ? '/' : `/${segments.join('/')}`; +} + +function secretValuesFromForm(formData: FormData) { + const values: Record = {}; + for (const [key, value] of formData.entries()) { + if (key.startsWith('value:')) values[key.slice(6)] = String(value); + } + return values; +} + +function parseEnvContent(content: string) { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#') && line.includes('=')) + .map((line) => { + const separatorIndex = line.indexOf('='); + const key = line.slice(0, separatorIndex).trim().replace(/^export\s+/, ''); + const value = line + .slice(separatorIndex + 1) + .trim() + .replace(/^['"]|['"]$/g, ''); + return [key, value] as const; + }) + .filter(([key]) => key.length > 0); +} + +export const actions = { + createSecret: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create'); + const formData = await request.formData(); + await vaultService.createSecret(project.id, { + folderId: String(formData.get('folderId') || '') || null, + key: String(formData.get('key') || ''), + description: String(formData.get('description') || ''), + values: secretValuesFromForm(formData), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo crear el secreto', + }); + } + }, + updateSecret: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:update'); + const formData = await request.formData(); + await vaultService.updateSecret(project.id, String(formData.get('id') || ''), { + folderId: String(formData.get('folderId') || '') || null, + key: String(formData.get('key') || ''), + description: String(formData.get('description') || ''), + values: secretValuesFromForm(formData), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo actualizar el secreto', + }); + } + }, + createFolder: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create'); + const formData = await request.formData(); + await vaultService.createFolder(project.id, { + parentFolderId: String(formData.get('parentFolderId') || '') || null, + name: String(formData.get('name') || ''), + description: String(formData.get('description') || ''), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo crear la carpeta', + }); + } + }, + linkFolder: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create'); + const formData = await request.formData(); + const linkedPath = normalizeFolderPath(String(formData.get('linkedPath') || '')); + const linkedFolder = await vaultService.getFolderByPath(project.id, linkedPath); + + if (!linkedFolder) throw new Error('No se puede enlazar la raiz'); + + await vaultService.linkFolder(project.id, { + parentFolderId: String(formData.get('parentFolderId') || '') || null, + linkedFolderId: linkedFolder.id, + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo enlazar la carpeta', + }); + } + }, + importSecrets: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create'); + const formData = await request.formData(); + const folderId = String(formData.get('folderId') || '') || null; + const environmentSlug = String(formData.get('environmentSlug') || ''); + const entries = parseEnvContent(String(formData.get('content') || '')); + + if (entries.length === 0) throw new Error('No se encontraron secretos validos'); + + for (const [key, value] of entries) { + await vaultService.createSecret(project.id, { + folderId, + key, + description: '', + values: { [environmentSlug]: value }, + }); + } + + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudieron importar los secretos', + }); + } + }, +}; diff --git a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.svelte new file mode 100644 index 0000000..4b305f6 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts new file mode 100644 index 0000000..9faa3cd --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts @@ -0,0 +1,104 @@ +import { error, fail } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; +import { vaultService } from '$modules/vault'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + const canRead = await cancanService.canSessionUser( + locals.user, + 'project:vault:environments:read', + { scope: 'project', projectId: project.id, organizationId: project.organization?.id }, + ); + + if (!canRead) throw error(403, 'Forbidden'); + + return { + environments: await vaultService.listEnvironments(project.id), + canManageEnvironments: await cancanService.canSessionUser( + locals.user, + 'project:vault:environments:update', + { scope: 'project', projectId: project.id, organizationId: project.organization?.id }, + ), + canDeleteEnvironments: await cancanService.canSessionUser( + locals.user, + 'project:vault:environments:delete', + { scope: 'project', projectId: project.id, organizationId: project.organization?.id }, + ), + }; +} + +async function getAuthorizedProject( + params: { slug: string }, + locals: RequestEvent['locals'], + permission: string, +) { + const project = await projectService.getProjectBySlug(params.slug); + const allowed = await cancanService.canSessionUser(locals.user, permission, { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + if (!allowed) throw new Error('Forbidden'); + return project; +} + +export const actions = { + create: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:create', + ); + const formData = await request.formData(); + await vaultService.createEnvironment(project.id, { + name: String(formData.get('name') || ''), + slug: String(formData.get('slug') || ''), + description: String(formData.get('description') || ''), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo crear el entorno', + }); + } + }, + update: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:update', + ); + const formData = await request.formData(); + await vaultService.updateEnvironment(project.id, String(formData.get('id') || ''), { + name: String(formData.get('name') || ''), + slug: String(formData.get('slug') || ''), + description: String(formData.get('description') || ''), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo actualizar el entorno', + }); + } + }, + delete: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:delete', + ); + const formData = await request.formData(); + await vaultService.deleteEnvironment(project.id, String(formData.get('id') || '')); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo borrar el entorno', + }); + } + }, +}; diff --git a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte new file mode 100644 index 0000000..6de24fd --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte @@ -0,0 +1,245 @@ + + + + Vault environments - GitOps + + +
+
+
+

Vault

+

Entornos

+

+ Gestiona los entornos que aparecen como columnas de valores en la tabla de secretos. +

+
+ + {#if data.canManageEnvironments} + + {/if} +
+ + {#if success} +
+ {success} +
+ {/if} + +
+
+ + + + + + + + + + + + {#each data.environments as environment (environment.id)} + + + + + + + + {/each} + +
NombreSlugDescripcionCreadoAcciones
{environment.name}{environment.slug}{environment.description || '-'}{formatDate(environment.createdAt)} + {#if data.canManageEnvironments || data.canDeleteEnvironments} + {#if data.canManageEnvironments} + + {/if} + {#if data.canDeleteEnvironments} + + {/if} + {/if} +
+
+
+
+ +{#if createOpen || editingEnvironment} +
+
+ {#if editingEnvironment}{/if} +

+ {editingEnvironment ? 'Editar entorno' : 'Crear entorno'} +

+ {#if formError}

+ {formError} +

{/if} +
+ + + +
+
+ +
+
+
+{/if} + +{#if deletingEnvironment} +
+
+ +

Borrar entorno

+

+ Se borrara {deletingEnvironment.name} de la configuracion del vault. +

+ {#if formError}

+ {formError} +

{/if} +
+ +
+
+
+{/if} From 1556a1d27faf482bbe2f6f0c372ed8fa18eb11e2 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Wed, 9 Sep 2026 20:41:57 +0200 Subject: [PATCH 04/14] vault view and download working --- .../vault/application/vault.service.ts | 37 ++++++++++++++++++ .../[org]/projects/[slug]/vault/+page.svelte | 38 ++++++++++--------- .../vault/[env]/[...path]/+page.server.ts | 15 ++++++++ 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/modules/vault/application/vault.service.ts b/src/modules/vault/application/vault.service.ts index 898e51e..2e81279 100644 --- a/src/modules/vault/application/vault.service.ts +++ b/src/modules/vault/application/vault.service.ts @@ -155,6 +155,43 @@ export class VaultService { return folder.toJson(); } + async exportEnvFile(projectId: string, environmentSlug: string, path = '/') { + const environment = await this.repository.findEnvironmentBySlug(projectId, environmentSlug); + if (!environment) throw new Error('Environment not found'); + + const folders = (await this.repository.listFolders(projectId)).map((folder) => folder.toJson()); + const targetFolder = path === '/' ? null : folders.find((folder) => folder.path === path); + if (path !== '/' && !targetFolder) throw new Error('Folder not found'); + + const scope = this.collectExportFolderIds(targetFolder?.id ?? null, folders); + const secrets = (await this.repository.listSecrets(projectId)).map((secret) => secret.toJson()); + + return secrets + .filter((secret) => scope.has(secret.folderId ?? null)) + .sort((a, b) => a.key.localeCompare(b.key)) + .map((secret) => `${secret.key}=${secret.values?.[environmentSlug] ?? ''}`) + .join('\n'); + } + + // Un export incluye los secretos del path y los de las carpetas enlazadas desde el, no el resto del arbol. + private collectExportFolderIds( + folderId: string | null, + folders: Array<{ id: string; parentFolderId: string | null; linkedFolderId: string | null }>, + scope = new Set(), + ) { + if (scope.has(folderId)) return scope; + scope.add(folderId); + + const folder = folderId ? folders.find((candidate) => candidate.id === folderId) : null; + if (folder?.linkedFolderId) this.collectExportFolderIds(folder.linkedFolderId, folders, scope); + + for (const child of folders.filter((candidate) => candidate.parentFolderId === folderId)) { + if (child.linkedFolderId) this.collectExportFolderIds(child.linkedFolderId, folders, scope); + } + + return scope; + } + async createSecret(projectId: string, input: VaultSecretInput) { await this.requireOptionalProjectFolder(projectId, input.folderId); const key = this.normalizeSecretKey(input.key); diff --git a/src/routes/org/[org]/projects/[slug]/vault/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/+page.svelte index e0338ff..028794e 100644 --- a/src/routes/org/[org]/projects/[slug]/vault/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/vault/+page.svelte @@ -1,5 +1,5 @@ @@ -118,6 +141,7 @@ + Nombre Slug Descripcion @@ -126,8 +150,28 @@ - {#each data.environments as environment (environment.id)} + {#each data.environments as environment, index (environment.id)} + + {#if data.canManageEnvironments} +
+ + +
+ {/if} + {environment.name} {environment.slug} {environment.description || '-'} From 62ed9bd55fe80db3340b6e0a1fbccaa147f049dd Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 11 Sep 2026 09:17:24 +0200 Subject: [PATCH 06/14] vault settings works --- src/lib/components/AppSidebar.svelte | 4 +- src/lib/components/Dropdown.svelte | 40 +- src/lib/database/schemas.ts | 22 ++ .../auth/domain/role-permissions.data.ts | 4 + .../vault/application/vault.service.ts | 48 ++- src/modules/vault/domain/vault.domain.ts | 26 ++ src/modules/vault/index.ts | 3 +- .../repositories/vault.repository.ts | 36 ++ .../[org]/projects/[slug]/vault/+page.svelte | 12 +- .../vault/[env]/[...path]/+page.server.ts | 2 + .../[slug]/vault/environments/+page.server.ts | 123 +----- .../[slug]/vault/environments/+page.svelte | 288 +------------- .../[slug]/vault/settings/+page.server.ts | 155 ++++++++ .../[slug]/vault/settings/+page.svelte | 361 ++++++++++++++++++ 14 files changed, 692 insertions(+), 432 deletions(-) create mode 100644 src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts create mode 100644 src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index 0aafa53..f6fc0c9 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -118,8 +118,8 @@ icon: Shield, }, { - label: 'Environments', - href: `${projectBase}/vault/environments`, + label: 'Settings', + href: `${projectBase}/vault/settings`, icon: Settings, }, ], diff --git a/src/lib/components/Dropdown.svelte b/src/lib/components/Dropdown.svelte index 6463af1..54b03c4 100644 --- a/src/lib/components/Dropdown.svelte +++ b/src/lib/components/Dropdown.svelte @@ -2,9 +2,10 @@ import { createEventDispatcher } from 'svelte'; import { ChevronDown, Check } from '@lucide/svelte'; - export let options: Array<{ id: string; name: string }> = []; + export let options: Array<{ id: string; name: string; disabled?: boolean; badge?: string }> = []; export let value = ''; export let ariaLabel = 'Select option'; + export let disabled = false; let isOpen = false; @@ -13,6 +14,7 @@ $: selected = options.find((option) => option.id === value) || options[0] || null; function toggleOpen() { + if (disabled) return; isOpen = !isOpen; } @@ -20,8 +22,9 @@ isOpen = false; } - function selectOption(id: string) { - dispatch('change', { id }); + function selectOption(option: { id: string; disabled?: boolean }) { + if (option.disabled) return; + dispatch('change', { id: option.id }); close(); } @@ -29,13 +32,14 @@
@@ -53,17 +57,29 @@
  • {/each} diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts index 9febcc2..eb43457 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -251,6 +251,23 @@ export const VaultSecretEntity = entity('vault_secrets', { .$defaultFn(() => new Date().toISOString()), }); +export const VaultSettingsEntity = entity('vault_settings', { + id: uuid().primaryKey(), + projectId: uuid().notNull(), + capitalizeSecrets: bool() + .notNull() + .$defaultFn(() => true), + encryptionProvider: text() + .notNull() + .$defaultFn(() => 'gitops_kms'), + createdAt: timestamp() + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: timestamp() + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + relations.for(ProjectEntity, ({ one, many }) => ({ roles: many(RoleEntity, { fields: ['id'], references: ['projectId'] }), organization: one(OrganizationEntity, { fields: ['organizationId'], references: ['id'] }), @@ -266,6 +283,7 @@ relations.for(ProjectEntity, ({ one, many }) => ({ vaultEnvironments: many(VaultEnvironmentEntity, { fields: ['id'], references: ['projectId'] }), vaultFolders: many(VaultFolderEntity, { fields: ['id'], references: ['projectId'] }), vaultSecrets: many(VaultSecretEntity, { fields: ['id'], references: ['projectId'] }), + vaultSettings: many(VaultSettingsEntity, { fields: ['id'], references: ['projectId'] }), })); relations.for(CodeReportSecurityPolicyEntity, ({ one }) => ({ @@ -289,6 +307,10 @@ relations.for(VaultSecretEntity, ({ one }) => ({ folder: one(VaultFolderEntity, { fields: ['folderId'], references: ['id'] }), })); +relations.for(VaultSettingsEntity, ({ one }) => ({ + project: one(ProjectEntity, { fields: ['projectId'], references: ['id'] }), +})); + relations.for(CodeReportServiceEntity, ({ one, many }) => ({ project: one(ProjectEntity, { fields: ['projectId'], references: ['id'] }), analyses: many(CodeReportAnalysisEntity, { fields: ['id'], references: ['serviceId'] }), diff --git a/src/modules/auth/domain/role-permissions.data.ts b/src/modules/auth/domain/role-permissions.data.ts index 42a5798..b7a98af 100644 --- a/src/modules/auth/domain/role-permissions.data.ts +++ b/src/modules/auth/domain/role-permissions.data.ts @@ -32,6 +32,7 @@ export const PROJECT_ADMIN_PERMISSIONS = [ 'project:audit:all', 'project:vault:secrets:all', 'project:vault:environments:all', + 'project:vault:settings:all', 'project:codereport:reports:all', 'project:codereport:dependencies:all', 'project:codereport:vulnerabilities:all', @@ -49,6 +50,8 @@ export const PROJECT_DEVELOPER_PERMISSIONS = [ 'project:vault:environments:create', 'project:vault:environments:update', 'project:vault:environments:delete', + 'project:vault:settings:read', + 'project:vault:settings:update', 'project:codereport:reports:read', 'project:codereport:reports:create', 'project:codereport:reports:update', @@ -77,6 +80,7 @@ export const PROJECT_VIEWER_PERMISSIONS = [ 'project:audit:read', 'project:vault:secrets:read', 'project:vault:environments:read', + 'project:vault:settings:read', 'project:codereport:reports:read', 'project:codereport:dependencies:read', 'project:codereport:vulnerabilities:read', diff --git a/src/modules/vault/application/vault.service.ts b/src/modules/vault/application/vault.service.ts index bcdf2e7..5d5a4af 100644 --- a/src/modules/vault/application/vault.service.ts +++ b/src/modules/vault/application/vault.service.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; import { VaultRepository } from '../infrastructure/repositories/vault.repository'; -import type { VaultSecretValues } from '../domain/vault.domain'; +import type { VaultEncryptionProvider, VaultSecretValues } from '../domain/vault.domain'; const DEFAULT_ENVIRONMENTS = [ { slug: 'dev', name: 'Development' }, @@ -8,6 +8,10 @@ const DEFAULT_ENVIRONMENTS = [ { slug: 'prod', name: 'Production' }, ]; +export const VAULT_ENCRYPTION_PROVIDERS: { value: VaultEncryptionProvider; label: string }[] = [ + { value: 'gitops_kms', label: 'GitOps KMS' }, +]; + export type VaultSecretInput = { folderId?: string | null; key: string; @@ -247,12 +251,13 @@ export class VaultService { const entries = this.parseEnvContent(content); if (entries.length === 0) throw new Error('No se encontraron secretos validos'); + const capitalize = (await this.getSettings(projectId)).capitalizeSecrets; const secrets = (await this.repository.listSecrets(projectId)).map((secret) => secret.toJson()); let created = 0; let updated = 0; for (const [key, value] of entries) { - const normalizedKey = this.normalizeSecretKey(key); + const normalizedKey = this.normalizeSecretKey(key, capitalize); const existing = secrets.find( (secret) => (secret.folderId ?? null) === (folderId ?? null) && secret.key === normalizedKey, ); @@ -327,7 +332,8 @@ export class VaultService { async createSecret(projectId: string, input: VaultSecretInput) { await this.requireOptionalProjectFolder(projectId, input.folderId); - const key = this.normalizeSecretKey(input.key); + const capitalize = (await this.getSettings(projectId)).capitalizeSecrets; + const key = this.normalizeSecretKey(input.key, capitalize); const values = this.normalizeValues(input.values); await this.repository.createSecret({ @@ -344,10 +350,11 @@ export class VaultService { const secret = await this.repository.findSecretById(id); if (!secret || secret.projectId !== projectId) throw new Error('Secret not found'); await this.requireOptionalProjectFolder(projectId, input.folderId); + const capitalize = (await this.getSettings(projectId)).capitalizeSecrets; await this.repository.updateSecret(id, { folderId: input.folderId || null, - key: this.normalizeSecretKey(input.key), + key: this.normalizeSecretKey(input.key, capitalize), description: input.description?.trim(), values: this.normalizeValues(input.values), }); @@ -360,6 +367,29 @@ export class VaultService { await this.repository.deleteSecret(id); } + async getSettings(projectId: string) { + const existing = await this.repository.findSettingsByProjectId(projectId); + if (existing) return existing.toJson(); + + const id = crypto.randomUUID(); + await this.repository.createSettings({ + id, + projectId, + capitalizeSecrets: true, + encryptionProvider: 'gitops_kms', + }); + return (await this.repository.findSettingsByProjectId(projectId))!.toJson(); + } + + async updateSettings( + projectId: string, + changes: { capitalizeSecrets?: boolean; encryptionProvider?: VaultEncryptionProvider }, + ) { + const settings = await this.getSettings(projectId); + await this.repository.updateSettings(settings.id, changes); + return this.getSettings(projectId); + } + private async ensureDefaultEnvironments(projectId: string) { const environments = await this.repository.listEnvironments(projectId); if (environments.length > 0) return; @@ -398,11 +428,11 @@ export class VaultService { return normalized; } - private normalizeSecretKey(value: string) { - const key = value - .trim() - .toUpperCase() - .replace(/[^A-Z0-9_]+/g, '_') + private normalizeSecretKey(value: string, capitalize: boolean) { + const trimmed = value.trim(); + const sanitized = capitalize ? trimmed.toUpperCase() : trimmed; + const key = sanitized + .replace(/[^A-Za-z0-9_]+/g, '_') .replace(/^_+|_+$/g, ''); if (!key) throw new Error('La key del secreto es obligatoria'); return key; diff --git a/src/modules/vault/domain/vault.domain.ts b/src/modules/vault/domain/vault.domain.ts index c7f987e..7a0a2be 100644 --- a/src/modules/vault/domain/vault.domain.ts +++ b/src/modules/vault/domain/vault.domain.ts @@ -94,3 +94,29 @@ export class VaultSecretDomain extends Domain { }; } } + +export type VaultEncryptionProvider = 'gitops_kms'; + +export class VaultSettingsDomain extends Domain { + public projectId = ''; + public capitalizeSecrets = true; + public encryptionProvider: VaultEncryptionProvider = 'gitops_kms'; + + constructor(data: any) { + super(data); + this.projectId = data.projectId; + this.capitalizeSecrets = data.capitalizeSecrets ?? true; + this.encryptionProvider = data.encryptionProvider ?? 'gitops_kms'; + } + + toJson() { + return { + id: this.id, + projectId: this.projectId, + capitalizeSecrets: this.capitalizeSecrets, + encryptionProvider: this.encryptionProvider, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/vault/index.ts b/src/modules/vault/index.ts index 180c353..0797d4c 100644 --- a/src/modules/vault/index.ts +++ b/src/modules/vault/index.ts @@ -1,6 +1,7 @@ -import { VaultService } from './application/vault.service'; +import { VAULT_ENCRYPTION_PROVIDERS, VaultService } from './application/vault.service'; import { VaultRepository } from './infrastructure/repositories/vault.repository'; const vaultRepository = new VaultRepository(); export const vaultService = new VaultService(vaultRepository); +export { VAULT_ENCRYPTION_PROVIDERS }; diff --git a/src/modules/vault/infrastructure/repositories/vault.repository.ts b/src/modules/vault/infrastructure/repositories/vault.repository.ts index 3cfd5a4..5caed83 100644 --- a/src/modules/vault/infrastructure/repositories/vault.repository.ts +++ b/src/modules/vault/infrastructure/repositories/vault.repository.ts @@ -3,11 +3,14 @@ import { VaultEnvironmentEntity, VaultFolderEntity, VaultSecretEntity, + VaultSettingsEntity, } from '$lib/database/schemas'; import { VaultEnvironmentDomain, VaultFolderDomain, VaultSecretDomain, + VaultSettingsDomain, + type VaultEncryptionProvider, type VaultSecretValues, } from '../../domain/vault.domain'; @@ -165,4 +168,37 @@ export class VaultRepository extends Repository { async deleteSecret(id: string): Promise { await this.db.delete(VaultSecretEntity).where({ id }); } + + async findSettingsByProjectId(projectId: string): Promise { + const result = await this.db + .select() + .from(VaultSettingsEntity) + .where({ projectId }) + .limit(1); + const row = result.rows[0]; + return row ? new VaultSettingsDomain(row) : null; + } + + async createSettings(input: { + id: string; + projectId: string; + capitalizeSecrets: boolean; + encryptionProvider: VaultEncryptionProvider; + }): Promise { + await this.db.insert(VaultSettingsEntity).values({ + ...input, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + async updateSettings( + id: string, + changes: { capitalizeSecrets?: boolean; encryptionProvider?: VaultEncryptionProvider }, + ): Promise { + await this.db + .update(VaultSettingsEntity) + .set({ ...changes, updatedAt: new Date().toISOString() }) + .where({ id }); + } } diff --git a/src/routes/org/[org]/projects/[slug]/vault/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/+page.svelte index 919d030..db4d3b1 100644 --- a/src/routes/org/[org]/projects/[slug]/vault/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/vault/+page.svelte @@ -56,6 +56,7 @@ currentPath?: string; canManageSecrets: boolean; canDeleteSecrets: boolean; + capitalizeSecrets: boolean; project?: { name?: string | null; slug?: string | null } | null; }; @@ -264,6 +265,11 @@ URL.revokeObjectURL(url); } + function normalizeKeyInput(event: Event) { + const value = (event.target as HTMLInputElement).value; + return data.capitalizeSecrets ? value.toUpperCase() : value; + } + function resetSecretForm() { newSecretKey = ''; newSecretValue = ''; @@ -717,7 +723,8 @@ @@ -769,7 +776,8 @@ diff --git a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts index d0220d2..aab49c9 100644 --- a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts @@ -31,6 +31,7 @@ export async function load({ params, locals }) { } const vault = await vaultService.getProjectVault(project.id); + const settings = await vaultService.getSettings(project.id); const currentEnvironment = vault.environments.find( (environment) => environment.slug === params.env, ); @@ -72,6 +73,7 @@ export async function load({ params, locals }) { organizationId, }, ), + capitalizeSecrets: settings.capitalizeSecrets, }; } diff --git a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts index 39f980b..1e40dcc 100644 --- a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts @@ -1,121 +1,6 @@ -import { error, fail } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; -import { cancanService } from '$modules/auth'; -import { projectService } from '$modules/projects'; -import { vaultService } from '$modules/vault'; +import { redirect } from '@sveltejs/kit'; -export async function load({ parent, locals }) { - const { project } = await parent(); - const canRead = await cancanService.canSessionUser( - locals.user, - 'project:vault:environments:read', - { scope: 'project', projectId: project.id, organizationId: project.organization?.id }, - ); - - if (!canRead) throw error(403, 'Forbidden'); - - return { - environments: await vaultService.listEnvironments(project.id), - canManageEnvironments: await cancanService.canSessionUser( - locals.user, - 'project:vault:environments:update', - { scope: 'project', projectId: project.id, organizationId: project.organization?.id }, - ), - canDeleteEnvironments: await cancanService.canSessionUser( - locals.user, - 'project:vault:environments:delete', - { scope: 'project', projectId: project.id, organizationId: project.organization?.id }, - ), - }; +// Environments management moved into Vault > Settings. +export async function load({ params }) { + throw redirect(302, `/org/${params.org}/projects/${params.slug}/vault/settings`); } - -async function getAuthorizedProject( - params: { slug: string }, - locals: RequestEvent['locals'], - permission: string, -) { - const project = await projectService.getProjectBySlug(params.slug); - const allowed = await cancanService.canSessionUser(locals.user, permission, { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); - if (!allowed) throw new Error('Forbidden'); - return project; -} - -export const actions = { - create: async ({ request, params, locals }) => { - try { - const project = await getAuthorizedProject( - params, - locals, - 'project:vault:environments:create', - ); - const formData = await request.formData(); - await vaultService.createEnvironment(project.id, { - name: String(formData.get('name') || ''), - slug: String(formData.get('slug') || ''), - description: String(formData.get('description') || ''), - }); - return { success: true }; - } catch (err) { - return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { - error: err instanceof Error ? err.message : 'No se pudo crear el entorno', - }); - } - }, - update: async ({ request, params, locals }) => { - try { - const project = await getAuthorizedProject( - params, - locals, - 'project:vault:environments:update', - ); - const formData = await request.formData(); - await vaultService.updateEnvironment(project.id, String(formData.get('id') || ''), { - name: String(formData.get('name') || ''), - slug: String(formData.get('slug') || ''), - description: String(formData.get('description') || ''), - }); - return { success: true }; - } catch (err) { - return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { - error: err instanceof Error ? err.message : 'No se pudo actualizar el entorno', - }); - } - }, - delete: async ({ request, params, locals }) => { - try { - const project = await getAuthorizedProject( - params, - locals, - 'project:vault:environments:delete', - ); - const formData = await request.formData(); - await vaultService.deleteEnvironment(project.id, String(formData.get('id') || '')); - return { success: true }; - } catch (err) { - return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { - error: err instanceof Error ? err.message : 'No se pudo borrar el entorno', - }); - } - }, - move: async ({ request, params, locals }) => { - try { - const project = await getAuthorizedProject( - params, - locals, - 'project:vault:environments:update', - ); - const formData = await request.formData(); - const direction = String(formData.get('direction') || '') === 'up' ? 'up' : 'down'; - await vaultService.moveEnvironment(project.id, String(formData.get('id') || ''), direction); - return { success: true }; - } catch (err) { - return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { - error: err instanceof Error ? err.message : 'No se pudo reordenar el entorno', - }); - } - }, -}; diff --git a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte index aa80b7d..72e26aa 100644 --- a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte @@ -1,289 +1,3 @@ - - - Vault environments - GitOps - - -
    -
    -
    -

    Vault

    -

    Entornos

    -

    - Gestiona los entornos que aparecen como columnas de valores en la tabla de secretos. -

    -
    - - {#if data.canManageEnvironments} - - {/if} -
    - - {#if success} -
    - {success} -
    - {/if} - -
    -
    - - - - - - - - - - - - - {#each data.environments as environment, index (environment.id)} - - - - - - - - - {/each} - -
    NombreSlugDescripcionCreadoAcciones
    - {#if data.canManageEnvironments} -
    - - -
    - {/if} -
    {environment.name}{environment.slug}{environment.description || '-'}{formatDate(environment.createdAt)} - {#if data.canManageEnvironments || data.canDeleteEnvironments} - {#if data.canManageEnvironments} - - {/if} - {#if data.canDeleteEnvironments} - - {/if} - {/if} -
    -
    -
    -
    - -{#if createOpen || editingEnvironment} -
    -
    - {#if editingEnvironment}{/if} -

    - {editingEnvironment ? 'Editar entorno' : 'Crear entorno'} -

    - {#if formError}

    - {formError} -

    {/if} -
    - - - -
    -
    - -
    -
    -
    -{/if} - -{#if deletingEnvironment} -
    -
    - -

    Borrar entorno

    -

    - Se borrara {deletingEnvironment.name} de la configuracion del vault. -

    - {#if formError}

    - {formError} -

    {/if} -
    - -
    -
    -
    -{/if} diff --git a/src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts new file mode 100644 index 0000000..01cd3db --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts @@ -0,0 +1,155 @@ +import { error, fail } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; +import { vaultService, VAULT_ENCRYPTION_PROVIDERS } from '$modules/vault'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + const organizationId = project.organization?.id; + + const [canReadSettings, canReadEnvironments] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:vault:settings:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:vault:environments:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + ]); + + if (!canReadSettings && !canReadEnvironments) throw error(403, 'Forbidden'); + + return { + settings: await vaultService.getSettings(project.id), + encryptionProviders: VAULT_ENCRYPTION_PROVIDERS, + canManageSettings: await cancanService.canSessionUser( + locals.user, + 'project:vault:settings:update', + { scope: 'project', projectId: project.id, organizationId }, + ), + environments: await vaultService.listEnvironments(project.id), + canManageEnvironments: await cancanService.canSessionUser( + locals.user, + 'project:vault:environments:update', + { scope: 'project', projectId: project.id, organizationId }, + ), + canDeleteEnvironments: await cancanService.canSessionUser( + locals.user, + 'project:vault:environments:delete', + { scope: 'project', projectId: project.id, organizationId }, + ), + }; +} + +async function getAuthorizedProject( + params: { slug: string }, + locals: RequestEvent['locals'], + permission: string, +) { + const project = await projectService.getProjectBySlug(params.slug); + const allowed = await cancanService.canSessionUser(locals.user, permission, { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + if (!allowed) throw new Error('Forbidden'); + return project; +} + +export const actions = { + updateSettings: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:settings:update'); + const formData = await request.formData(); + await vaultService.updateSettings(project.id, { + capitalizeSecrets: formData.get('capitalizeSecrets') === 'on', + encryptionProvider: String( + formData.get('encryptionProvider') || 'gitops_kms', + ) as 'gitops_kms', + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo actualizar la configuracion', + }); + } + }, + createEnvironment: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:create', + ); + const formData = await request.formData(); + await vaultService.createEnvironment(project.id, { + name: String(formData.get('name') || ''), + slug: String(formData.get('slug') || ''), + description: String(formData.get('description') || ''), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo crear el entorno', + }); + } + }, + updateEnvironment: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:update', + ); + const formData = await request.formData(); + await vaultService.updateEnvironment(project.id, String(formData.get('id') || ''), { + name: String(formData.get('name') || ''), + slug: String(formData.get('slug') || ''), + description: String(formData.get('description') || ''), + }); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo actualizar el entorno', + }); + } + }, + deleteEnvironment: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:delete', + ); + const formData = await request.formData(); + await vaultService.deleteEnvironment(project.id, String(formData.get('id') || '')); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo borrar el entorno', + }); + } + }, + moveEnvironment: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject( + params, + locals, + 'project:vault:environments:update', + ); + const formData = await request.formData(); + const direction = String(formData.get('direction') || '') === 'up' ? 'up' : 'down'; + await vaultService.moveEnvironment(project.id, String(formData.get('id') || ''), direction); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudo reordenar el entorno', + }); + } + }, +}; + diff --git a/src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte new file mode 100644 index 0000000..e45d7e0 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte @@ -0,0 +1,361 @@ + + + + Vault settings - GitOps + + +
    +
    +

    Vault

    +

    Settings

    +

    + Configura el comportamiento general del vault de este proyecto. +

    +
    + + {#if success} +
    + {success} +
    + {/if} + + {#if formError} +

    {formError}

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

    Capitalize all secrets

    +

    + Fuerza que las keys de los secretos se guarden siempre en mayusculas. +

    +
    + +
    +
    + + +
    +
    +
    +

    Environments

    +

    + Gestiona los entornos que aparecen como columnas de valores en la tabla de secretos. +

    +
    + {#if data.canManageEnvironments} + + {/if} +
    + +
    +
    + + + {#each data.environments as environment, index (environment.id)} + + + + + + {/each} + +
    + {#if data.canManageEnvironments} +
    + + +
    + {/if} +
    +

    {environment.name}

    +

    {environment.slug}

    +
    + {#if data.canManageEnvironments} + + {/if} + {#if data.canDeleteEnvironments} + + {/if} +
    +
    +
    +
    + + +
    +
    +

    Encryption

    + + Soon + +
    +

    Proveedor usado para cifrar los secretos en reposo.

    +
    + + + ({ + id: provider.value, + name: provider.label, + }))} + value={encryptionProvider} + disabled + ariaLabel="Encryption provider" + on:change={(event) => (encryptionProvider = event.detail.id)} + /> + + +
    +
    +
    + +{#if createEnvironmentOpen || editingEnvironment} +
    +
    + {#if editingEnvironment}{/if} +

    + {editingEnvironment ? 'Editar entorno' : 'Crear entorno'} +

    + {#if formError}

    + {formError} +

    {/if} +
    + + + +
    +
    + +
    +
    +
    +{/if} + +{#if deletingEnvironment} +
    +
    + +

    Borrar entorno

    +

    + Se borrara {deletingEnvironment.name} de la configuracion del vault. +

    + {#if formError}

    + {formError} +

    {/if} +
    + +
    +
    +
    +{/if} From ce870a9703f7c6e767912836f5d2149546489355 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 11 Sep 2026 10:38:19 +0200 Subject: [PATCH 07/14] api vault working --- .../vault/application/vault.service.test.ts | 85 +++++++++ .../vault/application/vault.service.ts | 30 +++- src/routes/api/vault/export/+server.ts | 83 +++++++++ src/routes/api/vault/export/server.test.ts | 166 ++++++++++++++++++ 4 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 src/modules/vault/application/vault.service.test.ts create mode 100644 src/routes/api/vault/export/+server.ts create mode 100644 src/routes/api/vault/export/server.test.ts diff --git a/src/modules/vault/application/vault.service.test.ts b/src/modules/vault/application/vault.service.test.ts new file mode 100644 index 0000000..5790777 --- /dev/null +++ b/src/modules/vault/application/vault.service.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { VaultService } from './vault.service'; + +type Json = Record; + +function jsonRow(data: Json) { + return { toJson: () => data }; +} + +class FakeVaultRepository { + environments: Json[] = []; + folders: Json[] = []; + secrets: Json[] = []; + + async findEnvironmentBySlug(projectId: string, slug: string) { + const found = this.environments.find( + (environment) => environment.projectId === projectId && environment.slug === slug, + ); + return found ? jsonRow(found) : null; + } + + async listFolders(projectId: string) { + return this.folders.filter((folder) => folder.projectId === projectId).map(jsonRow); + } + + async listSecrets(projectId: string) { + return this.secrets.filter((secret) => secret.projectId === projectId).map(jsonRow); + } +} + +describe('VaultService export', () => { + let repository: FakeVaultRepository; + let service: VaultService; + + beforeEach(() => { + repository = new FakeVaultRepository(); + service = new VaultService(repository as never); + + repository.environments.push({ id: 'env-1', projectId: 'p1', slug: 'prod' }); + repository.folders.push({ + id: 'folder-1', + projectId: 'p1', + path: '/database', + parentFolderId: null, + linkedFolderId: null, + }); + repository.secrets.push( + { id: 's1', projectId: 'p1', folderId: null, key: 'ZONE', values: { prod: 'eu' } }, + { id: 's2', projectId: 'p1', folderId: null, key: 'API_KEY', values: { prod: 'abc' } }, + { id: 's3', projectId: 'p1', folderId: null, key: 'ONLY_DEV', values: { dev: 'x' } }, + { + id: 's4', + projectId: 'p1', + folderId: 'folder-1', + key: 'DB_HOST', + values: { prod: 'localhost' }, + }, + ); + }); + + it('exports root secrets sorted by key in env format', async () => { + const content = await service.exportEnvFile('p1', 'prod', '/'); + expect(content).toBe('API_KEY=abc\nONLY_DEV=\nZONE=eu'); + }); + + it('exports secrets of a folder path', async () => { + const content = await service.exportEnvFile('p1', 'prod', '/database'); + expect(content).toBe('DB_HOST=localhost'); + }); + + it('exports json format', async () => { + const content = await service.exportJsonFile('p1', 'prod', '/'); + expect(JSON.parse(content)).toEqual({ API_KEY: 'abc', ONLY_DEV: '', ZONE: 'eu' }); + }); + + it('throws when the environment does not exist', async () => { + await expect(service.exportEnvFile('p1', 'missing', '/')).rejects.toThrow( + 'Environment not found', + ); + }); + + it('throws when the path does not exist', async () => { + await expect(service.exportJsonFile('p1', 'prod', '/nope')).rejects.toThrow('Folder not found'); + }); +}); diff --git a/src/modules/vault/application/vault.service.ts b/src/modules/vault/application/vault.service.ts index 5d5a4af..9476b89 100644 --- a/src/modules/vault/application/vault.service.ts +++ b/src/modules/vault/application/vault.service.ts @@ -55,7 +55,8 @@ export class VaultService { if (existing) throw new Error('Ya existe un entorno con ese slug'); const environments = await this.repository.listEnvironments(projectId); - const nextOrder = environments.reduce((max, environment) => Math.max(max, environment.order), -1) + 1; + const nextOrder = + environments.reduce((max, environment) => Math.max(max, environment.order), -1) + 1; const id = crypto.randomUUID(); await this.repository.createEnvironment({ @@ -201,7 +202,7 @@ export class VaultService { await this.repository.deleteFolder(folder.id); } - async exportEnvFile(projectId: string, environmentSlug: string, path = '/') { + async exportSecrets(projectId: string, environmentSlug: string, path = '/') { const environment = await this.repository.findEnvironmentBySlug(projectId, environmentSlug); if (!environment) throw new Error('Environment not found'); @@ -215,8 +216,17 @@ export class VaultService { return secrets .filter((secret) => scope.has(secret.folderId ?? null)) .sort((a, b) => a.key.localeCompare(b.key)) - .map((secret) => `${secret.key}=${secret.values?.[environmentSlug] ?? ''}`) - .join('\n'); + .map((secret) => [secret.key, secret.values?.[environmentSlug] ?? ''] as const); + } + + async exportEnvFile(projectId: string, environmentSlug: string, path = '/') { + const entries = await this.exportSecrets(projectId, environmentSlug, path); + return entries.map(([key, value]) => `${key}=${value}`).join('\n'); + } + + async exportJsonFile(projectId: string, environmentSlug: string, path = '/') { + const entries = await this.exportSecrets(projectId, environmentSlug, path); + return JSON.stringify(Object.fromEntries(entries), null, 2); } // Un export incluye los secretos del path y los de las carpetas enlazadas desde el, no el resto del arbol. @@ -259,7 +269,8 @@ export class VaultService { for (const [key, value] of entries) { const normalizedKey = this.normalizeSecretKey(key, capitalize); const existing = secrets.find( - (secret) => (secret.folderId ?? null) === (folderId ?? null) && secret.key === normalizedKey, + (secret) => + (secret.folderId ?? null) === (folderId ?? null) && secret.key === normalizedKey, ); if (existing) { @@ -320,7 +331,10 @@ export class VaultService { .filter((line) => line && !line.startsWith('#') && line.includes('=')) .map((line) => { const separatorIndex = line.indexOf('='); - const key = line.slice(0, separatorIndex).trim().replace(/^export\s+/, ''); + const key = line + .slice(0, separatorIndex) + .trim() + .replace(/^export\s+/, ''); const value = line .slice(separatorIndex + 1) .trim() @@ -431,9 +445,7 @@ export class VaultService { private normalizeSecretKey(value: string, capitalize: boolean) { const trimmed = value.trim(); const sanitized = capitalize ? trimmed.toUpperCase() : trimmed; - const key = sanitized - .replace(/[^A-Za-z0-9_]+/g, '_') - .replace(/^_+|_+$/g, ''); + const key = sanitized.replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''); if (!key) throw new Error('La key del secreto es obligatoria'); return key; } diff --git a/src/routes/api/vault/export/+server.ts b/src/routes/api/vault/export/+server.ts new file mode 100644 index 0000000..fc7c089 --- /dev/null +++ b/src/routes/api/vault/export/+server.ts @@ -0,0 +1,83 @@ +import { json } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; +import { vaultService } from '$modules/vault'; + +const FORMATS = ['env', 'json'] as const; +type ExportFormat = (typeof FORMATS)[number]; + +function normalizePath(path: string | null) { + const segments = (path ?? '').split('/').filter(Boolean); + return segments.length === 0 ? '/' : `/${segments.join('/')}`; +} + +function errorMessage(err: unknown) { + return err instanceof Error ? err.message : 'Vault export failed'; +} + +// machine-to-machine + session endpoint: exports the secrets of a project path for one +// environment. The identity's organization must own the project, whatever its permissions are. +export async function GET({ url, locals }) { + const log = locals.logger; + const projectSlug = url.searchParams.get('project')?.trim(); + const environmentSlug = url.searchParams.get('env')?.trim(); + const format = (url.searchParams.get('format')?.trim() || 'env') as ExportFormat; + const path = normalizePath(url.searchParams.get('path')); + + if (!projectSlug) { + return json({ error: 'project is required' }, { status: 400 }); + } + if (!environmentSlug) { + return json({ error: 'env is required' }, { status: 400 }); + } + if (!FORMATS.includes(format)) { + return json({ error: "format must be 'env' or 'json'" }, { status: 400 }); + } + + const project = await projectService.tryFindBySlug(projectSlug); + if (!project) { + return json({ error: 'Project not found' }, { status: 404 }); + } + + const organizationId = project.organization?.id ?? null; + const apiKey = locals.apiKey; + const context = { + scope: 'project' as const, + projectId: project.id, + organizationId: organizationId ?? undefined, + }; + + if (apiKey) { + if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { + return json({ error: 'Project does not belong to the token organization' }, { status: 403 }); + } + if (!cancanService.canApiKey(apiKey, 'project:vault:secrets:read', context)) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + } else if ( + !(await cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', context)) + ) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const content = + format === 'json' + ? await vaultService.exportJsonFile(project.id, environmentSlug, path) + : await vaultService.exportEnvFile(project.id, environmentSlug, path); + + const filename = `${project.slug}-${environmentSlug}.${format}`; + + return new Response(content, { + headers: { + 'content-type': + format === 'json' ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8', + 'content-disposition': `attachment; filename="${filename}"`, + 'cache-control': 'no-store', + }, + }); + } catch (err) { + log?.warn({ err, projectSlug, environmentSlug, path }, '[vault] export failed'); + return json({ error: errorMessage(err) }, { status: 400 }); + } +} diff --git a/src/routes/api/vault/export/server.test.ts b/src/routes/api/vault/export/server.test.ts new file mode 100644 index 0000000..c6770ed --- /dev/null +++ b/src/routes/api/vault/export/server.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tryFindBySlug = vi.fn(); +const canApiKey = vi.fn(); +const canSessionUser = vi.fn(); +const exportEnvFile = vi.fn(); +const exportJsonFile = vi.fn(); + +vi.mock('$modules/projects', () => ({ + projectService: { + tryFindBySlug: (slug: string) => tryFindBySlug(slug), + }, +})); + +vi.mock('$modules/auth', () => ({ + cancanService: { + canApiKey: (...args: unknown[]) => canApiKey(...args), + canSessionUser: (...args: unknown[]) => canSessionUser(...args), + }, +})); + +vi.mock('$modules/vault', () => ({ + vaultService: { + exportEnvFile: (...args: unknown[]) => exportEnvFile(...args), + exportJsonFile: (...args: unknown[]) => exportJsonFile(...args), + }, +})); + +const { GET } = await import('./+server'); + +const project = { + id: 'project-1', + slug: 'kettu', + organization: { id: 'org-1', slug: 'kettu-org' }, +}; + +const apiKey = { + id: 'key-1', + name: 'ci', + keyPrefix: 'gvs_', + projectId: 'project-1', + organizationId: 'org-1', + userId: null, + role: null, +}; + +function request(query: string, locals: Record = { apiKey }) { + return GET({ + url: new URL(`http://localhost/api/vault/export${query}`), + locals: { logger: { warn: vi.fn() }, ...locals }, + } as never); +} + +describe('GET /api/vault/export', () => { + beforeEach(() => { + vi.clearAllMocks(); + tryFindBySlug.mockResolvedValue(project); + canApiKey.mockReturnValue(true); + canSessionUser.mockResolvedValue(true); + exportEnvFile.mockResolvedValue('API_KEY=abc\nDB_HOST=localhost'); + exportJsonFile.mockResolvedValue('{\n "API_KEY": "abc"\n}'); + }); + + it('requires the project slug', async () => { + const response = await request('?env=prod'); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'project is required' }); + }); + + it('requires the environment', async () => { + const response = await request('?project=kettu'); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'env is required' }); + }); + + it('rejects unsupported formats', async () => { + const response = await request('?project=kettu&env=prod&format=yaml'); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "format must be 'env' or 'json'" }); + }); + + it('returns 404 when the project does not exist', async () => { + tryFindBySlug.mockResolvedValue(null); + const response = await request('?project=missing&env=prod'); + expect(response.status).toBe(404); + }); + + it('rejects a token whose organization does not own the project', async () => { + const response = await request('?project=kettu&env=prod', { + apiKey: { ...apiKey, organizationId: 'org-2' }, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: 'Project does not belong to the token organization', + }); + expect(exportEnvFile).not.toHaveBeenCalled(); + }); + + it('rejects a token without organization', async () => { + const response = await request('?project=kettu&env=prod', { + apiKey: { ...apiKey, organizationId: null }, + }); + expect(response.status).toBe(403); + expect(exportEnvFile).not.toHaveBeenCalled(); + }); + + it('rejects a token without the read permission', async () => { + canApiKey.mockReturnValue(false); + const response = await request('?project=kettu&env=prod'); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: 'Forbidden' }); + }); + + it('exports the env format by default', async () => { + const response = await request('?project=kettu&env=prod'); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8'); + expect(response.headers.get('content-disposition')).toBe( + 'attachment; filename="kettu-prod.env"', + ); + await expect(response.text()).resolves.toBe('API_KEY=abc\nDB_HOST=localhost'); + expect(exportEnvFile).toHaveBeenCalledWith('project-1', 'prod', '/'); + }); + + it('exports the json format', async () => { + const response = await request('?project=kettu&env=prod&format=json&path=/database/creds'); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8'); + expect(response.headers.get('content-disposition')).toBe( + 'attachment; filename="kettu-prod.json"', + ); + await expect(response.json()).resolves.toEqual({ API_KEY: 'abc' }); + expect(exportJsonFile).toHaveBeenCalledWith('project-1', 'prod', '/database/creds'); + }); + + it('normalizes the requested path', async () => { + await request('?project=kettu&env=prod&path=database//creds/'); + expect(exportEnvFile).toHaveBeenCalledWith('project-1', 'prod', '/database/creds'); + }); + + it('authorizes a session user when no api key is present', async () => { + const response = await request('?project=kettu&env=prod', { user: { id: 'user-1' } }); + + expect(response.status).toBe(200); + expect(canSessionUser).toHaveBeenCalledWith({ id: 'user-1' }, 'project:vault:secrets:read', { + scope: 'project', + projectId: 'project-1', + organizationId: 'org-1', + }); + }); + + it('rejects a session user without permission', async () => { + canSessionUser.mockResolvedValue(false); + const response = await request('?project=kettu&env=prod', { user: { id: 'user-1' } }); + expect(response.status).toBe(403); + }); + + it('maps service errors to 400', async () => { + exportEnvFile.mockRejectedValue(new Error('Environment not found')); + const response = await request('?project=kettu&env=nope'); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Environment not found' }); + }); +}); From 78c61e86c00beef5bcef2e9c1862ec685127cf7e Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 11 Sep 2026 11:48:55 +0200 Subject: [PATCH 08/14] vault roles + json --- src/modules/auth/domain/role-permissions.data.ts | 2 ++ src/routes/api/vault/export/+server.ts | 4 ++-- src/routes/api/vault/export/server.test.ts | 2 +- .../projects/[slug]/vault/[env]/[...path]/+page.server.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/modules/auth/domain/role-permissions.data.ts b/src/modules/auth/domain/role-permissions.data.ts index b7a98af..1b7c5dc 100644 --- a/src/modules/auth/domain/role-permissions.data.ts +++ b/src/modules/auth/domain/role-permissions.data.ts @@ -46,6 +46,7 @@ export const PROJECT_DEVELOPER_PERMISSIONS = [ 'project:vault:secrets:read', 'project:vault:secrets:create', 'project:vault:secrets:update', + 'project:vault:secrets:export', 'project:vault:environments:read', 'project:vault:environments:create', 'project:vault:environments:update', @@ -79,6 +80,7 @@ export const PROJECT_VIEWER_PERMISSIONS = [ 'project:server-keys:read', 'project:audit:read', 'project:vault:secrets:read', + 'project:vault:secrets:export', 'project:vault:environments:read', 'project:vault:settings:read', 'project:codereport:reports:read', diff --git a/src/routes/api/vault/export/+server.ts b/src/routes/api/vault/export/+server.ts index fc7c089..1289d5e 100644 --- a/src/routes/api/vault/export/+server.ts +++ b/src/routes/api/vault/export/+server.ts @@ -51,11 +51,11 @@ export async function GET({ url, locals }) { if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { return json({ error: 'Project does not belong to the token organization' }, { status: 403 }); } - if (!cancanService.canApiKey(apiKey, 'project:vault:secrets:read', context)) { + if (!cancanService.canApiKey(apiKey, 'project:vault:secrets:export', context)) { return json({ error: 'Forbidden' }, { status: 403 }); } } else if ( - !(await cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', context)) + !(await cancanService.canSessionUser(locals.user, 'project:vault:secrets:export', context)) ) { return json({ error: 'Forbidden' }, { status: 403 }); } diff --git a/src/routes/api/vault/export/server.test.ts b/src/routes/api/vault/export/server.test.ts index c6770ed..bb39b7a 100644 --- a/src/routes/api/vault/export/server.test.ts +++ b/src/routes/api/vault/export/server.test.ts @@ -144,7 +144,7 @@ describe('GET /api/vault/export', () => { const response = await request('?project=kettu&env=prod', { user: { id: 'user-1' } }); expect(response.status).toBe(200); - expect(canSessionUser).toHaveBeenCalledWith({ id: 'user-1' }, 'project:vault:secrets:read', { + expect(canSessionUser).toHaveBeenCalledWith({ id: 'user-1' }, 'project:vault:secrets:export', { scope: 'project', projectId: 'project-1', organizationId: 'org-1', diff --git a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts index aab49c9..5fb95e1 100644 --- a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts @@ -232,7 +232,7 @@ export const actions = { }, exportSecrets: async ({ params, locals }) => { try { - const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:read'); + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:export'); const content = await vaultService.exportEnvFile( project.id, params.env, From 5f24f000de3a8bc38f0576bed5fc8c6526fc1f3f Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 13 Sep 2026 11:01:36 +0200 Subject: [PATCH 09/14] vault integrations --- .env.example | 4 + src/lib/components/AppSidebar.svelte | 6 + .../crypto/secret-cipher.test.ts | 87 ++++++++ .../infrastructure/crypto/secret-cipher.ts | 111 ++++++++++ .../repositories/vault.repository.ts | 23 ++- .../[slug]/vault/integrations/+page.server.ts | 16 ++ .../[slug]/vault/integrations/+page.svelte | 192 ++++++++++++++++++ static/integrations/bitbucket.svg | 1 + static/integrations/django.svg | 1 + static/integrations/dotnet.svg | 1 + static/integrations/express.svg | 1 + static/integrations/flask.svg | 1 + static/integrations/githubactions.svg | 1 + static/integrations/gitlab.svg | 1 + static/integrations/jenkins.svg | 1 + static/integrations/nestjs.svg | 1 + static/integrations/nextjs.svg | 1 + static/integrations/nuxt.svg | 1 + static/integrations/react.svg | 1 + static/integrations/remix.svg | 1 + static/integrations/sveltekit.svg | 1 + static/integrations/vite.svg | 1 + 22 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 src/modules/vault/infrastructure/crypto/secret-cipher.test.ts create mode 100644 src/modules/vault/infrastructure/crypto/secret-cipher.ts create mode 100644 src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts create mode 100644 src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte create mode 100644 static/integrations/bitbucket.svg create mode 100644 static/integrations/django.svg create mode 100644 static/integrations/dotnet.svg create mode 100644 static/integrations/express.svg create mode 100644 static/integrations/flask.svg create mode 100644 static/integrations/githubactions.svg create mode 100644 static/integrations/gitlab.svg create mode 100644 static/integrations/jenkins.svg create mode 100644 static/integrations/nestjs.svg create mode 100644 static/integrations/nextjs.svg create mode 100644 static/integrations/nuxt.svg create mode 100644 static/integrations/react.svg create mode 100644 static/integrations/remix.svg create mode 100644 static/integrations/sveltekit.svg create mode 100644 static/integrations/vite.svg diff --git a/.env.example b/.env.example index 63cda1e..e4cd806 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ GITDB_AUTHOR_NAME=gitops GITDB_AUTHOR_EMAIL=gitops@example.com GITDB_SYNC_POLL_SECONDS=60 +# Optional dedicated key for the vault secret values (AES-256-GCM). +# Falls back to GITDB_ENCRYPTION_KEY. Rotating it makes existing secrets unreadable. +# VAULT_ENCRYPTION_KEY= + # Optional HTTP(S) authentication for the GitDB repository # GITDB_TOKEN= # GITDB_USERNAME= diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index f6fc0c9..12f355c 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -14,6 +14,7 @@ Lock, PanelLeftClose, PanelLeftOpen, + Plug, ScrollText, Settings, Shield, @@ -117,6 +118,11 @@ href: `${projectBase}/vault`, icon: Shield, }, + { + label: 'Integrations', + href: `${projectBase}/vault/integrations`, + icon: Plug, + }, { label: 'Settings', href: `${projectBase}/vault/settings`, diff --git a/src/modules/vault/infrastructure/crypto/secret-cipher.test.ts b/src/modules/vault/infrastructure/crypto/secret-cipher.test.ts new file mode 100644 index 0000000..338549e --- /dev/null +++ b/src/modules/vault/infrastructure/crypto/secret-cipher.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + decryptSecretValue, + decryptSecretValues, + encryptSecretValue, + encryptSecretValues, + isEncryptedSecretValue, +} from './secret-cipher'; + +describe('vault secret cipher', () => { + beforeEach(() => { + process.env.VAULT_ENCRYPTION_KEY = 'test-vault-key'; + delete process.env.GITDB_ENCRYPTION_KEY; + }); + + it('round-trips a value with AES-256-GCM', () => { + const sealed = encryptSecretValue('secret-1', 'prod', 'super-secret'); + + expect(sealed).not.toContain('super-secret'); + expect(sealed.split(':')).toHaveLength(4); + expect(isEncryptedSecretValue(sealed)).toBe(true); + expect(decryptSecretValue('secret-1', 'prod', sealed)).toBe('super-secret'); + }); + + it('produces a different ciphertext on every write', () => { + const first = encryptSecretValue('secret-1', 'prod', 'same'); + const second = encryptSecretValue('secret-1', 'prod', 'same'); + + expect(first).not.toBe(second); + expect(decryptSecretValue('secret-1', 'prod', second)).toBe('same'); + }); + + it('rejects a ciphertext replayed on another secret or environment', () => { + const sealed = encryptSecretValue('secret-1', 'prod', 'super-secret'); + + expect(() => decryptSecretValue('secret-2', 'prod', sealed)).toThrow(/descifrar/); + expect(() => decryptSecretValue('secret-1', 'dev', sealed)).toThrow(/descifrar/); + }); + + it('rejects a tampered ciphertext', () => { + const [version, iv, tag, payload] = encryptSecretValue('secret-1', 'prod', 'value').split(':'); + const tampered = [version, iv, tag, Buffer.from('hacked').toString('base64')].join(':'); + + expect(() => decryptSecretValue('secret-1', 'prod', tampered)).toThrow(/descifrar/); + }); + + it('rejects a ciphertext sealed with another key', () => { + const sealed = encryptSecretValue('secret-1', 'prod', 'value'); + process.env.VAULT_ENCRYPTION_KEY = 'another-key'; + + expect(() => decryptSecretValue('secret-1', 'prod', sealed)).toThrow(/descifrar/); + }); + + it('falls back to GITDB_ENCRYPTION_KEY', () => { + delete process.env.VAULT_ENCRYPTION_KEY; + process.env.GITDB_ENCRYPTION_KEY = 'cluster-key'; + + const sealed = encryptSecretValue('secret-1', 'prod', 'value'); + expect(decryptSecretValue('secret-1', 'prod', sealed)).toBe('value'); + }); + + it('throws when no key is configured', () => { + delete process.env.VAULT_ENCRYPTION_KEY; + delete process.env.GITDB_ENCRYPTION_KEY; + + expect(() => encryptSecretValue('secret-1', 'prod', 'value')).toThrow(/VAULT_ENCRYPTION_KEY/); + }); + + it('reads legacy plaintext values untouched', () => { + expect(isEncryptedSecretValue('plain-value')).toBe(false); + expect(decryptSecretValue('secret-1', 'prod', 'plain-value')).toBe('plain-value'); + }); + + it('seals and opens every environment of a secret', () => { + const values = { dev: 'a', stage: '', prod: 'c' }; + const sealed = encryptSecretValues('secret-1', values); + + expect(Object.keys(sealed)).toEqual(['dev', 'stage', 'prod']); + expect(Object.values(sealed).every(isEncryptedSecretValue)).toBe(true); + expect(decryptSecretValues('secret-1', sealed)).toEqual(values); + }); + + it('handles empty value maps', () => { + expect(encryptSecretValues('secret-1', undefined)).toEqual({}); + expect(decryptSecretValues('secret-1', undefined)).toEqual({}); + }); +}); diff --git a/src/modules/vault/infrastructure/crypto/secret-cipher.ts b/src/modules/vault/infrastructure/crypto/secret-cipher.ts new file mode 100644 index 0000000..c3ef8f0 --- /dev/null +++ b/src/modules/vault/infrastructure/crypto/secret-cipher.ts @@ -0,0 +1,111 @@ +import crypto from 'crypto'; +import { env } from '$env/dynamic/private'; +import type { VaultSecretValues } from '../../domain/vault.domain'; + +const ALGORITHM = 'aes-256-gcm'; +const VERSION = 'v1'; +const KEY_SALT = 'gitops:vault:secret-values:v1'; +const KEY_BYTES = 32; +const IV_BYTES = 12; +const AUTH_TAG_BYTES = 16; + +let cachedKey: { material: string; key: Buffer } | null = null; + +function encryptionKey(): Buffer { + const material = (env.VAULT_ENCRYPTION_KEY || env.GITDB_ENCRYPTION_KEY || '').trim(); + if (!material) { + throw new Error('Missing VAULT_ENCRYPTION_KEY (or GITDB_ENCRYPTION_KEY) environment variable'); + } + + if (cachedKey?.material !== material) { + cachedKey = { material, key: crypto.scryptSync(material, KEY_SALT, KEY_BYTES) }; + } + + return cachedKey.key; +} + +// binding the ciphertext to its secret and environment makes a stored value unusable if it is +// copied onto another row or another environment of the same row +function additionalData(secretId: string, environmentSlug: string): Buffer { + return Buffer.from(`${VERSION}:${secretId}:${environmentSlug}`, 'utf8'); +} + +export function isEncryptedSecretValue(value: string): boolean { + return ( + typeof value === 'string' && value.startsWith(`${VERSION}:`) && value.split(':').length === 4 + ); +} + +export function encryptSecretValue( + secretId: string, + environmentSlug: string, + value: string, +): string { + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey(), iv, { + authTagLength: AUTH_TAG_BYTES, + }); + cipher.setAAD(additionalData(secretId, environmentSlug)); + + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + + return [ + VERSION, + iv.toString('base64'), + cipher.getAuthTag().toString('base64'), + ciphertext.toString('base64'), + ].join(':'); +} + +export function decryptSecretValue( + secretId: string, + environmentSlug: string, + stored: string, +): string { + // rows written before encryption was introduced are still plaintext + if (!isEncryptedSecretValue(stored)) return stored; + + const [, iv, authTag, ciphertext] = stored.split(':'); + + try { + const decipher = crypto.createDecipheriv( + ALGORITHM, + encryptionKey(), + Buffer.from(iv, 'base64'), + { authTagLength: AUTH_TAG_BYTES }, + ); + decipher.setAAD(additionalData(secretId, environmentSlug)); + decipher.setAuthTag(Buffer.from(authTag, 'base64')); + + return Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'base64')), + decipher.final(), + ]).toString('utf8'); + } catch { + throw new Error(`No se pudo descifrar el secreto ${secretId} (${environmentSlug})`); + } +} + +export function encryptSecretValues( + secretId: string, + values: VaultSecretValues | undefined, +): VaultSecretValues { + return Object.fromEntries( + Object.entries(values ?? {}).map(([environmentSlug, value]) => [ + environmentSlug, + encryptSecretValue(secretId, environmentSlug, String(value ?? '')), + ]), + ); +} + +export function decryptSecretValues( + secretId: string, + values: VaultSecretValues | undefined, +): VaultSecretValues { + return Object.fromEntries( + Object.entries(values ?? {}).map(([environmentSlug, value]) => [ + environmentSlug, + decryptSecretValue(secretId, environmentSlug, String(value ?? '')), + ]), + ); +} diff --git a/src/modules/vault/infrastructure/repositories/vault.repository.ts b/src/modules/vault/infrastructure/repositories/vault.repository.ts index 5caed83..a32f3d5 100644 --- a/src/modules/vault/infrastructure/repositories/vault.repository.ts +++ b/src/modules/vault/infrastructure/repositories/vault.repository.ts @@ -13,6 +13,7 @@ import { type VaultEncryptionProvider, type VaultSecretValues, } from '../../domain/vault.domain'; +import { decryptSecretValues, encryptSecretValues } from '../crypto/secret-cipher'; export class VaultRepository extends Repository { async listEnvironments(projectId: string): Promise { @@ -125,13 +126,18 @@ export class VaultRepository extends Repository { .from(VaultSecretEntity) .where({ projectId }) .orderBy('createdAt', 'desc'); - return result.rows.map((row: any) => new VaultSecretDomain(row)); + return result.rows.map((row: any) => this.toSecretDomain(row)); } async findSecretById(id: string): Promise { const result = await this.db.select().from(VaultSecretEntity).where({ id }).limit(1); const row = result.rows[0]; - return row ? new VaultSecretDomain(row) : null; + return row ? this.toSecretDomain(row) : null; + } + + // secret values never leave this repository in plaintext: they are sealed with AES-256-GCM + private toSecretDomain(row: any): VaultSecretDomain { + return new VaultSecretDomain({ ...row, values: decryptSecretValues(row.id, row.values) }); } async createSecret(input: { @@ -145,6 +151,7 @@ export class VaultRepository extends Repository { await this.db.insert(VaultSecretEntity).values({ ...input, folderId: input.folderId || null, + values: encryptSecretValues(input.id, input.values), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -161,7 +168,11 @@ export class VaultRepository extends Repository { ): Promise { await this.db .update(VaultSecretEntity) - .set({ ...changes, updatedAt: new Date().toISOString() }) + .set({ + ...changes, + ...(changes.values ? { values: encryptSecretValues(id, changes.values) } : {}), + updatedAt: new Date().toISOString(), + }) .where({ id }); } @@ -170,11 +181,7 @@ export class VaultRepository extends Repository { } async findSettingsByProjectId(projectId: string): Promise { - const result = await this.db - .select() - .from(VaultSettingsEntity) - .where({ projectId }) - .limit(1); + const result = await this.db.select().from(VaultSettingsEntity).where({ projectId }).limit(1); const row = result.rows[0]; return row ? new VaultSettingsDomain(row) : null; } diff --git a/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts new file mode 100644 index 0000000..de23433 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) throw error(403, 'Forbidden'); + + return {}; +} diff --git a/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte new file mode 100644 index 0000000..aec9caa --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte @@ -0,0 +1,192 @@ + + + + Vault integrations - GitOps + + +
    +
    +

    Vault

    +

    Integrations

    +

    + Conecta el vault de este proyecto con tu stack. Cada guia explica como cargar los secretos por + entorno sin escribirlos en el repositorio. +

    +
    + + {#each sections as section (section.id)} +
    +
    +

    {section.title}

    +

    {section.description}

    +
    + + +
    + {/each} +
    diff --git a/static/integrations/bitbucket.svg b/static/integrations/bitbucket.svg new file mode 100644 index 0000000..6475973 --- /dev/null +++ b/static/integrations/bitbucket.svg @@ -0,0 +1 @@ +Bitbucket \ No newline at end of file diff --git a/static/integrations/django.svg b/static/integrations/django.svg new file mode 100644 index 0000000..0365832 --- /dev/null +++ b/static/integrations/django.svg @@ -0,0 +1 @@ +Django \ No newline at end of file diff --git a/static/integrations/dotnet.svg b/static/integrations/dotnet.svg new file mode 100644 index 0000000..e747771 --- /dev/null +++ b/static/integrations/dotnet.svg @@ -0,0 +1 @@ +.NET \ No newline at end of file diff --git a/static/integrations/express.svg b/static/integrations/express.svg new file mode 100644 index 0000000..98b8e09 --- /dev/null +++ b/static/integrations/express.svg @@ -0,0 +1 @@ +Express \ No newline at end of file diff --git a/static/integrations/flask.svg b/static/integrations/flask.svg new file mode 100644 index 0000000..4c32ea9 --- /dev/null +++ b/static/integrations/flask.svg @@ -0,0 +1 @@ +Flask \ No newline at end of file diff --git a/static/integrations/githubactions.svg b/static/integrations/githubactions.svg new file mode 100644 index 0000000..2a75ee5 --- /dev/null +++ b/static/integrations/githubactions.svg @@ -0,0 +1 @@ +GitHub Actions \ No newline at end of file diff --git a/static/integrations/gitlab.svg b/static/integrations/gitlab.svg new file mode 100644 index 0000000..509fa7f --- /dev/null +++ b/static/integrations/gitlab.svg @@ -0,0 +1 @@ +GitLab \ No newline at end of file diff --git a/static/integrations/jenkins.svg b/static/integrations/jenkins.svg new file mode 100644 index 0000000..9b8910f --- /dev/null +++ b/static/integrations/jenkins.svg @@ -0,0 +1 @@ +Jenkins \ No newline at end of file diff --git a/static/integrations/nestjs.svg b/static/integrations/nestjs.svg new file mode 100644 index 0000000..61770a7 --- /dev/null +++ b/static/integrations/nestjs.svg @@ -0,0 +1 @@ +NestJS \ No newline at end of file diff --git a/static/integrations/nextjs.svg b/static/integrations/nextjs.svg new file mode 100644 index 0000000..558820d --- /dev/null +++ b/static/integrations/nextjs.svg @@ -0,0 +1 @@ +Next.js \ No newline at end of file diff --git a/static/integrations/nuxt.svg b/static/integrations/nuxt.svg new file mode 100644 index 0000000..024d246 --- /dev/null +++ b/static/integrations/nuxt.svg @@ -0,0 +1 @@ +Nuxt \ No newline at end of file diff --git a/static/integrations/react.svg b/static/integrations/react.svg new file mode 100644 index 0000000..88b9c77 --- /dev/null +++ b/static/integrations/react.svg @@ -0,0 +1 @@ +React \ No newline at end of file diff --git a/static/integrations/remix.svg b/static/integrations/remix.svg new file mode 100644 index 0000000..c001fa8 --- /dev/null +++ b/static/integrations/remix.svg @@ -0,0 +1 @@ +Remix \ No newline at end of file diff --git a/static/integrations/sveltekit.svg b/static/integrations/sveltekit.svg new file mode 100644 index 0000000..a3f86d5 --- /dev/null +++ b/static/integrations/sveltekit.svg @@ -0,0 +1 @@ +Svelte \ No newline at end of file diff --git a/static/integrations/vite.svg b/static/integrations/vite.svg new file mode 100644 index 0000000..c912945 --- /dev/null +++ b/static/integrations/vite.svg @@ -0,0 +1 @@ +Vite \ No newline at end of file From bc9c379bd2afde946dbd86242645f99e6240fe80 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 13 Sep 2026 13:00:57 +0200 Subject: [PATCH 10/14] vault api + tests --- e2e/specs/vault-stateiac.spec.ts | 4 +- src/lib/config/permissions.ts | 4 + .../auth/application/role.service.test.ts | 8 +- .../vault/application/vault.service.test.ts | 130 ++++++++++- .../vault/application/vault.service.ts | 40 +++- .../repositories/vault.repository.ts | 14 ++ src/routes/api/vault/secret/+server.ts | 80 +++++++ src/routes/api/vault/secret/[key]/+server.ts | 136 ++++++++++++ .../api/vault/secret/[key]/server.test.ts | 202 ++++++++++++++++++ src/routes/api/vault/secret/server.test.ts | 143 +++++++++++++ 10 files changed, 754 insertions(+), 7 deletions(-) create mode 100644 src/routes/api/vault/secret/+server.ts create mode 100644 src/routes/api/vault/secret/[key]/+server.ts create mode 100644 src/routes/api/vault/secret/[key]/server.test.ts create mode 100644 src/routes/api/vault/secret/server.test.ts diff --git a/e2e/specs/vault-stateiac.spec.ts b/e2e/specs/vault-stateiac.spec.ts index 295637f..8dacf40 100644 --- a/e2e/specs/vault-stateiac.spec.ts +++ b/e2e/specs/vault-stateiac.spec.ts @@ -11,7 +11,7 @@ test.describe('vault — secrets:read alone is enough', () => { loginAs, }) => { await loginAs('vaultSecretsOnly'); - await expectAllowed(page, `${base}/vault`); + await expectAllowed(page, `${base}/vault/dev`); }); }); @@ -21,7 +21,7 @@ test.describe('vault — environments:read alone is enough', () => { loginAs, }) => { await loginAs('vaultEnvironmentsOnly'); - await expectAllowed(page, `${base}/vault`); + await expectAllowed(page, `${base}/vault/dev`); }); }); diff --git a/src/lib/config/permissions.ts b/src/lib/config/permissions.ts index 9df2ab5..91f169f 100644 --- a/src/lib/config/permissions.ts +++ b/src/lib/config/permissions.ts @@ -95,6 +95,10 @@ const permissions = { resource: 'environments', permissions: grantsFor('project:vault', 'environments'), }, + settings: { + resource: 'settings', + permissions: grantsFor('project:vault', 'settings'), + }, }, }, codereport: { diff --git a/src/modules/auth/application/role.service.test.ts b/src/modules/auth/application/role.service.test.ts index ea7d346..6eb096f 100644 --- a/src/modules/auth/application/role.service.test.ts +++ b/src/modules/auth/application/role.service.test.ts @@ -280,9 +280,11 @@ describe('RoleService', () => { expect(developer.permissions).not.toContain('project:roles:all'); const viewer = roles.find((role) => role.slug === 'project-viewer'); - expect(viewer.permissions.every((permission: string) => permission.endsWith(':read'))).toBe( - true, - ); + expect( + viewer.permissions.every( + (permission: string) => !/:(create|update|delete|all)$/.test(permission), + ), + ).toBe(true); }); }); }); diff --git a/src/modules/vault/application/vault.service.test.ts b/src/modules/vault/application/vault.service.test.ts index 5790777..287ecf1 100644 --- a/src/modules/vault/application/vault.service.test.ts +++ b/src/modules/vault/application/vault.service.test.ts @@ -4,13 +4,14 @@ import { VaultService } from './vault.service'; type Json = Record; function jsonRow(data: Json) { - return { toJson: () => data }; + return { ...data, toJson: () => data }; } class FakeVaultRepository { environments: Json[] = []; folders: Json[] = []; secrets: Json[] = []; + settings: Json[] = []; async findEnvironmentBySlug(projectId: string, slug: string) { const found = this.environments.find( @@ -23,9 +24,51 @@ class FakeVaultRepository { return this.folders.filter((folder) => folder.projectId === projectId).map(jsonRow); } + async findFolderById(id: string) { + const found = this.folders.find((folder) => folder.id === id); + return found ? jsonRow(found) : null; + } + async listSecrets(projectId: string) { return this.secrets.filter((secret) => secret.projectId === projectId).map(jsonRow); } + + async findSecretById(id: string) { + const found = this.secrets.find((secret) => secret.id === id); + return found ? jsonRow(found) : null; + } + + async findSecretByKey(projectId: string, folderId: string | null, key: string) { + const found = this.secrets.find( + (secret) => + secret.projectId === projectId && + (secret.folderId ?? null) === folderId && + secret.key === key, + ); + return found ? jsonRow(found) : null; + } + + async createSecret(input: Json) { + this.secrets.push({ ...input, folderId: input.folderId ?? null }); + } + + async updateSecret(id: string, changes: Json) { + const secret = this.secrets.find((candidate) => candidate.id === id); + if (secret) Object.assign(secret, changes); + } + + async deleteSecret(id: string) { + this.secrets = this.secrets.filter((secret) => secret.id !== id); + } + + async findSettingsByProjectId(projectId: string) { + const found = this.settings.find((setting) => setting.projectId === projectId); + return found ? jsonRow(found) : null; + } + + async createSettings(input: Json) { + this.settings.push(input); + } } describe('VaultService export', () => { @@ -83,3 +126,88 @@ describe('VaultService export', () => { await expect(service.exportJsonFile('p1', 'prod', '/nope')).rejects.toThrow('Folder not found'); }); }); + +describe('VaultService secrets', () => { + let repository: FakeVaultRepository; + let service: VaultService; + + beforeEach(() => { + repository = new FakeVaultRepository(); + service = new VaultService(repository as never); + }); + + it('creates a secret with normalized (capitalized) key', async () => { + const secret = await service.createSecret('p1', { key: 'api key', values: { prod: 'abc' } }); + expect(secret).toMatchObject({ key: 'API_KEY', folderId: null, values: { prod: 'abc' } }); + }); + + it('rejects creating a secret in a folder from another project', async () => { + repository.folders.push({ id: 'folder-1', projectId: 'other', path: '/x' }); + await expect( + service.createSecret('p1', { folderId: 'folder-1', key: 'X' }), + ).rejects.toThrow('Folder not found'); + }); + + it('finds a secret by key', async () => { + await service.createSecret('p1', { key: 'zone', values: { prod: 'eu' } }); + const found = await service.findSecretByKey('p1', null, 'zone'); + expect(found).toMatchObject({ key: 'ZONE', values: { prod: 'eu' } }); + }); + + it('returns null when a secret key does not exist', async () => { + await expect(service.findSecretByKey('p1', null, 'missing')).resolves.toBeNull(); + }); + + it('updates a secret replacing its values', async () => { + const created = await service.createSecret('p1', { key: 'zone', values: { prod: 'eu' } }); + const updated = await service.updateSecret('p1', created.id, { + key: 'zone', + values: { prod: 'us' }, + }); + expect(updated.values).toEqual({ prod: 'us' }); + }); + + it('throws when updating a secret from another project', async () => { + await repository.createSecret({ id: 's1', projectId: 'other', folderId: null, key: 'X' }); + await expect( + service.updateSecret('p1', 's1', { key: 'X', values: {} }), + ).rejects.toThrow('Secret not found'); + }); + + it('deletes a secret', async () => { + const created = await service.createSecret('p1', { key: 'zone' }); + await service.deleteSecret('p1', created.id); + await expect(service.findSecretByKey('p1', null, 'zone')).resolves.toBeNull(); + }); + + it('throws when deleting a secret by key that does not exist', async () => { + await expect(service.deleteSecretByKey('p1', null, 'missing')).rejects.toThrow( + 'Secret not found', + ); + }); + + it('sets a single environment value while keeping the others and the description', async () => { + await service.createSecret('p1', { + key: 'zone', + description: 'Region', + values: { prod: 'eu', dev: 'eu-dev' }, + }); + const updated = await service.setSecretValue('p1', null, 'zone', 'prod', 'us'); + expect(updated).toMatchObject({ + description: 'Region', + values: { prod: 'us', dev: 'eu-dev' }, + }); + }); + + it('overrides the description when provided to setSecretValue', async () => { + await service.createSecret('p1', { key: 'zone', description: 'Region', values: { prod: 'eu' } }); + const updated = await service.setSecretValue('p1', null, 'zone', 'prod', 'us', 'New region'); + expect(updated.description).toBe('New region'); + }); + + it('throws when setting the value of a secret that does not exist', async () => { + await expect(service.setSecretValue('p1', null, 'missing', 'prod', 'us')).rejects.toThrow( + 'Secret not found', + ); + }); +}); diff --git a/src/modules/vault/application/vault.service.ts b/src/modules/vault/application/vault.service.ts index 9476b89..02ced2f 100644 --- a/src/modules/vault/application/vault.service.ts +++ b/src/modules/vault/application/vault.service.ts @@ -350,14 +350,16 @@ export class VaultService { const key = this.normalizeSecretKey(input.key, capitalize); const values = this.normalizeValues(input.values); + const id = crypto.randomUUID(); await this.repository.createSecret({ - id: crypto.randomUUID(), + id, projectId, folderId: input.folderId || null, key, description: input.description?.trim() || undefined, values, }); + return (await this.repository.findSecretById(id))!.toJson(); } async updateSecret(projectId: string, id: string, input: VaultSecretInput) { @@ -372,6 +374,7 @@ export class VaultService { description: input.description?.trim(), values: this.normalizeValues(input.values), }); + return (await this.repository.findSecretById(id))!.toJson(); } async deleteSecret(projectId: string, id: string) { @@ -381,6 +384,41 @@ export class VaultService { await this.repository.deleteSecret(id); } + async findSecretByKey(projectId: string, folderId: string | null, key: string) { + const capitalize = (await this.getSettings(projectId)).capitalizeSecrets; + const secret = await this.repository.findSecretByKey( + projectId, + folderId, + this.normalizeSecretKey(key, capitalize), + ); + return secret?.toJson() ?? null; + } + + async setSecretValue( + projectId: string, + folderId: string | null, + key: string, + environment: string, + value: string, + description?: string, + ) { + const secret = await this.findSecretByKey(projectId, folderId, key); + if (!secret) throw new Error('Secret not found'); + + return this.updateSecret(projectId, secret.id, { + folderId, + key, + description: description !== undefined ? description : secret.description ?? undefined, + values: { ...secret.values, [environment]: value }, + }); + } + + async deleteSecretByKey(projectId: string, folderId: string | null, key: string) { + const secret = await this.findSecretByKey(projectId, folderId, key); + if (!secret) throw new Error('Secret not found'); + await this.deleteSecret(projectId, secret.id); + } + async getSettings(projectId: string) { const existing = await this.repository.findSettingsByProjectId(projectId); if (existing) return existing.toJson(); diff --git a/src/modules/vault/infrastructure/repositories/vault.repository.ts b/src/modules/vault/infrastructure/repositories/vault.repository.ts index a32f3d5..e02ec38 100644 --- a/src/modules/vault/infrastructure/repositories/vault.repository.ts +++ b/src/modules/vault/infrastructure/repositories/vault.repository.ts @@ -135,6 +135,20 @@ export class VaultRepository extends Repository { return row ? this.toSecretDomain(row) : null; } + async findSecretByKey( + projectId: string, + folderId: string | null, + key: string, + ): Promise { + const result = await this.db + .select() + .from(VaultSecretEntity) + .where({ projectId, folderId, key }) + .limit(1); + const row = result.rows[0]; + return row ? this.toSecretDomain(row) : null; + } + // secret values never leave this repository in plaintext: they are sealed with AES-256-GCM private toSecretDomain(row: any): VaultSecretDomain { return new VaultSecretDomain({ ...row, values: decryptSecretValues(row.id, row.values) }); diff --git a/src/routes/api/vault/secret/+server.ts b/src/routes/api/vault/secret/+server.ts new file mode 100644 index 0000000..914b667 --- /dev/null +++ b/src/routes/api/vault/secret/+server.ts @@ -0,0 +1,80 @@ +import { json } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; +import { vaultService } from '$modules/vault'; + +type CreateSecretBody = { + project?: string; + path?: string; + key?: string; + description?: string; + values?: Record; +}; + +function errorMessage(err: unknown) { + return err instanceof Error ? err.message : 'Vault secret creation failed'; +} + +function normalizePath(path: string | undefined) { + const segments = (path ?? '').split('/').filter(Boolean); + return segments.length === 0 ? '/' : `/${segments.join('/')}`; +} + +// machine-to-machine + session endpoint: creates a secret in a project's vault. +export async function POST({ request, locals }) { + const log = locals.logger; + const body = (await request.json().catch(() => ({}))) as CreateSecretBody; + const projectSlug = body.project?.trim(); + const path = normalizePath(body.path); + + if (!projectSlug) { + return json({ error: 'project is required' }, { status: 400 }); + } + if (!body.key?.trim()) { + return json({ error: 'key is required' }, { status: 400 }); + } + + const project = await projectService.tryFindBySlug(projectSlug); + if (!project) { + return json({ error: 'Project not found' }, { status: 404 }); + } + + const organizationId = project.organization?.id ?? null; + const apiKey = locals.apiKey; + const context = { + scope: 'project' as const, + projectId: project.id, + organizationId: organizationId ?? undefined, + }; + + if (apiKey) { + if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { + return json({ error: 'Project does not belong to the token organization' }, { status: 403 }); + } + if (!cancanService.canApiKey(apiKey, 'project:vault:secrets:create', context)) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + } else if ( + !(await cancanService.canSessionUser(locals.user, 'project:vault:secrets:create', context)) + ) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const folder = await vaultService.getFolderByPath(project.id, path); + const secret = await vaultService.createSecret(project.id, { + folderId: folder?.id ?? null, + key: body.key, + description: body.description, + values: body.values, + }); + return json({ secret }, { status: 201 }); + } catch (err) { + log?.warn({ err, projectSlug, key: body.key, path }, '[vault] create secret failed'); + return json( + { error: errorMessage(err) }, + { status: err instanceof Error && err.message === 'Folder not found' ? 404 : 400 }, + ); + } +} + diff --git a/src/routes/api/vault/secret/[key]/+server.ts b/src/routes/api/vault/secret/[key]/+server.ts new file mode 100644 index 0000000..3bc6c25 --- /dev/null +++ b/src/routes/api/vault/secret/[key]/+server.ts @@ -0,0 +1,136 @@ +import { json } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; +import { vaultService } from '$modules/vault'; + +type UpdateSecretBody = { + environment?: string; + value?: string; + description?: string; +}; + +function errorMessage(err: unknown) { + return err instanceof Error ? err.message : 'Vault secret operation failed'; +} + +function notFoundStatus(err: unknown) { + return err instanceof Error && (err.message === 'Secret not found' || err.message === 'Folder not found') + ? 404 + : 400; +} + +function normalizePath(path: string | null) { + const segments = (path ?? '').split('/').filter(Boolean); + return segments.length === 0 ? '/' : `/${segments.join('/')}`; +} + +// resolves the project from the `project` query param and checks the given permission, +// for either a project-scoped API key or a session user +async function authorize( + url: URL, + locals: App.Locals, + permission: 'project:vault:secrets:read' | 'project:vault:secrets:update' | 'project:vault:secrets:delete', +) { + const projectSlug = url.searchParams.get('project')?.trim(); + if (!projectSlug) { + return { error: json({ error: 'project is required' }, { status: 400 }) }; + } + + const project = await projectService.tryFindBySlug(projectSlug); + if (!project) { + return { error: json({ error: 'Project not found' }, { status: 404 }) }; + } + + const organizationId = project.organization?.id ?? null; + const apiKey = locals.apiKey; + const context = { + scope: 'project' as const, + projectId: project.id, + organizationId: organizationId ?? undefined, + }; + + if (apiKey) { + if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { + return { + error: json( + { error: 'Project does not belong to the token organization' }, + { status: 403 }, + }; + } + if (!cancanService.canApiKey(apiKey, permission, context)) { + return { error: json({ error: 'Forbidden' }, { status: 403 }) }; + } + } else if (!(await cancanService.canSessionUser(locals.user, permission, context))) { + return { error: json({ error: 'Forbidden' }, { status: 403 }) }; + } + + return { project }; +} + +// machine-to-machine + session endpoint: reads a secret by key from a project's vault. +export async function GET({ params, url, locals }) { + const auth = await authorize(url, locals, 'project:vault:secrets:read'); + if (auth.error) return auth.error; + + const path = normalizePath(url.searchParams.get('path')); + + try { + const folder = await vaultService.getFolderByPath(auth.project.id, path); + const secret = await vaultService.findSecretByKey(auth.project.id, folder?.id ?? null, params.key); + if (!secret) return json({ error: 'Secret not found' }, { status: 404 }); + return json({ secret }); + } catch (err) { + locals.logger?.warn({ err, key: params.key, path }, '[vault] get secret failed'); + return json({ error: errorMessage(err) }, { status: notFoundStatus(err) }); + } +} + +// machine-to-machine + session endpoint: sets the description and/or a single environment's +// value of a secret by key. Key and path cannot be changed here. +export async function PATCH({ params, url, request, locals }) { + const auth = await authorize(url, locals, 'project:vault:secrets:update'); + if (auth.error) return auth.error; + + const body = (await request.json().catch(() => ({}))) as UpdateSecretBody; + const path = normalizePath(url.searchParams.get('path')); + + if (!body.environment?.trim()) { + return json({ error: 'environment is required' }, { status: 400 }); + } + if (body.value === undefined) { + return json({ error: 'value is required' }, { status: 400 }); + } + + try { + const folder = await vaultService.getFolderByPath(auth.project.id, path); + const secret = await vaultService.setSecretValue( + auth.project.id, + folder?.id ?? null, + params.key, + body.environment, + body.value, + body.description, + ); + return json({ secret }); + } catch (err) { + locals.logger?.warn({ err, key: params.key, path }, '[vault] update secret failed'); + return json({ error: errorMessage(err) }, { status: notFoundStatus(err) }); + } +} + +// machine-to-machine + session endpoint: deletes a secret by key from a project's vault. +export async function DELETE({ params, url, locals }) { + const auth = await authorize(url, locals, 'project:vault:secrets:delete'); + if (auth.error) return auth.error; + + const path = normalizePath(url.searchParams.get('path')); + + try { + const folder = await vaultService.getFolderByPath(auth.project.id, path); + await vaultService.deleteSecretByKey(auth.project.id, folder?.id ?? null, params.key); + return json({ success: true }); + } catch (err) { + locals.logger?.warn({ err, key: params.key, path }, '[vault] delete secret failed'); + return json({ error: errorMessage(err) }, { status: notFoundStatus(err) }); + } +} diff --git a/src/routes/api/vault/secret/[key]/server.test.ts b/src/routes/api/vault/secret/[key]/server.test.ts new file mode 100644 index 0000000..4827e5b --- /dev/null +++ b/src/routes/api/vault/secret/[key]/server.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tryFindBySlug = vi.fn(); +const canApiKey = vi.fn(); +const canSessionUser = vi.fn(); +const getFolderByPath = vi.fn(); +const findSecretByKey = vi.fn(); +const setSecretValue = vi.fn(); +const deleteSecretByKey = vi.fn(); + +vi.mock('$modules/projects', () => ({ + projectService: { + tryFindBySlug: (slug: string) => tryFindBySlug(slug), + }, +})); + +vi.mock('$modules/auth', () => ({ + cancanService: { + canApiKey: (...args: unknown[]) => canApiKey(...args), + canSessionUser: (...args: unknown[]) => canSessionUser(...args), + }, +})); + +vi.mock('$modules/vault', () => ({ + vaultService: { + getFolderByPath: (...args: unknown[]) => getFolderByPath(...args), + findSecretByKey: (...args: unknown[]) => findSecretByKey(...args), + setSecretValue: (...args: unknown[]) => setSecretValue(...args), + deleteSecretByKey: (...args: unknown[]) => deleteSecretByKey(...args), + }, +})); + +const { GET, PATCH, DELETE } = await import('./+server'); + +const project = { + id: 'project-1', + slug: 'kettu', + organization: { id: 'org-1', slug: 'kettu-org' }, +}; + +const apiKey = { + id: 'key-1', + name: 'ci', + keyPrefix: 'gvs_', + projectId: 'project-1', + organizationId: 'org-1', + userId: null, + role: null, +}; + +function locals(overrides: Record = { apiKey }) { + return { logger: { warn: vi.fn() }, ...overrides }; +} + +function get(query: string, overrides?: Record) { + return GET({ + params: { key: 'API_KEY' }, + url: new URL(`http://localhost/api/vault/secret/API_KEY${query}`), + locals: locals(overrides), + } as never); +} + +function patch(query: string, body: Record, overrides?: Record) { + return PATCH({ + params: { key: 'API_KEY' }, + url: new URL(`http://localhost/api/vault/secret/API_KEY${query}`), + request: { json: () => Promise.resolve(body) }, + locals: locals(overrides), + } as never); +} + +function del(query: string, overrides?: Record) { + return DELETE({ + params: { key: 'API_KEY' }, + url: new URL(`http://localhost/api/vault/secret/API_KEY${query}`), + locals: locals(overrides), + } as never); +} + +describe('/api/vault/secret/[key]', () => { + beforeEach(() => { + vi.clearAllMocks(); + tryFindBySlug.mockResolvedValue(project); + canApiKey.mockReturnValue(true); + canSessionUser.mockResolvedValue(true); + getFolderByPath.mockResolvedValue(null); + findSecretByKey.mockResolvedValue({ id: 's1', key: 'API_KEY', values: { prod: 'x' } }); + setSecretValue.mockResolvedValue({ id: 's1', key: 'API_KEY', values: { prod: 'y' } }); + deleteSecretByKey.mockResolvedValue(undefined); + }); + + describe('GET', () => { + it('requires the project slug', async () => { + const response = await get(''); + expect(response.status).toBe(400); + }); + + it('rejects a token without the read permission', async () => { + canApiKey.mockReturnValue(false); + const response = await get('?project=kettu'); + expect(response.status).toBe(403); + }); + + it('returns the secret at the root path by default', async () => { + const response = await get('?project=kettu'); + expect(getFolderByPath).toHaveBeenCalledWith('project-1', '/'); + expect(findSecretByKey).toHaveBeenCalledWith('project-1', null, 'API_KEY'); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + secret: { id: 's1', key: 'API_KEY', values: { prod: 'x' } }, + }); + }); + + it('returns 404 when the secret does not exist', async () => { + findSecretByKey.mockResolvedValue(null); + const response = await get('?project=kettu'); + expect(response.status).toBe(404); + }); + + it('maps an unknown path to 404', async () => { + getFolderByPath.mockRejectedValue(new Error('Folder not found')); + const response = await get('?project=kettu&path=nope'); + expect(response.status).toBe(404); + }); + }); + + describe('PATCH', () => { + it('requires the environment', async () => { + const response = await patch('?project=kettu', { value: 'y' }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'environment is required' }); + }); + + it('requires the value', async () => { + const response = await patch('?project=kettu', { environment: 'prod' }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'value is required' }); + }); + + it('rejects a token without the update permission', async () => { + canApiKey.mockReturnValue(false); + const response = await patch('?project=kettu', { environment: 'prod', value: 'y' }); + expect(response.status).toBe(403); + }); + + it('sets the value for the given environment', async () => { + const response = await patch('?project=kettu', { environment: 'prod', value: 'y' }); + + expect(setSecretValue).toHaveBeenCalledWith( + 'project-1', + null, + 'API_KEY', + 'prod', + 'y', + undefined, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + secret: { id: 's1', key: 'API_KEY', values: { prod: 'y' } }, + }); + }); + + it('passes an optional description through', async () => { + await patch('?project=kettu', { environment: 'prod', value: 'y', description: 'new' }); + expect(setSecretValue).toHaveBeenCalledWith( + 'project-1', + null, + 'API_KEY', + 'prod', + 'y', + 'new', + ); + }); + + it('returns 404 when the secret does not exist', async () => { + setSecretValue.mockRejectedValue(new Error('Secret not found')); + const response = await patch('?project=kettu', { environment: 'prod', value: 'y' }); + expect(response.status).toBe(404); + }); + }); + + describe('DELETE', () => { + it('rejects a token without the delete permission', async () => { + canApiKey.mockReturnValue(false); + const response = await del('?project=kettu'); + expect(response.status).toBe(403); + }); + + it('deletes the secret at the root path by default', async () => { + const response = await del('?project=kettu'); + expect(deleteSecretByKey).toHaveBeenCalledWith('project-1', null, 'API_KEY'); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ success: true }); + }); + + it('returns 404 when the secret does not exist', async () => { + deleteSecretByKey.mockRejectedValue(new Error('Secret not found')); + const response = await del('?project=kettu'); + expect(response.status).toBe(404); + }); + }); +}); diff --git a/src/routes/api/vault/secret/server.test.ts b/src/routes/api/vault/secret/server.test.ts new file mode 100644 index 0000000..baf3a94 --- /dev/null +++ b/src/routes/api/vault/secret/server.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tryFindBySlug = vi.fn(); +const canApiKey = vi.fn(); +const canSessionUser = vi.fn(); +const getFolderByPath = vi.fn(); +const createSecret = vi.fn(); + +vi.mock('$modules/projects', () => ({ + projectService: { + tryFindBySlug: (slug: string) => tryFindBySlug(slug), + }, +})); + +vi.mock('$modules/auth', () => ({ + cancanService: { + canApiKey: (...args: unknown[]) => canApiKey(...args), + canSessionUser: (...args: unknown[]) => canSessionUser(...args), + }, +})); + +vi.mock('$modules/vault', () => ({ + vaultService: { + getFolderByPath: (...args: unknown[]) => getFolderByPath(...args), + createSecret: (...args: unknown[]) => createSecret(...args), + }, +})); + +const { POST } = await import('./+server'); + +const project = { + id: 'project-1', + slug: 'kettu', + organization: { id: 'org-1', slug: 'kettu-org' }, +}; + +const apiKey = { + id: 'key-1', + name: 'ci', + keyPrefix: 'gvs_', + projectId: 'project-1', + organizationId: 'org-1', + userId: null, + role: null, +}; + +function request(body: Record, locals: Record = { apiKey }) { + return POST({ + request: { json: () => Promise.resolve(body) }, + locals: { logger: { warn: vi.fn() }, ...locals }, + } as never); +} + +describe('POST /api/vault/secret', () => { + beforeEach(() => { + vi.clearAllMocks(); + tryFindBySlug.mockResolvedValue(project); + canApiKey.mockReturnValue(true); + canSessionUser.mockResolvedValue(true); + getFolderByPath.mockResolvedValue(null); + createSecret.mockResolvedValue({ id: 's1', key: 'API_KEY', values: {} }); + }); + + it('requires the project slug', async () => { + const response = await request({ key: 'API_KEY' }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'project is required' }); + }); + + it('requires the key', async () => { + const response = await request({ project: 'kettu' }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'key is required' }); + }); + + it('returns 404 when the project does not exist', async () => { + tryFindBySlug.mockResolvedValue(null); + const response = await request({ project: 'missing', key: 'API_KEY' }); + expect(response.status).toBe(404); + }); + + it('rejects a token whose organization does not own the project', async () => { + const response = await request( + { project: 'kettu', key: 'API_KEY' }, + { apiKey: { ...apiKey, organizationId: 'org-2' } }, + ); + expect(response.status).toBe(403); + expect(createSecret).not.toHaveBeenCalled(); + }); + + it('rejects a token without the create permission', async () => { + canApiKey.mockReturnValue(false); + const response = await request({ project: 'kettu', key: 'API_KEY' }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: 'Forbidden' }); + }); + + it('rejects a session user without permission', async () => { + canSessionUser.mockResolvedValue(false); + const response = await request({ project: 'kettu', key: 'API_KEY' }, { user: { id: 'u1' } }); + expect(response.status).toBe(403); + }); + + it('creates the secret at the root path by default', async () => { + const response = await request({ project: 'kettu', key: 'API_KEY', values: { prod: 'x' } }); + + expect(response.status).toBe(201); + expect(getFolderByPath).toHaveBeenCalledWith('project-1', '/'); + expect(createSecret).toHaveBeenCalledWith('project-1', { + folderId: null, + key: 'API_KEY', + description: undefined, + values: { prod: 'x' }, + }); + await expect(response.json()).resolves.toEqual({ + secret: { id: 's1', key: 'API_KEY', values: {} }, + }); + }); + + it('resolves the folder from the given path', async () => { + getFolderByPath.mockResolvedValue({ id: 'folder-1', path: '/database' }); + await request({ project: 'kettu', key: 'DB_HOST', path: 'database' }); + + expect(getFolderByPath).toHaveBeenCalledWith('project-1', '/database'); + expect(createSecret).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ folderId: 'folder-1' }), + ); + }); + + it('maps an unknown path to 404', async () => { + getFolderByPath.mockRejectedValue(new Error('Folder not found')); + const response = await request({ project: 'kettu', key: 'API_KEY', path: 'nope' }); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: 'Folder not found' }); + }); + + it('maps other service errors to 400', async () => { + createSecret.mockRejectedValue(new Error('La key del secreto es obligatoria')); + const response = await request({ project: 'kettu', key: 'API_KEY' }); + expect(response.status).toBe(400); + }); +}); From c4f6429c0179ec98d1d408b0b99164c80c2ef73b Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 13 Sep 2026 13:01:06 +0200 Subject: [PATCH 11/14] fix --- src/routes/api/vault/secret/[key]/+server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/api/vault/secret/[key]/+server.ts b/src/routes/api/vault/secret/[key]/+server.ts index 3bc6c25..725a927 100644 --- a/src/routes/api/vault/secret/[key]/+server.ts +++ b/src/routes/api/vault/secret/[key]/+server.ts @@ -55,6 +55,7 @@ async function authorize( error: json( { error: 'Project does not belong to the token organization' }, { status: 403 }, + ), }; } if (!cancanService.canApiKey(apiKey, permission, context)) { From d9b04c6757ff4d1c3b94d7a12471499984c3cf62 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Sun, 13 Sep 2026 20:47:31 +0200 Subject: [PATCH 12/14] vault working export --- .../vault/application/vault.service.test.ts | 12 +++++ .../vault/application/vault.service.ts | 13 +++-- src/routes/api/vault/export/+server.ts | 18 +++---- src/routes/api/vault/export/server.test.ts | 50 ++++++++----------- src/routes/api/vault/secret/+server.ts | 4 +- src/routes/api/vault/secret/[key]/+server.ts | 9 +--- src/routes/api/vault/secret/server.test.ts | 4 +- 7 files changed, 56 insertions(+), 54 deletions(-) diff --git a/src/modules/vault/application/vault.service.test.ts b/src/modules/vault/application/vault.service.test.ts index 287ecf1..ec53119 100644 --- a/src/modules/vault/application/vault.service.test.ts +++ b/src/modules/vault/application/vault.service.test.ts @@ -24,6 +24,13 @@ class FakeVaultRepository { return this.folders.filter((folder) => folder.projectId === projectId).map(jsonRow); } + async findFolderByPath(projectId: string, path: string) { + const found = this.folders.find( + (folder) => folder.projectId === projectId && folder.path === path, + ); + return found ? jsonRow(found) : null; + } + async findFolderById(id: string) { const found = this.folders.find((folder) => folder.id === id); return found ? jsonRow(found) : null; @@ -106,6 +113,11 @@ describe('VaultService export', () => { expect(content).toBe('API_KEY=abc\nONLY_DEV=\nZONE=eu'); }); + it('normalizes alternate root paths when exporting secrets', async () => { + const content = await service.exportEnvFile('p1', 'prod', '///'); + expect(content).toBe('API_KEY=abc\nONLY_DEV=\nZONE=eu'); + }); + it('exports secrets of a folder path', async () => { const content = await service.exportEnvFile('p1', 'prod', '/database'); expect(content).toBe('DB_HOST=localhost'); diff --git a/src/modules/vault/application/vault.service.ts b/src/modules/vault/application/vault.service.ts index 02ced2f..e86c2d0 100644 --- a/src/modules/vault/application/vault.service.ts +++ b/src/modules/vault/application/vault.service.ts @@ -175,8 +175,9 @@ export class VaultService { } async getFolderByPath(projectId: string, path: string) { - if (path === '/') return null; - const folder = await this.repository.findFolderByPath(projectId, path); + const normalizedPath = this.normalizeFolderPath(path); + if (normalizedPath === '/') return null; + const folder = await this.repository.findFolderByPath(projectId, normalizedPath); if (!folder) throw new Error('Folder not found'); return folder.toJson(); } @@ -206,9 +207,8 @@ export class VaultService { const environment = await this.repository.findEnvironmentBySlug(projectId, environmentSlug); if (!environment) throw new Error('Environment not found'); + const targetFolder = await this.getFolderByPath(projectId, path); const folders = (await this.repository.listFolders(projectId)).map((folder) => folder.toJson()); - const targetFolder = path === '/' ? null : folders.find((folder) => folder.path === path); - if (path !== '/' && !targetFolder) throw new Error('Folder not found'); const scope = this.collectExportFolderIds(targetFolder?.id ?? null, folders); const secrets = (await this.repository.listSecrets(projectId)).map((secret) => secret.toJson()); @@ -248,6 +248,11 @@ export class VaultService { return scope; } + private normalizeFolderPath(path: string) { + const segments = path.split('/').filter(Boolean); + return segments.length === 0 ? '/' : `/${segments.join('/')}`; + } + async importEnvFile( projectId: string, environmentSlug: string, diff --git a/src/routes/api/vault/export/+server.ts b/src/routes/api/vault/export/+server.ts index 1289d5e..9ca0f86 100644 --- a/src/routes/api/vault/export/+server.ts +++ b/src/routes/api/vault/export/+server.ts @@ -19,13 +19,13 @@ function errorMessage(err: unknown) { // environment. The identity's organization must own the project, whatever its permissions are. export async function GET({ url, locals }) { const log = locals.logger; - const projectSlug = url.searchParams.get('project')?.trim(); + const projectId = url.searchParams.get('projectId')?.trim(); const environmentSlug = url.searchParams.get('env')?.trim(); const format = (url.searchParams.get('format')?.trim() || 'env') as ExportFormat; const path = normalizePath(url.searchParams.get('path')); - if (!projectSlug) { - return json({ error: 'project is required' }, { status: 400 }); + if (!projectId) { + return json({ error: 'projectId is required' }, { status: 400 }); } if (!environmentSlug) { return json({ error: 'env is required' }, { status: 400 }); @@ -34,7 +34,7 @@ export async function GET({ url, locals }) { return json({ error: "format must be 'env' or 'json'" }, { status: 400 }); } - const project = await projectService.tryFindBySlug(projectSlug); + const project = await projectService.getProject(projectId).catch(() => null); if (!project) { return json({ error: 'Project not found' }, { status: 404 }); } @@ -48,16 +48,16 @@ export async function GET({ url, locals }) { }; if (apiKey) { - if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { - return json({ error: 'Project does not belong to the token organization' }, { status: 403 }); + if (apiKey.projectId !== project.id) { + return json({ error: 'Project mismatch', projectId: apiKey.projectId, project }, { status: 403 }); } if (!cancanService.canApiKey(apiKey, 'project:vault:secrets:export', context)) { - return json({ error: 'Forbidden' }, { status: 403 }); + return json({ error: 'Permission denied' }, { status: 403 }); } } else if ( !(await cancanService.canSessionUser(locals.user, 'project:vault:secrets:export', context)) ) { - return json({ error: 'Forbidden' }, { status: 403 }); + return json({ error: 'User does not have permission' }, { status: 403 }); } try { @@ -77,7 +77,7 @@ export async function GET({ url, locals }) { }, }); } catch (err) { - log?.warn({ err, projectSlug, environmentSlug, path }, '[vault] export failed'); + log?.warn({ err, projectId, environmentSlug, path }, '[vault] export failed'); return json({ error: errorMessage(err) }, { status: 400 }); } } diff --git a/src/routes/api/vault/export/server.test.ts b/src/routes/api/vault/export/server.test.ts index bb39b7a..68214fe 100644 --- a/src/routes/api/vault/export/server.test.ts +++ b/src/routes/api/vault/export/server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const tryFindBySlug = vi.fn(); +const getProject = vi.fn(); const canApiKey = vi.fn(); const canSessionUser = vi.fn(); const exportEnvFile = vi.fn(); @@ -8,7 +8,7 @@ const exportJsonFile = vi.fn(); vi.mock('$modules/projects', () => ({ projectService: { - tryFindBySlug: (slug: string) => tryFindBySlug(slug), + getProject: (id: string) => getProject(id), }, })); @@ -54,65 +54,55 @@ function request(query: string, locals: Record = { apiKey }) { describe('GET /api/vault/export', () => { beforeEach(() => { vi.clearAllMocks(); - tryFindBySlug.mockResolvedValue(project); + getProject.mockResolvedValue(project); canApiKey.mockReturnValue(true); canSessionUser.mockResolvedValue(true); exportEnvFile.mockResolvedValue('API_KEY=abc\nDB_HOST=localhost'); exportJsonFile.mockResolvedValue('{\n "API_KEY": "abc"\n}'); }); - it('requires the project slug', async () => { + it('requires the project ID', async () => { const response = await request('?env=prod'); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: 'project is required' }); + await expect(response.json()).resolves.toEqual({ error: 'projectId is required' }); }); it('requires the environment', async () => { - const response = await request('?project=kettu'); + const response = await request('?projectId=project-1'); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'env is required' }); }); it('rejects unsupported formats', async () => { - const response = await request('?project=kettu&env=prod&format=yaml'); + const response = await request('?projectId=project-1&env=prod&format=yaml'); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: "format must be 'env' or 'json'" }); }); it('returns 404 when the project does not exist', async () => { - tryFindBySlug.mockResolvedValue(null); - const response = await request('?project=missing&env=prod'); + getProject.mockResolvedValue(null); + const response = await request('?projectId=missing&env=prod'); expect(response.status).toBe(404); }); - it('rejects a token whose organization does not own the project', async () => { - const response = await request('?project=kettu&env=prod', { - apiKey: { ...apiKey, organizationId: 'org-2' }, - }); - expect(response.status).toBe(403); - await expect(response.json()).resolves.toEqual({ - error: 'Project does not belong to the token organization', - }); - expect(exportEnvFile).not.toHaveBeenCalled(); - }); - - it('rejects a token without organization', async () => { - const response = await request('?project=kettu&env=prod', { - apiKey: { ...apiKey, organizationId: null }, + it('rejects an API key from another project', async () => { + const response = await request('?projectId=project-1&env=prod', { + apiKey: { ...apiKey, projectId: 'project-2' }, }); expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: 'Forbidden' }); expect(exportEnvFile).not.toHaveBeenCalled(); }); it('rejects a token without the read permission', async () => { canApiKey.mockReturnValue(false); - const response = await request('?project=kettu&env=prod'); + const response = await request('?projectId=project-1&env=prod'); expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: 'Forbidden' }); }); it('exports the env format by default', async () => { - const response = await request('?project=kettu&env=prod'); + const response = await request('?projectId=project-1&env=prod'); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8'); @@ -124,7 +114,7 @@ describe('GET /api/vault/export', () => { }); it('exports the json format', async () => { - const response = await request('?project=kettu&env=prod&format=json&path=/database/creds'); + const response = await request('?projectId=project-1&env=prod&format=json&path=/database/creds'); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8'); @@ -136,12 +126,12 @@ describe('GET /api/vault/export', () => { }); it('normalizes the requested path', async () => { - await request('?project=kettu&env=prod&path=database//creds/'); + await request('?projectId=project-1&env=prod&path=database//creds/'); expect(exportEnvFile).toHaveBeenCalledWith('project-1', 'prod', '/database/creds'); }); it('authorizes a session user when no api key is present', async () => { - const response = await request('?project=kettu&env=prod', { user: { id: 'user-1' } }); + const response = await request('?projectId=project-1&env=prod', { user: { id: 'user-1' } }); expect(response.status).toBe(200); expect(canSessionUser).toHaveBeenCalledWith({ id: 'user-1' }, 'project:vault:secrets:export', { @@ -153,13 +143,13 @@ describe('GET /api/vault/export', () => { it('rejects a session user without permission', async () => { canSessionUser.mockResolvedValue(false); - const response = await request('?project=kettu&env=prod', { user: { id: 'user-1' } }); + const response = await request('?projectId=project-1&env=prod', { user: { id: 'user-1' } }); expect(response.status).toBe(403); }); it('maps service errors to 400', async () => { exportEnvFile.mockRejectedValue(new Error('Environment not found')); - const response = await request('?project=kettu&env=nope'); + const response = await request('?projectId=project-1&env=nope'); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'Environment not found' }); }); diff --git a/src/routes/api/vault/secret/+server.ts b/src/routes/api/vault/secret/+server.ts index 914b667..72b705c 100644 --- a/src/routes/api/vault/secret/+server.ts +++ b/src/routes/api/vault/secret/+server.ts @@ -48,8 +48,8 @@ export async function POST({ request, locals }) { }; if (apiKey) { - if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { - return json({ error: 'Project does not belong to the token organization' }, { status: 403 }); + if (apiKey.projectId !== project.id) { + return json({ error: 'Forbidden' }, { status: 403 }); } if (!cancanService.canApiKey(apiKey, 'project:vault:secrets:create', context)) { return json({ error: 'Forbidden' }, { status: 403 }); diff --git a/src/routes/api/vault/secret/[key]/+server.ts b/src/routes/api/vault/secret/[key]/+server.ts index 725a927..c617928 100644 --- a/src/routes/api/vault/secret/[key]/+server.ts +++ b/src/routes/api/vault/secret/[key]/+server.ts @@ -50,13 +50,8 @@ async function authorize( }; if (apiKey) { - if (!apiKey.organizationId || apiKey.organizationId !== organizationId) { - return { - error: json( - { error: 'Project does not belong to the token organization' }, - { status: 403 }, - ), - }; + if (apiKey.projectId !== project.id) { + return { error: json({ error: 'Forbidden' }, { status: 403 }) }; } if (!cancanService.canApiKey(apiKey, permission, context)) { return { error: json({ error: 'Forbidden' }, { status: 403 }) }; diff --git a/src/routes/api/vault/secret/server.test.ts b/src/routes/api/vault/secret/server.test.ts index baf3a94..8bf3f69 100644 --- a/src/routes/api/vault/secret/server.test.ts +++ b/src/routes/api/vault/secret/server.test.ts @@ -79,10 +79,10 @@ describe('POST /api/vault/secret', () => { expect(response.status).toBe(404); }); - it('rejects a token whose organization does not own the project', async () => { + it('rejects an API key from another project', async () => { const response = await request( { project: 'kettu', key: 'API_KEY' }, - { apiKey: { ...apiKey, organizationId: 'org-2' } }, + { apiKey: { ...apiKey, projectId: 'project-2' } }, ); expect(response.status).toBe(403); expect(createSecret).not.toHaveBeenCalled(); From 3fa5ce867b28b6342475ea6baf9ad7d8d02811a7 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 14 Sep 2026 11:32:30 +0200 Subject: [PATCH 13/14] fixed endpoints to comands --- src/routes/api/vault/secret/+server.ts | 12 +++---- src/routes/api/vault/secret/[key]/+server.ts | 13 +++---- .../api/vault/secret/[key]/server.test.ts | 36 +++++++++---------- src/routes/api/vault/secret/server.test.ts | 30 ++++++++-------- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/src/routes/api/vault/secret/+server.ts b/src/routes/api/vault/secret/+server.ts index 72b705c..3435167 100644 --- a/src/routes/api/vault/secret/+server.ts +++ b/src/routes/api/vault/secret/+server.ts @@ -4,7 +4,7 @@ import { projectService } from '$modules/projects'; import { vaultService } from '$modules/vault'; type CreateSecretBody = { - project?: string; + projectId?: string; path?: string; key?: string; description?: string; @@ -24,17 +24,17 @@ function normalizePath(path: string | undefined) { export async function POST({ request, locals }) { const log = locals.logger; const body = (await request.json().catch(() => ({}))) as CreateSecretBody; - const projectSlug = body.project?.trim(); + const projectId = body.projectId?.trim(); const path = normalizePath(body.path); - if (!projectSlug) { - return json({ error: 'project is required' }, { status: 400 }); + if (!projectId) { + return json({ error: 'projectId is required' }, { status: 400 }); } if (!body.key?.trim()) { return json({ error: 'key is required' }, { status: 400 }); } - const project = await projectService.tryFindBySlug(projectSlug); + const project = await projectService.getProject(projectId).catch(() => null); if (!project) { return json({ error: 'Project not found' }, { status: 404 }); } @@ -70,7 +70,7 @@ export async function POST({ request, locals }) { }); return json({ secret }, { status: 201 }); } catch (err) { - log?.warn({ err, projectSlug, key: body.key, path }, '[vault] create secret failed'); + log?.warn({ err, projectId, key: body.key, path }, '[vault] create secret failed'); return json( { error: errorMessage(err) }, { status: err instanceof Error && err.message === 'Folder not found' ? 404 : 400 }, diff --git a/src/routes/api/vault/secret/[key]/+server.ts b/src/routes/api/vault/secret/[key]/+server.ts index c617928..b8b5c7e 100644 --- a/src/routes/api/vault/secret/[key]/+server.ts +++ b/src/routes/api/vault/secret/[key]/+server.ts @@ -24,19 +24,19 @@ function normalizePath(path: string | null) { return segments.length === 0 ? '/' : `/${segments.join('/')}`; } -// resolves the project from the `project` query param and checks the given permission, +// resolves the project from the `projectId` query param and checks the given permission, // for either a project-scoped API key or a session user async function authorize( url: URL, locals: App.Locals, permission: 'project:vault:secrets:read' | 'project:vault:secrets:update' | 'project:vault:secrets:delete', ) { - const projectSlug = url.searchParams.get('project')?.trim(); - if (!projectSlug) { - return { error: json({ error: 'project is required' }, { status: 400 }) }; + const projectId = url.searchParams.get('projectId')?.trim(); + if (!projectId) { + return { error: json({ error: 'projectId is required' }, { status: 400 }) }; } - const project = await projectService.tryFindBySlug(projectSlug); + const project = await projectService.getProject(projectId).catch(() => null); if (!project) { return { error: json({ error: 'Project not found' }, { status: 404 }) }; } @@ -107,7 +107,8 @@ export async function PATCH({ params, url, request, locals }) { body.value, body.description, ); - return json({ secret }); + const { values: _, ...secretWithoutValues } = secret; + return json({ secret: { ...secretWithoutValues, value: body.value } }); } catch (err) { locals.logger?.warn({ err, key: params.key, path }, '[vault] update secret failed'); return json({ error: errorMessage(err) }, { status: notFoundStatus(err) }); diff --git a/src/routes/api/vault/secret/[key]/server.test.ts b/src/routes/api/vault/secret/[key]/server.test.ts index 4827e5b..2880709 100644 --- a/src/routes/api/vault/secret/[key]/server.test.ts +++ b/src/routes/api/vault/secret/[key]/server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const tryFindBySlug = vi.fn(); +const getProject = vi.fn(); const canApiKey = vi.fn(); const canSessionUser = vi.fn(); const getFolderByPath = vi.fn(); @@ -10,7 +10,7 @@ const deleteSecretByKey = vi.fn(); vi.mock('$modules/projects', () => ({ projectService: { - tryFindBySlug: (slug: string) => tryFindBySlug(slug), + getProject: (id: string) => getProject(id), }, })); @@ -80,12 +80,12 @@ function del(query: string, overrides?: Record) { describe('/api/vault/secret/[key]', () => { beforeEach(() => { vi.clearAllMocks(); - tryFindBySlug.mockResolvedValue(project); + getProject.mockResolvedValue(project); canApiKey.mockReturnValue(true); canSessionUser.mockResolvedValue(true); getFolderByPath.mockResolvedValue(null); findSecretByKey.mockResolvedValue({ id: 's1', key: 'API_KEY', values: { prod: 'x' } }); - setSecretValue.mockResolvedValue({ id: 's1', key: 'API_KEY', values: { prod: 'y' } }); + setSecretValue.mockResolvedValue({ id: 's1', key: 'API_KEY', values: { dev: 'x', prod: 'y' } }); deleteSecretByKey.mockResolvedValue(undefined); }); @@ -97,12 +97,12 @@ describe('/api/vault/secret/[key]', () => { it('rejects a token without the read permission', async () => { canApiKey.mockReturnValue(false); - const response = await get('?project=kettu'); + const response = await get('?projectId=project-1'); expect(response.status).toBe(403); }); it('returns the secret at the root path by default', async () => { - const response = await get('?project=kettu'); + const response = await get('?projectId=project-1'); expect(getFolderByPath).toHaveBeenCalledWith('project-1', '/'); expect(findSecretByKey).toHaveBeenCalledWith('project-1', null, 'API_KEY'); expect(response.status).toBe(200); @@ -113,38 +113,38 @@ describe('/api/vault/secret/[key]', () => { it('returns 404 when the secret does not exist', async () => { findSecretByKey.mockResolvedValue(null); - const response = await get('?project=kettu'); + const response = await get('?projectId=project-1'); expect(response.status).toBe(404); }); it('maps an unknown path to 404', async () => { getFolderByPath.mockRejectedValue(new Error('Folder not found')); - const response = await get('?project=kettu&path=nope'); + const response = await get('?projectId=project-1&path=nope'); expect(response.status).toBe(404); }); }); describe('PATCH', () => { it('requires the environment', async () => { - const response = await patch('?project=kettu', { value: 'y' }); + const response = await patch('?projectId=project-1', { value: 'y' }); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'environment is required' }); }); it('requires the value', async () => { - const response = await patch('?project=kettu', { environment: 'prod' }); + const response = await patch('?projectId=project-1', { environment: 'prod' }); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'value is required' }); }); it('rejects a token without the update permission', async () => { canApiKey.mockReturnValue(false); - const response = await patch('?project=kettu', { environment: 'prod', value: 'y' }); + const response = await patch('?projectId=project-1', { environment: 'prod', value: 'y' }); expect(response.status).toBe(403); }); it('sets the value for the given environment', async () => { - const response = await patch('?project=kettu', { environment: 'prod', value: 'y' }); + const response = await patch('?projectId=project-1', { environment: 'prod', value: 'y' }); expect(setSecretValue).toHaveBeenCalledWith( 'project-1', @@ -156,12 +156,12 @@ describe('/api/vault/secret/[key]', () => { ); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ - secret: { id: 's1', key: 'API_KEY', values: { prod: 'y' } }, + secret: { id: 's1', key: 'API_KEY', value: 'y' }, }); }); it('passes an optional description through', async () => { - await patch('?project=kettu', { environment: 'prod', value: 'y', description: 'new' }); + await patch('?projectId=project-1', { environment: 'prod', value: 'y', description: 'new' }); expect(setSecretValue).toHaveBeenCalledWith( 'project-1', null, @@ -174,7 +174,7 @@ describe('/api/vault/secret/[key]', () => { it('returns 404 when the secret does not exist', async () => { setSecretValue.mockRejectedValue(new Error('Secret not found')); - const response = await patch('?project=kettu', { environment: 'prod', value: 'y' }); + const response = await patch('?projectId=project-1', { environment: 'prod', value: 'y' }); expect(response.status).toBe(404); }); }); @@ -182,12 +182,12 @@ describe('/api/vault/secret/[key]', () => { describe('DELETE', () => { it('rejects a token without the delete permission', async () => { canApiKey.mockReturnValue(false); - const response = await del('?project=kettu'); + const response = await del('?projectId=project-1'); expect(response.status).toBe(403); }); it('deletes the secret at the root path by default', async () => { - const response = await del('?project=kettu'); + const response = await del('?projectId=project-1'); expect(deleteSecretByKey).toHaveBeenCalledWith('project-1', null, 'API_KEY'); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ success: true }); @@ -195,7 +195,7 @@ describe('/api/vault/secret/[key]', () => { it('returns 404 when the secret does not exist', async () => { deleteSecretByKey.mockRejectedValue(new Error('Secret not found')); - const response = await del('?project=kettu'); + const response = await del('?projectId=project-1'); expect(response.status).toBe(404); }); }); diff --git a/src/routes/api/vault/secret/server.test.ts b/src/routes/api/vault/secret/server.test.ts index 8bf3f69..a2dee87 100644 --- a/src/routes/api/vault/secret/server.test.ts +++ b/src/routes/api/vault/secret/server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const tryFindBySlug = vi.fn(); +const getProject = vi.fn(); const canApiKey = vi.fn(); const canSessionUser = vi.fn(); const getFolderByPath = vi.fn(); @@ -8,7 +8,7 @@ const createSecret = vi.fn(); vi.mock('$modules/projects', () => ({ projectService: { - tryFindBySlug: (slug: string) => tryFindBySlug(slug), + getProject: (id: string) => getProject(id), }, })); @@ -54,34 +54,34 @@ function request(body: Record, locals: Record describe('POST /api/vault/secret', () => { beforeEach(() => { vi.clearAllMocks(); - tryFindBySlug.mockResolvedValue(project); + getProject.mockResolvedValue(project); canApiKey.mockReturnValue(true); canSessionUser.mockResolvedValue(true); getFolderByPath.mockResolvedValue(null); createSecret.mockResolvedValue({ id: 's1', key: 'API_KEY', values: {} }); }); - it('requires the project slug', async () => { + it('requires the project ID', async () => { const response = await request({ key: 'API_KEY' }); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: 'project is required' }); + await expect(response.json()).resolves.toEqual({ error: 'projectId is required' }); }); it('requires the key', async () => { - const response = await request({ project: 'kettu' }); + const response = await request({ projectId: 'project-1' }); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'key is required' }); }); it('returns 404 when the project does not exist', async () => { - tryFindBySlug.mockResolvedValue(null); - const response = await request({ project: 'missing', key: 'API_KEY' }); + getProject.mockResolvedValue(null); + const response = await request({ projectId: 'missing', key: 'API_KEY' }); expect(response.status).toBe(404); }); it('rejects an API key from another project', async () => { const response = await request( - { project: 'kettu', key: 'API_KEY' }, + { projectId: 'project-1', key: 'API_KEY' }, { apiKey: { ...apiKey, projectId: 'project-2' } }, ); expect(response.status).toBe(403); @@ -90,19 +90,19 @@ describe('POST /api/vault/secret', () => { it('rejects a token without the create permission', async () => { canApiKey.mockReturnValue(false); - const response = await request({ project: 'kettu', key: 'API_KEY' }); + const response = await request({ projectId: 'project-1', key: 'API_KEY' }); expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ error: 'Forbidden' }); }); it('rejects a session user without permission', async () => { canSessionUser.mockResolvedValue(false); - const response = await request({ project: 'kettu', key: 'API_KEY' }, { user: { id: 'u1' } }); + const response = await request({ projectId: 'project-1', key: 'API_KEY' }, { user: { id: 'u1' } }); expect(response.status).toBe(403); }); it('creates the secret at the root path by default', async () => { - const response = await request({ project: 'kettu', key: 'API_KEY', values: { prod: 'x' } }); + const response = await request({ projectId: 'project-1', key: 'API_KEY', values: { prod: 'x' } }); expect(response.status).toBe(201); expect(getFolderByPath).toHaveBeenCalledWith('project-1', '/'); @@ -119,7 +119,7 @@ describe('POST /api/vault/secret', () => { it('resolves the folder from the given path', async () => { getFolderByPath.mockResolvedValue({ id: 'folder-1', path: '/database' }); - await request({ project: 'kettu', key: 'DB_HOST', path: 'database' }); + await request({ projectId: 'project-1', key: 'DB_HOST', path: 'database' }); expect(getFolderByPath).toHaveBeenCalledWith('project-1', '/database'); expect(createSecret).toHaveBeenCalledWith( @@ -130,14 +130,14 @@ describe('POST /api/vault/secret', () => { it('maps an unknown path to 404', async () => { getFolderByPath.mockRejectedValue(new Error('Folder not found')); - const response = await request({ project: 'kettu', key: 'API_KEY', path: 'nope' }); + const response = await request({ projectId: 'project-1', key: 'API_KEY', path: 'nope' }); expect(response.status).toBe(404); await expect(response.json()).resolves.toEqual({ error: 'Folder not found' }); }); it('maps other service errors to 400', async () => { createSecret.mockRejectedValue(new Error('La key del secreto es obligatoria')); - const response = await request({ project: 'kettu', key: 'API_KEY' }); + const response = await request({ projectId: 'project-1', key: 'API_KEY' }); expect(response.status).toBe(400); }); }); From a0ab2515163a73b477d89e9cd47acbf25a374f7b Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 14 Sep 2026 11:34:31 +0200 Subject: [PATCH 14/14] 1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33ed49f..ce3bfb4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gitops", "private": true, - "version": "1.2.2", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite dev",