Skip to content

Commit e3c83a5

Browse files
committed
feat: surface in-memory audit log entries
1 parent dc4a0e3 commit e3c83a5

10 files changed

Lines changed: 347 additions & 8 deletions

File tree

docs/configuration/config.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ text = "System maintenance scheduled for Sunday 2am UTC"
3333
link = "https://status.example.com"
3434
color = "#7c3aed"
3535

36+
[general.audit]
37+
retention_days = 30
38+
3639
[branding]
3740
logo = "https://example.com/your-logo.svg"
3841
logo_link = "https://internal.example.com"
@@ -164,6 +167,19 @@ link = "https://status.example.com"
164167
color = "#7c3aed"
165168
```
166169

170+
## Audit Log
171+
172+
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.
173+
174+
| Field | Description | Required |
175+
|-------|-------------|----------|
176+
| `retention_days` | Positive integer number of days to keep audit entries in memory. When omitted, entries are retained indefinitely until server restart. | |
177+
178+
```toml pgconsole.toml
179+
[general.audit]
180+
retention_days = 30
181+
```
182+
167183
## Branding
168184

169185
Replace the pgconsole logo with your own.

docs/features/audit-log.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,20 @@
22
title: Audit Log
33
---
44

5-
pgconsole emits audit logs as JSON lines to stdout, allowing you to capture and process them with your existing log infrastructure.
5+
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.
6+
7+
## In-App Audit Log
8+
9+
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.
10+
11+
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:
12+
13+
```toml pgconsole.toml
14+
[general.audit]
15+
retention_days = 30
16+
```
17+
18+
See the [configuration reference](/configuration/config#audit-log) for details.
619

720
## Events
821

pgconsole.example.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
# link = "https://status.example.com" # Optional - makes entire banner clickable (opens in new tab)
1414
# color = "#7c3aed" # Optional
1515

16+
# =============================================================================
17+
# Audit log settings
18+
# =============================================================================
19+
# Audit entries are retained in memory for the /audit-log page. By default they
20+
# are retained indefinitely until server restart. Set retention_days to prune
21+
# entries older than the configured number of days.
22+
#
23+
# [general.audit]
24+
# retention_days = 30
25+
1626
# =============================================================================
1727
# Branding
1828
# =============================================================================

proto/query.proto

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ service QueryService {
2121
rpc GetFunctionDependencies(GetFunctionDependenciesRequest) returns (GetFunctionDependenciesResponse);
2222
rpc GetActiveSessions(GetActiveSessionsRequest) returns (GetActiveSessionsResponse);
2323
rpc TerminateSession(TerminateSessionRequest) returns (TerminateSessionResponse);
24+
rpc GetAuditLogEntries(GetAuditLogEntriesRequest) returns (GetAuditLogEntriesResponse);
2425
rpc AuditExport(AuditExportRequest) returns (AuditExportResponse);
2526
}
2627

@@ -346,6 +347,32 @@ message TerminateSessionResponse {
346347
string error = 2;
347348
}
348349

350+
message GetAuditLogEntriesRequest {
351+
string connection_id = 1;
352+
int32 limit = 2;
353+
}
354+
355+
message GetAuditLogEntriesResponse {
356+
repeated AuditLogEntry entries = 1;
357+
}
358+
359+
message AuditLogEntry {
360+
string timestamp = 1;
361+
string actor = 2;
362+
string action = 3;
363+
string connection = 4;
364+
string database = 5;
365+
string sql = 6;
366+
bool success = 7;
367+
optional int32 duration_ms = 8;
368+
optional int32 row_count = 9;
369+
string error = 10;
370+
string format = 11;
371+
string source = 12;
372+
string tool = 13;
373+
string agent = 14;
374+
}
375+
349376
message AuditExportRequest {
350377
string connection_id = 1;
351378
string sql = 2;

server/lib/audit.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
// Audit logging - emits JSON lines to stdout
1+
import { getAuditRetentionDays } from './config'
2+
3+
// Audit logging - emits JSON lines to stdout and keeps recent entries in memory
24
interface BaseEvent {
35
type: 'audit'
46
ts: string
@@ -43,9 +45,36 @@ interface DataExportEvent extends BaseEvent {
4345
format: string
4446
}
4547

46-
type AuditEvent = AuthLoginEvent | AuthLogoutEvent | SQLExecuteEvent | DataExportEvent
48+
export type AuditEvent = AuthLoginEvent | AuthLogoutEvent | SQLExecuteEvent | DataExportEvent
49+
50+
const auditEvents: AuditEvent[] = []
51+
52+
function pruneRetainedEvents(mode: 'prefix' | 'all'): void {
53+
const retentionDays = getAuditRetentionDays()
54+
if (retentionDays === undefined) return
55+
56+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
57+
if (mode === 'prefix') {
58+
let removeCount = 0
59+
for (const event of auditEvents) {
60+
if (Date.parse(event.ts) >= cutoff) break
61+
removeCount++
62+
}
63+
if (removeCount > 0) {
64+
auditEvents.splice(0, removeCount)
65+
}
66+
} else {
67+
for (let i = auditEvents.length - 1; i >= 0; i--) {
68+
if (Date.parse(auditEvents[i].ts) < cutoff) {
69+
auditEvents.splice(i, 1)
70+
}
71+
}
72+
}
73+
}
4774

4875
function emit(event: AuditEvent): void {
76+
auditEvents.push(event)
77+
pruneRetainedEvents('prefix')
4978
console.log(JSON.stringify(event))
5079
}
5180

@@ -129,3 +158,19 @@ export function auditExport(
129158
format,
130159
})
131160
}
161+
162+
export function listAuditEvents(connectionId: string, limit: number): AuditEvent[] {
163+
pruneRetainedEvents('all')
164+
const entries: AuditEvent[] = []
165+
for (let i = auditEvents.length - 1; i >= 0 && entries.length < limit; i--) {
166+
const event = auditEvents[i]
167+
if ('connection' in event && event.connection === connectionId) {
168+
entries.push(event)
169+
}
170+
}
171+
return entries
172+
}
173+
174+
export function clearAuditEventsForTest(): void {
175+
auditEvents.length = 0
176+
}

server/lib/config.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export interface BrandingConfig {
8484
logo_link?: string
8585
}
8686

87+
export interface AuditConfig {
88+
retentionDays?: number
89+
}
90+
8791
export interface GroupConfig {
8892
id: string
8993
name: string
@@ -102,6 +106,7 @@ interface Config {
102106
external_url?: string
103107
banner?: BannerConfig
104108
branding?: BrandingConfig
109+
audit?: AuditConfig
105110
users: UserConfig[]
106111
groups: GroupConfig[]
107112
labels: LabelConfig[]
@@ -136,7 +141,7 @@ function parsePermissionList(raw: unknown, label: string): Permission[] {
136141
return permissions
137142
}
138143

139-
const DEFAULT_CONFIG: Config = { users: [], groups: [], labels: [], connections: [], auth: undefined, ai: undefined, agents: [], banner: undefined, branding: undefined, iam: [] }
144+
const DEFAULT_CONFIG: Config = { users: [], groups: [], labels: [], connections: [], auth: undefined, ai: undefined, agents: [], banner: undefined, branding: undefined, audit: undefined, iam: [] }
140145

141146
let loadedConfig: Config = { ...DEFAULT_CONFIG }
142147
let demoMode = false
@@ -176,6 +181,7 @@ export async function loadConfigFromString(content: string): Promise<void> {
176181
// Parse [general] section
177182
let external_url: string | undefined = undefined
178183
let banner: BannerConfig | undefined = undefined
184+
let audit: AuditConfig | undefined = undefined
179185
if (parsed.general) {
180186
const g = parsed.general
181187
if (g.external_url !== undefined) {
@@ -226,6 +232,21 @@ export async function loadConfigFromString(content: string): Promise<void> {
226232
banner = bannerConfig
227233
}
228234
}
235+
236+
// Parse [general.audit] section
237+
const rawAudit = g.audit as Record<string, unknown> | undefined
238+
if (rawAudit) {
239+
const auditConfig: AuditConfig = {}
240+
if (rawAudit.retention_days !== undefined) {
241+
if (typeof rawAudit.retention_days !== 'number' || !Number.isInteger(rawAudit.retention_days) || rawAudit.retention_days <= 0) {
242+
throw new Error('general.audit.retention_days must be a positive integer')
243+
}
244+
auditConfig.retentionDays = rawAudit.retention_days
245+
}
246+
if (auditConfig.retentionDays !== undefined) {
247+
audit = auditConfig
248+
}
249+
}
229250
}
230251

231252
// Parse [branding] section
@@ -747,7 +768,7 @@ export async function loadConfigFromString(content: string): Promise<void> {
747768
iam.push({ connection, permissions, members })
748769
}
749770

750-
loadedConfig = { external_url, banner, branding, users, groups, labels, connections, auth, ai, agents, iam }
771+
loadedConfig = { external_url, banner, branding, audit, users, groups, labels, connections, auth, ai, agents, iam }
751772
}
752773

753774
export function getLabels(): LabelConfig[] {
@@ -810,6 +831,10 @@ export function getBranding(): BrandingConfig | undefined {
810831
return loadedConfig.branding
811832
}
812833

834+
export function getAuditRetentionDays(): number | undefined {
835+
return loadedConfig.audit?.retentionDays
836+
}
837+
813838
export function getAIConfig(): AIConfig | undefined {
814839
return loadedConfig.ai
815840
}

server/services/query-service.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type postgres from "postgres";
77
import { getUserFromContext } from "../connect";
88
import { hasPermission, requirePermission, requirePermissions, requireAnyPermission } from "../lib/iam";
99
import { detectRequiredPermissions } from "../lib/sql-permissions";
10-
import { auditSQL, auditExport } from "../lib/audit";
10+
import { auditSQL, auditExport, listAuditEvents } from "../lib/audit";
1111

1212
// Track active queries by queryId -> { pid, connectionDetails, email }
1313
const activeQueries = new Map<string, { pid: number; details: ConnectionDetails; email: string }>();
@@ -1214,6 +1214,35 @@ export const queryServiceHandlers: ServiceImpl<typeof QueryService> = {
12141214
}
12151215
},
12161216

1217+
async getAuditLogEntries(req, context) {
1218+
if (!req.connectionId) {
1219+
throw new ConnectError("connection_id is required", Code.InvalidArgument);
1220+
}
1221+
1222+
const user = await getUserFromContext(context.values);
1223+
requirePermission(user, req.connectionId, 'admin', 'view audit log');
1224+
1225+
const limit = req.limit > 0 ? Math.min(req.limit, 500) : 100;
1226+
const entries = listAuditEvents(req.connectionId, limit).map((event) => ({
1227+
timestamp: event.ts,
1228+
actor: event.actor,
1229+
action: event.action,
1230+
connection: 'connection' in event ? event.connection : '',
1231+
database: 'database' in event ? event.database : '',
1232+
sql: 'sql' in event ? event.sql : '',
1233+
success: 'success' in event ? event.success : true,
1234+
durationMs: 'duration_ms' in event ? event.duration_ms : undefined,
1235+
rowCount: 'row_count' in event && event.row_count !== undefined ? event.row_count : undefined,
1236+
error: 'error' in event && event.error ? event.error : '',
1237+
format: 'format' in event ? event.format : '',
1238+
source: 'source' in event && event.source ? event.source : '',
1239+
tool: 'tool' in event && event.tool ? event.tool : '',
1240+
agent: 'agent' in event && event.agent ? event.agent : '',
1241+
}));
1242+
1243+
return { entries };
1244+
},
1245+
12171246
async auditExport(req, context) {
12181247
if (!req.connectionId) {
12191248
throw new ConnectError("connection_id is required", Code.InvalidArgument);

src/hooks/useQuery.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const queryKeys = {
2020
functionInfo: (connectionId: string, schema: string, name: string, args?: string) => [...queryKeys.all, 'functionInfo', connectionId, schema, name, args] as const,
2121
functionDependencies: (connectionId: string, schema: string, name: string, args?: string) => [...queryKeys.all, 'functionDependencies', connectionId, schema, name, args] as const,
2222
processes: (connectionId: string) => [...queryKeys.all, 'processes', connectionId] as const,
23+
auditLog: (connectionId: string) => [...queryKeys.all, 'auditLog', connectionId] as const,
2324
};
2425

2526
export function invalidateSchemaQueries(qc: QueryClient, connectionId: string) {
@@ -326,6 +327,18 @@ export function useTerminateProcess() {
326327
});
327328
}
328329

330+
export function useAuditLogEntries(connectionId: string, enabled = true) {
331+
return useQuery({
332+
queryKey: queryKeys.auditLog(connectionId),
333+
queryFn: async () => {
334+
const response = await queryClient.getAuditLogEntries({ connectionId, limit: 100 });
335+
return response.entries;
336+
},
337+
enabled: enabled && !!connectionId,
338+
refetchInterval: 5000,
339+
});
340+
}
341+
329342
// Refresh AI schema cache
330343
export function useRefreshSchemaCache() {
331344
return useMutation({

0 commit comments

Comments
 (0)