c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
369 lines
12 KiB
Go
369 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
|
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
)
|
|
|
|
// Deprecated: Use apperrors.NotFound("error.contractor_not_found") instead
|
|
// var (
|
|
// ErrContractorNotFound = errors.New("contractor not found")
|
|
// ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
|
|
// )
|
|
|
|
// ContractorService handles contractor business logic
|
|
type ContractorService struct {
|
|
contractorRepo *repositories.ContractorRepository
|
|
residenceRepo *repositories.ResidenceRepository
|
|
cache *CacheService
|
|
}
|
|
|
|
// NewContractorService creates a new contractor service
|
|
func NewContractorService(contractorRepo *repositories.ContractorRepository, residenceRepo *repositories.ResidenceRepository) *ContractorService {
|
|
return &ContractorService{
|
|
contractorRepo: contractorRepo,
|
|
residenceRepo: residenceRepo,
|
|
}
|
|
}
|
|
|
|
// SetCacheService wires Redis caching for residence-ID lookups.
|
|
func (s *ContractorService) SetCacheService(cache *CacheService) {
|
|
s.cache = cache
|
|
}
|
|
|
|
// GetContractor gets a contractor by ID with access check
|
|
func (s *ContractorService) GetContractor(ctx context.Context, contractorID, userID uint) (*responses.ContractorResponse, error) {
|
|
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.contractor_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
if !s.hasContractorAccess(ctx, contractor, userID) {
|
|
return nil, apperrors.Forbidden("error.contractor_access_denied")
|
|
}
|
|
|
|
resp := responses.NewContractorResponse(contractor)
|
|
return &resp, nil
|
|
}
|
|
|
|
// hasContractorAccess checks if user has access to a contractor
|
|
// Access rules:
|
|
// - If contractor has no residence: only the creator has access
|
|
// - If contractor has a residence: all users with access to that residence
|
|
func (s *ContractorService) hasContractorAccess(ctx context.Context, contractor *models.Contractor, userID uint) bool {
|
|
if contractor.ResidenceID == nil {
|
|
// Personal contractor - only creator has access
|
|
return contractor.CreatedByID == userID
|
|
}
|
|
|
|
// Residence contractor - check residence access
|
|
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(*contractor.ResidenceID, userID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return hasAccess
|
|
}
|
|
|
|
// ListContractors lists all contractors accessible to a user
|
|
func (s *ContractorService) ListContractors(ctx context.Context, userID uint) ([]responses.ContractorResponse, error) {
|
|
// Get residence IDs (lightweight - no preloads)
|
|
residenceIDs, err := cachedResidenceIDsForUser(ctx, s.cache, s.residenceRepo, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// FindByUser now handles both personal and residence contractors
|
|
contractors, err := s.contractorRepo.WithContext(ctx).FindByUser(userID, residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewContractorListResponse(contractors), nil
|
|
}
|
|
|
|
// CreateContractor creates a new contractor
|
|
func (s *ContractorService) CreateContractor(ctx context.Context, req *requests.CreateContractorRequest, userID uint) (*responses.ContractorResponse, error) {
|
|
// If residence is provided, check access
|
|
if req.ResidenceID != nil {
|
|
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(*req.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
|
}
|
|
}
|
|
|
|
isFavorite := false
|
|
if req.IsFavorite != nil {
|
|
isFavorite = *req.IsFavorite
|
|
}
|
|
|
|
contractor := &models.Contractor{
|
|
ResidenceID: req.ResidenceID,
|
|
CreatedByID: userID,
|
|
Name: req.Name,
|
|
Company: req.Company,
|
|
Phone: req.Phone,
|
|
Email: req.Email,
|
|
Website: req.Website,
|
|
Notes: req.Notes,
|
|
StreetAddress: req.StreetAddress,
|
|
City: req.City,
|
|
StateProvince: req.StateProvince,
|
|
PostalCode: req.PostalCode,
|
|
Rating: req.Rating,
|
|
IsFavorite: isFavorite,
|
|
IsActive: true,
|
|
}
|
|
|
|
if err := s.contractorRepo.WithContext(ctx).Create(contractor); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Set specialties if provided
|
|
if len(req.SpecialtyIDs) > 0 {
|
|
if err := s.contractorRepo.WithContext(ctx).SetSpecialties(contractor.ID, req.SpecialtyIDs); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
// Reload with relations
|
|
contractor, reloadErr := s.contractorRepo.WithContext(ctx).FindByID(contractor.ID)
|
|
if reloadErr != nil {
|
|
return nil, apperrors.Internal(reloadErr)
|
|
}
|
|
|
|
// contractors_count for every user with access to this residence just
|
|
// changed. Contractor without a residence is rare (created via global
|
|
// add) and only the creator counts it — drop only their cache then.
|
|
if req.ResidenceID != nil {
|
|
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, *req.ResidenceID)
|
|
} else if s.cache != nil {
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
|
}
|
|
|
|
resp := responses.NewContractorResponse(contractor)
|
|
return &resp, nil
|
|
}
|
|
|
|
// UpdateContractor updates a contractor
|
|
func (s *ContractorService) UpdateContractor(ctx context.Context, contractorID, userID uint, req *requests.UpdateContractorRequest) (*responses.ContractorResponse, error) {
|
|
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.contractor_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
if !s.hasContractorAccess(ctx, contractor, userID) {
|
|
return nil, apperrors.Forbidden("error.contractor_access_denied")
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Name != nil {
|
|
contractor.Name = *req.Name
|
|
}
|
|
if req.Company != nil {
|
|
contractor.Company = *req.Company
|
|
}
|
|
if req.Phone != nil {
|
|
contractor.Phone = *req.Phone
|
|
}
|
|
if req.Email != nil {
|
|
contractor.Email = *req.Email
|
|
}
|
|
if req.Website != nil {
|
|
contractor.Website = *req.Website
|
|
}
|
|
if req.Notes != nil {
|
|
contractor.Notes = *req.Notes
|
|
}
|
|
if req.StreetAddress != nil {
|
|
contractor.StreetAddress = *req.StreetAddress
|
|
}
|
|
if req.City != nil {
|
|
contractor.City = *req.City
|
|
}
|
|
if req.StateProvince != nil {
|
|
contractor.StateProvince = *req.StateProvince
|
|
}
|
|
if req.PostalCode != nil {
|
|
contractor.PostalCode = *req.PostalCode
|
|
}
|
|
if req.Rating != nil {
|
|
contractor.Rating = req.Rating
|
|
}
|
|
if req.IsFavorite != nil {
|
|
contractor.IsFavorite = *req.IsFavorite
|
|
}
|
|
// If residence_id is provided, verify the user has access to the NEW residence.
|
|
// This prevents an attacker from reassigning a contractor to someone else's residence.
|
|
if req.ResidenceID != nil {
|
|
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(*req.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
|
}
|
|
}
|
|
|
|
// If residence_id is not sent in the request (nil), it means the user
|
|
// removed the residence association - contractor becomes personal
|
|
contractor.ResidenceID = req.ResidenceID
|
|
|
|
if err := s.contractorRepo.WithContext(ctx).Update(contractor); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Update specialties if provided
|
|
if req.SpecialtyIDs != nil {
|
|
if err := s.contractorRepo.WithContext(ctx).SetSpecialties(contractorID, req.SpecialtyIDs); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
// Reload
|
|
contractor, err = s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
resp := responses.NewContractorResponse(contractor)
|
|
return &resp, nil
|
|
}
|
|
|
|
// DeleteContractor soft-deletes a contractor
|
|
func (s *ContractorService) DeleteContractor(ctx context.Context, contractorID, userID uint) error {
|
|
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return apperrors.NotFound("error.contractor_not_found")
|
|
}
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
if !s.hasContractorAccess(ctx, contractor, userID) {
|
|
return apperrors.Forbidden("error.contractor_access_denied")
|
|
}
|
|
|
|
if err := s.contractorRepo.WithContext(ctx).Delete(contractorID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
if contractor.ResidenceID != nil {
|
|
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, *contractor.ResidenceID)
|
|
} else if s.cache != nil {
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToggleFavorite toggles the favorite status of a contractor and returns the updated contractor
|
|
func (s *ContractorService) ToggleFavorite(ctx context.Context, contractorID, userID uint) (*responses.ContractorResponse, error) {
|
|
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.contractor_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
if !s.hasContractorAccess(ctx, contractor, userID) {
|
|
return nil, apperrors.Forbidden("error.contractor_access_denied")
|
|
}
|
|
|
|
_, err = s.contractorRepo.WithContext(ctx).ToggleFavorite(contractorID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Re-fetch to get the updated state with all relations. Audit M12: if the
|
|
// contractor was deleted concurrently between the toggle and this read,
|
|
// surface a clean 404 instead of a 500.
|
|
contractor, err = s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.contractor_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
resp := responses.NewContractorResponse(contractor)
|
|
return &resp, nil
|
|
}
|
|
|
|
// GetContractorTasks gets all tasks for a contractor
|
|
func (s *ContractorService) GetContractorTasks(ctx context.Context, contractorID, userID uint) ([]responses.TaskResponse, error) {
|
|
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.contractor_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
if !s.hasContractorAccess(ctx, contractor, userID) {
|
|
return nil, apperrors.Forbidden("error.contractor_access_denied")
|
|
}
|
|
|
|
tasks, err := s.contractorRepo.WithContext(ctx).GetTasksForContractor(contractorID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewTaskListResponse(tasks), nil
|
|
}
|
|
|
|
// ListContractorsByResidence lists all contractors for a specific residence
|
|
func (s *ContractorService) ListContractorsByResidence(ctx context.Context, residenceID, userID uint) ([]responses.ContractorResponse, error) {
|
|
// Check user has access to the residence
|
|
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
|
}
|
|
|
|
contractors, err := s.contractorRepo.WithContext(ctx).FindByResidence(residenceID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewContractorListResponse(contractors), nil
|
|
}
|
|
|
|
// GetSpecialties returns all contractor specialties
|
|
func (s *ContractorService) GetSpecialties(ctx context.Context) ([]responses.ContractorSpecialtyResponse, error) {
|
|
specialties, err := s.contractorRepo.WithContext(ctx).GetAllSpecialties()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.ContractorSpecialtyResponse, len(specialties))
|
|
for i, sp := range specialties {
|
|
result[i] = responses.NewContractorSpecialtyResponse(&sp)
|
|
}
|
|
return result, nil
|
|
}
|