update audit view

This commit is contained in:
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. - Include `tapm_broker` in the existing Galera backup policy.
- Audit records are retained indefinitely unless an administrator establishes a - Audit records are retained indefinitely unless an administrator establishes a
database retention policy. The Codes view previews the newest 15 records; the 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. matching events.
- Keep `.env` outside Git and readable only by the service administrator. - Keep `.env` outside Git and readable only by the service administrator.
- Rotate both Gitea package tokens and the OAuth secret if either webserver is - Rotate both Gitea package tokens and the OAuth secret if either webserver is
+22 -17
View File
@@ -11,18 +11,19 @@ func TestAuditFiltersFromRequest(t *testing.T) {
t.Parallel() t.Parallel()
request := httptest.NewRequest( request := httptest.NewRequest(
"GET", "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, nil,
) )
filters := auditFiltersFromRequest(request) filters := auditFiltersFromRequest(request)
expected := auditFilters{ expected := auditFilters{
TimeRange: "7d", TimeRange: "7d",
EventType: "package_downloaded", EventType: "package_downloaded",
Actor: "taiadmin", CustomerLabel: "Acme",
Hostname: "pve01", Actor: "taiadmin",
PackageSlug: "sentinelone-linux", Hostname: "pve01",
SourceIP: "10.10.1.25", PackageSlug: "sentinelone-linux",
Details: "install-rmm", SourceIP: "10.10.1.25",
Details: "install-rmm",
} }
if !reflect.DeepEqual(filters, expected) { if !reflect.DeepEqual(filters, expected) {
t.Fatalf("filters = %#v, want %#v", filters, expected) t.Fatalf("filters = %#v, want %#v", filters, expected)
@@ -50,26 +51,30 @@ func TestHasAuditQuery(t *testing.T) {
func TestAuditQueryUsesPlaceholders(t *testing.T) { func TestAuditQueryUsesPlaceholders(t *testing.T) {
t.Parallel() t.Parallel()
filters := auditFilters{ filters := auditFilters{
TimeRange: "all", TimeRange: "all",
EventType: "package_downloaded", EventType: "package_downloaded",
Actor: "taiadmin", CustomerLabel: "Acme",
Hostname: "pve01", Actor: "taiadmin",
PackageSlug: "sentinelone-linux", Hostname: "pve01",
SourceIP: "10.10.1.25", PackageSlug: "sentinelone-linux",
Details: "install-rmm", SourceIP: "10.10.1.25",
Details: "install-rmm",
} }
query, arguments := auditQuery(filters, 250) 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") t.Fatal("filter values must not be interpolated into the SQL query")
} }
if strings.Contains(query, "INTERVAL") { if strings.Contains(query, "INTERVAL") {
t.Fatal("all-time query must not include a time restriction") 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) t.Fatalf("query has unexpected limit: %s", query)
} }
expectedArguments := []any{ expectedArguments := []any{
"package_downloaded", "package_downloaded",
"Acme",
"taiadmin", "taiadmin",
"pve01", "pve01",
"sentinelone-linux", "sentinelone-linux",
+26 -20
View File
@@ -61,6 +61,7 @@ func hasAuditQuery(r *http.Request) bool {
for _, field := range []string{ for _, field := range []string{
"audit_range", "audit_range",
"audit_event", "audit_event",
"audit_customer",
"audit_actor", "audit_actor",
"audit_hostname", "audit_hostname",
"audit_package", "audit_package",
@@ -155,13 +156,14 @@ func auditFiltersFromRequest(r *http.Request) auditFilters {
timeRange = "30d" timeRange = "30d"
} }
return auditFilters{ return auditFilters{
TimeRange: timeRange, TimeRange: timeRange,
EventType: limitedFilter(query.Get("audit_event"), 100), EventType: limitedFilter(query.Get("audit_event"), 100),
Actor: limitedFilter(query.Get("audit_actor"), 255), CustomerLabel: limitedFilter(query.Get("audit_customer"), 255),
Hostname: limitedFilter(query.Get("audit_hostname"), 255), Actor: limitedFilter(query.Get("audit_actor"), 255),
PackageSlug: limitedFilter(query.Get("audit_package"), 100), Hostname: limitedFilter(query.Get("audit_hostname"), 255),
SourceIP: limitedFilter(query.Get("audit_ip"), 64), PackageSlug: limitedFilter(query.Get("audit_package"), 100),
Details: limitedFilter(query.Get("audit_details"), 255), 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) { func auditQuery(filters auditFilters, limit int) (string, []any) {
query := `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at query := `SELECT ae.event_type, COALESCE(a.customer_label, ''), ae.actor,
FROM audit_events 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` WHERE 1 = 1`
var arguments []any var arguments []any
timeClauses := map[string]string{ timeClauses := map[string]string{
"24h": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY", "24h": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY",
"7d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY", "7d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY",
"30d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY", "30d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY",
"90d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY", "90d": " AND ae.created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY",
} }
query += timeClauses[filters.TimeRange] query += timeClauses[filters.TimeRange]
if filters.EventType != "" { if filters.EventType != "" {
query += " AND event_type = ?" query += " AND ae.event_type = ?"
arguments = append(arguments, filters.EventType) arguments = append(arguments, filters.EventType)
} }
containsFilters := []struct { containsFilters := []struct {
column string column string
value string value string
}{ }{
{"actor", filters.Actor}, {"COALESCE(a.customer_label, '')", filters.CustomerLabel},
{"hostname", filters.Hostname}, {"ae.actor", filters.Actor},
{"package_slug", filters.PackageSlug}, {"ae.hostname", filters.Hostname},
{"source_ip", filters.SourceIP}, {"ae.package_slug", filters.PackageSlug},
{"details", filters.Details}, {"ae.source_ip", filters.SourceIP},
{"ae.details", filters.Details},
} }
for _, filter := range containsFilters { for _, filter := range containsFilters {
if filter.value == "" { if filter.value == "" {
@@ -232,7 +237,7 @@ func auditQuery(filters auditFilters, limit int) (string, []any) {
query += " AND LOCATE(?, " + filter.column + ") > 0" query += " AND LOCATE(?, " + filter.column + ") > 0"
arguments = append(arguments, filter.value) arguments = append(arguments, filter.value)
} }
query += " ORDER BY created_at DESC LIMIT ?" query += " ORDER BY ae.created_at DESC LIMIT ?"
arguments = append(arguments, limit) arguments = append(arguments, limit)
return query, arguments return query, arguments
} }
@@ -254,6 +259,7 @@ func (s *Server) listAuditEvents(r *http.Request, filters auditFilters, limit in
var record auditRecord var record auditRecord
if err := rows.Scan( if err := rows.Scan(
&record.EventType, &record.EventType,
&record.CustomerLabel,
&record.Actor, &record.Actor,
&record.Hostname, &record.Hostname,
&record.PackageSlug, &record.PackageSlug,
+16 -14
View File
@@ -69,23 +69,25 @@ type authorizationRecord struct {
} }
type auditRecord struct { type auditRecord struct {
EventType string EventType string
Actor string CustomerLabel string
Hostname string Actor string
PackageSlug string Hostname string
SourceIP string PackageSlug string
Details string SourceIP string
CreatedAt time.Time Details string
CreatedAt time.Time
} }
type auditFilters struct { type auditFilters struct {
TimeRange string TimeRange string
EventType string EventType string
Actor string CustomerLabel string
Hostname string Actor string
PackageSlug string Hostname string
SourceIP string PackageSlug string
Details string SourceIP string
Details string
} }
type pageData struct { type pageData struct {
+4 -3
View File
@@ -67,9 +67,10 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
data.AuditFilters.TimeRange = "30d" data.AuditFilters.TimeRange = "30d"
data.AuditEventTypes = []string{"package_downloaded"} data.AuditEventTypes = []string{"package_downloaded"}
data.AuditEvents = []auditRecord{{ data.AuditEvents = []auditRecord{{
EventType: "package_downloaded", EventType: "package_downloaded",
Hostname: "pve01", CustomerLabel: "Acme cluster refresh",
CreatedAt: time.Now(), Hostname: "pve01",
CreatedAt: time.Now(),
}} }}
if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil { if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil {
t.Fatal(err) t.Fatal(err)
+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-table { display: grid; max-height: 520px; overflow: auto; }
.audit-row { .audit-row {
display: grid; 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; gap: 22px;
padding: 15px 28px; padding: 15px 28px;
border-bottom: 1px solid var(--line); 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:last-child { border-bottom: 0; }
.audit-row > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } .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; } .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; } .hero, .content-grid, .package-layout { grid-template-columns: 1fr; }
.audit-filters { grid-template-columns: 1fr 1fr; } .audit-filters { grid-template-columns: 1fr 1fr; }
.audit-row { 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%; } .policy-card { width: 100%; }
.package-table { border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 24px; } .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; } .page { width: 94vw; padding-top: 34px; }
.field-row, dl { grid-template-columns: 1fr; } .field-row, dl { grid-template-columns: 1fr; }
.audit-filters { 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; } .code-reveal { align-items: stretch; flex-direction: column; }
.panel-heading-action { align-items: flex-start; flex-direction: column; } .panel-heading-action { align-items: flex-start; flex-direction: column; }
} }
+1
View File
@@ -38,6 +38,7 @@
{{end}} {{end}}
</select> </select>
</label> </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>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>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> <label>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
+9
View File
@@ -25,12 +25,21 @@
{{define "audit-rows"}} {{define "audit-rows"}}
<div class="audit-table"> <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}} {{range .AuditEvents}}
<div class="audit-row"> <div class="audit-row">
<div> <div>
<strong>{{.EventType}}</strong> <strong>{{.EventType}}</strong>
<small>{{formatTime .CreatedAt}}</small> <small>{{formatTime .CreatedAt}}</small>
</div> </div>
<div>
{{if .CustomerLabel}}<span>{{.CustomerLabel}}</span>{{else}}<span></span>{{end}}
</div>
<div> <div>
{{if .Actor}}<span>{{.Actor}}</span>{{end}} {{if .Actor}}<span>{{.Actor}}</span>{{end}}
{{if .Hostname}}<span>{{.Hostname}}</span>{{end}} {{if .Hostname}}<span>{{.Hostname}}</span>{{end}}