diff --git a/docs/deployment.md b/docs/deployment.md index e1e06f4..22304fe 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -177,7 +177,8 @@ technician's Gitea credentials for package operations. - Include `tapm_broker` in the existing Galera backup policy. - Audit records are retained indefinitely unless an administrator establishes a database retention policy. The Codes view previews the newest 15 records; the - Audit view can filter the retained history and displays up to the newest 250 + Audit view can filter retained history by event, customer/deployment label, + actor, host, package, source IP, or details and displays up to the newest 250 matching events. - Keep `.env` outside Git and readable only by the service administrator. - Rotate both Gitea package tokens and the OAuth secret if either webserver is diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go index 8ec6754..52d0530 100644 --- a/internal/app/audit_test.go +++ b/internal/app/audit_test.go @@ -11,18 +11,19 @@ func TestAuditFiltersFromRequest(t *testing.T) { t.Parallel() request := httptest.NewRequest( "GET", - "/portal?audit_range=7d&audit_event=package_downloaded&audit_actor=taiadmin&audit_hostname=pve01&audit_package=sentinelone-linux&audit_ip=10.10.1.25&audit_details=install-rmm", + "/portal?audit_range=7d&audit_event=package_downloaded&audit_customer=Acme&audit_actor=taiadmin&audit_hostname=pve01&audit_package=sentinelone-linux&audit_ip=10.10.1.25&audit_details=install-rmm", nil, ) filters := auditFiltersFromRequest(request) expected := auditFilters{ - TimeRange: "7d", - EventType: "package_downloaded", - Actor: "taiadmin", - Hostname: "pve01", - PackageSlug: "sentinelone-linux", - SourceIP: "10.10.1.25", - Details: "install-rmm", + TimeRange: "7d", + EventType: "package_downloaded", + CustomerLabel: "Acme", + Actor: "taiadmin", + Hostname: "pve01", + PackageSlug: "sentinelone-linux", + SourceIP: "10.10.1.25", + Details: "install-rmm", } if !reflect.DeepEqual(filters, expected) { t.Fatalf("filters = %#v, want %#v", filters, expected) @@ -50,26 +51,30 @@ func TestHasAuditQuery(t *testing.T) { func TestAuditQueryUsesPlaceholders(t *testing.T) { t.Parallel() filters := auditFilters{ - TimeRange: "all", - EventType: "package_downloaded", - Actor: "taiadmin", - Hostname: "pve01", - PackageSlug: "sentinelone-linux", - SourceIP: "10.10.1.25", - Details: "install-rmm", + TimeRange: "all", + EventType: "package_downloaded", + CustomerLabel: "Acme", + Actor: "taiadmin", + Hostname: "pve01", + PackageSlug: "sentinelone-linux", + SourceIP: "10.10.1.25", + Details: "install-rmm", } query, arguments := auditQuery(filters, 250) - if strings.Contains(query, filters.Actor) || strings.Contains(query, filters.Hostname) { + if strings.Contains(query, filters.CustomerLabel) || + strings.Contains(query, filters.Actor) || + strings.Contains(query, filters.Hostname) { t.Fatal("filter values must not be interpolated into the SQL query") } if strings.Contains(query, "INTERVAL") { t.Fatal("all-time query must not include a time restriction") } - if !strings.HasSuffix(query, "ORDER BY created_at DESC LIMIT ?") { + if !strings.HasSuffix(query, "ORDER BY ae.created_at DESC LIMIT ?") { t.Fatalf("query has unexpected limit: %s", query) } expectedArguments := []any{ "package_downloaded", + "Acme", "taiadmin", "pve01", "sentinelone-linux", diff --git a/internal/app/authorization.go b/internal/app/authorization.go index 6775799..a3ca546 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -61,6 +61,7 @@ func hasAuditQuery(r *http.Request) bool { for _, field := range []string{ "audit_range", "audit_event", + "audit_customer", "audit_actor", "audit_hostname", "audit_package", @@ -155,13 +156,14 @@ func auditFiltersFromRequest(r *http.Request) auditFilters { timeRange = "30d" } return auditFilters{ - TimeRange: timeRange, - EventType: limitedFilter(query.Get("audit_event"), 100), - Actor: limitedFilter(query.Get("audit_actor"), 255), - Hostname: limitedFilter(query.Get("audit_hostname"), 255), - PackageSlug: limitedFilter(query.Get("audit_package"), 100), - SourceIP: limitedFilter(query.Get("audit_ip"), 64), - Details: limitedFilter(query.Get("audit_details"), 255), + TimeRange: timeRange, + EventType: limitedFilter(query.Get("audit_event"), 100), + CustomerLabel: limitedFilter(query.Get("audit_customer"), 255), + Actor: limitedFilter(query.Get("audit_actor"), 255), + Hostname: limitedFilter(query.Get("audit_hostname"), 255), + PackageSlug: limitedFilter(query.Get("audit_package"), 100), + SourceIP: limitedFilter(query.Get("audit_ip"), 64), + Details: limitedFilter(query.Get("audit_details"), 255), } } @@ -198,32 +200,35 @@ func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) { } func auditQuery(filters auditFilters, limit int) (string, []any) { - query := `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at - FROM audit_events + query := `SELECT ae.event_type, COALESCE(a.customer_label, ''), ae.actor, + ae.hostname, ae.package_slug, ae.source_ip, ae.details, ae.created_at + FROM audit_events ae + LEFT JOIN authorizations a ON a.id = ae.authorization_id WHERE 1 = 1` var arguments []any timeClauses := map[string]string{ - "24h": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY", - "7d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY", - "30d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY", - "90d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY", + "24h": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY", + "7d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY", + "30d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY", + "90d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY", } query += timeClauses[filters.TimeRange] if filters.EventType != "" { - query += " AND event_type = ?" + query += " AND ae.event_type = ?" arguments = append(arguments, filters.EventType) } containsFilters := []struct { column string value string }{ - {"actor", filters.Actor}, - {"hostname", filters.Hostname}, - {"package_slug", filters.PackageSlug}, - {"source_ip", filters.SourceIP}, - {"details", filters.Details}, + {"COALESCE(a.customer_label, '')", filters.CustomerLabel}, + {"ae.actor", filters.Actor}, + {"ae.hostname", filters.Hostname}, + {"ae.package_slug", filters.PackageSlug}, + {"ae.source_ip", filters.SourceIP}, + {"ae.details", filters.Details}, } for _, filter := range containsFilters { if filter.value == "" { @@ -232,7 +237,7 @@ func auditQuery(filters auditFilters, limit int) (string, []any) { query += " AND LOCATE(?, " + filter.column + ") > 0" arguments = append(arguments, filter.value) } - query += " ORDER BY created_at DESC LIMIT ?" + query += " ORDER BY ae.created_at DESC LIMIT ?" arguments = append(arguments, limit) return query, arguments } @@ -254,6 +259,7 @@ func (s *Server) listAuditEvents(r *http.Request, filters auditFilters, limit in var record auditRecord if err := rows.Scan( &record.EventType, + &record.CustomerLabel, &record.Actor, &record.Hostname, &record.PackageSlug, diff --git a/internal/app/server.go b/internal/app/server.go index 7caf902..e5bec3b 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -69,23 +69,25 @@ type authorizationRecord struct { } type auditRecord struct { - EventType string - Actor string - Hostname string - PackageSlug string - SourceIP string - Details string - CreatedAt time.Time + EventType string + CustomerLabel string + Actor string + Hostname string + PackageSlug string + SourceIP string + Details string + CreatedAt time.Time } type auditFilters struct { - TimeRange string - EventType string - Actor string - Hostname string - PackageSlug string - SourceIP string - Details string + TimeRange string + EventType string + CustomerLabel string + Actor string + Hostname string + PackageSlug string + SourceIP string + Details string } type pageData struct { diff --git a/internal/app/server_test.go b/internal/app/server_test.go index ea3d6e6..6befba4 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -67,9 +67,10 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) { data.AuditFilters.TimeRange = "30d" data.AuditEventTypes = []string{"package_downloaded"} data.AuditEvents = []auditRecord{{ - EventType: "package_downloaded", - Hostname: "pve01", - CreatedAt: time.Now(), + EventType: "package_downloaded", + CustomerLabel: "Acme cluster refresh", + Hostname: "pve01", + CreatedAt: time.Now(), }} if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil { t.Fatal(err) diff --git a/internal/app/static/app.css b/internal/app/static/app.css index 545bf9e..951be74 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -265,11 +265,13 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .audit-table { display: grid; max-height: 520px; overflow: auto; } .audit-row { display: grid; - grid-template-columns: minmax(190px, .8fr) minmax(190px, 1fr) minmax(220px, 1.2fr); + grid-template-columns: minmax(165px, .8fr) minmax(180px, 1fr) minmax(190px, 1fr) minmax(220px, 1.2fr); gap: 22px; padding: 15px 28px; border-bottom: 1px solid var(--line); } +.audit-header { background: var(--panel-2); } +.audit-header span { color: var(--ink); font-size: .68rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } .audit-row:last-child { border-bottom: 0; } .audit-row > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } .audit-row span, .audit-row small { color: var(--muted); overflow-wrap: anywhere; } @@ -302,7 +304,6 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .hero, .content-grid, .package-layout { grid-template-columns: 1fr; } .audit-filters { grid-template-columns: 1fr 1fr; } .audit-row { grid-template-columns: 1fr 1fr; } - .audit-row > div:last-child { grid-column: 1 / -1; } .policy-card { width: 100%; } .package-table { border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 24px; } } @@ -319,6 +320,8 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .page { width: 94vw; padding-top: 34px; } .field-row, dl { grid-template-columns: 1fr; } .audit-filters { grid-template-columns: 1fr; } + .audit-row { grid-template-columns: 1fr; } + .audit-header { display: none; } .code-reveal { align-items: stretch; flex-direction: column; } .panel-heading-action { align-items: flex-start; flex-direction: column; } } diff --git a/internal/app/templates/audit.html b/internal/app/templates/audit.html index d3846ad..0683acd 100644 --- a/internal/app/templates/audit.html +++ b/internal/app/templates/audit.html @@ -38,6 +38,7 @@ {{end}} + diff --git a/internal/app/templates/components.html b/internal/app/templates/components.html index e259f6f..bc3f8f7 100644 --- a/internal/app/templates/components.html +++ b/internal/app/templates/components.html @@ -25,12 +25,21 @@ {{define "audit-rows"}}