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)
|
||||
}
|
||||
Reference in New Issue
Block a user