From 0a568016cbdb8fd76f337061846662ae60ac7052 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Thu, 25 Jun 2026 22:53:37 -0700 Subject: [PATCH 1/2] feat(audit-log): add System tab for instance-level auth events The /audit-log page now has two tabs: - Connection: the existing per-connection SQL execution / data export view (unchanged), gated on `admin` for the selected connection. - System: instance-level events not tied to a connection (auth.login / auth.logout) with provider + source IP, gated on instance `owner`. Owner gating (rather than connection-admin) keeps system-wide login/IP activity from leaking to an admin of a single connection. - proto: add GetSystemAuditLogEntries RPC; add provider/ip to AuditLogEntry - audit: add listSystemAuditEvents() (events with no connection) - query-service: owner-gated handler; extract shared toAuditLogEntry mapping - ui: tab switcher; System tab shown only to owners - tests: system vs connection separation + system limit - docs: document the two tabs Co-Authored-By: Claude Opus 4.8 --- docs/features/audit-log.mdx | 7 +- proto/query.proto | 13 ++ server/lib/audit.ts | 14 ++ server/services/query-service.ts | 62 +++++--- src/hooks/useQuery.ts | 15 ++ src/pages/AuditLog.tsx | 260 +++++++++++++++++++++---------- tests/audit.test.ts | 26 +++- 7 files changed, 292 insertions(+), 105 deletions(-) diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index b7dec99..4551842 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -2,11 +2,14 @@ title: Audit Log --- -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. +pgconsole emits audit logs as JSON lines to stdout, allowing you to capture and process them with your existing log infrastructure. It also keeps audit entries in memory so they can be inspected 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. +The `/audit-log` page has two tabs: + +- **Connection** — SQL execution and data export entries for the selected connection, newest first. Requires `admin` permission on that connection. +- **System** — instance-level events that aren't tied to a connection (`auth.login` / `auth.logout`), including the auth provider and source IP. Only visible to an instance **owner**, since these events span all users. 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: diff --git a/proto/query.proto b/proto/query.proto index 2e82076..715aea4 100644 --- a/proto/query.proto +++ b/proto/query.proto @@ -22,6 +22,7 @@ service QueryService { rpc GetActiveSessions(GetActiveSessionsRequest) returns (GetActiveSessionsResponse); rpc TerminateSession(TerminateSessionRequest) returns (TerminateSessionResponse); rpc GetAuditLogEntries(GetAuditLogEntriesRequest) returns (GetAuditLogEntriesResponse); + rpc GetSystemAuditLogEntries(GetSystemAuditLogEntriesRequest) returns (GetSystemAuditLogEntriesResponse); rpc AuditExport(AuditExportRequest) returns (AuditExportResponse); } @@ -356,6 +357,15 @@ message GetAuditLogEntriesResponse { repeated AuditLogEntry entries = 1; } +// System-level (non-connection-scoped) audit events, e.g. auth.login / auth.logout. +message GetSystemAuditLogEntriesRequest { + int32 limit = 1; +} + +message GetSystemAuditLogEntriesResponse { + repeated AuditLogEntry entries = 1; +} + message AuditLogEntry { string timestamp = 1; string actor = 2; @@ -371,6 +381,9 @@ message AuditLogEntry { string source = 12; string tool = 13; string agent = 14; + // Auth-event fields (auth.login); blank for other actions. + string provider = 15; + string ip = 16; } message AuditExportRequest { diff --git a/server/lib/audit.ts b/server/lib/audit.ts index 050e1ec..6c2fecb 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -176,6 +176,20 @@ export function listAuditEvents(connectionId: string, limit: number): AuditEvent return entries } +// System-level audit events — those not scoped to a connection (auth.login / auth.logout), +// surfaced in the instance-owner-only "System" tab. Newest first, bounded by limit. +export function listSystemAuditEvents(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)) { + entries.push(event) + } + } + return entries +} + export function clearAuditEventsForTest(): void { auditEvents.length = 0 } diff --git a/server/services/query-service.ts b/server/services/query-service.ts index 7be029f..2fbb460 100644 --- a/server/services/query-service.ts +++ b/server/services/query-service.ts @@ -1,18 +1,41 @@ import { ConnectError, Code } from "@connectrpc/connect"; import type { ServiceImpl } from "@connectrpc/connect"; import { QueryService } from "../../src/gen/query_connect"; -import { getConnectionById } from "../lib/config"; +import { getConnectionById, isOwner } from "../lib/config"; import { createClient, formatAppName, buildConnectionDetails, type ConnectionDetails } from "../lib/db"; import type postgres from "postgres"; import { getUserFromContext } from "../connect"; import { hasPermission, requirePermission, requirePermissions, requireAnyPermission } from "../lib/iam"; import { detectRequiredPermissions } from "../lib/sql-permissions"; import { buildExecutableSql, formatExecutionError } from "../lib/execute-sql"; -import { auditSQL, auditExport, listAuditEvents } from "../lib/audit"; +import { auditSQL, auditExport, listAuditEvents, listSystemAuditEvents, type AuditEvent } from "../lib/audit"; // Track active queries by queryId -> { pid, connectionDetails, email } const activeQueries = new Map(); +// Map an in-memory audit event to the wire AuditLogEntry. Fields absent on a given +// event kind map to undefined (numbers, so presence is preserved) or '' (strings). +function toAuditLogEntry(event: AuditEvent) { + return { + 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 : '', + provider: 'provider' in event ? event.provider : '', + ip: 'ip' in event ? event.ip : '', + }; +} + function getConnectionDetails(connectionId: string): ConnectionDetails { const details = buildConnectionDetails(connectionId); if (!details) { @@ -1201,22 +1224,25 @@ export const queryServiceHandlers: ServiceImpl = { 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 : '', - })); + const entries = listAuditEvents(req.connectionId, limit).map(toAuditLogEntry); + + return { entries }; + }, + + async getSystemAuditLogEntries(req, context) { + // System-level audit (auth.login/logout) is instance-wide, not connection-scoped, + // so it is gated on the instance owner rather than per-connection admin — this avoids + // leaking everyone's login/IP activity to an admin of a single connection. + const user = await getUserFromContext(context.values); + if (!user) { + throw new ConnectError("Authentication required", Code.Unauthenticated); + } + if (!isOwner(user.email)) { + throw new ConnectError("Permission denied: viewing the system audit log requires instance owner", Code.PermissionDenied); + } + + const limit = req.limit > 0 ? Math.min(req.limit, 500) : 100; + const entries = listSystemAuditEvents(limit).map(toAuditLogEntry); return { entries }; }, diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts index 6f00119..8f013f1 100644 --- a/src/hooks/useQuery.ts +++ b/src/hooks/useQuery.ts @@ -21,6 +21,7 @@ export const queryKeys = { 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, + systemAuditLog: () => [...queryKeys.all, 'systemAuditLog'] as const, }; export function invalidateSchemaQueries(qc: QueryClient, connectionId: string) { @@ -339,6 +340,20 @@ export function useAuditLogEntries(connectionId: string, enabled = true) { }); } +// System-level audit entries (auth.login / auth.logout). Owner-gated server-side, so +// only enable the query for instance owners. +export function useSystemAuditLogEntries(enabled = true) { + return useQuery({ + queryKey: queryKeys.systemAuditLog(), + queryFn: async () => { + const response = await queryClient.getSystemAuditLogEntries({ limit: 100 }); + return response.entries; + }, + enabled, + refetchInterval: 5000, + }); +} + // Refresh AI schema cache export function useRefreshSchemaCache() { return useMutation({ diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 70e699e..1710638 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -4,28 +4,75 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@ import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' -import { useConnections, useAuditLogEntries } from '@/hooks/useQuery' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { useConnections, useAuditLogEntries, useSystemAuditLogEntries } from '@/hooks/useQuery' import { useConnectionPermissions } from '@/hooks/usePermissions' +import { useOwner } from '@/hooks/useOwner' +import type { AuditLogEntry } from '@/gen/query_pb' interface AuditLogProps { connectionId: string } +function StatusBadge({ success }: { success: boolean }) { + return ( + {success ? 'Success' : 'Failed'} + ) +} + +function ActionCell({ action, source }: { action: string; source?: string }) { + return ( +
+ {action} + {source && {source}} +
+ ) +} + +function EmptyState({ label }: { label: string }) { + return ( +
+ +

{label}

+
+ ) +} + +// Shared loading / error / empty handling for a tab's entries; renders the table only +// once entries have loaded and are non-empty. +function EntriesPanel({ + entries, + isLoading, + error, + emptyLabel, + children, +}: { + entries: AuditLogEntry[] | undefined + isLoading: boolean + error: unknown + emptyLabel: string + children: (entries: AuditLogEntry[]) => React.ReactNode +}) { + if (error) return

Failed to load audit log entries.

+ if (isLoading || !entries) return
Loading audit log…
+ if (entries.length === 0) return + return <>{children(entries)} +} + export default function AuditLog({ connectionId }: AuditLogProps) { const navigate = useNavigate() + const isOwner = useOwner() 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 - // see the denied state on load, and errors/empty lists show misleading UI. - const content = error ? ( + const canLoadConnEntries = !!connections && connections.length > 0 && hasAdmin + const connQuery = useAuditLogEntries(connectionId, canLoadConnEntries) + // System tab is instance-owner only; the query is owner-gated server-side too. + const sysQuery = useSystemAuditLogEntries(isOwner) + + // Connection tab: resolve the connections query (loading/error/empty) and the per-connection + // admin gate before showing entries — otherwise admins briefly see the denied state on load. + const connectionContent = error ? (

Failed to load connections.

) : isLoading || !connections ? (
Loading…
@@ -35,59 +82,93 @@ 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, idx) => ( - - - {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 || '—'} -
-
-
- ))} -
-
-
+ + {(entries) => ( +
+ + + + Time + Actor + Action + Status + Rows + Duration + SQL + + + + {entries.map((entry, idx) => ( + + {new Date(entry.timestamp).toLocaleString()} + {entry.actor} + + + {entry.rowCount !== undefined ? entry.rowCount : '—'} + {entry.durationMs !== undefined ? `${entry.durationMs}ms` : '—'} + +
+ {entry.error || entry.sql || '—'} +
+
+
+ ))} +
+
+
+ )} +
+ ) + + // System tab: app-level auth events (login/logout), not tied to a connection. + const systemContent = ( + + {(entries) => ( +
+ + + + Time + Actor + Action + Status + Provider + IP + Detail + + + + {entries.map((entry, idx) => ( + + {new Date(entry.timestamp).toLocaleString()} + {entry.actor} + + + {entry.provider || '—'} + {entry.ip || '—'} + +
+ {entry.error || '—'} +
+
+
+ ))} +
+
+
+ )} +
) return ( @@ -95,28 +176,39 @@ export default function AuditLog({ connectionId }: AuditLogProps) {

Audit Log

- {/* Connection selector stays outside the gate so a user without admin on the - current connection can still switch to one where they do have access. */} -
- - -
+ + + Connection + {isOwner && System} + + + + {/* Connection selector stays outside the admin gate so a user without admin on the + current connection can still switch to one where they do have access. */} +
+ + +
+ + {connectionContent} +
- {content} + {isOwner && {systemContent}} +
) diff --git a/tests/audit.test.ts b/tests/audit.test.ts index 38e2fde..db0eb6e 100644 --- a/tests/audit.test.ts +++ b/tests/audit.test.ts @@ -1,6 +1,6 @@ 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' +import { auditSQL, auditExport, auditLogin, auditLogout, clearAuditEventsForTest, listAuditEvents, listSystemAuditEvents } from '../server/lib/audit' const BASE = ` [[connections]] @@ -68,6 +68,30 @@ describe('audit event store', () => { expect('sql' in entries[0] ? entries[0].sql : '').toBe('SELECT 2') }) + it('separates system (auth) events from connection-scoped events', () => { + auditLogin('alice@example.com', 'password', '10.0.0.1', true) + auditSQL('alice@example.com', 'prod', 'postgres', 'SELECT 1', true, 1, 1) + auditLogout('alice@example.com') + + // System tab: only the non-connection auth events, newest-first. + const system = listSystemAuditEvents(10) + expect(system.map((e) => e.action)).toEqual(['auth.logout', 'auth.login']) + + // Connection tab is unaffected — still only connection-scoped events. + const conn = listAuditEvents('prod', 10) + expect(conn).toHaveLength(1) + expect(conn[0].action).toBe('sql.execute') + }) + + it('applies the response limit to system events', () => { + auditLogin('alice@example.com', 'password', '10.0.0.1', true) + auditLogin('bob@example.com', 'password', '10.0.0.2', false, 'bad password') + + const system = listSystemAuditEvents(1) + expect(system).toHaveLength(1) + expect(system[0].actor).toBe('bob@example.com') + }) + it('prunes events older than retention_days on insert', async () => { await loadConfigFromString(`${BASE} [general.audit] From 9a4badef5366441a9c214ae73655fa1f7c02bc5c Mon Sep 17 00:00:00 2001 From: tianzhou Date: Thu, 25 Jun 2026 23:07:24 -0700 Subject: [PATCH 2/2] refactor(audit-log): address Greptile review - listSystemAuditEvents: match system events by explicit action (auth.login/auth.logout) instead of the absence of a `connection` field, so a future connection-less event can't leak into the System tab. - EntriesPanel: only show the loading state while actually fetching; a disabled query (non-owner) now falls through to the empty state instead of spinning forever. Co-Authored-By: Claude Opus 4.8 --- server/lib/audit.ts | 6 ++++-- src/pages/AuditLog.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/server/lib/audit.ts b/server/lib/audit.ts index 6c2fecb..9c08f95 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -176,14 +176,16 @@ export function listAuditEvents(connectionId: string, limit: number): AuditEvent return entries } -// System-level audit events — those not scoped to a connection (auth.login / auth.logout), +// System-level audit events — instance-wide auth events not scoped to a connection, // surfaced in the instance-owner-only "System" tab. Newest first, bounded by limit. +// Match by explicit action (not the absence of a `connection` field) so a future +// connection-less event can't silently leak into the System tab. export function listSystemAuditEvents(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)) { + if (event.action === 'auth.login' || event.action === 'auth.logout') { entries.push(event) } } diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 1710638..4b306b2 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -53,9 +53,12 @@ function EntriesPanel({ emptyLabel: string children: (entries: AuditLogEntry[]) => React.ReactNode }) { + // Only show the spinner while actually fetching. When the query is disabled + // (e.g. a non-owner), React Query leaves `isLoading` false and `data` undefined — + // fall through to the empty state rather than spinning forever. if (error) return

Failed to load audit log entries.

- if (isLoading || !entries) return
Loading audit log…
- if (entries.length === 0) return + if (isLoading) return
Loading audit log…
+ if (!entries || entries.length === 0) return return <>{children(entries)} }