package store import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "strings" "time" ) type AuditActorType string const ( AuditActorUser AuditActorType = "user" AuditActorService AuditActorType = "service" AuditActorSystem AuditActorType = "system" ) // AuditContext is supplied by a verified caller boundary. Repository callers // without an HTTP principal use the explicit system/sense fallback. type AuditContext struct { ActorType AuditActorType ActorID string Reason string TraceID string } type auditContextKey struct{} func WithAuditContext(ctx context.Context, value AuditContext) context.Context { return context.WithValue(ctx, auditContextKey{}, value) } func auditFromContext(ctx context.Context) AuditContext { value, ok := ctx.Value(auditContextKey{}).(AuditContext) if !ok { return AuditContext{ActorType: AuditActorSystem, ActorID: "sense"} } return value } func validateAudit(value AuditContext) error { if value.ActorType != AuditActorUser && value.ActorType != AuditActorService && value.ActorType != AuditActorSystem { return errors.New("invalid audit actor type") } if strings.TrimSpace(value.ActorID) == "" || len(value.ActorID) > 200 { return errors.New("invalid audit actor ID") } if len(value.Reason) > 500 { return errors.New("audit reason is too long") } if len(value.TraceID) > 128 { return errors.New("audit trace ID is too long") } return nil } type postgresAuditEvent struct { EventType string TenantID string SiteID string DeviceID string Generation int64 QuotaSourceVersion int64 AreaPolicySourceVersion int64 Payload any OccurredAt time.Time } func insertPostgresAudit( ctx context.Context, tx *sql.Tx, event postgresAuditEvent, ) error { audit := auditFromContext(ctx) if err := validateAudit(audit); err != nil { return err } eventID, err := newAuditEventID() if err != nil { return errors.New("generate audit event ID") } payload, err := json.Marshal(event.Payload) if err != nil { return errors.New("encode audit payload") } if !json.Valid(payload) { return errors.New("invalid audit payload") } occurredAt := event.OccurredAt.UTC() if occurredAt.IsZero() { occurredAt = time.Now().UTC() } _, err = tx.ExecContext(ctx, `INSERT INTO sense.device_operation_outbox( event_id, event_type, tenant_id, site_id, device_id, actor_type, actor_id, reason, trace_id, aggregate_generation, quota_source_version, area_policy_source_version, payload, occurred_at, available_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''), $10, $11, $12, $13, $14, $14)`, eventID, event.EventType, event.TenantID, event.SiteID, event.DeviceID, audit.ActorType, audit.ActorID, audit.Reason, audit.TraceID, event.Generation, nullableVersion(event.QuotaSourceVersion), nullableVersion(event.AreaPolicySourceVersion), payload, occurredAt) if err != nil { return errors.New("insert device operation audit") } return nil } func newAuditEventID() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return "", err } return "audit_" + hex.EncodeToString(value), nil } func nullableVersion(value int64) any { if value < 1 { return nil } return value }