Implement proper storage metrics (P0-009)\n\n- Add dedicated storage_metrics table\n- Create StorageMetricReport models with proper field names\n- Add ReportStorageMetrics to agent client\n- Update storage scanner to use new method\n- Implement server-side handlers and queries\n- Register new routes and update UI\n- Remove legacy Scan() method\n- Follow ETHOS principles: honest naming, clean architecture

This commit is contained in:
Fimeg
2025-12-17 16:38:36 -05:00
parent f7c8d23c5d
commit 0fff047cb5
43 changed files with 3641 additions and 248 deletions

View File

@@ -0,0 +1,162 @@
package queries
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/alexedwards/argon2id"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type AdminQueries struct {
db *sqlx.DB
}
func NewAdminQueries(db *sqlx.DB) *AdminQueries {
return &AdminQueries{db: db}
}
type Admin struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
// CreateAdminIfNotExists creates an admin user if they don't already exist
func (q *AdminQueries) CreateAdminIfNotExists(username, email, password string) error {
ctx := context.Background()
// Check if admin already exists
var exists bool
err := q.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if admin exists: %w", err)
}
if exists {
return nil // Admin already exists, nothing to do
}
// Hash the password
hashedPassword, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Create the admin
query := `
INSERT INTO users (username, email, password_hash, created_at)
VALUES ($1, $2, $3, NOW())
`
_, err = q.db.ExecContext(ctx, query, username, email, hashedPassword)
if err != nil {
return fmt.Errorf("failed to create admin: %w", err)
}
return nil
}
// UpdateAdminPassword updates the admin's password (always updates from .env)
func (q *AdminQueries) UpdateAdminPassword(username, password string) error {
ctx := context.Background()
// Hash the password
hashedPassword, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Update the password
query := `
UPDATE users
SET password_hash = $1
WHERE username = $2
`
result, err := q.db.ExecContext(ctx, query, hashedPassword, username)
if err != nil {
return fmt.Errorf("failed to update admin password: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("admin not found")
}
return nil
}
// VerifyAdminCredentials validates username and password against the database hash
func (q *AdminQueries) VerifyAdminCredentials(username, password string) (*Admin, error) {
ctx := context.Background()
var admin Admin
query := `
SELECT id, username, email, password_hash, created_at
FROM users
WHERE username = $1
`
err := q.db.QueryRowContext(ctx, query, username).Scan(
&admin.ID,
&admin.Username,
&admin.Email,
&admin.Password,
&admin.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("admin not found")
}
if err != nil {
return nil, fmt.Errorf("failed to query admin: %w", err)
}
// Verify the password
match, err := argon2id.ComparePasswordAndHash(password, admin.Password)
if err != nil {
return nil, fmt.Errorf("failed to compare password: %w", err)
}
if !match {
return nil, fmt.Errorf("invalid credentials")
}
return &admin, nil
}
// GetAdminByUsername retrieves admin by username (for JWT claims)
func (q *AdminQueries) GetAdminByUsername(username string) (*Admin, error) {
ctx := context.Background()
var admin Admin
query := `
SELECT id, username, email, created_at
FROM users
WHERE username = $1
`
err := q.db.QueryRowContext(ctx, query, username).Scan(
&admin.ID,
&admin.Username,
&admin.Email,
&admin.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("admin not found")
}
if err != nil {
return nil, fmt.Errorf("failed to query admin: %w", err)
}
return &admin, nil
}

View File

@@ -0,0 +1,35 @@
package queries
import (
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// PackageQueries provides an alias for AgentUpdateQueries to match the expected interface
// This maintains backward compatibility while using the existing agent update package system
type PackageQueries struct {
*AgentUpdateQueries
}
// NewPackageQueries creates a new PackageQueries instance
func NewPackageQueries(db *sqlx.DB) *PackageQueries {
return &PackageQueries{
AgentUpdateQueries: NewAgentUpdateQueries(db),
}
}
// StoreSignedPackage stores a signed agent package (alias for CreateUpdatePackage)
func (pq *PackageQueries) StoreSignedPackage(pkg *models.AgentUpdatePackage) error {
return pq.CreateUpdatePackage(pkg)
}
// GetSignedPackage retrieves a signed package (alias for GetUpdatePackageByVersion)
func (pq *PackageQueries) GetSignedPackage(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
return pq.GetUpdatePackageByVersion(version, platform, architecture)
}
// GetSignedPackageByID retrieves a signed package by ID (alias for GetUpdatePackage)
func (pq *PackageQueries) GetSignedPackageByID(id uuid.UUID) (*models.AgentUpdatePackage, error) {
return pq.GetUpdatePackage(id)
}

View File

@@ -0,0 +1,131 @@
package queries
import (
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
// ScannerConfigQueries handles scanner timeout configuration in database
type ScannerConfigQueries struct {
db *sqlx.DB
}
// NewScannerConfigQueries creates new scanner config queries
func NewScannerConfigQueries(db *sqlx.DB) *ScannerConfigQueries {
return &ScannerConfigQueries{db: db}
}
// ScannerTimeoutConfig represents a scanner timeout configuration
type ScannerTimeoutConfig struct {
ScannerName string `db:"scanner_name" json:"scanner_name"`
TimeoutMs int `db:"timeout_ms" json:"timeout_ms"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// UpsertScannerConfig inserts or updates scanner timeout configuration
func (q *ScannerConfigQueries) UpsertScannerConfig(scannerName string, timeout time.Duration) error {
if q.db == nil {
return fmt.Errorf("database connection not available")
}
query := `
INSERT INTO scanner_config (scanner_name, timeout_ms, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (scanner_name)
DO UPDATE SET
timeout_ms = EXCLUDED.timeout_ms,
updated_at = CURRENT_TIMESTAMP
`
_, err := q.db.Exec(query, scannerName, timeout.Milliseconds())
if err != nil {
return fmt.Errorf("failed to upsert scanner config: %w", err)
}
return nil
}
// GetScannerConfig retrieves scanner timeout configuration for a specific scanner
func (q *ScannerConfigQueries) GetScannerConfig(scannerName string) (*ScannerTimeoutConfig, error) {
if q.db == nil {
return nil, fmt.Errorf("database connection not available")
}
var config ScannerTimeoutConfig
query := `SELECT scanner_name, timeout_ms, updated_at FROM scanner_config WHERE scanner_name = $1`
err := q.db.Get(&config, query, scannerName)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // Return nil if not found
}
return nil, fmt.Errorf("failed to get scanner config: %w", err)
}
return &config, nil
}
// GetAllScannerConfigs retrieves all scanner timeout configurations
func (q *ScannerConfigQueries) GetAllScannerConfigs() (map[string]ScannerTimeoutConfig, error) {
if q.db == nil {
return nil, fmt.Errorf("database connection not available")
}
var configs []ScannerTimeoutConfig
query := `SELECT scanner_name, timeout_ms, updated_at FROM scanner_config ORDER BY scanner_name`
err := q.db.Select(&configs, query)
if err != nil {
return nil, fmt.Errorf("failed to get all scanner configs: %w", err)
}
// Convert slice to map
configMap := make(map[string]ScannerTimeoutConfig)
for _, cfg := range configs {
configMap[cfg.ScannerName] = cfg
}
return configMap, nil
}
// DeleteScannerConfig removes scanner timeout configuration
func (q *ScannerConfigQueries) DeleteScannerConfig(scannerName string) error {
if q.db == nil {
return fmt.Errorf("database connection not available")
}
query := `DELETE FROM scanner_config WHERE scanner_name = $1`
result, err := q.db.Exec(query, scannerName)
if err != nil {
return fmt.Errorf("failed to delete scanner config: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to verify delete: %w", err)
}
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
// GetScannerTimeoutWithDefault returns scanner timeout from DB or default value
func (q *ScannerConfigQueries) GetScannerTimeoutWithDefault(scannerName string, defaultTimeout time.Duration) time.Duration {
config, err := q.GetScannerConfig(scannerName)
if err != nil {
return defaultTimeout
}
if config == nil {
return defaultTimeout
}
return time.Duration(config.TimeoutMs) * time.Millisecond
}

View File

@@ -0,0 +1,255 @@
package queries
import (
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type SecuritySettingsQueries struct {
db *sqlx.DB
}
func NewSecuritySettingsQueries(db *sqlx.DB) *SecuritySettingsQueries {
return &SecuritySettingsQueries{db: db}
}
// GetSetting retrieves a specific security setting by category and key
func (q *SecuritySettingsQueries) GetSetting(category, key string) (*models.SecuritySetting, error) {
query := `
SELECT id, category, key, value, is_encrypted, created_at, updated_at, created_by, updated_by
FROM security_settings
WHERE category = $1 AND key = $2
`
var setting models.SecuritySetting
err := q.db.Get(&setting, query, category, key)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get security setting: %w", err)
}
return &setting, nil
}
// GetAllSettings retrieves all security settings
func (q *SecuritySettingsQueries) GetAllSettings() ([]models.SecuritySetting, error) {
query := `
SELECT id, category, key, value, is_encrypted, created_at, updated_at, created_by, updated_by
FROM security_settings
ORDER BY category, key
`
var settings []models.SecuritySetting
err := q.db.Select(&settings, query)
if err != nil {
return nil, fmt.Errorf("failed to get all security settings: %w", err)
}
return settings, nil
}
// GetSettingsByCategory retrieves all settings for a specific category
func (q *SecuritySettingsQueries) GetSettingsByCategory(category string) ([]models.SecuritySetting, error) {
query := `
SELECT id, category, key, value, is_encrypted, created_at, updated_at, created_by, updated_by
FROM security_settings
WHERE category = $1
ORDER BY key
`
var settings []models.SecuritySetting
err := q.db.Select(&settings, query, category)
if err != nil {
return nil, fmt.Errorf("failed to get security settings by category: %w", err)
}
return settings, nil
}
// CreateSetting creates a new security setting
func (q *SecuritySettingsQueries) CreateSetting(category, key string, value interface{}, isEncrypted bool, createdBy *uuid.UUID) (*models.SecuritySetting, error) {
// Convert value to JSON string
valueJSON, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("failed to marshal setting value: %w", err)
}
setting := &models.SecuritySetting{
ID: uuid.New(),
Category: category,
Key: key,
Value: string(valueJSON),
IsEncrypted: isEncrypted,
CreatedAt: time.Now().UTC(),
CreatedBy: createdBy,
}
query := `
INSERT INTO security_settings (
id, category, key, value, is_encrypted, created_at, created_by
) VALUES (
:id, :category, :key, :value, :is_encrypted, :created_at, :created_by
)
RETURNING *
`
rows, err := q.db.NamedQuery(query, setting)
if err != nil {
return nil, fmt.Errorf("failed to create security setting: %w", err)
}
defer rows.Close()
if rows.Next() {
var createdSetting models.SecuritySetting
if err := rows.StructScan(&createdSetting); err != nil {
return nil, fmt.Errorf("failed to scan created setting: %w", err)
}
return &createdSetting, nil
}
return nil, fmt.Errorf("failed to create security setting: no rows returned")
}
// UpdateSetting updates an existing security setting
func (q *SecuritySettingsQueries) UpdateSetting(category, key string, value interface{}, updatedBy *uuid.UUID) (*models.SecuritySetting, *string, error) {
// Get the old value first
oldSetting, err := q.GetSetting(category, key)
if err != nil {
return nil, nil, fmt.Errorf("failed to get old setting: %w", err)
}
if oldSetting == nil {
return nil, nil, fmt.Errorf("setting not found")
}
var oldValue *string
if oldSetting != nil {
oldValue = &oldSetting.Value
}
// Convert new value to JSON string
valueJSON, err := json.Marshal(value)
if err != nil {
return nil, oldValue, fmt.Errorf("failed to marshal setting value: %w", err)
}
now := time.Now().UTC()
query := `
UPDATE security_settings
SET value = $1, updated_at = $2, updated_by = $3
WHERE category = $4 AND key = $5
RETURNING id, category, key, value, is_encrypted, created_at, updated_at, created_by, updated_by
`
var updatedSetting models.SecuritySetting
err = q.db.QueryRow(query, string(valueJSON), now, updatedBy, category, key).Scan(
&updatedSetting.ID,
&updatedSetting.Category,
&updatedSetting.Key,
&updatedSetting.Value,
&updatedSetting.IsEncrypted,
&updatedSetting.CreatedAt,
&updatedSetting.UpdatedAt,
&updatedSetting.CreatedBy,
&updatedSetting.UpdatedBy,
)
if err != nil {
return nil, oldValue, fmt.Errorf("failed to update security setting: %w", err)
}
return &updatedSetting, oldValue, nil
}
// DeleteSetting deletes a security setting
func (q *SecuritySettingsQueries) DeleteSetting(category, key string) (*string, error) {
// Get the old value first
oldSetting, err := q.GetSetting(category, key)
if err != nil {
return nil, fmt.Errorf("failed to get old setting: %w", err)
}
if oldSetting == nil {
return nil, nil
}
query := `
DELETE FROM security_settings
WHERE category = $1 AND key = $2
RETURNING value
`
var oldValue string
err = q.db.QueryRow(query, category, key).Scan(&oldValue)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("failed to delete security setting: %w", err)
}
return &oldValue, nil
}
// CreateAuditLog creates an audit log entry for setting changes
func (q *SecuritySettingsQueries) CreateAuditLog(settingID, userID uuid.UUID, action, oldValue, newValue, reason string) error {
audit := &models.SecuritySettingAudit{
ID: uuid.New(),
SettingID: settingID,
UserID: userID,
Action: action,
OldValue: &oldValue,
NewValue: &newValue,
Reason: reason,
CreatedAt: time.Now().UTC(),
}
// Handle null values for old/new values
if oldValue == "" {
audit.OldValue = nil
}
if newValue == "" {
audit.NewValue = nil
}
query := `
INSERT INTO security_setting_audit (
id, setting_id, user_id, action, old_value, new_value, reason, created_at
) VALUES (
:id, :setting_id, :user_id, :action, :old_value, :new_value, :reason, :created_at
)
`
_, err := q.db.NamedExec(query, audit)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
}
// GetAuditLogs retrieves audit logs for a setting
func (q *SecuritySettingsQueries) GetAuditLogs(category, key string, limit int) ([]models.SecuritySettingAudit, error) {
query := `
SELECT sa.id, sa.setting_id, sa.user_id, sa.action, sa.old_value, sa.new_value, sa.reason, sa.created_at
FROM security_setting_audit sa
INNER JOIN security_settings s ON sa.setting_id = s.id
WHERE s.category = $1 AND s.key = $2
ORDER BY sa.created_at DESC
LIMIT $3
`
var audits []models.SecuritySettingAudit
err := q.db.Select(&audits, query, category, key, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
return audits, nil
}

View File

@@ -0,0 +1,167 @@
package queries
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/lib/pq"
)
// StorageMetricsQueries handles storage metrics database operations
type StorageMetricsQueries struct {
db *sql.DB
}
// NewStorageMetricsQueries creates a new storage metrics queries instance
func NewStorageMetricsQueries(db *sql.DB) *StorageMetricsQueries {
return &StorageMetricsQueries{db: db}
}
// InsertStorageMetric inserts a new storage metric
func (q *StorageMetricsQueries) InsertStorageMetric(ctx context.Context, metric models.StorageMetric) error {
query := `
INSERT INTO storage_metrics (
id, agent_id, mountpoint, device, disk_type, filesystem,
total_bytes, used_bytes, available_bytes, used_percent,
severity, metadata, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`
_, err := q.db.ExecContext(ctx, query,
metric.ID, metric.AgentID, metric.Mountpoint, metric.Device,
metric.DiskType, metric.Filesystem, metric.TotalBytes,
metric.UsedBytes, metric.AvailableBytes, metric.UsedPercent,
metric.Severity, pq.Array(metric.Metadata), metric.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to insert storage metric: %w", err)
}
return nil
}
// GetStorageMetricsByAgentID retrieves storage metrics for an agent
func (q *StorageMetricsQueries) GetStorageMetricsByAgentID(ctx context.Context, agentID uuid.UUID, limit, offset int) ([]models.StorageMetric, error) {
query := `
SELECT id, agent_id, mountpoint, device, disk_type, filesystem,
total_bytes, used_bytes, available_bytes, used_percent,
severity, metadata, created_at
FROM storage_metrics
WHERE agent_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := q.db.QueryContext(ctx, query, agentID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to query storage metrics: %w", err)
}
defer rows.Close()
var metrics []models.StorageMetric
for rows.Next() {
var metric models.StorageMetric
var metadataMap map[string]interface{}
err := rows.Scan(
&metric.ID, &metric.AgentID, &metric.Mountpoint, &metric.Device,
&metric.DiskType, &metric.Filesystem, &metric.TotalBytes,
&metric.UsedBytes, &metric.AvailableBytes, &metric.UsedPercent,
&metric.Severity, &metadataMap, &metric.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan storage metric: %w", err)
}
metric.Metadata = metadataMap
metrics = append(metrics, metric)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating storage metrics: %w", err)
}
return metrics, nil
}
// GetLatestStorageMetrics retrieves the most recent storage metrics per mountpoint
func (q *StorageMetricsQueries) GetLatestStorageMetrics(ctx context.Context, agentID uuid.UUID) ([]models.StorageMetric, error) {
query := `
SELECT DISTINCT ON (mountpoint)
id, agent_id, mountpoint, device, disk_type, filesystem,
total_bytes, used_bytes, available_bytes, used_percent,
severity, metadata, created_at
FROM storage_metrics
WHERE agent_id = $1
ORDER BY mountpoint, created_at DESC
`
rows, err := q.db.QueryContext(ctx, query, agentID)
if err != nil {
return nil, fmt.Errorf("failed to query latest storage metrics: %w", err)
}
defer rows.Close()
var metrics []models.StorageMetric
for rows.Next() {
var metric models.StorageMetric
var metadataMap map[string]interface{}
err := rows.Scan(
&metric.ID, &metric.AgentID, &metric.Mountpoint, &metric.Device,
&metric.DiskType, &metric.Filesystem, &metric.TotalBytes,
&metric.UsedBytes, &metric.AvailableBytes, &metric.UsedPercent,
&metric.Severity, &metadataMap, &metric.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan storage metric: %w", err)
}
metric.Metadata = metadataMap
metrics = append(metrics, metric)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating latest storage metrics: %w", err)
}
return metrics, nil
}
// GetStorageMetricsSummary returns summary statistics for an agent
func (q *StorageMetricsQueries) GetStorageMetricsSummary(ctx context.Context, agentID uuid.UUID) (map[string]interface{}, error) {
query := `
SELECT
COUNT(*) as total_disks,
COUNT(CASE WHEN severity = 'critical' THEN 1 END) as critical_disks,
COUNT(CASE WHEN severity = 'important' THEN 1 END) as important_disks,
AVG(used_percent) as avg_used_percent,
MAX(used_percent) as max_used_percent,
MIN(created_at) as first_collected_at,
MAX(created_at) as last_collected_at
FROM storage_metrics
WHERE agent_id = $1
AND created_at >= NOW() - INTERVAL '24 hours'
`
var summary map[string]interface{}
err := q.db.QueryRowContext(ctx, query, agentID).Scan(
&summary["total_disks"],
&summary["critical_disks"],
&summary["important_disks"],
&summary["avg_used_percent"],
&summary["max_used_percent"],
&summary["first_collected_at"],
&summary["last_collected_at"],
)
if err != nil {
return nil, fmt.Errorf("failed to get storage metrics summary: %w", err)
}
return summary, nil
}