From 96d7dd6aecda5af70b0d5d2c8c95a626416918a3 Mon Sep 17 00:00:00 2001 From: gitops Date: Thu, 27 Aug 2026 16:18:09 +0200 Subject: [PATCH 1/5] cluster audit --- src/lib/components/AppSidebar.svelte | 1 + src/lib/components/AuditFieldValue.svelte | 50 +++ src/lib/components/AuditLog.svelte | 307 ++++++++++++++++++ src/lib/components/audit-summary.ts | 191 +++++++++++ .../audit/application/audit.service.ts | 16 + src/modules/audit/index.ts | 4 + .../cluster-settings/audit/+page.server.ts | 26 ++ .../cluster-settings/audit/+page.svelte | 12 + 8 files changed, 607 insertions(+) create mode 100644 src/lib/components/AuditFieldValue.svelte create mode 100644 src/lib/components/AuditLog.svelte create mode 100644 src/lib/components/audit-summary.ts create mode 100644 src/modules/audit/application/audit.service.ts create mode 100644 src/modules/audit/index.ts create mode 100644 src/routes/cluster-settings/audit/+page.server.ts create mode 100644 src/routes/cluster-settings/audit/+page.svelte diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index 18d5c20..1c26dd7 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -242,6 +242,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..ab38779 --- /dev/null +++ b/src/lib/components/AuditLog.svelte @@ -0,0 +1,307 @@ + + + + {title} + + +
+
+

{title}

+

{description}

+
+ +
+
+ + +
+
+ + {#if filteredEvents.length === 0} +
+ {events.length === 0 ? 'No audit events found.' : 'No audit events match your search.'} +
+ {:else} +
+
+ + + + + + + + + + + + + {#each filteredEvents as event (event.commitHash + event.reason)} + + + + + + + + + {/each} + +
DateActionEntityAuthorCommitActions
{formatDate(event.timestamp)} + + {event.action} + + {event.entity ?? '-'}{event.author}{event.commitHash.slice(0, 7)} + {#if event.entity} +
+ + + +
+ {/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/modules/audit/application/audit.service.ts b/src/modules/audit/application/audit.service.ts new file mode 100644 index 0000000..1dfc43b --- /dev/null +++ b/src/modules/audit/application/audit.service.ts @@ -0,0 +1,16 @@ +import { getGitDb } from '$lib/server/gitdb'; +import type { AuditEvent, AuditQueryOptions, AuditQueryResult, EntityRowChange } from '@getgitops/gitdb'; + +export class AuditService { + async listEvents(options?: AuditQueryOptions): Promise { + const gitdb = getGitDb(); + return gitdb.auditLog(options); + } + + async getEntityDiff(commitHash: string, entity: string): Promise { + const gitdb = getGitDb(); + return gitdb.entityDiff(commitHash, entity); + } +} + +export type { AuditEvent, AuditQueryOptions, AuditQueryResult, EntityRowChange }; diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..19e82ae --- /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, AuditQueryOptions, AuditQueryResult, 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..70b46c7 --- /dev/null +++ b/src/routes/cluster-settings/audit/+page.server.ts @@ -0,0 +1,26 @@ +import { fail } from '@sveltejs/kit'; +import { auditService } from '$modules/audit'; + +export async function load() { + const { events } = await auditService.listEvents(); + return { events }; +} + +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..44ddc14 --- /dev/null +++ b/src/routes/cluster-settings/audit/+page.svelte @@ -0,0 +1,12 @@ + + + From 537cc5a36ae5c58d5bc17e889a5d496d401292d3 Mon Sep 17 00:00:00 2001 From: gitops Date: Thu, 27 Aug 2026 16:44:56 +0200 Subject: [PATCH 2/5] audit working --- src/lib/components/AppSidebar.svelte | 5 +++ src/lib/components/AuditLog.svelte | 40 +++++++++++++------ src/lib/server/gitdb/config.ts | 32 +++++++++++++++ src/lib/server/gitdb/index.ts | 18 ++++++++- .../audit/application/audit.service.ts | 11 ++++- .../cluster-settings/audit/+page.server.ts | 2 +- .../cluster-settings/audit/+page.svelte | 3 +- .../org/[org]/settings/audit/+page.server.ts | 27 +++++++++++++ .../org/[org]/settings/audit/+page.svelte | 13 ++++++ 9 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 src/routes/org/[org]/settings/audit/+page.server.ts create mode 100644 src/routes/org/[org]/settings/audit/+page.svelte diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index 1c26dd7..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, + }, ], }, ] diff --git a/src/lib/components/AuditLog.svelte b/src/lib/components/AuditLog.svelte index ab38779..05eb49c 100644 --- a/src/lib/components/AuditLog.svelte +++ b/src/lib/components/AuditLog.svelte @@ -1,5 +1,5 @@ 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..a8eec44 --- /dev/null +++ b/src/routes/org/[org]/settings/audit/+page.server.ts @@ -0,0 +1,27 @@ +import { fail } from '@sveltejs/kit'; +import { auditService } from '$modules/audit'; + +export async function load({ parent }) { + const { organization } = await parent(); + const { events } = await auditService.listEvents({ organizationId: organization.id }); + return { events, 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..e7b80ab --- /dev/null +++ b/src/routes/org/[org]/settings/audit/+page.svelte @@ -0,0 +1,13 @@ + + + From 832636030d7893d8cf74df484f92826a6bb9c37b Mon Sep 17 00:00:00 2001 From: gitops Date: Thu, 27 Aug 2026 17:02:35 +0200 Subject: [PATCH 3/5] filters --- src/lib/components/AuditLog.svelte | 240 ++++++++++++++++-- .../audit/application/audit.service.ts | 40 ++- src/modules/audit/index.ts | 2 +- .../cluster-settings/audit/+page.server.ts | 23 +- .../cluster-settings/audit/+page.svelte | 12 +- .../org/[org]/settings/audit/+page.server.ts | 18 +- .../org/[org]/settings/audit/+page.svelte | 5 +- 7 files changed, 306 insertions(+), 34 deletions(-) diff --git a/src/lib/components/AuditLog.svelte b/src/lib/components/AuditLog.svelte index 05eb49c..a2f0831 100644 --- a/src/lib/components/AuditLog.svelte +++ b/src/lib/components/AuditLog.svelte @@ -1,6 +1,7 @@ Date: Thu, 27 Aug 2026 17:20:12 +0200 Subject: [PATCH 4/5] run as actor --- src/hooks.server.ts | 9 +++++++-- src/lib/server/gitdb/index.ts | 11 ++++++----- src/lib/server/request-context.ts | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 src/lib/server/request-context.ts 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/server/gitdb/index.ts b/src/lib/server/gitdb/index.ts index d178848..70b773c 100644 --- a/src/lib/server/gitdb/index.ts +++ b/src/lib/server/gitdb/index.ts @@ -7,6 +7,7 @@ import { resolveRepositoryWebUrl, } from './config'; import { gitDbSyncService } from './sync'; +import { getCurrentActor } from '../request-context'; let instance: GitDB | null = null; let startup: Promise | null = null; @@ -64,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; +} From e939a3bc6f4464f6408d21eb0ef22c4283aadcb7 Mon Sep 17 00:00:00 2001 From: gitops Date: Thu, 27 Aug 2026 17:24:05 +0200 Subject: [PATCH 5/5] getgitops/gitdb update --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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"