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.
|
- 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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user