feat(audit): server-side paging, search and category filter; one event format
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.
Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.
Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.
audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.
Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
This commit is contained in:
@@ -140,6 +140,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure workload indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuditIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure audit indexes: %v", err)
|
||||
}
|
||||
|
||||
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
|
||||
log.Printf("warning: failed to list instances for default step seeding: %v", err)
|
||||
} else {
|
||||
|
||||
@@ -527,18 +527,29 @@ echo "vantage-agent updated to ${VERSION} and restarted."
|
||||
}
|
||||
|
||||
func listAuditEvents(c *gin.Context) {
|
||||
limit := int64(100)
|
||||
f := services.AuditFilter{
|
||||
Search: c.Query("q"),
|
||||
Category: c.Query("category"),
|
||||
}
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
|
||||
limit = n
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
events, err := services.ListAuditEvents(auth.InstanceID(c), limit)
|
||||
if s := c.Query("skip"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n >= 0 {
|
||||
f.Skip = n
|
||||
}
|
||||
}
|
||||
|
||||
events, total, err := services.ListAuditEvents(auth.InstanceID(c), f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
// An object rather than a bare array: a page is meaningless without the
|
||||
// total it came from, and a short page is not proof of the end of the log.
|
||||
c.JSON(http.StatusOK, gin.H{"events": events, "total": total})
|
||||
}
|
||||
|
||||
func getSettings(c *gin.Context) {
|
||||
|
||||
@@ -3,11 +3,14 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
@@ -29,23 +32,119 @@ func LogEvent(instanceID, eventType, actor, serverID, keyID, details string) {
|
||||
}
|
||||
}
|
||||
|
||||
func ListAuditEvents(instanceID string, limit int64) ([]models.AuditEvent, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// AuditFilter narrows a page of the audit log.
|
||||
//
|
||||
// Filtering is done here rather than in the browser because the audit log is
|
||||
// the one collection deliberately kept for months — audit_retention_days is a
|
||||
// licensed entitlement — and it is read to answer questions about the past
|
||||
// ("who removed that key in March"). A browser filtering the most recent 200
|
||||
// rows would answer "no results" for an event that exists, which is worse than
|
||||
// having no search at all.
|
||||
type AuditFilter struct {
|
||||
// Search matches actor, details or event type, case-insensitively.
|
||||
Search string
|
||||
// Category matches the segment before the first dot in an event type —
|
||||
// "workflow", "key", "server". Event types are named consistently enough
|
||||
// that the prefix is a real grouping rather than a guess.
|
||||
Category string
|
||||
Limit int64
|
||||
Skip int64
|
||||
}
|
||||
|
||||
const (
|
||||
auditDefaultLimit = 50
|
||||
auditMaxLimit = 200
|
||||
)
|
||||
|
||||
// ListAuditEvents returns one page of the audit log, newest first, along with
|
||||
// the total number of events matching the filter.
|
||||
//
|
||||
// The total is what the pager needs to say "1–50 of 3,214", and it is counted
|
||||
// rather than inferred: a page that comes back short is not evidence of the
|
||||
// end of the log, only of the end of this page.
|
||||
func ListAuditEvents(instanceID string, f AuditFilter) ([]models.AuditEvent, int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = auditDefaultLimit
|
||||
}
|
||||
if limit > auditMaxLimit {
|
||||
limit = auditMaxLimit
|
||||
}
|
||||
skip := f.Skip
|
||||
if skip < 0 {
|
||||
skip = 0
|
||||
}
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
|
||||
if cat := strings.TrimSpace(f.Category); cat != "" {
|
||||
// Anchored and quoted: the category arrives from a query string, and an
|
||||
// unescaped value would let a caller inject a regex that scans the
|
||||
// collection for as long as it likes.
|
||||
filter["event_type"] = bson.M{"$regex": "^" + regexp.QuoteMeta(cat) + `\.`}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(f.Search); q != "" {
|
||||
rx := bson.M{"$regex": regexp.QuoteMeta(q), "$options": "i"}
|
||||
// Event type is searched alongside actor and details so the raw name is
|
||||
// still a way in for anyone who knows it, even though the UI shows a
|
||||
// friendly label.
|
||||
and := []bson.M{{"$or": []bson.M{
|
||||
{"actor": rx},
|
||||
{"details": rx},
|
||||
{"event_type": rx},
|
||||
}}}
|
||||
if existing, ok := filter["event_type"]; ok {
|
||||
and = append(and, bson.M{"event_type": existing})
|
||||
delete(filter, "event_type")
|
||||
}
|
||||
filter["$and"] = and
|
||||
}
|
||||
|
||||
total, err := db.Col("audit_logs").CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
opts := options.Find().
|
||||
SetSort(bson.D{{Key: "created_at", Value: -1}}).
|
||||
SetSkip(skip).
|
||||
SetLimit(limit)
|
||||
|
||||
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"instance_id": instanceID}, opts)
|
||||
cursor, err := db.Col("audit_logs").Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var events []models.AuditEvent
|
||||
if err := cursor.All(ctx, &events); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
return events, nil
|
||||
return events, total, nil
|
||||
}
|
||||
|
||||
// EnsureAuditIndexes declares the audit log's read and sweep indexes.
|
||||
//
|
||||
// There were none at all, so every page was a collection scan with an in-memory
|
||||
// sort over a collection that is only ever appended to and kept for as long as
|
||||
// the licence allows. Warn rather than fatal, matching EnsureSecretIndexes: a
|
||||
// missing index is slow, not wrong.
|
||||
func EnsureAuditIndexes() error {
|
||||
ctx := context.Background()
|
||||
|
||||
idx := []mongo.IndexModel{
|
||||
// Serves both the page query and its sort.
|
||||
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "created_at", Value: -1}}},
|
||||
// The retention sweeper deletes by age within an instance.
|
||||
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "event_type", Value: 1}, {Key: "created_at", Value: -1}}},
|
||||
}
|
||||
if _, err := db.Col("audit_logs").Indexes().CreateMany(ctx, idx); err != nil {
|
||||
log.Printf("warning: audit_logs indexes: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user