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..9c08f95 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -176,6 +176,22 @@ export function listAuditEvents(connectionId: string, limit: number): AuditEvent return entries } +// 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 (event.action === 'auth.login' || event.action === 'auth.logout') { + 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..4b306b2 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -4,28 +4,78 @@ 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 +}) { + // 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) return
Loading audit log…
+ if (!entries || 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 +85,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 +179,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]