diff --git a/.env.example b/.env.example index 6dc5001..f9a0545 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,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/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/package.json b/package.json index 41e57f9..ce3bfb4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gitops", "private": true, - "version": "1.2.3", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src/app.html b/src/app.html index adf8bd8..e55e40b 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,9 @@ + + + %sveltekit.head% diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index d1b5819..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, @@ -110,12 +111,23 @@ { name: $_('sidebar.modules.vault'), icon: Shield, + base: `${projectBase}/vault`, items: [ { - label: $_('sidebar.modules.vault'), + label: 'Secrets', href: `${projectBase}/vault`, icon: Shield, }, + { + label: 'Integrations', + href: `${projectBase}/vault/integrations`, + icon: Plug, + }, + { + 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/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 { deserialize, enhance } from '$app/forms'; + import { goto, invalidateAll } 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, + Info, + Link2, + Plus, + Search, + Shield, + Upload, + X, + } 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; + canDeleteSecrets: boolean; + capitalizeSecrets: 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 copyEnvOpen = false; + let copySourceSlug = ''; + let dropActive = false; + let dropError = ''; + let createSecretOpen = false; + let createFolderOpen = false; + let linkFolderOpen = false; + let editingSecret: VaultSecret | null = null; + let deletingSecret: VaultSecret | null = null; + let deletingFolder: VaultFolder | 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 ? folderScopeIds(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 folderScopeIds(folder: VaultFolder): Array { + const ids: Array = [folder.id]; + + if (folder.linkedFolderId) { + const linkedFolder = data.folders.find((candidate) => candidate.id === folder.linkedFolderId); + if (linkedFolder) ids.push(...folderScopeIds(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]), + ); + } + + async function uploadEnvContent(content: string) { + const body = new FormData(); + body.set('folderId', currentFolderId ?? ''); + body.set('environmentSlug', selectedEnvironmentSlug); + body.set('content', content); + + const response = await fetch('?/importSecrets', { + method: 'POST', + headers: { 'x-sveltekit-action': 'true' }, + body, + }); + const result = deserialize(await response.text()); + + if (result.type !== 'success') { + dropError = + result.type === 'failure' && result.data?.error + ? String(result.data.error) + : 'No se pudo importar el archivo'; + return; + } + + dropError = ''; + await invalidateAll(); + flashSuccess('Secretos importados'); + } + + async function handleDrop(event: DragEvent) { + dropActive = false; + const file = event.dataTransfer?.files?.[0]; + if (!file) return; + + dropError = ''; + await uploadEnvContent(await file.text()); + } + + function handleDragLeave(event: DragEvent) { + const related = event.relatedTarget as Node | null; + if (related && (event.currentTarget as HTMLElement).contains(related)) return; + dropActive = false; + } + + async function downloadEnvFile() { + const response = await fetch('?/exportSecrets', { + method: 'POST', + headers: { 'x-sveltekit-action': 'true' }, + body: new FormData(), + }); + const result = deserialize(await response.text()); + + if (result.type !== 'success' || !result.data) { + formError = 'No se pudieron exportar los secretos'; + return; + } + + const { content, filename } = result.data as { content: string; filename: string }; + const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + } + + function normalizeKeyInput(event: Event) { + const value = (event.target as HTMLInputElement).value; + return data.capitalizeSecrets ? value.toUpperCase() : value; + } + + 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; + } + + function openCopyEnvironment() { + formError = ''; + copySourceSlug = + data.environments.find((environment) => environment.slug !== selectedEnvironmentSlug)?.slug ?? + ''; + copyEnvOpen = 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; + copyEnvOpen = false; + editingSecret = null; + deletingSecret = null; + deletingFolder = 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} + + +
    + {#if data.canDeleteSecrets} + + {/if} + +
    +
    + +

    {secret.key}

    + {#if secret.description} + + + + {secret.description} + + + + {/if} +
    +
    + + + {#if data.canManageSecrets} + + {/if} + {#if data.canDeleteSecrets} + + {/if} +
    +
    -

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

    -

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

    + + {#if data.canManageSecrets} +
    (dropActive = true)} + on:dragover|preventDefault|stopPropagation={() => (dropActive = true)} + on:dragleave|preventDefault|stopPropagation={handleDragLeave} + on:drop|preventDefault|stopPropagation={handleDrop} + class="flex min-h-40 flex-col items-center justify-center rounded-md border-2 border-dashed px-4 py-16 text-center transition {dropActive + ? 'border-slate-400 bg-slate-50' + : 'border-slate-200 bg-white'}" + > +

    + Drag and drop .env, or + +

    +

    + OR + +

    + {#if dropError} +

    {dropError}

    + {/if} +
    + {/if}
    + +{#if createSecretOpen} +
    +
    +

    Crear secreto

    + {#if formError}

    + {formError} +

    {/if} + +
    + + +
    +
    + +
    +
    +
    +{/if} + +{#if editingSecret} +
    +
    + + + {#each data.environments as environment (environment.id)} + {#if environment.slug !== selectedEnvironmentSlug} + + {/if} + {/each} +

    Detalle del secreto

    + {#if formError}

    + {formError} +

    {/if} +
    + + + +
    +
    + +
    +
    +
    +{/if} + +{#if deletingSecret} +
    +
    + +

    Borrar secreto

    +

    + Se borrara {deletingSecret.key} de + forma permanente. +

    + {#if formError}

    + {formError} +

    {/if} +
    + +
    +
    +
    +{/if} + +{#if deletingFolder} +
    +
    + +

    Borrar carpeta

    +

    + Se borrara {deletingFolder.name} + de forma permanente. Debe estar vacia. +

    + {#if formError}

    + {formError} +

    {/if} +
    + +
    +
    +
    +{/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} + +{#if copyEnvOpen} +
    +
    +

    Copiar desde otro entorno

    +

    + Copia los valores de los secretos de {currentPath} hacia + {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..5fb95e1 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts @@ -0,0 +1,265 @@ +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 settings = await vaultService.getSettings(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, + }, + ), + canDeleteSecrets: await cancanService.canSessionUser( + locals.user, + 'project:vault:secrets:delete', + { + scope: 'project', + projectId: project.id, + organizationId, + }, + ), + capitalizeSecrets: settings.capitalizeSecrets, + }; +} + +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; +} + +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', + }); + } + }, + deleteSecret: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:delete'); + const formData = await request.formData(); + await vaultService.deleteSecret(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 secreto', + }); + } + }, + deleteFolder: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:delete'); + const formData = await request.formData(); + await vaultService.deleteFolder(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 la carpeta', + }); + } + }, + 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') || params.env); + await vaultService.importEnvFile( + project.id, + environmentSlug, + folderId, + String(formData.get('content') || ''), + ); + + 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', + }); + } + }, + exportSecrets: async ({ params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:export'); + const content = await vaultService.exportEnvFile( + project.id, + params.env, + routePath(params.path), + ); + return { content, filename: `${params.slug}-${params.env}.env` }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudieron exportar los secretos', + }); + } + }, + copyEnvironment: async ({ request, params, locals }) => { + try { + const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:update'); + const formData = await request.formData(); + await vaultService.copyEnvironmentValues( + project.id, + String(formData.get('sourceEnvironment') || ''), + params.env, + String(formData.get('folderId') || '') || null, + ); + return { success: true }; + } catch (err) { + return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, { + error: err instanceof Error ? err.message : 'No se pudieron copiar 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..1e40dcc --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; + +// Environments management moved into Vault > Settings. +export async function load({ params }) { + throw redirect(302, `/org/${params.org}/projects/${params.slug}/vault/settings`); +} 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..72e26aa --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte @@ -0,0 +1,3 @@ + 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/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} diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..7ccff9a Binary files /dev/null and b/static/favicon.png differ diff --git a/static/icono_128.png b/static/icono_128.png new file mode 100644 index 0000000..6ecb1ae Binary files /dev/null and b/static/icono_128.png differ diff --git a/static/icono_258.png b/static/icono_258.png new file mode 100644 index 0000000..ffb74f7 Binary files /dev/null and b/static/icono_258.png differ diff --git a/static/icono_512.png b/static/icono_512.png new file mode 100644 index 0000000..412f6bf Binary files /dev/null and b/static/icono_512.png differ 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 diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..478a3b2 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: