diff --git a/bun.lock b/bun.lock index 7e661e5..ea57514 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "gitvault-suite", "dependencies": { "@aws-sdk/client-s3": "^3.1119.0", - "@getgitops/gitdb": "^0.5.0", + "@getgitops/gitdb": "^0.8.0", "@google-cloud/storage": "^8.0.1", "@lucide/svelte": "^1.34.0", "chart.js": "^4.5.1", @@ -152,7 +152,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@getgitops/gitdb": ["@getgitops/gitdb@0.5.0", "", {}, "sha512-5tgn+XWtrGboPLv9dShZD6TeEvRXFf0uDXA//kH7A6LBOVkP3vuU0f3YxqcAYCYoGKo0V0xkgXk1CHSnJ2DQPg=="], + "@getgitops/gitdb": ["@getgitops/gitdb@0.8.0", "", {}, "sha512-OYeCchlR1n91UBuDA789+4zgl9i+27DzzLr6djahQlLd7haYrO+LQqaRoJQHVKBmn0SOYPsCY7R+Kc6YeGQOgA=="], "@google-cloud/paginator": ["@google-cloud/paginator@7.0.1", "", { "dependencies": { "extend": "^3.0.2" } }, "sha512-k32cWlHAF8yTgg8rciLI8mPMI6UzuJdKp53YRxISRwMFxUl2FYplvs+Mr2UHxKn0W7rXsqZnUZy73AOJFDP8iA=="], diff --git a/package.json b/package.json index b59203c..d0d765d 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1119.0", - "@getgitops/gitdb": "^0.5.0", + "@getgitops/gitdb": "^0.8.0", "@google-cloud/storage": "^8.0.1", "@lucide/svelte": "^1.34.0", "chart.js": "^4.5.1" diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 1e33c18..983b494 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,6 +4,7 @@ import { organizationService } from '$modules/organization'; import { isBootstrapCompleted, refreshBootstrapState } from '$lib/server/bootstrap'; import { startGitDb } from '$lib/server/gitdb'; import { isServerReady, markServerFailed, markServerReady } from '$lib/server/server-ready'; +import { runWithActor } from '$lib/server/request-context'; // clone, manifest, sync poll and bootstrap detection run once per process const serverReady = (async () => { @@ -73,7 +74,7 @@ export const handle: Handle = async ({ event, resolve }) => { if (!isAuthenticated) { return new Response(null, { status: 401 }); } - return resolve(event); + return runWithActor({ name: 'apikey', email: 'apikey@gitops.local' }, () => resolve(event)); } const sessionCookie = event.cookies.get('pos_session'); @@ -114,5 +115,9 @@ export const handle: Handle = async ({ event, resolve }) => { } } - return resolve(event); + const actor = { + name: currentUser.username, + email: currentUser.email || `${currentUser.username}@gitops.local`, + }; + return runWithActor(actor, () => resolve(event)); }; diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index 18d5c20..3cc9278 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -224,6 +224,11 @@ href: `/org/${organizationSlug}/settings/server-access-keys`, icon: KeyRound, }, + { + label: 'Audit', + href: `/org/${organizationSlug}/settings/audit`, + icon: ScrollText, + }, ], }, ] @@ -242,6 +247,7 @@ }, { label: 'Users', href: '/cluster-settings/users', icon: Users }, { label: 'Database', href: '/cluster-settings/database', icon: Database }, + { label: 'Audit Log', href: '/cluster-settings/audit', icon: ScrollText }, ], }, ] diff --git a/src/lib/components/AuditFieldValue.svelte b/src/lib/components/AuditFieldValue.svelte new file mode 100644 index 0000000..b10fc1b --- /dev/null +++ b/src/lib/components/AuditFieldValue.svelte @@ -0,0 +1,50 @@ + + +{#if !value} + +{:else if value.kind === 'text'} + {value.text} +{:else if value.kind === 'flags'} +
+ {#each value.flags as flag} + + {flag.label} + + {/each} +
+{:else if value.kind === 'list'} +
+ {#each value.items as item} + {@const [scope, action] = item.split(':')} + + {#if action} + {scope} + {action} + {:else} + {item} + {/if} + + {/each} +
+{:else} +
{JSON.stringify(value.json, null, 2)}
+{/if} diff --git a/src/lib/components/AuditLog.svelte b/src/lib/components/AuditLog.svelte new file mode 100644 index 0000000..a2f0831 --- /dev/null +++ b/src/lib/components/AuditLog.svelte @@ -0,0 +1,523 @@ + + + + {title} + + +
+
+

{title}

+

{description}

+
+ +
+
+ + +
+
+ + {#if showCustomRange} + + + + {:else} + + + {/if} +
+ {#if organizations} + + {/if} + + + {#if hasActiveFilters} + Clear + {/if} +
+ + {#if events.length === 0} +
+ {hasActiveFilters ? 'No audit events match your filters.' : 'No audit events found.'} +
+ {:else} +
+
+ + + + + + + + + + + + + {#each events as event (event.commitHash + event.reason)} + + + + + + + + + {/each} + +
DateActionEntityAuthorCommitActions
{formatDate(event.timestamp)} + + {event.action} + + {event.entity ?? '-'}{event.author}{event.commitHash.slice(0, 7)} +
+ {#if commitBaseUrl} + + + Commit + + {/if} + {#if event.entity} +
+ + + +
+ {/if} +
+
+
+ +
+
+ + {(pagination.page - 1) * pagination.perPage + 1}–{Math.min( + pagination.page * pagination.perPage, + pagination.total, + )} of {pagination.total} + + +
+ +
+ {#if pagination.page > 1} + + + Previous + + {:else} + + + Previous + + {/if} + + Page {pagination.page} of {pagination.totalPages} + + {#if pagination.page < pagination.totalPages} + + Next + + + {:else} + + Next + + + {/if} +
+
+
+ {/if} +
+ +{#if diffModalEvent} + +
+ +
+{/if} + diff --git a/src/lib/components/audit-summary.ts b/src/lib/components/audit-summary.ts new file mode 100644 index 0000000..2960d4d --- /dev/null +++ b/src/lib/components/audit-summary.ts @@ -0,0 +1,191 @@ +/** Human-readable summaries for gitdb entity row changes, used by the audit log UI. */ + +export type EntityRowChange = + | { type: 'added'; row: Record } + | { type: 'removed'; row: Record } + | { type: 'modified'; before: Record; after: Record; changedFields: string[] }; + +export type FieldValue = + | { kind: 'text'; text: string } + | { kind: 'flags'; flags: { label: string; value: boolean }[] } + | { kind: 'list'; items: string[] } + | { kind: 'json'; json: unknown }; + +export type ListDiffEntry = { item: string; status: 'added' | 'removed' | 'unchanged' }; + +export type FieldSummary = { + label: string; + before?: FieldValue; + after?: FieldValue; +}; + +export type ChangeSummary = { + title: string; + fields: FieldSummary[]; +}; + +type EntityLabel = { label: string; feminine: boolean }; + +const ENTITY_LABELS: Record = { + organizations: { label: 'organización', feminine: true }, + roles: { label: 'rol', feminine: false }, + users: { label: 'usuario', feminine: false }, + projects: { label: 'proyecto', feminine: false }, + user_access: { label: 'acceso de usuario', feminine: false }, +}; + +// fields that are noise in a human summary (always touched, rarely meaningful on their own) +const IGNORED_FIELDS = new Set(['id', 'createdAt', 'updatedAt']); + +const FIELD_LABELS: Record = { + id: 'ID', + name: 'Nombre', + slug: 'Slug', + description: 'Descripción', + email: 'Email', + permissions: 'Permisos', + scope: 'Alcance', + organizationId: 'Organización', + projectId: 'Proyecto', + roleId: 'Rol', + userId: 'Usuario', + isActive: 'Activo', + modules: 'Módulos', + settings: 'Configuración', + status: 'Estado', +}; + +function entityLabel(entity: string): EntityLabel { + return ENTITY_LABELS[entity] ?? { label: entity, feminine: false }; +} + +function humanizeField(field: string): string { + if (FIELD_LABELS[field]) { + return FIELD_LABELS[field]; + } + + return field + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (char) => char.toUpperCase()) + .trim(); +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + if (typeof value === 'boolean') { + return value ? 'Sí' : 'No'; + } + if (Array.isArray(value)) { + return value.length ? value.map((item) => formatValue(item)).join(', ') : '—'; + } + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + } + return String(value); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// { key: boolean, ... } shapes (e.g. project `modules`) render as toggle chips instead of raw JSON +function isFlagsObject(value: Record): boolean { + const values = Object.values(value); + return values.length > 0 && values.every((entry) => typeof entry === 'boolean'); +} + +/** Classifies a raw field value into how it should be rendered: plain text, flag chips, a chip list, or a JSON block. */ +export function classifyValue(value: unknown): FieldValue { + if (isPlainObject(value)) { + if (isFlagsObject(value)) { + return { + kind: 'flags', + flags: Object.entries(value).map(([key, entry]) => ({ label: humanizeField(key), value: Boolean(entry) })), + }; + } + return { kind: 'json', json: value }; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return { kind: 'text', text: '—' }; + } + if (value.every((item) => typeof item === 'string' || typeof item === 'number')) { + return { kind: 'list', items: value.map((item) => String(item)) }; + } + return { kind: 'json', json: value }; + } + + return { kind: 'text', text: formatValue(value) }; +} + +/** Diffs two string lists (e.g. role permissions) so additions/removals can be highlighted together. */ +export function diffListItems(before: string[], after: string[]): ListDiffEntry[] { + const beforeSet = new Set(before); + const afterSet = new Set(after); + const entries: ListDiffEntry[] = after.map((item) => ({ + item, + status: beforeSet.has(item) ? 'unchanged' : 'added', + })); + + for (const item of before) { + if (!afterSet.has(item)) { + entries.push({ item, status: 'removed' }); + } + } + + return entries; +} + +function displayName(row: Record | undefined): string { + if (!row) return ''; + const candidate = row.name ?? row.slug ?? row.title ?? row.email ?? row.id; + return candidate !== undefined && candidate !== null ? String(candidate) : ''; +} + +export function summarizeChange(entity: string, change: EntityRowChange): ChangeSummary { + const { label, feminine } = entityLabel(entity); + const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1); + + if (change.type === 'added') { + const name = displayName(change.row); + const newWord = feminine ? 'Nueva' : 'Nuevo'; + const fields = Object.entries(change.row) + .filter(([field]) => !IGNORED_FIELDS.has(field)) + .map(([field, value]) => ({ label: humanizeField(field), after: classifyValue(value) })); + + return { + title: `${newWord} ${label} cread${feminine ? 'a' : 'o'}${name ? `: ${name}` : ''}`, + fields, + }; + } + + if (change.type === 'removed') { + const name = displayName(change.row); + const fields = Object.entries(change.row) + .filter(([field]) => !IGNORED_FIELDS.has(field)) + .map(([field, value]) => ({ label: humanizeField(field), before: classifyValue(value) })); + + return { + title: `${capitalizedLabel} eliminad${feminine ? 'a' : 'o'}${name ? `: ${name}` : ''}`, + fields, + }; + } + + const name = displayName(change.after) || displayName(change.before); + const fields = change.changedFields + .filter((field) => !IGNORED_FIELDS.has(field)) + .map((field) => ({ + label: humanizeField(field), + before: classifyValue(change.before[field]), + after: classifyValue(change.after[field]), + })); + + return { + title: `${capitalizedLabel} actualizad${feminine ? 'a' : 'o'}${name ? `: ${name}` : ''}`, + fields, + }; +} diff --git a/src/lib/server/gitdb/config.ts b/src/lib/server/gitdb/config.ts index bb2ecfc..53a1b2e 100644 --- a/src/lib/server/gitdb/config.ts +++ b/src/lib/server/gitdb/config.ts @@ -142,3 +142,35 @@ export function buildAuthenticatedUrl(config: GitDbRepositoryConfig): string { export function redactUrl(value: string): string { return value.replace(/\/\/[^/@\s]+@/g, '//***@'); } + +/** Best-effort browsable repo URL (github/gitlab/bitbucket-style), or null when it can't be derived. */ +export function resolveRepositoryWebUrl(repositoryUrl: string): string | null { + const withoutGitSuffix = repositoryUrl.replace(/\.git$/, ''); + + // git@host:owner/repo -> https://host/owner/repo + const scpMatch = withoutGitSuffix.match(/^[\w-]+@([^:]+):(.+)$/); + if (scpMatch) { + return `https://${scpMatch[1]}/${scpMatch[2]}`; + } + + try { + const url = new URL(withoutGitSuffix); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + url.username = ''; + url.password = ''; + return url.toString().replace(/\/$/, ''); + } catch { + return null; + } +} + +/** Web URL for a specific commit in the configured repository, or null when unavailable. */ +export function resolveCommitUrl(commitHash: string): string | null { + const config = readRepositoryConfig(); + if (!config) return null; + + const webUrl = resolveRepositoryWebUrl(config.repositoryUrl); + return webUrl ? `${webUrl}/commit/${commitHash}` : null; +} diff --git a/src/lib/server/gitdb/index.ts b/src/lib/server/gitdb/index.ts index 5619008..70b773c 100644 --- a/src/lib/server/gitdb/index.ts +++ b/src/lib/server/gitdb/index.ts @@ -1,12 +1,29 @@ import { gitDb, type GitDB } from '@getgitops/gitdb'; -import { buildAuthenticatedUrl, isRepositoryConfigured, redactUrl, requireRepositoryConfig } from './config'; +import { + buildAuthenticatedUrl, + isRepositoryConfigured, + redactUrl, + requireRepositoryConfig, + resolveRepositoryWebUrl, +} from './config'; import { gitDbSyncService } from './sync'; +import { getCurrentActor } from '../request-context'; let instance: GitDB | null = null; let startup: Promise | null = null; export { isRepositoryConfigured }; +/** Browsable repo URL for building commit links, or null when it can't be derived. */ +export function getRepositoryWebUrl(): string | null { + try { + const config = requireRepositoryConfig(); + return resolveRepositoryWebUrl(config.repositoryUrl); + } catch { + return null; + } +} + /** * Initializes GitDB instance and sync state. * Runs once per process, not per request. @@ -48,11 +65,11 @@ function createClient(authorName: string, authorEmail: string): GitDB { } export function getGitDb(): GitDB { - if (instance) { - return instance; + if (!instance) { + const config = requireRepositoryConfig(); + instance = createClient(config.authorName, config.authorEmail); } - const config = requireRepositoryConfig(); - instance = createClient(config.authorName, config.authorEmail); - return instance; + const actor = getCurrentActor(); + return actor ? instance.as(actor) : instance; } \ No newline at end of file diff --git a/src/lib/server/request-context.ts b/src/lib/server/request-context.ts new file mode 100644 index 0000000..3938892 --- /dev/null +++ b/src/lib/server/request-context.ts @@ -0,0 +1,18 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export type RequestActor = { + name: string; + email: string; +}; + +const storage = new AsyncLocalStorage(); + +/** Runs `fn` with `actor` available to any `getGitDb()` call made during its execution. */ +export function runWithActor(actor: RequestActor, fn: () => T): T { + return storage.run(actor, fn); +} + +/** The actor for the request currently being handled, if one was set. */ +export function getCurrentActor(): RequestActor | null { + return storage.getStore() ?? null; +} diff --git a/src/modules/audit/application/audit.service.ts b/src/modules/audit/application/audit.service.ts new file mode 100644 index 0000000..e213254 --- /dev/null +++ b/src/modules/audit/application/audit.service.ts @@ -0,0 +1,53 @@ +import { getGitDb, getRepositoryWebUrl } from '$lib/server/gitdb'; +import type { AuditEvent, EntityRowChange } from '@getgitops/gitdb'; + +export type AuditListOptions = { + search?: string; + organizationId?: string; + dateFrom?: string; + dateTo?: string; + page?: number; + perPage?: number; +}; + +export type AuditListResult = { + events: AuditEvent[]; + total: number; + page: number; + perPage: number; + totalPages: number; +}; + +const DEFAULT_PER_PAGE = 20; +const MAX_PER_PAGE = 100; + +export class AuditService { + /** Only real insert/update/delete events with a resolved entity are audit-worthy; drop the rest. */ + async listEvents(options: AuditListOptions = {}): Promise { + const gitdb = getGitDb(); + const { search, organizationId, dateFrom, dateTo } = options; + const { events } = await gitdb.auditLog({ search, organizationId, dateFrom, dateTo }); + const filtered = events.filter((event) => event.action !== 'other' && event.entity !== null); + + const perPage = Math.min(Math.max(Math.trunc(options.perPage ?? DEFAULT_PER_PAGE), 1), MAX_PER_PAGE); + const total = filtered.length; + const totalPages = Math.max(Math.ceil(total / perPage), 1); + const page = Math.min(Math.max(Math.trunc(options.page ?? 1), 1), totalPages); + + const start = (page - 1) * perPage; + const pageEvents = filtered.slice(start, start + perPage); + + return { events: pageEvents, total, page, perPage, totalPages }; + } + + async getEntityDiff(commitHash: string, entity: string): Promise { + const gitdb = getGitDb(); + return gitdb.entityDiff(commitHash, entity); + } + + getRepositoryWebUrl(): string | null { + return getRepositoryWebUrl(); + } +} + +export type { AuditEvent, EntityRowChange }; diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..3cc8047 --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1,4 @@ +import { AuditService } from './application/audit.service'; + +export const auditService = new AuditService(); +export type { AuditEvent, AuditListOptions, AuditListResult, EntityRowChange } from './application/audit.service'; diff --git a/src/routes/cluster-settings/audit/+page.server.ts b/src/routes/cluster-settings/audit/+page.server.ts new file mode 100644 index 0000000..cb5b6ba --- /dev/null +++ b/src/routes/cluster-settings/audit/+page.server.ts @@ -0,0 +1,43 @@ +import { fail } from '@sveltejs/kit'; +import { auditService } from '$modules/audit'; +import { organizationService } from '$modules/organization'; + +export async function load({ url }) { + const [{ events, page, perPage, total, totalPages }, organizations] = await Promise.all([ + auditService.listEvents({ + search: url.searchParams.get('search') || undefined, + organizationId: url.searchParams.get('organizationId') || undefined, + dateFrom: url.searchParams.get('from') || undefined, + dateTo: url.searchParams.get('to') || undefined, + page: Number(url.searchParams.get('page')) || undefined, + perPage: Number(url.searchParams.get('perPage')) || undefined, + }), + organizationService.listOrganizations(), + ]); + + return { + events, + pagination: { page, perPage, total, totalPages }, + organizations, + commitBaseUrl: auditService.getRepositoryWebUrl(), + }; +} + +export const actions = { + async viewDiff({ request }) { + const form = await request.formData(); + const commit = String(form.get('commit') ?? ''); + const entity = String(form.get('entity') ?? ''); + + if (!commit || !entity) { + return fail(400, { error: 'commit and entity are required.' }); + } + + try { + const changes = await auditService.getEntityDiff(commit, entity); + return { success: true, changes }; + } catch (error: unknown) { + return fail(500, { error: error instanceof Error ? error.message : 'Failed to load changes.' }); + } + }, +}; diff --git a/src/routes/cluster-settings/audit/+page.svelte b/src/routes/cluster-settings/audit/+page.svelte new file mode 100644 index 0000000..91003dd --- /dev/null +++ b/src/routes/cluster-settings/audit/+page.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/routes/org/[org]/settings/audit/+page.server.ts b/src/routes/org/[org]/settings/audit/+page.server.ts new file mode 100644 index 0000000..7038645 --- /dev/null +++ b/src/routes/org/[org]/settings/audit/+page.server.ts @@ -0,0 +1,39 @@ +import { fail } from '@sveltejs/kit'; +import { auditService } from '$modules/audit'; + +export async function load({ parent, url }) { + const { organization } = await parent(); + const { events, page, perPage, total, totalPages } = await auditService.listEvents({ + organizationId: organization.id, + search: url.searchParams.get('search') || undefined, + dateFrom: url.searchParams.get('from') || undefined, + dateTo: url.searchParams.get('to') || undefined, + page: Number(url.searchParams.get('page')) || undefined, + perPage: Number(url.searchParams.get('perPage')) || undefined, + }); + + return { + events, + pagination: { page, perPage, total, totalPages }, + commitBaseUrl: auditService.getRepositoryWebUrl(), + }; +} + +export const actions = { + async viewDiff({ request }) { + const form = await request.formData(); + const commit = String(form.get('commit') ?? ''); + const entity = String(form.get('entity') ?? ''); + + if (!commit || !entity) { + return fail(400, { error: 'commit and entity are required.' }); + } + + try { + const changes = await auditService.getEntityDiff(commit, entity); + return { success: true, changes }; + } catch (error: unknown) { + return fail(500, { error: error instanceof Error ? error.message : 'Failed to load changes.' }); + } + }, +}; diff --git a/src/routes/org/[org]/settings/audit/+page.svelte b/src/routes/org/[org]/settings/audit/+page.svelte new file mode 100644 index 0000000..6215c94 --- /dev/null +++ b/src/routes/org/[org]/settings/audit/+page.svelte @@ -0,0 +1,16 @@ + + +