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:
@@ -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()
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
// migration represents a single schema migration with an ID and SQL statement.
|
||||
type migration struct {
|
||||
id int
|
||||
sql string
|
||||
id int
|
||||
}
|
||||
|
||||
// migrations is the ordered list of schema migrations applied to the database.
|
||||
|
||||
Reference in New Issue
Block a user