update audit view

This commit is contained in:
David Schroeder
2026-07-25 15:50:06 -05:00
parent 01a3d1250a
commit 90d5b637ac
8 changed files with 85 additions and 57 deletions
+2 -1
View File
@@ -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
+8 -3
View File
@@ -11,13 +11,14 @@ 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",
CustomerLabel: "Acme",
Actor: "taiadmin",
Hostname: "pve01",
PackageSlug: "sentinelone-linux",
@@ -52,6 +53,7 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) {
filters := auditFilters{
TimeRange: "all",
EventType: "package_downloaded",
CustomerLabel: "Acme",
Actor: "taiadmin",
Hostname: "pve01",
PackageSlug: "sentinelone-linux",
@@ -59,17 +61,20 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) {
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",
+19 -13
View File
@@ -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",
@@ -157,6 +158,7 @@ func auditFiltersFromRequest(r *http.Request) auditFilters {
return auditFilters{
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),
@@ -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,
+2
View File
@@ -70,6 +70,7 @@ type authorizationRecord struct {
type auditRecord struct {
EventType string
CustomerLabel string
Actor string
Hostname string
PackageSlug string
@@ -81,6 +82,7 @@ type auditRecord struct {
type auditFilters struct {
TimeRange string
EventType string
CustomerLabel string
Actor string
Hostname string
PackageSlug string
+1
View File
@@ -68,6 +68,7 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
data.AuditEventTypes = []string{"package_downloaded"}
data.AuditEvents = []auditRecord{{
EventType: "package_downloaded",
CustomerLabel: "Acme cluster refresh",
Hostname: "pve01",
CreatedAt: time.Now(),
}}
+5 -2
View File
@@ -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; }
}
+1
View File
@@ -38,6 +38,7 @@
{{end}}
</select>
</label>
<label>Customer / deployment <input name="audit_customer" value="{{.AuditFilters.CustomerLabel}}" placeholder="Acme cluster refresh"></label>
<label>Technician / actor <input name="audit_actor" value="{{.AuditFilters.Actor}}" placeholder="taiadmin"></label>
<label>Hostname <input name="audit_hostname" value="{{.AuditFilters.Hostname}}" placeholder="pve01"></label>
<label>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
+9
View File
@@ -25,12 +25,21 @@
{{define "audit-rows"}}
<div class="audit-table">
<div class="audit-row audit-header" aria-hidden="true">
<span>Event</span>
<span>Customer / deployment</span>
<span>Actor / host / package</span>
<span>Source / details</span>
</div>
{{range .AuditEvents}}
<div class="audit-row">
<div>
<strong>{{.EventType}}</strong>
<small>{{formatTime .CreatedAt}}</small>
</div>
<div>
{{if .CustomerLabel}}<span>{{.CustomerLabel}}</span>{{else}}<span></span>{{end}}
</div>
<div>
{{if .Actor}}<span>{{.Actor}}</span>{{end}}
{{if .Hostname}}<span>{{.Hostname}}</span>{{end}}