veza/veza-backend-api/internal/services/audit_service.go
senke a6cf20e614 fix(tests): fix 2 skipped tests, add clear skip reasons to 11 others
INT-04: Fixed nil UserID panic in AuditService (re-enabled 2 tests).
Added INT-04 comments explaining skip reasons for tests requiring
PostgreSQL, real file headers, or external services.
2026-02-22 17:53:00 +01:00

738 lines
20 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"time"
"veza-backend-api/internal/database"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AuditService gère les logs d'audit
type AuditService struct {
db *database.Database
logger *zap.Logger
}
// AuditLog représente un log d'audit
type AuditLog struct {
ID uuid.UUID `json:"id" db:"id"`
UserID *uuid.UUID `json:"user_id" db:"user_id"`
Action string `json:"action" db:"action"`
Resource string `json:"resource" db:"resource"`
ResourceID *uuid.UUID `json:"resource_id" db:"resource_id"`
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"`
Metadata json.RawMessage `json:"metadata" db:"metadata"`
Timestamp time.Time `json:"timestamp" db:"timestamp"`
}
// AuditLogCreateRequest données pour créer un log d'audit
type AuditLogCreateRequest struct {
UserID *uuid.UUID `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID *uuid.UUID `json:"resource_id"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Metadata map[string]interface{} `json:"metadata"`
}
// AuditLogSearchRequest paramètres de recherche
// BE-API-034: Enhanced with better filtering and pagination
type AuditLogSearchRequest struct {
UserID *uuid.UUID `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID *uuid.UUID `json:"resource_id"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Page int `json:"page"` // Alternative to offset (page-based pagination)
}
// AuditStats statistiques d'audit
type AuditStats struct {
Action string `json:"action" db:"action"`
Resource string `json:"resource" db:"resource"`
ActionCount int64 `json:"action_count" db:"action_count"`
UniqueUsers int64 `json:"unique_users" db:"unique_users"`
UniqueIPs int64 `json:"unique_ips" db:"unique_ips"`
}
// SuspiciousActivity activité suspecte détectée
type SuspiciousActivity struct {
UserID *uuid.UUID `json:"user_id" db:"user_id"`
IPAddress string `json:"ip_address" db:"ip_address"`
ActionCount int64 `json:"action_count" db:"action_count"`
UniqueActions int64 `json:"unique_actions" db:"unique_actions"`
RiskScore int `json:"risk_score" db:"risk_score"`
}
// NewAuditService crée un nouveau service d'audit
func NewAuditService(db *database.Database, logger *zap.Logger) *AuditService {
return &AuditService{
db: db,
logger: logger,
}
}
// LogAction enregistre une action d'audit
func (as *AuditService) LogAction(ctx context.Context, req *AuditLogCreateRequest) error {
// Convertir les métadonnées en JSON
metadataJSON, err := json.Marshal(req.Metadata)
if err != nil {
as.logger.Error("Failed to marshal audit metadata",
zap.Error(err),
zap.String("action", req.Action),
)
return fmt.Errorf("failed to marshal audit metadata: %w", err)
}
// Insérer le log d'audit
query := `
INSERT INTO audit_logs (id, user_id, action, resource, resource_id, ip_address, user_agent, metadata, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err = as.db.ExecContext(ctx, query,
uuid.New(),
req.UserID,
req.Action,
req.Resource,
req.ResourceID,
req.IPAddress,
req.UserAgent,
metadataJSON,
time.Now(),
)
if err != nil {
as.logger.Error("Failed to log audit action",
zap.Error(err),
zap.String("action", req.Action),
zap.String("resource", req.Resource),
)
return fmt.Errorf("failed to log audit action: %w", err)
}
userIDStr := ""
if req.UserID != nil {
userIDStr = req.UserID.String()
}
as.logger.Debug("Audit action logged",
zap.String("action", req.Action),
zap.String("resource", req.Resource),
zap.String("user_id", userIDStr),
)
return nil
}
// LogLogin enregistre une tentative de connexion
func (as *AuditService) LogLogin(ctx context.Context, userID *uuid.UUID, success bool, ipAddress, userAgent string, metadata map[string]interface{}) error {
action := "login_failed"
if success {
action = "login_success"
}
req := &AuditLogCreateRequest{
UserID: userID,
Action: action,
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: metadata,
}
return as.LogAction(ctx, req)
}
// LogLogout enregistre une déconnexion
func (as *AuditService) LogLogout(ctx context.Context, userID uuid.UUID, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "logout",
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// LogUpload enregistre un upload de fichier
func (as *AuditService) LogUpload(ctx context.Context, userID uuid.UUID, resourceID uuid.UUID, fileName string, fileSize int64, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "upload",
Resource: "track",
ResourceID: &resourceID,
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{
"file_name": fileName,
"file_size": fileSize,
},
}
return as.LogAction(ctx, req)
}
// LogPermissionChange enregistre un changement de permission
func (as *AuditService) LogPermissionChange(ctx context.Context, userID uuid.UUID, targetUserID uuid.UUID, oldPermissions, newPermissions []string, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "permission_change",
Resource: "user",
ResourceID: &targetUserID,
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{
"old_permissions": oldPermissions,
"new_permissions": newPermissions,
},
}
return as.LogAction(ctx, req)
}
// LogDeletion enregistre une suppression
func (as *AuditService) LogDeletion(ctx context.Context, userID uuid.UUID, resource string, resourceID uuid.UUID, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "delete",
Resource: resource,
ResourceID: &resourceID,
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogPasswordChange enregistre un changement de mot de passe
func (as *AuditService) LogPasswordChange(ctx context.Context, userID uuid.UUID, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "password_change",
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogPasswordResetRequest enregistre une demande de réinitialisation de mot de passe
func (as *AuditService) LogPasswordResetRequest(ctx context.Context, userID *uuid.UUID, email string, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: userID,
Action: "password_reset_request",
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{
"email": email,
},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogPasswordReset enregistre une réinitialisation de mot de passe
func (as *AuditService) LogPasswordReset(ctx context.Context, userID uuid.UUID, success bool, ipAddress, userAgent string) error {
action := "password_reset_failed"
if success {
action = "password_reset_success"
}
req := &AuditLogCreateRequest{
UserID: &userID,
Action: action,
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogTwoFactorEnabled enregistre l'activation de 2FA
func (as *AuditService) LogTwoFactorEnabled(ctx context.Context, userID uuid.UUID, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "2fa_enabled",
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogTwoFactorDisabled enregistre la désactivation de 2FA
func (as *AuditService) LogTwoFactorDisabled(ctx context.Context, userID uuid.UUID, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "2fa_disabled",
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogTwoFactorVerification enregistre une vérification 2FA
func (as *AuditService) LogTwoFactorVerification(ctx context.Context, userID uuid.UUID, success bool, ipAddress, userAgent string) error {
action := "2fa_verification_failed"
if success {
action = "2fa_verification_success"
}
req := &AuditLogCreateRequest{
UserID: &userID,
Action: action,
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogAccessDenied enregistre un accès refusé
func (as *AuditService) LogAccessDenied(ctx context.Context, userID *uuid.UUID, resource string, resourceID *uuid.UUID, reason string, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: userID,
Action: "access_denied",
Resource: resource,
ResourceID: resourceID,
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{
"reason": reason,
},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogRoleChange enregistre un changement de rôle
func (as *AuditService) LogRoleChange(ctx context.Context, userID uuid.UUID, targetUserID uuid.UUID, oldRole, newRole string, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: &userID,
Action: "role_change",
Resource: "user",
ResourceID: &targetUserID,
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: map[string]interface{}{
"old_role": oldRole,
"new_role": newRole,
},
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogAccountLocked enregistre un verrouillage de compte
func (as *AuditService) LogAccountLocked(ctx context.Context, email string, reason string, lockedUntil *time.Time, ipAddress, userAgent string) error {
metadata := map[string]interface{}{
"email": email,
"reason": reason,
}
if lockedUntil != nil {
metadata["locked_until"] = lockedUntil.Format(time.RFC3339)
}
req := &AuditLogCreateRequest{
UserID: nil, // May not have userID if account locked before login
Action: "account_locked",
Resource: "user",
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: metadata,
}
return as.LogAction(ctx, req)
}
// BE-SEC-013: LogSecurityEvent enregistre un événement de sécurité générique
func (as *AuditService) LogSecurityEvent(ctx context.Context, userID *uuid.UUID, action string, resource string, resourceID *uuid.UUID, details map[string]interface{}, ipAddress, userAgent string) error {
req := &AuditLogCreateRequest{
UserID: userID,
Action: action,
Resource: resource,
ResourceID: resourceID,
IPAddress: ipAddress,
UserAgent: userAgent,
Metadata: details,
}
return as.LogAction(ctx, req)
}
// SearchLogs recherche des logs d'audit
func (as *AuditService) SearchLogs(ctx context.Context, req *AuditLogSearchRequest) ([]*AuditLog, error) {
// Construire la requête dynamiquement
query := `
SELECT id, user_id, action, resource, resource_id, ip_address, user_agent, metadata, timestamp
FROM audit_logs
WHERE 1=1
`
args := []interface{}{}
argIndex := 1
if req.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIndex)
args = append(args, *req.UserID)
argIndex++
}
if req.Action != "" {
query += fmt.Sprintf(" AND action = $%d", argIndex)
args = append(args, req.Action)
argIndex++
}
if req.Resource != "" {
query += fmt.Sprintf(" AND resource = $%d", argIndex)
args = append(args, req.Resource)
argIndex++
}
if req.StartDate != nil {
query += fmt.Sprintf(" AND timestamp >= $%d", argIndex)
args = append(args, *req.StartDate)
argIndex++
}
if req.EndDate != nil {
query += fmt.Sprintf(" AND timestamp <= $%d", argIndex)
args = append(args, *req.EndDate)
argIndex++
}
query += " ORDER BY timestamp DESC"
if req.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, req.Limit)
argIndex++
}
if req.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, req.Offset)
}
rows, err := as.db.QueryContext(ctx, query, args...)
if err != nil {
as.logger.Error("Failed to search audit logs",
zap.Error(err),
)
return nil, fmt.Errorf("failed to search audit logs: %w", err)
}
defer rows.Close()
var logs []*AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(
&log.ID,
&log.UserID,
&log.Action,
&log.Resource,
&log.ResourceID,
&log.IPAddress,
&log.UserAgent,
&log.Metadata,
&log.Timestamp,
)
if err != nil {
as.logger.Error("Failed to scan audit log",
zap.Error(err),
)
continue
}
logs = append(logs, &log)
}
return logs, nil
}
// CountLogs compte le nombre total de logs correspondant aux critères de recherche
// BE-API-034: Added total count for pagination
func (as *AuditService) CountLogs(ctx context.Context, req *AuditLogSearchRequest) (int64, error) {
query := `
SELECT COUNT(*)
FROM audit_logs
WHERE 1=1
`
args := []interface{}{}
argIndex := 1
if req.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIndex)
args = append(args, *req.UserID)
argIndex++
}
if req.Action != "" {
query += fmt.Sprintf(" AND action = $%d", argIndex)
args = append(args, req.Action)
argIndex++
}
if req.Resource != "" {
query += fmt.Sprintf(" AND resource = $%d", argIndex)
args = append(args, req.Resource)
argIndex++
}
if req.ResourceID != nil {
query += fmt.Sprintf(" AND resource_id = $%d", argIndex)
args = append(args, *req.ResourceID)
argIndex++
}
if req.IPAddress != "" {
query += fmt.Sprintf(" AND ip_address = $%d", argIndex)
args = append(args, req.IPAddress)
argIndex++
}
if req.UserAgent != "" {
query += fmt.Sprintf(" AND user_agent LIKE $%d", argIndex)
args = append(args, "%"+req.UserAgent+"%")
argIndex++
}
if req.StartDate != nil {
query += fmt.Sprintf(" AND timestamp >= $%d", argIndex)
args = append(args, *req.StartDate)
argIndex++
}
if req.EndDate != nil {
query += fmt.Sprintf(" AND timestamp <= $%d", argIndex)
args = append(args, *req.EndDate)
argIndex++
}
var count int64
err := as.db.QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
as.logger.Error("Failed to count audit logs",
zap.Error(err),
)
return 0, fmt.Errorf("failed to count audit logs: %w", err)
}
return count, nil
}
// GetStats récupère les statistiques d'audit
func (as *AuditService) GetStats(ctx context.Context, startDate, endDate time.Time) ([]*AuditStats, error) {
query := `
SELECT action, resource, COUNT(*) as action_count,
COUNT(DISTINCT user_id) as unique_users,
COUNT(DISTINCT ip_address) as unique_ips
FROM audit_logs
WHERE timestamp BETWEEN $1 AND $2
GROUP BY action, resource
ORDER BY action_count DESC
`
rows, err := as.db.QueryContext(ctx, query, startDate, endDate)
if err != nil {
as.logger.Error("Failed to get audit stats",
zap.Error(err),
)
return nil, fmt.Errorf("failed to get audit stats: %w", err)
}
defer rows.Close()
var stats []*AuditStats
for rows.Next() {
var stat AuditStats
err := rows.Scan(
&stat.Action,
&stat.Resource,
&stat.ActionCount,
&stat.UniqueUsers,
&stat.UniqueIPs,
)
if err != nil {
as.logger.Error("Failed to scan audit stat",
zap.Error(err),
)
continue
}
stats = append(stats, &stat)
}
return stats, nil
}
// DetectSuspiciousActivity détecte les activités suspectes
func (as *AuditService) DetectSuspiciousActivity(ctx context.Context, hours int) ([]*SuspiciousActivity, error) {
query := `
WITH user_activity AS (
SELECT
user_id,
ip_address,
COUNT(*) as action_count,
COUNT(DISTINCT action) as unique_actions
FROM audit_logs
WHERE timestamp >= NOW() - INTERVAL '%d hours'
GROUP BY user_id, ip_address
)
SELECT
user_id,
ip_address,
action_count,
unique_actions,
CASE
WHEN action_count > 1000 THEN 100
WHEN action_count > 500 THEN 80
WHEN action_count > 100 THEN 60
WHEN action_count > 50 THEN 40
WHEN action_count > 20 THEN 20
ELSE 0
END as risk_score
FROM user_activity
WHERE action_count > 20
ORDER BY risk_score DESC, action_count DESC
`
rows, err := as.db.QueryContext(ctx, fmt.Sprintf(query, hours))
if err != nil {
as.logger.Error("Failed to detect suspicious activity",
zap.Error(err),
)
return nil, fmt.Errorf("failed to detect suspicious activity: %w", err)
}
defer rows.Close()
var activities []*SuspiciousActivity
for rows.Next() {
var activity SuspiciousActivity
err := rows.Scan(
&activity.UserID,
&activity.IPAddress,
&activity.ActionCount,
&activity.UniqueActions,
&activity.RiskScore,
)
if err != nil {
as.logger.Error("Failed to scan suspicious activity",
zap.Error(err),
)
continue
}
activities = append(activities, &activity)
}
return activities, nil
}
// CleanupOldLogs nettoie les anciens logs d'audit
func (as *AuditService) CleanupOldLogs(ctx context.Context, retentionDays int) (int64, error) {
query := `
DELETE FROM audit_logs
WHERE timestamp < NOW() - INTERVAL '%d days'
`
result, err := as.db.ExecContext(ctx, fmt.Sprintf(query, retentionDays))
if err != nil {
as.logger.Error("Failed to cleanup old audit logs",
zap.Error(err),
)
return 0, fmt.Errorf("failed to cleanup old audit logs: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to get rows affected: %w", err)
}
as.logger.Info("Old audit logs cleaned up",
zap.Int64("deleted_count", rowsAffected),
zap.Int("retention_days", retentionDays),
)
return rowsAffected, nil
}
// GetUserActivity récupère l'activité d'un utilisateur
func (as *AuditService) GetUserActivity(ctx context.Context, userID uuid.UUID, limit int) ([]*AuditLog, error) {
req := &AuditLogSearchRequest{
UserID: &userID,
Limit: limit,
}
return as.SearchLogs(ctx, req)
}
// GetIPActivity récupère l'activité d'une IP
func (as *AuditService) GetIPActivity(ctx context.Context, ipAddress string, limit int) ([]*AuditLog, error) {
query := `
SELECT id, user_id, action, resource, resource_id, ip_address, user_agent, metadata, timestamp
FROM audit_logs
WHERE ip_address = $1
ORDER BY timestamp DESC
LIMIT $2
`
rows, err := as.db.QueryContext(ctx, query, ipAddress, limit)
if err != nil {
as.logger.Error("Failed to get IP activity",
zap.Error(err),
zap.String("ip_address", ipAddress),
)
return nil, fmt.Errorf("failed to get IP activity: %w", err)
}
defer rows.Close()
var logs []*AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(
&log.ID,
&log.UserID,
&log.Action,
&log.Resource,
&log.ResourceID,
&log.IPAddress,
&log.UserAgent,
&log.Metadata,
&log.Timestamp,
)
if err != nil {
as.logger.Error("Failed to scan audit log",
zap.Error(err),
)
continue
}
logs = append(logs, &log)
}
return logs, nil
}