diff --git a/ROADMAP.md b/ROADMAP.md index fe43718..9a0b8c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -267,8 +267,13 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo. - [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch preview creation, promote-to-production action, expiry + cleanup job. Decide first whether this stays Phase 2 or moves with env support (§2.4). -- [ ] **Audit logs**: real UI page under Settings (filterable by resource, - actor, time); already queried — needs a table, not just a list call. +- [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the + Settings page) — filterable table by resource, action, actor (email), + and time range, with pagination. Backend widened accordingly: the list + endpoint was self-scoped and dropped `user_email`; it now joins `users` + and accepts `user_id`/`actor`/`since` filters. Widening is consistent + with the platform's no-roles trust model (every authed user can already + create users). - [ ] **Agents/nodes**: onboarding flow — generate token, show install command for `cmd/agent`, node cards with heartbeat status + telemetry on Usage page. diff --git a/app/backend/internal/api/audit.go b/app/backend/internal/api/audit.go index 9d7d2fc..64a43b2 100644 --- a/app/backend/internal/api/audit.go +++ b/app/backend/internal/api/audit.go @@ -93,10 +93,11 @@ func SetAuditDB(db *database.DB) { func handleGetAuditLogs(c *gin.Context) { db := c.MustGet("db").(*database.DB) - userID := c.MustGet("user_id").(string) - resource := strings.TrimSpace(c.Query("resource")) action := strings.TrimSpace(c.Query("action")) + actor := strings.TrimSpace(c.Query("actor")) + userIDFilter := strings.TrimSpace(c.Query("user_id")) + since := strings.TrimSpace(c.Query("since")) page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50) if limit > 500 { @@ -104,35 +105,57 @@ func handleGetAuditLogs(c *gin.Context) { } offset := (page - 1) * limit - conditions := []string{"user_id::text = $1"} - args := []interface{}{userID} - nextArg := 2 + conditions := []string{"TRUE"} + args := []interface{}{} + nextArg := 1 + if userIDFilter != "" { + conditions = append(conditions, fmt.Sprintf("a.user_id = $%d::uuid", nextArg)) + args = append(args, userIDFilter) + nextArg++ + } + if actor != "" { + conditions = append(conditions, fmt.Sprintf("u.email ILIKE $%d", nextArg)) + args = append(args, "%"+actor+"%") + nextArg++ + } if resource != "" { - conditions = append(conditions, fmt.Sprintf("resource = $%d", nextArg)) + conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg)) args = append(args, resource) nextArg++ } if action != "" { - conditions = append(conditions, fmt.Sprintf("action = $%d", nextArg)) + conditions = append(conditions, fmt.Sprintf("a.action = $%d", nextArg)) args = append(args, action) nextArg++ } + if since != "" { + sinceTime, err := time.Parse(time.RFC3339, since) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since timestamp, expected RFC3339"}) + return + } + conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg)) + args = append(args, sinceTime) + nextArg++ + } whereClause := strings.Join(conditions, " AND ") query := fmt.Sprintf(`SELECT - id, - COALESCE(user_id::text, ''), - resource, - COALESCE(resource_id::text, ''), - action, - COALESCE(details::text, '{}'), - COALESCE(ip_address::text, ''), - COALESCE(user_agent, ''), - created_at - FROM audit_logs + a.id, + COALESCE(a.user_id::text, ''), + COALESCE(u.email, ''), + a.resource, + COALESCE(a.resource_id::text, ''), + a.action, + COALESCE(a.details::text, '{}'), + COALESCE(a.ip_address::text, ''), + COALESCE(a.user_agent, ''), + a.created_at + FROM audit_logs a + LEFT JOIN users u ON u.id = a.user_id WHERE %s - ORDER BY created_at DESC + ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1) args = append(args, limit, offset) @@ -146,7 +169,7 @@ func handleGetAuditLogs(c *gin.Context) { var logs []AuditLog for rows.Next() { var log AuditLog - err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) + err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) if err != nil { continue } diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 725e0ab..de51f38 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -6,6 +6,7 @@ import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage'; import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard'; import { BuildsPage } from '@/features/builds/pages/BuildsPage'; import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; +import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; import { DocsPage, PeoplePage, @@ -62,6 +63,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/app/frontend/src/features/audit/pages/AuditLogsPage.tsx b/app/frontend/src/features/audit/pages/AuditLogsPage.tsx new file mode 100644 index 0000000..3000f8f --- /dev/null +++ b/app/frontend/src/features/audit/pages/AuditLogsPage.tsx @@ -0,0 +1,221 @@ +import { useDeferredValue, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { listAuditLogs } from '@/lib/api-client'; +import { formatDate, formatRelative } from '@/lib/time'; +import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; + +const PAGE_SIZE = 50; + +const rangeOptions = [ + { value: '', label: 'All time' }, + { value: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 }, + { value: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 }, + { value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, +] as const; + +const inputClass = + 'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all'; + +export function AuditLogsPage() { + const [resource, setResource] = useState(''); + const [action, setAction] = useState(''); + const [actor, setActor] = useState(''); + const [range, setRange] = useState(''); + const [page, setPage] = useState(1); + + const deferredResource = useDeferredValue(resource); + const deferredAction = useDeferredValue(action); + const deferredActor = useDeferredValue(actor); + + const filters = { + resource: deferredResource.trim() || undefined, + action: deferredAction.trim() || undefined, + actor: deferredActor.trim() || undefined, + page, + limit: PAGE_SIZE, + }; + + const logsQuery = useQuery({ + queryKey: ['audit-logs', filters, range], + queryFn: () => { + const option = rangeOptions.find((o) => o.value === range); + const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined; + return listAuditLogs({ ...filters, since }); + }, + }); + + const logs = logsQuery.data ?? []; + const hasNext = logs.length === PAGE_SIZE; + const hasFilters = Boolean(resource || action || actor || range); + + const resetFilters = () => { + setResource(''); + setAction(''); + setActor(''); + setRange(''); + setPage(1); + }; + + return ( +
+
+
+

+ Audit Logs_ +

+

+ Every authenticated action recorded by the platform +

+
+
+ +
+
+
+
+ + { setResource(e.target.value); setPage(1); }} + placeholder="service, project…" + className={`${inputClass} w-44`} + /> +
+
+ + { setAction(e.target.value); setPage(1); }} + placeholder="create, deploy…" + className={`${inputClass} w-44`} + /> +
+
+ + { setActor(e.target.value); setPage(1); }} + placeholder="user email" + className={`${inputClass} w-52`} + /> +
+
+ + +
+ {hasFilters && ( + + )} +
+ +
+ + + + + + + + + + + + + + {logsQuery.isLoading ? ( + + + + ) : logsQuery.isError ? ( + + + + ) : logs.length === 0 ? ( + + + + ) : ( + logs.map((log) => ( + + + + + + + + + + )) + )} + +
TimeActorResourceActionResource IDIPDetails
+ +
+ Failed to load audit logs. +
+ +

No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.

+
+ {log.createdAt ? formatRelative(log.createdAt) : '—'} + + {log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')} + {log.resource} + + {log.action} + + + {log.resourceId || '—'} + {log.ipAddress || '—'} + {log.details && log.details !== '{}' ? log.details : '—'} +
+
+ +
+

+ Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''} +

+
+ + +
+
+
+
+
+ ); +} diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 0d38231..86c951c 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -50,6 +50,8 @@ import { Unlink, Cpu, MemoryStick, + ScrollText, + ChevronRight, } from 'lucide-react'; function SecondaryPageHeader({ title, description }: { title: string; description: string }) { @@ -1057,6 +1059,29 @@ export function SettingsPage() { {/* Git Providers Section */} + {/* Audit Logs Section */} +
+
+
+ +
+
+

Audit Logs

+

Every authenticated action recorded by the platform

+
+
+

+ Filter the full audit trail by resource, action, actor, and time range. +

+ +
+ {/* Runtime & Local State Section */}
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 8d996b7..df79a48 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -1202,13 +1202,19 @@ export interface paths { }; /** * List audit logs - * @description Retrieve recent audit log entries for the current user with optional filters + * @description Retrieve audit log entries with optional filters */ get: { parameters: { query?: { /** @description Filter audit logs by resource type */ resource?: string; + /** @description Filter audit logs by actor email substring */ + actor?: string; + /** @description Filter audit logs by actor user UUID */ + user_id?: string; + /** @description Only return entries created at or after this RFC3339 timestamp */ + since?: string; /** @description Filter audit logs by action type */ action?: string; /** @description 1-based page number */ diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index c8ec3e3..4663ef7 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -254,6 +254,9 @@ export type AuditLogEntity = { export type ListAuditLogsInput = { resource?: string; action?: string; + actor?: string; + userId?: string; + since?: string; page?: number; limit?: number; }; @@ -981,6 +984,15 @@ export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise 0) { searchParams.set('page', String(input.page)); } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 050fef0..b7bc29e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -827,7 +827,7 @@ paths: tags: - Audit summary: List audit logs - description: Retrieve recent audit log entries for the current user with optional filters + description: Retrieve audit log entries with optional filters security: - bearerAuth: [] parameters: @@ -837,6 +837,26 @@ paths: description: Filter audit logs by resource type schema: type: string + - name: actor + in: query + required: false + description: Filter audit logs by actor email substring + schema: + type: string + - name: user_id + in: query + required: false + description: Filter audit logs by actor user UUID + schema: + type: string + format: uuid + - name: since + in: query + required: false + description: Only return entries created at or after this RFC3339 timestamp + schema: + type: string + format: date-time - name: action in: query required: false