update audit view
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user