Harden API security: input validation, safe auth extraction, new tests, and deploy config

Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-02 09:48:01 -06:00
parent 56d6fa4514
commit 7690f07a2b
123 changed files with 8321 additions and 750 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
@@ -25,6 +26,50 @@ func NewTaskRepository(db *gorm.DB) *TaskRepository {
return &TaskRepository{db: db}
}
// DB returns the underlying database connection.
// Used by services that need to run transactions spanning multiple operations.
func (r *TaskRepository) DB() *gorm.DB {
return r.db
}
// CreateCompletionTx creates a new task completion within an existing transaction.
func (r *TaskRepository) CreateCompletionTx(tx *gorm.DB, completion *models.TaskCompletion) error {
return tx.Create(completion).Error
}
// UpdateTx updates a task with optimistic locking within an existing transaction.
func (r *TaskRepository) UpdateTx(tx *gorm.DB, task *models.Task) error {
result := tx.Model(task).
Where("id = ? AND version = ?", task.ID, task.Version).
Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").
Updates(map[string]interface{}{
"title": task.Title,
"description": task.Description,
"category_id": task.CategoryID,
"priority_id": task.PriorityID,
"frequency_id": task.FrequencyID,
"custom_interval_days": task.CustomIntervalDays,
"in_progress": task.InProgress,
"assigned_to_id": task.AssignedToID,
"due_date": task.DueDate,
"next_due_date": task.NextDueDate,
"estimated_cost": task.EstimatedCost,
"actual_cost": task.ActualCost,
"contractor_id": task.ContractorID,
"is_cancelled": task.IsCancelled,
"is_archived": task.IsArchived,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
task.Version++ // Update local copy
return nil
}
// === Task Filter Options ===
// TaskFilterOptions provides flexible filtering for task queries.
@@ -495,55 +540,39 @@ func buildKanbanColumns(
}
// GetKanbanData retrieves tasks organized for kanban display.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// Fetches all non-cancelled, non-archived tasks for the residence in a single query,
// then categorizes them in-memory using the task categorization chain for consistency
// with the predicate-based logic used throughout the application.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Optimization: Single query with preloads, then in-memory categorization.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
opts := TaskFilterOptions{
ResidenceID: residenceID,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadCompletions: true,
// Fetch all tasks for this residence in a single query (excluding cancelled/archived)
var allTasks []models.Task
query := r.db.Model(&models.Task{}).
Where("task_task.residence_id = ?", residenceID).
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Scopes(task.ScopeKanbanOrder)
if err := query.Find(&allTasks).Error; err != nil {
return nil, fmt.Errorf("get tasks for kanban: %w", err)
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
// Categorize all tasks in-memory using the categorization chain
columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now)
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
// Intentionally hidden from board:
// cancelled/archived tasks are not returned as a kanban column.
// cancelled, err := r.GetCancelledTasks(opts)
// if err != nil {
// return nil, fmt.Errorf("get cancelled tasks: %w", err)
// }
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed)
columns := buildKanbanColumns(
columnMap[categorization.ColumnOverdue],
columnMap[categorization.ColumnInProgress],
columnMap[categorization.ColumnDueSoon],
columnMap[categorization.ColumnUpcoming],
columnMap[categorization.ColumnCompleted],
)
return &models.KanbanBoard{
Columns: columns,
@@ -553,56 +582,39 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// Fetches all tasks in a single query, then categorizes them in-memory using the
// task categorization chain for consistency with predicate-based logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Optimization: Single query with preloads, then in-memory categorization.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
opts := TaskFilterOptions{
ResidenceIDs: residenceIDs,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadResidence: true,
PreloadCompletions: true,
// Fetch all tasks for these residences in a single query (excluding cancelled/archived)
var allTasks []models.Task
query := r.db.Model(&models.Task{}).
Where("task_task.residence_id IN ?", residenceIDs).
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Residence").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Scopes(task.ScopeKanbanOrder)
if err := query.Find(&allTasks).Error; err != nil {
return nil, fmt.Errorf("get tasks for kanban: %w", err)
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
// Categorize all tasks in-memory using the categorization chain
columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now)
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
// Intentionally hidden from board:
// cancelled/archived tasks are not returned as a kanban column.
// cancelled, err := r.GetCancelledTasks(opts)
// if err != nil {
// return nil, fmt.Errorf("get cancelled tasks: %w", err)
// }
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed)
columns := buildKanbanColumns(
columnMap[categorization.ColumnOverdue],
columnMap[categorization.ColumnInProgress],
columnMap[categorization.ColumnDueSoon],
columnMap[categorization.ColumnUpcoming],
columnMap[categorization.ColumnCompleted],
)
return &models.KanbanBoard{
Columns: columns,
@@ -653,6 +665,19 @@ func (r *TaskRepository) CountByResidence(residenceID uint) (int64, error) {
return count, err
}
// CountByResidenceIDs counts all active tasks across multiple residences in a single query.
// Returns the total count of non-cancelled, non-archived tasks for the given residence IDs.
func (r *TaskRepository) CountByResidenceIDs(residenceIDs []uint) (int64, error) {
if len(residenceIDs) == 0 {
return 0, nil
}
var count int64
err := r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Count(&count).Error
return count, err
}
// === Task Completion Operations ===
// CreateCompletion creates a new task completion
@@ -705,7 +730,9 @@ func (r *TaskRepository) UpdateCompletion(completion *models.TaskCompletion) err
// DeleteCompletion deletes a task completion
func (r *TaskRepository) DeleteCompletion(id uint) error {
// Delete images first
r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{})
if err := r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{}).Error; err != nil {
log.Error().Err(err).Uint("completion_id", id).Msg("Failed to delete completion images")
}
return r.db.Delete(&models.TaskCompletion{}, id).Error
}