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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user