Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/configuration/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion docs/features/audit-log.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment thread
tianzhou marked this conversation as resolved.

See the [configuration reference](/configuration/config#audit-log) for details.

## Events

Expand Down
10 changes: 10 additions & 0 deletions pgconsole.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down
27 changes: 27 additions & 0 deletions proto/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Comment thread
tianzhou marked this conversation as resolved.
string format = 11;
string source = 12;
string tool = 13;
string agent = 14;
}

message AuditExportRequest {
string connection_id = 1;
string sql = 2;
Expand Down
54 changes: 52 additions & 2 deletions server/lib/audit.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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[] = []

Comment thread
tianzhou marked this conversation as resolved.
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))
}
Comment thread
tianzhou marked this conversation as resolved.
Comment thread
tianzhou marked this conversation as resolved.

Expand Down Expand Up @@ -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
}
Comment thread
tianzhou marked this conversation as resolved.

export function clearAuditEventsForTest(): void {
auditEvents.length = 0
}
29 changes: 27 additions & 2 deletions server/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export interface BrandingConfig {
logo_link?: string
}

export interface AuditConfig {
retentionDays?: number
}

export interface GroupConfig {
id: string
name: string
Expand All @@ -102,6 +106,7 @@ interface Config {
external_url?: string
banner?: BannerConfig
branding?: BrandingConfig
audit?: AuditConfig
users: UserConfig[]
groups: GroupConfig[]
labels: LabelConfig[]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +181,7 @@ export async function loadConfigFromString(content: string): Promise<void> {
// 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) {
Expand Down Expand Up @@ -226,6 +232,21 @@ export async function loadConfigFromString(content: string): Promise<void> {
banner = bannerConfig
}
}

// Parse [general.audit] section
const rawAudit = g.audit as Record<string, unknown> | 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
Expand Down Expand Up @@ -747,7 +768,7 @@ export async function loadConfigFromString(content: string): Promise<void> {
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[] {
Expand Down Expand Up @@ -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
}
Expand Down
32 changes: 31 additions & 1 deletion server/services/query-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { pid: number; details: ConnectionDetails; email: string }>();
Expand Down Expand Up @@ -1214,6 +1214,36 @@ export const queryServiceHandlers: ServiceImpl<typeof QueryService> = {
}
},

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 : '',
Comment thread
tianzhou marked this conversation as resolved.
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 };
},
Comment thread
tianzhou marked this conversation as resolved.

async auditExport(req, context) {
if (!req.connectionId) {
throw new ConnectError("connection_id is required", Code.InvalidArgument);
Expand Down
13 changes: 13 additions & 0 deletions src/hooks/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading