128 lines
3.3 KiB
Go
128 lines
3.3 KiB
Go
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
|
|
}
|