From ca126abe91f68943de575086aead3b218da6173c Mon Sep 17 00:00:00 2001 From: tianzhou Date: Thu, 25 Jun 2026 05:07:31 -0700 Subject: [PATCH 1/3] feat: surface in-memory audit log entries --- docs/configuration/config.mdx | 16 +++++ docs/features/audit-log.mdx | 15 ++++- pgconsole.example.toml | 10 +++ proto/query.proto | 27 ++++++++ server/lib/audit.ts | 54 +++++++++++++++- server/lib/config.ts | 29 ++++++++- server/services/query-service.ts | 32 ++++++++- src/hooks/useQuery.ts | 13 ++++ src/pages/AuditLog.tsx | 60 ++++++++++++++++- tests/audit.test.ts | 107 +++++++++++++++++++++++++++++++ 10 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 tests/audit.test.ts diff --git a/docs/configuration/config.mdx b/docs/configuration/config.mdx index 06b25b3..e4b8a5d 100644 --- a/docs/configuration/config.mdx +++ b/docs/configuration/config.mdx @@ -33,6 +33,9 @@ text = "System maintenance scheduled for Sunday 2am UTC" link = "https://status.example.com" color = "#7c3aed" +[general.audit] +retention_days = 30 + [branding] logo = "https://example.com/your-logo.svg" logo_link = "https://internal.example.com" @@ -164,6 +167,19 @@ link = "https://status.example.com" color = "#7c3aed" ``` +## Audit Log + +Controls in-memory retention for the [Audit Log](/features/audit-log) page. Audit events are always emitted to stdout; this section only controls how long entries stay available in the running pgconsole process. + +| Field | Description | Required | +|-------|-------------|----------| +| `retention_days` | Positive integer number of days to keep audit entries in memory. When omitted, entries are retained indefinitely until server restart. | | + +```toml pgconsole.toml +[general.audit] +retention_days = 30 +``` + ## Branding Replace the pgconsole logo with your own. diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 7e0d493..b7dec99 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -2,7 +2,20 @@ title: Audit Log --- -pgconsole emits audit logs as JSON lines to stdout, allowing you to capture and process them with your existing log infrastructure. +pgconsole emits audit logs as JSON lines to stdout, allowing you to capture and process them with your existing log infrastructure. It also keeps connection-scoped audit entries in memory so admins can inspect recent activity from the `/audit-log` page. + +## In-App Audit Log + +The `/audit-log` page shows SQL execution and data export entries for the selected connection, newest first. Viewing entries requires `admin` permission on that connection. + +Entries are stored in memory only. They are lost when the server restarts. By default, pgconsole retains entries indefinitely while the process is running, so memory usage grows with audit volume. For high-traffic deployments, set `retention_days` to prune older entries: + +```toml pgconsole.toml +[general.audit] +retention_days = 30 +``` + +See the [configuration reference](/configuration/config#audit-log) for details. ## Events diff --git a/pgconsole.example.toml b/pgconsole.example.toml index aa8598b..5a17ef0 100644 --- a/pgconsole.example.toml +++ b/pgconsole.example.toml @@ -13,6 +13,16 @@ # link = "https://status.example.com" # Optional - makes entire banner clickable (opens in new tab) # color = "#7c3aed" # Optional +# ============================================================================= +# Audit log settings +# ============================================================================= +# Audit entries are retained in memory for the /audit-log page. By default they +# are retained indefinitely until server restart. Set retention_days to prune +# entries older than the configured number of days. +# +# [general.audit] +# retention_days = 30 + # ============================================================================= # Branding # ============================================================================= diff --git a/proto/query.proto b/proto/query.proto index 9e29e40..2e82076 100644 --- a/proto/query.proto +++ b/proto/query.proto @@ -21,6 +21,7 @@ service QueryService { rpc GetFunctionDependencies(GetFunctionDependenciesRequest) returns (GetFunctionDependenciesResponse); rpc GetActiveSessions(GetActiveSessionsRequest) returns (GetActiveSessionsResponse); rpc TerminateSession(TerminateSessionRequest) returns (TerminateSessionResponse); + rpc GetAuditLogEntries(GetAuditLogEntriesRequest) returns (GetAuditLogEntriesResponse); rpc AuditExport(AuditExportRequest) returns (AuditExportResponse); } @@ -346,6 +347,32 @@ message TerminateSessionResponse { string error = 2; } +message GetAuditLogEntriesRequest { + string connection_id = 1; + int32 limit = 2; +} + +message GetAuditLogEntriesResponse { + repeated AuditLogEntry entries = 1; +} + +message AuditLogEntry { + string timestamp = 1; + string actor = 2; + string action = 3; + string connection = 4; + string database = 5; + string sql = 6; + bool success = 7; + optional int32 duration_ms = 8; + optional int32 row_count = 9; + string error = 10; + string format = 11; + string source = 12; + string tool = 13; + string agent = 14; +} + message AuditExportRequest { string connection_id = 1; string sql = 2; diff --git a/server/lib/audit.ts b/server/lib/audit.ts index 23ba896..050e1ec 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -1,4 +1,6 @@ -// Audit logging - emits JSON lines to stdout +import { getAuditRetentionDays } from './config' + +// Audit logging - emits JSON lines to stdout and keeps recent entries in memory interface BaseEvent { type: 'audit' ts: string @@ -43,9 +45,41 @@ interface DataExportEvent extends BaseEvent { format: string } -type AuditEvent = AuthLoginEvent | AuthLogoutEvent | SQLExecuteEvent | DataExportEvent +export type AuditEvent = AuthLoginEvent | AuthLogoutEvent | SQLExecuteEvent | DataExportEvent + +const auditEvents: AuditEvent[] = [] + +function pruneRetainedEvents(mode: 'prefix' | 'all'): void { + const retentionDays = getAuditRetentionDays() + if (retentionDays === undefined) return + + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 + if (mode === 'prefix') { + let removeCount = 0 + for (const event of auditEvents) { + if (Date.parse(event.ts) >= cutoff) break + removeCount++ + } + if (removeCount > 0) { + auditEvents.splice(0, removeCount) + } + } else { + let writeIndex = 0 + for (const event of auditEvents) { + if (Date.parse(event.ts) >= cutoff) { + auditEvents[writeIndex] = event + writeIndex++ + } + } + if (writeIndex < auditEvents.length) { + auditEvents.splice(writeIndex) + } + } +} function emit(event: AuditEvent): void { + auditEvents.push(event) + pruneRetainedEvents('prefix') console.log(JSON.stringify(event)) } @@ -129,3 +163,19 @@ export function auditExport( format, }) } + +export function listAuditEvents(connectionId: string, limit: number): AuditEvent[] { + pruneRetainedEvents('all') + const entries: AuditEvent[] = [] + for (let i = auditEvents.length - 1; i >= 0 && entries.length < limit; i--) { + const event = auditEvents[i] + if ('connection' in event && event.connection === connectionId) { + entries.push(event) + } + } + return entries +} + +export function clearAuditEventsForTest(): void { + auditEvents.length = 0 +} diff --git a/server/lib/config.ts b/server/lib/config.ts index ebd9169..e491869 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -84,6 +84,10 @@ export interface BrandingConfig { logo_link?: string } +export interface AuditConfig { + retentionDays?: number +} + export interface GroupConfig { id: string name: string @@ -102,6 +106,7 @@ interface Config { external_url?: string banner?: BannerConfig branding?: BrandingConfig + audit?: AuditConfig users: UserConfig[] groups: GroupConfig[] labels: LabelConfig[] @@ -136,7 +141,7 @@ function parsePermissionList(raw: unknown, label: string): Permission[] { return permissions } -const DEFAULT_CONFIG: Config = { users: [], groups: [], labels: [], connections: [], auth: undefined, ai: undefined, agents: [], banner: undefined, branding: undefined, iam: [] } +const DEFAULT_CONFIG: Config = { users: [], groups: [], labels: [], connections: [], auth: undefined, ai: undefined, agents: [], banner: undefined, branding: undefined, audit: undefined, iam: [] } let loadedConfig: Config = { ...DEFAULT_CONFIG } let demoMode = false @@ -176,6 +181,7 @@ export async function loadConfigFromString(content: string): Promise { // Parse [general] section let external_url: string | undefined = undefined let banner: BannerConfig | undefined = undefined + let audit: AuditConfig | undefined = undefined if (parsed.general) { const g = parsed.general if (g.external_url !== undefined) { @@ -226,6 +232,21 @@ export async function loadConfigFromString(content: string): Promise { banner = bannerConfig } } + + // Parse [general.audit] section + const rawAudit = g.audit as Record | undefined + if (rawAudit) { + const auditConfig: AuditConfig = {} + if (rawAudit.retention_days !== undefined) { + if (typeof rawAudit.retention_days !== 'number' || !Number.isInteger(rawAudit.retention_days) || rawAudit.retention_days <= 0) { + throw new Error('general.audit.retention_days must be a positive integer') + } + auditConfig.retentionDays = rawAudit.retention_days + } + if (auditConfig.retentionDays !== undefined) { + audit = auditConfig + } + } } // Parse [branding] section @@ -747,7 +768,7 @@ export async function loadConfigFromString(content: string): Promise { iam.push({ connection, permissions, members }) } - loadedConfig = { external_url, banner, branding, users, groups, labels, connections, auth, ai, agents, iam } + loadedConfig = { external_url, banner, branding, audit, users, groups, labels, connections, auth, ai, agents, iam } } export function getLabels(): LabelConfig[] { @@ -810,6 +831,10 @@ export function getBranding(): BrandingConfig | undefined { return loadedConfig.branding } +export function getAuditRetentionDays(): number | undefined { + return loadedConfig.audit?.retentionDays +} + export function getAIConfig(): AIConfig | undefined { return loadedConfig.ai } diff --git a/server/services/query-service.ts b/server/services/query-service.ts index e4162fe..ffc46de 100644 --- a/server/services/query-service.ts +++ b/server/services/query-service.ts @@ -7,7 +7,7 @@ import type postgres from "postgres"; import { getUserFromContext } from "../connect"; import { hasPermission, requirePermission, requirePermissions, requireAnyPermission } from "../lib/iam"; import { detectRequiredPermissions } from "../lib/sql-permissions"; -import { auditSQL, auditExport } from "../lib/audit"; +import { auditSQL, auditExport, listAuditEvents } from "../lib/audit"; // Track active queries by queryId -> { pid, connectionDetails, email } const activeQueries = new Map(); @@ -1214,6 +1214,36 @@ export const queryServiceHandlers: ServiceImpl = { } }, + async getAuditLogEntries(req, context) { + if (!req.connectionId) { + throw new ConnectError("connection_id is required", Code.InvalidArgument); + } + + const user = await getUserFromContext(context.values); + requirePermission(user, req.connectionId, 'admin', 'view audit log'); + getConnectionDetails(req.connectionId); + + const limit = req.limit > 0 ? Math.min(req.limit, 500) : 100; + const entries = listAuditEvents(req.connectionId, limit).map((event) => ({ + timestamp: event.ts, + actor: event.actor, + action: event.action, + connection: 'connection' in event ? event.connection : '', + database: 'database' in event ? event.database : '', + sql: 'sql' in event ? event.sql : '', + success: 'success' in event ? event.success : true, + durationMs: 'duration_ms' in event ? event.duration_ms : undefined, + rowCount: 'row_count' in event && event.row_count !== undefined ? event.row_count : undefined, + error: 'error' in event && event.error ? event.error : '', + format: 'format' in event ? event.format : '', + source: 'source' in event && event.source ? event.source : '', + tool: 'tool' in event && event.tool ? event.tool : '', + agent: 'agent' in event && event.agent ? event.agent : '', + })); + + return { entries }; + }, + async auditExport(req, context) { if (!req.connectionId) { throw new ConnectError("connection_id is required", Code.InvalidArgument); diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts index d128eee..6f00119 100644 --- a/src/hooks/useQuery.ts +++ b/src/hooks/useQuery.ts @@ -20,6 +20,7 @@ export const queryKeys = { functionInfo: (connectionId: string, schema: string, name: string, args?: string) => [...queryKeys.all, 'functionInfo', connectionId, schema, name, args] as const, functionDependencies: (connectionId: string, schema: string, name: string, args?: string) => [...queryKeys.all, 'functionDependencies', connectionId, schema, name, args] as const, processes: (connectionId: string) => [...queryKeys.all, 'processes', connectionId] as const, + auditLog: (connectionId: string) => [...queryKeys.all, 'auditLog', connectionId] as const, }; export function invalidateSchemaQueries(qc: QueryClient, connectionId: string) { @@ -326,6 +327,18 @@ export function useTerminateProcess() { }); } +export function useAuditLogEntries(connectionId: string, enabled = true) { + return useQuery({ + queryKey: queryKeys.auditLog(connectionId), + queryFn: async () => { + const response = await queryClient.getAuditLogEntries({ connectionId, limit: 100 }); + return response.entries; + }, + enabled: enabled && !!connectionId, + refetchInterval: 5000, + }); +} + // Refresh AI schema cache export function useRefreshSchemaCache() { return useMutation({ diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 8c1a453..154468d 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -2,7 +2,9 @@ import { useNavigate } from 'react-router-dom' import { ScrollText } from 'lucide-react' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select' import { Label } from '@/components/ui/label' -import { useConnections } from '@/hooks/useQuery' +import { Badge } from '@/components/ui/badge' +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' +import { useConnections, useAuditLogEntries } from '@/hooks/useQuery' import { useConnectionPermissions } from '@/hooks/usePermissions' interface AuditLogProps { @@ -13,6 +15,12 @@ export default function AuditLog({ connectionId }: AuditLogProps) { const navigate = useNavigate() const { data: connections, isLoading, error } = useConnections() const { hasAdmin } = useConnectionPermissions(connectionId) + const canLoadEntries = !!connections && connections.length > 0 && hasAdmin + const { + data: entries, + isLoading: entriesLoading, + error: entriesError, + } = useAuditLogEntries(connectionId, canLoadEntries) // Permissions are derived from the connections query, so resolve its loading, // error, and empty states before gating on hasAdmin — otherwise admins briefly @@ -27,11 +35,59 @@ export default function AuditLog({ connectionId }: AuditLogProps) {

You need admin permission on this connection to view its audit log.

- ) : ( + ) : entriesError ? ( +

Failed to load audit log entries.

+ ) : entriesLoading || !entries ? ( +
Loading audit log…
+ ) : entries.length === 0 ? (

No audit log entries yet.

+ ) : ( +
+ + + + Time + Actor + Action + Status + Rows + Duration + SQL + + + + {entries.map((entry, index) => ( + + + {new Date(entry.timestamp).toLocaleString()} + + {entry.actor} + +
+ {entry.action} + {entry.source && {entry.source}} +
+
+ + + {entry.success ? 'Success' : 'Failed'} + + + {entry.rowCount !== undefined ? entry.rowCount : '—'} + {entry.durationMs !== undefined ? `${entry.durationMs}ms` : '—'} + +
+ {entry.error || entry.sql || '—'} +
+
+
+ ))} +
+
+
) return ( diff --git a/tests/audit.test.ts b/tests/audit.test.ts new file mode 100644 index 0000000..38e2fde --- /dev/null +++ b/tests/audit.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { loadConfigFromString, getAuditRetentionDays } from '../server/lib/config' +import { auditSQL, auditExport, clearAuditEventsForTest, listAuditEvents } from '../server/lib/audit' + +const BASE = ` +[[connections]] +id = "prod" +name = "Prod" +host = "localhost" +port = 5432 +database = "postgres" +username = "postgres" +lazy = true +` + +describe('audit config', () => { + it('defaults to indefinite retention', async () => { + await loadConfigFromString(BASE) + expect(getAuditRetentionDays()).toBeUndefined() + }) + + it('parses retention days', async () => { + await loadConfigFromString(`${BASE} +[general.audit] +retention_days = 30 +`) + expect(getAuditRetentionDays()).toBe(30) + }) + + it.each(['0', '-1', '1.5', '"30"'])('rejects invalid retention_days = %s', async (value) => { + await expect(loadConfigFromString(`${BASE} +[general.audit] +retention_days = ${value} +`)).rejects.toThrow(/general\.audit\.retention_days must be a positive integer/) + }) +}) + +describe('audit event store', () => { + beforeEach(async () => { + vi.useRealTimers() + vi.spyOn(console, 'log').mockImplementation(() => {}) + clearAuditEventsForTest() + await loadConfigFromString(BASE) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('returns connection-scoped entries newest-first', () => { + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT 1', true, 3, 1) + auditSQL('alice@example.com', 'other', 'postgres', 'SELECT 2', true, 4, 1) + auditExport('alice@example.com', 'prod', 'postgres', 'SELECT 1', 1, 'csv') + + const entries = listAuditEvents('prod', 10) + expect(entries).toHaveLength(2) + expect(entries[0].action).toBe('data.export') + expect(entries[1].action).toBe('sql.execute') + }) + + it('applies the response limit', () => { + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT 1', true, 1, 1) + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT 2', true, 1, 1) + + const entries = listAuditEvents('prod', 1) + expect(entries).toHaveLength(1) + expect('sql' in entries[0] ? entries[0].sql : '').toBe('SELECT 2') + }) + + it('prunes events older than retention_days on insert', async () => { + await loadConfigFromString(`${BASE} +[general.audit] +retention_days = 1 +`) + vi.useFakeTimers() + + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT old', true, 1, 1) + + vi.setSystemTime(new Date('2026-01-02T00:00:01.000Z')) + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT new', true, 1, 1) + + const entries = listAuditEvents('prod', 10) + expect(entries).toHaveLength(1) + expect('sql' in entries[0] ? entries[0].sql : '').toBe('SELECT new') + }) + + it('prunes expired events even if timestamps are out of order', async () => { + await loadConfigFromString(`${BASE} +[general.audit] +retention_days = 1 +`) + vi.useFakeTimers() + + vi.setSystemTime(new Date('2026-01-02T12:00:00.000Z')) + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT keep', true, 1, 1) + + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT prune', true, 1, 1) + + vi.setSystemTime(new Date('2026-01-03T00:00:01.000Z')) + const entries = listAuditEvents('prod', 10) + expect(entries).toHaveLength(1) + expect('sql' in entries[0] ? entries[0].sql : '').toBe('SELECT keep') + }) +}) From a48ea7ae68f90c8b28afd96a8be39c7c8ab9e4f7 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Thu, 25 Jun 2026 23:43:35 +0800 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pages/AuditLog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 154468d..2d77aec 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -59,8 +59,8 @@ export default function AuditLog({ connectionId }: AuditLogProps) { - {entries.map((entry, index) => ( - + {entries.map((entry) => ( + {new Date(entry.timestamp).toLocaleString()} From 584374b17628d56f6ce2379d5563215e6839f3c8 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Thu, 25 Jun 2026 23:49:01 +0800 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pages/AuditLog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 2d77aec..70e699e 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -59,8 +59,8 @@ export default function AuditLog({ connectionId }: AuditLogProps) { - {entries.map((entry) => ( - + {entries.map((entry, idx) => ( + {new Date(entry.timestamp).toLocaleString()}