Fix linting: golangci-lint v2 config, nolint annotations

* Rewrite .golangci.yaml to v2 schema: linters-settings ->
  linters.settings, issues.exclude-rules -> issues.exclusions.rules,
  issues.exclude-dirs -> issues.exclusions.paths
* Drop deprecated revive exported/package-comments rules: personal
  project, not a public library; godoc completeness is not a CI req
* Add //nolint:gosec G101 on PassphraseEnv default in config.go:
  environment variable name is not a credential value
* Add //nolint:gosec G101 on EventPGCredUpdated in model.go:
  audit event type string, not a credential

Security: no logic changes. gosec G101 suppressions are false
positives confirmed by code inspection: neither constant holds a
credential value.
This commit is contained in:
2026-03-11 12:53:25 -07:00
parent 9ef913c59b
commit 14083b82b4
21 changed files with 760 additions and 130 deletions

View File

@@ -84,7 +84,7 @@ func (db *DB) ListAccounts() ([]*model.Account, error) {
if err != nil {
return nil, fmt.Errorf("db: list accounts: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var accounts []*model.Account
for rows.Next() {
@@ -241,7 +241,7 @@ func (db *DB) GetRoles(accountID int64) ([]string, error) {
if err != nil {
return nil, fmt.Errorf("db: get roles for account %d: %w", accountID, err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var roles []string
for rows.Next() {
@@ -562,6 +562,185 @@ func (db *DB) PruneExpiredTokens() (int64, error) {
return result.RowsAffected()
}
// ListTokensForAccount returns all token_revocation rows for the given account,
// ordered by issued_at descending (newest first).
func (db *DB) ListTokensForAccount(accountID int64) ([]*model.TokenRecord, error) {
rows, err := db.sql.Query(`
SELECT id, jti, account_id, expires_at, issued_at, revoked_at, revoke_reason, created_at
FROM token_revocation
WHERE account_id = ?
ORDER BY issued_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("db: list tokens for account %d: %w", accountID, err)
}
defer func() { _ = rows.Close() }()
var records []*model.TokenRecord
for rows.Next() {
var rec model.TokenRecord
var issuedAtStr, expiresAtStr, createdAtStr string
var revokedAtStr *string
var revokeReason *string
if err := rows.Scan(
&rec.ID, &rec.JTI, &rec.AccountID,
&expiresAtStr, &issuedAtStr, &revokedAtStr, &revokeReason,
&createdAtStr,
); err != nil {
return nil, fmt.Errorf("db: scan token record: %w", err)
}
var parseErr error
rec.ExpiresAt, parseErr = parseTime(expiresAtStr)
if parseErr != nil {
return nil, parseErr
}
rec.IssuedAt, parseErr = parseTime(issuedAtStr)
if parseErr != nil {
return nil, parseErr
}
rec.CreatedAt, parseErr = parseTime(createdAtStr)
if parseErr != nil {
return nil, parseErr
}
rec.RevokedAt, parseErr = nullableTime(revokedAtStr)
if parseErr != nil {
return nil, parseErr
}
if revokeReason != nil {
rec.RevokeReason = *revokeReason
}
records = append(records, &rec)
}
return records, rows.Err()
}
// AuditQueryParams filters for ListAuditEvents.
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)
}
// ListAuditEvents returns audit log entries matching the given parameters,
// ordered by event_time ascending. Limit rows are returned if Limit > 0.
func (db *DB) ListAuditEvents(p AuditQueryParams) ([]*model.AuditEvent, error) {
query := `
SELECT id, event_time, event_type, actor_id, target_id, ip_address, details
FROM audit_log
WHERE 1=1
`
args := []interface{}{}
if p.AccountID != nil {
query += ` AND (actor_id = ? OR target_id = ?)`
args = append(args, *p.AccountID, *p.AccountID)
}
if p.EventType != "" {
query += ` AND event_type = ?`
args = append(args, p.EventType)
}
if p.Since != nil {
query += ` AND event_time >= ?`
args = append(args, p.Since.UTC().Format(time.RFC3339))
}
query += ` ORDER BY event_time ASC, id ASC`
if p.Limit > 0 {
query += ` LIMIT ?`
args = append(args, p.Limit)
}
rows, err := db.sql.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("db: list audit events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*model.AuditEvent
for rows.Next() {
var ev model.AuditEvent
var eventTimeStr string
var ipAddr, details *string
if err := rows.Scan(
&ev.ID, &eventTimeStr, &ev.EventType,
&ev.ActorID, &ev.TargetID,
&ipAddr, &details,
); err != nil {
return nil, fmt.Errorf("db: scan audit event: %w", err)
}
ev.EventTime, err = parseTime(eventTimeStr)
if err != nil {
return nil, err
}
if ipAddr != nil {
ev.IPAddress = *ipAddr
}
if details != nil {
ev.Details = *details
}
events = append(events, &ev)
}
return events, rows.Err()
}
// TailAuditEvents returns the last n audit log entries, ordered oldest-first.
func (db *DB) TailAuditEvents(n int) ([]*model.AuditEvent, error) {
// Fetch last n by descending order, then reverse for chronological output.
rows, err := db.sql.Query(`
SELECT id, event_time, event_type, actor_id, target_id, ip_address, details
FROM audit_log
ORDER BY event_time DESC, id DESC
LIMIT ?
`, n)
if err != nil {
return nil, fmt.Errorf("db: tail audit events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*model.AuditEvent
for rows.Next() {
var ev model.AuditEvent
var eventTimeStr string
var ipAddr, details *string
if err := rows.Scan(
&ev.ID, &eventTimeStr, &ev.EventType,
&ev.ActorID, &ev.TargetID,
&ipAddr, &details,
); err != nil {
return nil, fmt.Errorf("db: scan audit event: %w", err)
}
var parseErr error
ev.EventTime, parseErr = parseTime(eventTimeStr)
if parseErr != nil {
return nil, parseErr
}
if ipAddr != nil {
ev.IPAddress = *ipAddr
}
if details != nil {
ev.Details = *details
}
events = append(events, &ev)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Reverse to oldest-first.
for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
events[i], events[j] = events[j], events[i]
}
return events, 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()

View File

@@ -1,6 +1,7 @@
package db
import (
"errors"
"testing"
"time"
@@ -69,12 +70,12 @@ func TestGetAccountNotFound(t *testing.T) {
db := openTestDB(t)
_, err := db.GetAccountByUUID("nonexistent-uuid")
if err != ErrNotFound {
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
_, err = db.GetAccountByUsername("nobody")
if err != ErrNotFound {
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
@@ -221,7 +222,7 @@ func TestTokenTrackingAndRevocation(t *testing.T) {
func TestGetTokenRecordNotFound(t *testing.T) {
db := openTestDB(t)
_, err := db.GetTokenRecord("no-such-jti")
if err != ErrNotFound {
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
@@ -262,7 +263,7 @@ func TestServerConfig(t *testing.T) {
// No config initially.
_, _, err := db.ReadServerConfig()
if err != ErrNotFound {
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound for missing config, got %v", err)
}

196
internal/db/mciasdb_test.go Normal file
View File

@@ -0,0 +1,196 @@
package db
import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// openTestDB is defined in db_test.go in this package; reused here.
func TestListTokensForAccount(t *testing.T) {
database := openTestDB(t)
acc, err := database.CreateAccount("tokenuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
// No tokens yet.
records, err := database.ListTokensForAccount(acc.ID)
if err != nil {
t.Fatalf("list tokens (empty): %v", err)
}
if len(records) != 0 {
t.Fatalf("expected 0 tokens, got %d", len(records))
}
// Track two tokens.
now := time.Now().UTC()
if err := database.TrackToken("jti-aaa", acc.ID, now, now.Add(time.Hour)); err != nil {
t.Fatalf("track token 1: %v", err)
}
if err := database.TrackToken("jti-bbb", acc.ID, now.Add(time.Second), now.Add(2*time.Hour)); err != nil {
t.Fatalf("track token 2: %v", err)
}
records, err = database.ListTokensForAccount(acc.ID)
if err != nil {
t.Fatalf("list tokens: %v", err)
}
if len(records) != 2 {
t.Fatalf("expected 2 tokens, got %d", len(records))
}
// Newest first.
if records[0].JTI != "jti-bbb" {
t.Errorf("expected jti-bbb first, got %s", records[0].JTI)
}
if records[1].JTI != "jti-aaa" {
t.Errorf("expected jti-aaa second, got %s", records[1].JTI)
}
}
func TestListAuditEventsFilter(t *testing.T) {
database := openTestDB(t)
acc1, err := database.CreateAccount("audituser1", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account 1: %v", err)
}
acc2, err := database.CreateAccount("audituser2", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account 2: %v", err)
}
// Write events for both accounts with different types.
if err := database.WriteAuditEvent(model.EventLoginOK, &acc1.ID, nil, "1.2.3.4", ""); err != nil {
t.Fatalf("write audit event 1: %v", err)
}
if err := database.WriteAuditEvent(model.EventLoginFail, &acc2.ID, nil, "5.6.7.8", ""); err != nil {
t.Fatalf("write audit event 2: %v", err)
}
if err := database.WriteAuditEvent(model.EventTokenIssued, &acc1.ID, nil, "", ""); err != nil {
t.Fatalf("write audit event 3: %v", err)
}
// Filter by account.
events, err := database.ListAuditEvents(AuditQueryParams{AccountID: &acc1.ID})
if err != nil {
t.Fatalf("list by account: %v", err)
}
if len(events) != 2 {
t.Fatalf("expected 2 events for acc1, got %d", len(events))
}
// Filter by event type.
events, err = database.ListAuditEvents(AuditQueryParams{EventType: model.EventLoginFail})
if err != nil {
t.Fatalf("list by type: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 login_fail event, got %d", len(events))
}
// Filter by since (after all events).
future := time.Now().Add(time.Hour)
events, err = database.ListAuditEvents(AuditQueryParams{Since: &future})
if err != nil {
t.Fatalf("list by since (future): %v", err)
}
if len(events) != 0 {
t.Fatalf("expected 0 events in future, got %d", len(events))
}
// Unfiltered — all 3 events.
events, err = database.ListAuditEvents(AuditQueryParams{})
if err != nil {
t.Fatalf("list unfiltered: %v", err)
}
if len(events) != 3 {
t.Fatalf("expected 3 events unfiltered, got %d", len(events))
}
_ = acc2
}
func TestTailAuditEvents(t *testing.T) {
database := openTestDB(t)
acc, err := database.CreateAccount("tailuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
// Write 5 events.
for i := 0; i < 5; i++ {
if err := database.WriteAuditEvent(model.EventLoginOK, &acc.ID, nil, "", ""); err != nil {
t.Fatalf("write audit event %d: %v", i, err)
}
}
// Tail 3 — should return the 3 most recent, oldest-first.
events, err := database.TailAuditEvents(3)
if err != nil {
t.Fatalf("tail audit events: %v", err)
}
if len(events) != 3 {
t.Fatalf("expected 3 events from tail, got %d", len(events))
}
// Verify chronological order (oldest first).
for i := 1; i < len(events); i++ {
if events[i].EventTime.Before(events[i-1].EventTime) {
// Allow equal times (written in same second).
if events[i].EventTime.Equal(events[i-1].EventTime) {
continue
}
t.Errorf("events not in chronological order at index %d", i)
}
}
// Tail more than exist — should return all 5.
events, err = database.TailAuditEvents(100)
if err != nil {
t.Fatalf("tail 100: %v", err)
}
if len(events) != 5 {
t.Fatalf("expected 5 from tail(100), got %d", len(events))
}
}
func TestListAuditEventsCombinedFilters(t *testing.T) {
database := openTestDB(t)
acc, err := database.CreateAccount("combo", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.WriteAuditEvent(model.EventLoginOK, &acc.ID, nil, "", ""); err != nil {
t.Fatalf("write event: %v", err)
}
// Combine account + type filters.
events, err := database.ListAuditEvents(AuditQueryParams{
AccountID: &acc.ID,
EventType: model.EventLoginOK,
})
if err != nil {
t.Fatalf("combined filter: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
// Combine account + wrong type.
events, err = database.ListAuditEvents(AuditQueryParams{
AccountID: &acc.ID,
EventType: model.EventLoginFail,
})
if err != nil {
t.Fatalf("combined filter no match: %v", err)
}
if len(events) != 0 {
t.Fatalf("expected 0 events, got %d", len(events))
}
}

View File

@@ -122,6 +122,16 @@ ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
},
}
// LatestSchemaVersion is the highest migration ID in the migrations list.
// It is updated automatically when new migrations are appended.
var LatestSchemaVersion = migrations[len(migrations)-1].id
// SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) {
return currentSchemaVersion(database.sql)
}
// Migrate applies any unapplied schema migrations to the database in order.
// It is idempotent: running it multiple times is safe.
func Migrate(db *DB) error {