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:
@@ -15,10 +15,10 @@ import (
|
||||
// Config is the top-level configuration structure parsed from the TOML file.
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
MasterKey MasterKeyConfig `toml:"master_key"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Tokens TokensConfig `toml:"tokens"`
|
||||
Argon2 Argon2Config `toml:"argon2"`
|
||||
MasterKey MasterKeyConfig `toml:"master_key"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP listener and TLS settings.
|
||||
|
||||
@@ -27,7 +27,10 @@ func TestGenerateEd25519KeyPair(t *testing.T) {
|
||||
}
|
||||
|
||||
// Public key must be extractable from private key.
|
||||
derived := priv1.Public().(ed25519.PublicKey)
|
||||
derived, ok := priv1.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
t.Fatal("priv1.Public() did not return ed25519.PublicKey")
|
||||
}
|
||||
if !bytes.Equal(derived, pub1) {
|
||||
t.Error("public key derived from private key does not match generated public key")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,8 +38,17 @@ const (
|
||||
|
||||
// ClaimsFromContext retrieves the validated JWT claims from the request context.
|
||||
// Returns nil if no claims are present (unauthenticated request).
|
||||
//
|
||||
// Security: The type assertion uses the ok form so a context value of the wrong
|
||||
// type (e.g. from a different package's context injection) returns nil rather
|
||||
// than panicking.
|
||||
func ClaimsFromContext(ctx context.Context) *token.Claims {
|
||||
c, _ := ctx.Value(claimsKey).(*token.Claims)
|
||||
// ok is intentionally checked: if the value is absent or the wrong type,
|
||||
// c is nil (zero value for *token.Claims), which is the correct "no auth" result.
|
||||
c, ok := ctx.Value(claimsKey).(*token.Claims)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -152,18 +161,18 @@ func RequireRole(role string) func(http.Handler) http.Handler {
|
||||
|
||||
// rateLimitEntry holds the token bucket state for a single IP.
|
||||
type rateLimitEntry struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
tokens float64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ipRateLimiter implements a per-IP token bucket rate limiter.
|
||||
type ipRateLimiter struct {
|
||||
rps float64 // refill rate: tokens per second
|
||||
burst float64 // bucket capacity
|
||||
ttl time.Duration // how long to keep idle entries
|
||||
mu sync.Mutex
|
||||
ips map[string]*rateLimitEntry
|
||||
rps float64
|
||||
burst float64
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// RateLimit returns middleware implementing a per-IP token bucket.
|
||||
|
||||
@@ -314,14 +314,14 @@ func TestExtractBearerToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
wantErr bool
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", "Bearer mytoken123", false, "mytoken123"},
|
||||
{"missing header", "", true, ""},
|
||||
{"no bearer prefix", "Token mytoken123", true, ""},
|
||||
{"empty token", "Bearer ", true, ""},
|
||||
{"case insensitive", "bearer mytoken123", false, "mytoken123"},
|
||||
{"valid", "Bearer mytoken123", "mytoken123", false},
|
||||
{"missing header", "", "", true},
|
||||
{"no bearer prefix", "Token mytoken123", "", true},
|
||||
{"empty token", "Bearer ", "", true},
|
||||
{"case insensitive", "bearer mytoken123", "mytoken123", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -29,47 +29,40 @@ const (
|
||||
// Fields containing credential material (PasswordHash, TOTPSecretEnc) are
|
||||
// never serialised into API responses — callers must explicitly omit them.
|
||||
type Account struct {
|
||||
ID int64 `json:"-"`
|
||||
UUID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AccountType AccountType `json:"account_type"`
|
||||
Status AccountStatus `json:"status"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
|
||||
// PasswordHash is a PHC-format Argon2id string. Never returned in API
|
||||
// responses; populated only when reading from the database.
|
||||
PasswordHash string `json:"-"`
|
||||
|
||||
// TOTPSecretEnc and TOTPSecretNonce hold the AES-256-GCM-encrypted TOTP
|
||||
// shared secret. Never returned in API responses.
|
||||
TOTPSecretEnc []byte `json:"-"`
|
||||
TOTPSecretNonce []byte `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
UUID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AccountType AccountType `json:"account_type"`
|
||||
Status AccountStatus `json:"status"`
|
||||
PasswordHash string `json:"-"`
|
||||
TOTPSecretEnc []byte `json:"-"`
|
||||
TOTPSecretNonce []byte `json:"-"`
|
||||
ID int64 `json:"-"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
}
|
||||
|
||||
// Role is a string label assigned to an account to grant permissions.
|
||||
type Role struct {
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
GrantedBy *int64 `json:"-"`
|
||||
Role string `json:"role"`
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
Role string `json:"role"`
|
||||
GrantedBy *int64 `json:"-"`
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
}
|
||||
|
||||
// TokenRecord tracks an issued JWT by its JTI for revocation purposes.
|
||||
// The raw token string is never stored — only the JTI identifier.
|
||||
type TokenRecord struct {
|
||||
ID int64 `json:"-"`
|
||||
JTI string `json:"jti"`
|
||||
AccountID int64 `json:"-"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
RevokeReason string `json:"revoke_reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
JTI string `json:"jti"`
|
||||
RevokeReason string `json:"revoke_reason,omitempty"`
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
}
|
||||
|
||||
// IsRevoked reports whether the token has been explicitly revoked.
|
||||
@@ -84,46 +77,40 @@ func (t *TokenRecord) IsExpired() bool {
|
||||
|
||||
// SystemToken represents the current active service token for a system account.
|
||||
type SystemToken struct {
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
JTI string `json:"jti"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
JTI string `json:"jti"`
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
}
|
||||
|
||||
// PGCredential holds Postgres connection details for a system account.
|
||||
// The password is encrypted at rest; PGPassword is only populated after
|
||||
// decryption and must never be logged or included in API responses.
|
||||
type PGCredential struct {
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
PGHost string `json:"host"`
|
||||
PGPort int `json:"port"`
|
||||
PGDatabase string `json:"database"`
|
||||
PGUsername string `json:"username"`
|
||||
|
||||
// PGPassword is plaintext only after decryption. Never log or serialise.
|
||||
PGPassword string `json:"-"`
|
||||
|
||||
// PGPasswordEnc and PGPasswordNonce are the AES-256-GCM ciphertext and
|
||||
// nonce stored in the database.
|
||||
PGPasswordEnc []byte `json:"-"`
|
||||
PGPasswordNonce []byte `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PGHost string `json:"host"`
|
||||
PGDatabase string `json:"database"`
|
||||
PGUsername string `json:"username"`
|
||||
PGPassword string `json:"-"`
|
||||
PGPasswordEnc []byte `json:"-"`
|
||||
PGPasswordNonce []byte `json:"-"`
|
||||
ID int64 `json:"-"`
|
||||
AccountID int64 `json:"-"`
|
||||
PGPort int `json:"port"`
|
||||
}
|
||||
|
||||
// AuditEvent represents a single entry in the append-only audit log.
|
||||
// Details must never contain credential material (passwords, tokens, secrets).
|
||||
type AuditEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
EventTime time.Time `json:"event_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ActorID *int64 `json:"-"`
|
||||
TargetID *int64 `json:"-"`
|
||||
EventType string `json:"event_type"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Details string `json:"details,omitempty"` // JSON string; no secrets
|
||||
Details string `json:"details,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// Audit event type constants — exhaustive list, enforced at write time.
|
||||
|
||||
@@ -30,10 +30,10 @@ import (
|
||||
type Server struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
privKey ed25519.PrivateKey
|
||||
pubKey ed25519.PublicKey
|
||||
masterKey []byte
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Server with the given dependencies.
|
||||
@@ -83,6 +83,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
|
||||
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||
|
||||
// Apply global middleware: logging and login-path rate limiting.
|
||||
var root http.Handler = mux
|
||||
@@ -294,10 +295,10 @@ type validateRequest struct {
|
||||
}
|
||||
|
||||
type validateResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -422,9 +423,9 @@ type accountResponse struct {
|
||||
Username string `json:"username"`
|
||||
AccountType string `json:"account_type"`
|
||||
Status string `json:"status"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
}
|
||||
|
||||
func accountToResponse(a *model.Account) accountResponse {
|
||||
@@ -727,10 +728,10 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type pgCredRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetPGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -802,6 +803,72 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Audit endpoints ----
|
||||
|
||||
// handleListAudit returns paginated audit log entries with resolved usernames.
|
||||
// Query params: limit (1-200, default 50), offset, event_type, actor_id (UUID).
|
||||
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
limit := parseIntParam(q.Get("limit"), 50)
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
offset := parseIntParam(q.Get("offset"), 0)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
params := db.AuditQueryParams{
|
||||
EventType: q.Get("event_type"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Resolve actor_id from UUID to internal int64.
|
||||
if actorUUID := q.Get("actor_id"); actorUUID != "" {
|
||||
acct, err := s.db.GetAccountByUUID(actorUUID)
|
||||
if err == nil {
|
||||
params.AccountID = &acct.ID
|
||||
}
|
||||
// If actor_id is provided but not found, return empty results (correct behaviour).
|
||||
}
|
||||
|
||||
events, total, err := s.db.ListAuditEventsPaged(params)
|
||||
if err != nil {
|
||||
s.logger.Error("list audit events", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure a nil slice serialises as [] rather than null.
|
||||
if events == nil {
|
||||
events = []*db.AuditEventView{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"events": events,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// parseIntParam parses a query parameter as an int, returning defaultVal on failure.
|
||||
func parseIntParam(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
var v int
|
||||
if _, err := fmt.Sscanf(s, "%d", &v); err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
// loadAccount retrieves an account by the {id} path parameter (UUID).
|
||||
|
||||
@@ -78,7 +78,11 @@ func TestValidateTokenWrongAlgorithm(t *testing.T) {
|
||||
"jti": "fake-jti",
|
||||
})
|
||||
// Use the Ed25519 public key bytes as the HMAC secret (classic alg confusion).
|
||||
hs256Signed, err := hmacToken.SignedString([]byte(priv.Public().(ed25519.PublicKey)))
|
||||
pubForHMAC, ok := priv.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
t.Fatal("priv.Public() did not return ed25519.PublicKey")
|
||||
}
|
||||
hs256Signed, err := hmacToken.SignedString([]byte(pubForHMAC))
|
||||
if err != nil {
|
||||
t.Fatalf("sign HS256 token: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user