Implement dashboard and audit log templates, add paginated audit log support

- Added `web/templates/{dashboard,audit,base,accounts,account_detail}.html` for a consistent UI.
- Implemented new audit log endpoint (`GET /v1/audit`) with filtering and pagination via `ListAuditEventsPaged`.
- Extended `AuditQueryParams`, added `AuditEventView` for joined actor/target usernames.
- Updated configuration (`goimports` preference), linting rules, and E2E tests.
- No logic changes to existing APIs.
This commit is contained in:
2026-03-11 14:05:08 -07:00
parent 14083b82b4
commit e63d9863b6
20 changed files with 829 additions and 84 deletions

View File

@@ -616,12 +616,23 @@ func (db *DB) ListTokensForAccount(accountID int64) ([]*model.TokenRecord, error
return records, rows.Err()
}
// AuditQueryParams filters for ListAuditEvents.
// AuditQueryParams filters for ListAuditEvents and ListAuditEventsPaged.
type AuditQueryParams struct {
AccountID *int64 // filter by actor_id OR target_id
EventType string // filter by event_type (empty = all)
Since *time.Time // filter by event_time >= Since
Limit int // maximum rows to return (0 = no limit)
AccountID *int64
Since *time.Time
EventType string
Limit int
Offset int
}
// AuditEventView extends AuditEvent with resolved actor/target usernames for display.
// Usernames are resolved via a LEFT JOIN and are empty if the actor/target is unknown.
// The fieldalignment hint is suppressed: the embedded model.AuditEvent layout is fixed
// and changing to explicit fields would break JSON serialisation.
type AuditEventView struct { //nolint:govet
model.AuditEvent
ActorUsername string `json:"actor_username,omitempty"`
TargetUsername string `json:"target_username,omitempty"`
}
// ListAuditEvents returns audit log entries matching the given parameters,
@@ -741,6 +752,90 @@ func (db *DB) TailAuditEvents(n int) ([]*model.AuditEvent, error) {
return events, nil
}
// ListAuditEventsPaged returns audit log entries matching params, newest first,
// with LEFT JOINed actor/target usernames for display. Returns the matching rows
// and the total count of matching rows (for pagination).
//
// Security: No credential material is included in audit_log rows per the
// WriteAuditEvent contract; joining account usernames is safe for display.
func (db *DB) ListAuditEventsPaged(p AuditQueryParams) ([]*AuditEventView, int64, error) {
// Build the shared WHERE clause and args.
where := " WHERE 1=1"
args := []interface{}{}
if p.AccountID != nil {
where += ` AND (al.actor_id = ? OR al.target_id = ?)`
args = append(args, *p.AccountID, *p.AccountID)
}
if p.EventType != "" {
where += ` AND al.event_type = ?`
args = append(args, p.EventType)
}
if p.Since != nil {
where += ` AND al.event_time >= ?`
args = append(args, p.Since.UTC().Format(time.RFC3339))
}
// Count total matching rows first.
countQuery := `SELECT COUNT(*) FROM audit_log al` + where
var total int64
if err := db.sql.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("db: count audit events: %w", err)
}
// Fetch the page with username resolution via LEFT JOIN.
query := `
SELECT al.id, al.event_time, al.event_type,
al.actor_id, al.target_id,
al.ip_address, al.details,
COALESCE(a1.username, ''), COALESCE(a2.username, '')
FROM audit_log al
LEFT JOIN accounts a1 ON al.actor_id = a1.id
LEFT JOIN accounts a2 ON al.target_id = a2.id` + where + `
ORDER BY al.event_time DESC, al.id DESC`
pageArgs := append(args, p.Limit, p.Offset) //nolint:gocritic // intentional new slice
query += ` LIMIT ? OFFSET ?`
rows, err := db.sql.Query(query, pageArgs...)
if err != nil {
return nil, 0, fmt.Errorf("db: list audit events paged: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*AuditEventView
for rows.Next() {
var ev AuditEventView
var eventTimeStr string
var ipAddr, details *string
if err := rows.Scan(
&ev.ID, &eventTimeStr, &ev.EventType,
&ev.ActorID, &ev.TargetID,
&ipAddr, &details,
&ev.ActorUsername, &ev.TargetUsername,
); err != nil {
return nil, 0, fmt.Errorf("db: scan audit event view: %w", err)
}
ev.EventTime, err = parseTime(eventTimeStr)
if err != nil {
return nil, 0, err
}
if ipAddr != nil {
ev.IPAddress = *ipAddr
}
if details != nil {
ev.Details = *details
}
events = append(events, &ev)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return events, total, nil
}
// SetSystemToken stores or replaces the active service token JTI for a system account.
func (db *DB) SetSystemToken(accountID int64, jti string, expiresAt time.Time) error {
n := now()