feat(bell): add immutable event store [T-015]
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
// Package event assembles and validates immutable Bell event facts.
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
const MaxPayloadBytes = 1 << 20
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
CodeInvalidJSON ErrorCode = "invalid_json"
|
||||
CodePayloadTooLarge ErrorCode = "payload_too_large"
|
||||
CodeUpstreamID ErrorCode = "upstream_id_forbidden"
|
||||
CodeSchema ErrorCode = "schema_invalid"
|
||||
CodeTimeOrder ErrorCode = "time_order_invalid"
|
||||
CodeLatency ErrorCode = "latency_inconsistent"
|
||||
CodeConfidence ErrorCode = "confidence_forbidden"
|
||||
CodeEvidence ErrorCode = "evidence_unsafe"
|
||||
CodePrimarySensor ErrorCode = "primary_sensor_invalid"
|
||||
CodePrivacyDenied ErrorCode = "privacy_denied"
|
||||
CodePrivacyUnavailable ErrorCode = "privacy_unavailable"
|
||||
)
|
||||
|
||||
// ValidationError exposes a stable code without returning sensitive payloads.
|
||||
type ValidationError struct {
|
||||
Code ErrorCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string { return string(e.Code) }
|
||||
func (e *ValidationError) Unwrap() error { return e.Err }
|
||||
|
||||
func validationError(code ErrorCode, err error) error {
|
||||
return &ValidationError{Code: code, Err: err}
|
||||
}
|
||||
|
||||
// IDGenerator is owned by Bell. Upstream candidates are not allowed to carry id.
|
||||
type IDGenerator interface {
|
||||
NewEventID() (string, error)
|
||||
}
|
||||
|
||||
type ULIDGenerator struct{}
|
||||
|
||||
func (ULIDGenerator) NewEventID() (string, error) {
|
||||
return "evt_" + ulid.Make().String(), nil
|
||||
}
|
||||
|
||||
// PrivacyPolicy resolves the authoritative device/Area policy. Implementations
|
||||
// must fail closed when the mapping is missing or stale.
|
||||
type PrivacyPolicy interface {
|
||||
VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error)
|
||||
}
|
||||
|
||||
// EvidencePolicy checks every evidence/observation URI before persistence.
|
||||
type EvidencePolicy interface {
|
||||
ValidateURI(rawURI string) error
|
||||
}
|
||||
|
||||
// EvidenceGuard rejects reusable credentials, network endpoints and configured
|
||||
// customer/tenant names from persisted evidence URIs.
|
||||
type EvidenceGuard struct {
|
||||
forbidden []string
|
||||
}
|
||||
|
||||
func NewEvidenceGuard(forbiddenNames ...string) (*EvidenceGuard, error) {
|
||||
guard := &EvidenceGuard{}
|
||||
for _, name := range forbiddenNames {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return nil, errors.New("forbidden evidence name cannot be blank")
|
||||
}
|
||||
guard.forbidden = append(guard.forbidden, name)
|
||||
}
|
||||
return guard, nil
|
||||
}
|
||||
|
||||
var ipv4Like = regexp.MustCompile(`(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)`)
|
||||
|
||||
func (g *EvidenceGuard) ValidateURI(rawURI string) error {
|
||||
parsed, err := url.Parse(rawURI)
|
||||
if err != nil || parsed.Scheme == "" {
|
||||
return errors.New("evidence URI is not absolute")
|
||||
}
|
||||
if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return errors.New("evidence URI contains reusable connection material")
|
||||
}
|
||||
if host := parsed.Hostname(); host != "" && net.ParseIP(host) != nil {
|
||||
return errors.New("evidence URI contains an IP address")
|
||||
}
|
||||
lower := strings.ToLower(rawURI)
|
||||
for _, marker := range []string{"password", "passwd", "credential", "secret", "token=", "rtsp://"} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return errors.New("evidence URI contains a forbidden marker")
|
||||
}
|
||||
}
|
||||
if ipv4Like.MatchString(lower) {
|
||||
return errors.New("evidence URI contains an IPv4-like value")
|
||||
}
|
||||
for _, name := range g.forbidden {
|
||||
if strings.Contains(lower, name) {
|
||||
return errors.New("evidence URI contains a configured sensitive name")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Sensor struct {
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Modality string `json:"modality"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type storedShape struct {
|
||||
ID string `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
SiteID int64 `json:"site_id"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Sensors []Sensor `json:"sensors"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
Confidence *float64 `json:"confidence"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
DetectedAt time.Time `json:"detected_at"`
|
||||
LatencySeconds float64 `json:"latency_seconds"`
|
||||
Observation *struct {
|
||||
BBoxSeqURI *string `json:"bbox_seq_uri"`
|
||||
KeypointSeqURI *string `json:"keypoint_seq_uri"`
|
||||
SignalSeqURI *string `json:"signal_seq_uri"`
|
||||
} `json:"observation"`
|
||||
Evidence struct {
|
||||
SnapshotURIs []string `json:"snapshot_uris"`
|
||||
ClipURI *string `json:"clip_uri"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
|
||||
// Event is a final, schema-valid immutable fact. JSON returns a defensive copy.
|
||||
type Event struct {
|
||||
shape storedShape
|
||||
payload []byte
|
||||
digest [sha256.Size]byte
|
||||
}
|
||||
|
||||
func (e Event) ID() string { return e.shape.ID }
|
||||
func (e Event) TenantID() int64 { return e.shape.TenantID }
|
||||
func (e Event) SiteID() int64 { return e.shape.SiteID }
|
||||
func (e Event) DeviceID() int64 { return e.shape.DeviceID }
|
||||
func (e Event) SourceEventID() string { return e.shape.SourceEventID }
|
||||
func (e Event) Kind() string { return e.shape.Kind }
|
||||
func (e Event) Severity() string { return e.shape.Severity }
|
||||
func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt }
|
||||
func (e Event) DetectedAt() time.Time { return e.shape.DetectedAt }
|
||||
func (e Event) Digest() [sha256.Size]byte { return e.digest }
|
||||
func (e Event) JSON() []byte { return bytes.Clone(e.payload) }
|
||||
|
||||
type Factory struct {
|
||||
schema *jsonschema.Schema
|
||||
ids IDGenerator
|
||||
privacy PrivacyPolicy
|
||||
evidence EvidencePolicy
|
||||
}
|
||||
|
||||
func NewFactory(schemaJSON []byte, ids IDGenerator, privacy PrivacyPolicy, evidence EvidencePolicy) (*Factory, error) {
|
||||
if ids == nil || privacy == nil || evidence == nil {
|
||||
return nil, errors.New("event factory dependencies are required")
|
||||
}
|
||||
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse event schema: %w", err)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
if err := compiler.AddResource("event-v0.1.schema.json", schemaDoc); err != nil {
|
||||
return nil, fmt.Errorf("register event schema: %w", err)
|
||||
}
|
||||
compiled, err := compiler.Compile("event-v0.1.schema.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile event schema: %w", err)
|
||||
}
|
||||
return &Factory{schema: compiled, ids: ids, privacy: privacy, evidence: evidence}, nil
|
||||
}
|
||||
|
||||
// Create turns a producer candidate into the final stored v0.1 event. The
|
||||
// candidate must contain every v0.1 field except the Bell-owned id.
|
||||
func (f *Factory) Create(ctx context.Context, candidate []byte) (Event, error) {
|
||||
if len(candidate) > MaxPayloadBytes {
|
||||
return Event{}, validationError(CodePayloadTooLarge, nil)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(candidate))
|
||||
decoder.UseNumber()
|
||||
var object map[string]any
|
||||
if err := decoder.Decode(&object); err != nil || object == nil {
|
||||
return Event{}, validationError(CodeInvalidJSON, err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return Event{}, validationError(CodeInvalidJSON, errors.New("multiple JSON values"))
|
||||
}
|
||||
if _, exists := object["id"]; exists {
|
||||
return Event{}, validationError(CodeUpstreamID, nil)
|
||||
}
|
||||
id, err := f.ids.NewEventID()
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("generate Bell event id: %w", err)
|
||||
}
|
||||
object["id"] = id
|
||||
payload, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return Event{}, validationError(CodeInvalidJSON, err)
|
||||
}
|
||||
if len(payload) > MaxPayloadBytes {
|
||||
return Event{}, validationError(CodePayloadTooLarge, nil)
|
||||
}
|
||||
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return Event{}, validationError(CodeInvalidJSON, err)
|
||||
}
|
||||
if err := f.schema.Validate(instance); err != nil {
|
||||
return Event{}, validationError(CodeSchema, nil)
|
||||
}
|
||||
var shape storedShape
|
||||
if err := json.Unmarshal(payload, &shape); err != nil {
|
||||
return Event{}, validationError(CodeInvalidJSON, err)
|
||||
}
|
||||
if err := f.assertSemantics(ctx, shape); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return Event{shape: shape, payload: payload, digest: sha256.Sum256(payload)}, nil
|
||||
}
|
||||
|
||||
func (f *Factory) assertSemantics(ctx context.Context, shape storedShape) error {
|
||||
if shape.DetectedAt.Before(shape.OccurredAt) {
|
||||
return validationError(CodeTimeOrder, nil)
|
||||
}
|
||||
actual := shape.DetectedAt.Sub(shape.OccurredAt).Seconds()
|
||||
if math.Abs(actual-shape.LatencySeconds) >= 0.1 {
|
||||
return validationError(CodeLatency, nil)
|
||||
}
|
||||
if shape.Confidence != nil {
|
||||
return validationError(CodeConfidence, nil)
|
||||
}
|
||||
primary := 0
|
||||
for _, sensor := range shape.Sensors {
|
||||
if sensor.Role == "primary" {
|
||||
primary++
|
||||
if sensor.DeviceID != shape.DeviceID {
|
||||
return validationError(CodePrimarySensor, nil)
|
||||
}
|
||||
}
|
||||
if sensor.Modality == "video" {
|
||||
allowed, err := f.privacy.VideoAllowed(ctx, shape.TenantID, shape.SiteID, sensor.DeviceID)
|
||||
if err != nil {
|
||||
return validationError(CodePrivacyUnavailable, nil)
|
||||
}
|
||||
if !allowed {
|
||||
return validationError(CodePrivacyDenied, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if primary != 1 {
|
||||
return validationError(CodePrimarySensor, nil)
|
||||
}
|
||||
var uris []string
|
||||
uris = append(uris, shape.Evidence.SnapshotURIs...)
|
||||
if shape.Evidence.ClipURI != nil {
|
||||
uris = append(uris, *shape.Evidence.ClipURI)
|
||||
}
|
||||
if shape.Observation != nil {
|
||||
for _, value := range []*string{
|
||||
shape.Observation.BBoxSeqURI,
|
||||
shape.Observation.KeypointSeqURI,
|
||||
shape.Observation.SignalSeqURI,
|
||||
} {
|
||||
if value != nil {
|
||||
uris = append(uris, *value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rawURI := range uris {
|
||||
if err := f.evidence.ValidateURI(rawURI); err != nil {
|
||||
return validationError(CodeEvidence, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
const fixedEventID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
|
||||
|
||||
type fixedIDs struct{ id string }
|
||||
|
||||
func (f fixedIDs) NewEventID() (string, error) { return f.id, nil }
|
||||
|
||||
type privacy struct {
|
||||
allowed bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *privacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||
p.calls++
|
||||
return p.allowed, p.err
|
||||
}
|
||||
|
||||
func contractPath(name string) string {
|
||||
return filepath.Join("..", "..", "..", "docs", "raw", "contracts", name)
|
||||
}
|
||||
|
||||
func candidate(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(contractPath(name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(object, "id")
|
||||
encoded, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func mutate(t *testing.T, raw []byte, fn func(map[string]any)) []byte {
|
||||
t.Helper()
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn(object)
|
||||
encoded, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func factory(t *testing.T, policy *privacy) *event.Factory {
|
||||
t.Helper()
|
||||
guard, err := event.NewEvidenceGuard("private-customer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, policy, guard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func assertCode(t *testing.T, err error, code event.ErrorCode) {
|
||||
t.Helper()
|
||||
var validation *event.ValidationError
|
||||
if !errors.As(err, &validation) || validation.Code != code {
|
||||
t.Fatalf("expected %s, got %v", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenContractCopyIsExact(t *testing.T) {
|
||||
raw, err := os.ReadFile(contractPath("event-v0.1.schema.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != string(contracts.EventV01Schema) {
|
||||
t.Fatal("Bell contract copy drifted from the frozen source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryAcceptsAllFrozenExamples(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"event-v0.1.example-current.json",
|
||||
"event-v0.1.example-target.json",
|
||||
"event-v0.1.example-radar.json",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
policy := &privacy{allowed: true}
|
||||
created, err := factory(t, policy).Create(context.Background(), candidate(t, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID() != fixedEventID || len(created.JSON()) == 0 {
|
||||
t.Fatal("Bell did not assemble the final event")
|
||||
}
|
||||
if name == "event-v0.1.example-radar.json" && policy.calls != 0 {
|
||||
t.Fatal("non-video event unexpectedly consulted video policy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryRejectsUpstreamIDAndUnknownField(t *testing.T) {
|
||||
policy := &privacy{allowed: true}
|
||||
base := candidate(t, "event-v0.1.example-current.json")
|
||||
withID := mutate(t, base, func(object map[string]any) { object["id"] = fixedEventID })
|
||||
_, err := factory(t, policy).Create(context.Background(), withID)
|
||||
assertCode(t, err, event.CodeUpstreamID)
|
||||
|
||||
unknown := mutate(t, base, func(object map[string]any) { object["surprise"] = true })
|
||||
_, err = factory(t, policy).Create(context.Background(), unknown)
|
||||
assertCode(t, err, event.CodeSchema)
|
||||
}
|
||||
|
||||
func TestFactoryEnforcesCrossFieldAssertions(t *testing.T) {
|
||||
base := candidate(t, "event-v0.1.example-current.json")
|
||||
tests := []struct {
|
||||
name string
|
||||
code event.ErrorCode
|
||||
edit func(map[string]any)
|
||||
}{
|
||||
{"time-order", event.CodeTimeOrder, func(v map[string]any) { v["occurred_at"] = "2026-08-03T10:31:23.000Z" }},
|
||||
{"latency", event.CodeLatency, func(v map[string]any) { v["latency_seconds"] = 9.0 }},
|
||||
{"confidence", event.CodeConfidence, func(v map[string]any) { v["confidence"] = 0.9 }},
|
||||
{"primary", event.CodePrimarySensor, func(v map[string]any) {
|
||||
v["sensors"] = []any{
|
||||
map[string]any{"device_id": float64(5012), "modality": "video", "role": "primary"},
|
||||
map[string]any{"device_id": float64(5013), "modality": "radar", "role": "primary"},
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := factory(t, &privacy{allowed: true}).Create(context.Background(), mutate(t, base, test.edit))
|
||||
assertCode(t, err, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryFailsClosedForPrivacyAndEvidence(t *testing.T) {
|
||||
base := candidate(t, "event-v0.1.example-current.json")
|
||||
_, err := factory(t, &privacy{err: errors.New("mapping unavailable")}).Create(context.Background(), base)
|
||||
assertCode(t, err, event.CodePrivacyUnavailable)
|
||||
|
||||
_, err = factory(t, &privacy{allowed: false}).Create(context.Background(), base)
|
||||
assertCode(t, err, event.CodePrivacyDenied)
|
||||
|
||||
unsafe := mutate(t, base, func(v map[string]any) {
|
||||
evidence := v["evidence"].(map[string]any)
|
||||
evidence["snapshot_uris"] = []any{"rtsp://user:password@10.0.0.1:554/private-customer.png"}
|
||||
})
|
||||
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), unsafe)
|
||||
assertCode(t, err, event.CodeEvidence)
|
||||
}
|
||||
|
||||
func TestFactoryRequiresFailClosedPoliciesAndPayloadLimit(t *testing.T) {
|
||||
guard, err := event.NewEvidenceGuard()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, nil, guard); err == nil {
|
||||
t.Fatal("nil privacy policy unexpectedly accepted")
|
||||
}
|
||||
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), make([]byte, event.MaxPayloadBytes+1))
|
||||
assertCode(t, err, event.CodePayloadTooLarge)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
var ErrConflict = errors.New("immutable record id conflict")
|
||||
|
||||
type Postgres struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenPostgres(ctx context.Context, db *sql.DB) (*Postgres, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("postgres database is required")
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping Bell postgres: %w", err)
|
||||
}
|
||||
var version int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 3 {
|
||||
return nil, errors.New("postgres Bell schema migration v3 is required")
|
||||
}
|
||||
var canInsert, canSelect, canUpdate, canDelete, canTruncate bool
|
||||
if err := db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'bell.events', 'INSERT'),
|
||||
has_table_privilege(current_user, 'bell.events', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.events', 'UPDATE'),
|
||||
has_table_privilege(current_user, 'bell.events', 'DELETE'),
|
||||
has_table_privilege(current_user, 'bell.events', 'TRUNCATE')`).Scan(
|
||||
&canInsert, &canSelect, &canUpdate, &canDelete, &canTruncate,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("inspect Bell event privileges: %w", err)
|
||||
}
|
||||
if !canInsert || !canSelect || canUpdate || canDelete || canTruncate {
|
||||
return nil, errors.New("Bell runtime event privileges violate append-only boundary")
|
||||
}
|
||||
return &Postgres{db: db}, nil
|
||||
}
|
||||
|
||||
// InsertEvent is idempotent only for the same platform ID and exact payload.
|
||||
func (p *Postgres) InsertEvent(ctx context.Context, value event.Event) (bool, error) {
|
||||
digest := value.Digest()
|
||||
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.events(
|
||||
id, tenant_id, site_id, device_id, source_event_id, kind, severity,
|
||||
occurred_at, detected_at, payload_hash, payload
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(),
|
||||
value.SourceEventID(), value.Kind(), value.Severity(), value.OccurredAt(),
|
||||
value.DetectedAt(), digest[:], value.JSON(),
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert immutable Bell event: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read Bell event insert result: %w", err)
|
||||
}
|
||||
if rows == 1 {
|
||||
return true, nil
|
||||
}
|
||||
var existing []byte
|
||||
if err := p.db.QueryRowContext(ctx, `SELECT payload_hash FROM bell.events WHERE id=$1`, value.ID()).Scan(&existing); err != nil {
|
||||
return false, fmt.Errorf("read existing Bell event digest: %w", err)
|
||||
}
|
||||
if !bytes.Equal(existing, digest[:]) {
|
||||
return false, ErrConflict
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"event_id"`
|
||||
Value string `json:"outcome"`
|
||||
Source string `json:"source"`
|
||||
Reason *string `json:"reason"`
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorID string `json:"actor_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
var outcomeID = regexp.MustCompile(`^out_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||
var eventID = regexp.MustCompile(`^evt_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||
|
||||
func (o Outcome) validate() error {
|
||||
if !outcomeID.MatchString(o.ID) || !eventID.MatchString(o.EventID) || o.ActorID == "" || o.OccurredAt.IsZero() {
|
||||
return errors.New("invalid outcome identity")
|
||||
}
|
||||
validOutcome := map[string]bool{"unknown": true, "true_positive": true, "false_positive": true, "subject_recovered": true, "duplicate": true, "test": true}
|
||||
if !validOutcome[o.Value] || (o.Source != "auto" && o.Source != "manual") {
|
||||
return errors.New("invalid outcome value or source")
|
||||
}
|
||||
if o.ActorType != "user" && o.ActorType != "service" && o.ActorType != "system" {
|
||||
return errors.New("invalid outcome actor type")
|
||||
}
|
||||
if o.Reason != nil && utf8.RuneCountInString(*o.Reason) > 500 {
|
||||
return errors.New("outcome reason is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendOutcome never mutates the event or an earlier outcome record.
|
||||
func (p *Postgres) AppendOutcome(ctx context.Context, value Outcome) (bool, error) {
|
||||
if err := value.validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("encode outcome: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.event_outcomes(
|
||||
id, event_id, outcome, outcome_source, reason, actor_type, actor_id,
|
||||
occurred_at, record_hash
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (id) DO NOTHING`, value.ID, value.EventID, value.Value, value.Source,
|
||||
value.Reason, value.ActorType, value.ActorID, value.OccurredAt, digest[:])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("append Bell event outcome: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read Bell outcome insert result: %w", err)
|
||||
}
|
||||
if rows == 1 {
|
||||
return true, nil
|
||||
}
|
||||
var existing []byte
|
||||
if err := p.db.QueryRowContext(ctx, `SELECT record_hash FROM bell.event_outcomes WHERE id=$1`, value.ID).Scan(&existing); err != nil {
|
||||
return false, fmt.Errorf("read existing Bell outcome digest: %w", err)
|
||||
}
|
||||
if !bytes.Equal(existing, digest[:]) {
|
||||
return false, ErrConflict
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/event"
|
||||
)
|
||||
|
||||
type storeIDs struct{ id string }
|
||||
|
||||
func (f storeIDs) NewEventID() (string, error) { return f.id, nil }
|
||||
|
||||
type allowVideo struct{}
|
||||
|
||||
func (allowVideo) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func testCandidate(t *testing.T, configVersion string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(object, "id")
|
||||
object["config_version"] = configVersion
|
||||
encoded, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func newEvent(t *testing.T, configVersion string) event.Event {
|
||||
t.Helper()
|
||||
guard, err := event.NewEvidenceGuard()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, err := event.NewFactory(
|
||||
contracts.EventV01Schema,
|
||||
storeIDs{id: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"},
|
||||
allowVideo{}, guard,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := factory.Create(context.Background(), testCandidate(t, configVersion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestPostgresImmutableEventAndOutcome(t *testing.T) {
|
||||
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
repo, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
value := newEvent(t, "sp-v1-2026.07.20")
|
||||
created, err := repo.InsertEvent(ctx, value)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first insert: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = repo.InsertEvent(ctx, value)
|
||||
if err != nil || created {
|
||||
t.Fatalf("idempotent replay: created=%v err=%v", created, err)
|
||||
}
|
||||
if _, err := repo.InsertEvent(ctx, newEvent(t, "sp-v1-conflict")); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected immutable conflict, got %v", err)
|
||||
}
|
||||
|
||||
reason := "confirmed by operator"
|
||||
outcome := Outcome{
|
||||
ID: "out_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", EventID: value.ID(),
|
||||
Value: "true_positive", Source: "manual", Reason: &reason,
|
||||
ActorType: "user", ActorID: "operator-1", OccurredAt: time.Now().UTC(),
|
||||
}
|
||||
created, err = repo.AppendOutcome(ctx, outcome)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("append outcome: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = repo.AppendOutcome(ctx, outcome)
|
||||
if err != nil || created {
|
||||
t.Fatalf("idempotent outcome replay: created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `UPDATE bell.events SET kind='changed' WHERE id=$1`, value.ID()); err == nil {
|
||||
t.Fatal("runtime unexpectedly updated immutable event")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM bell.event_outcomes WHERE id=$1`, outcome.ID); err == nil {
|
||||
t.Fatal("runtime unexpectedly deleted immutable outcome")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user