feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-11 15:41:07 +08:00
parent b7fe44eeb0
commit bd964e8831
33 changed files with 2762 additions and 57 deletions
+1
View File
@@ -163,6 +163,7 @@ 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) Sensors() []Sensor { return append([]Sensor(nil), e.shape.Sensors...) }
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 }
+270
View File
@@ -0,0 +1,270 @@
// Package ingress authenticates Brain event candidates and delegates their
// atomic persistence to Bell's repository.
package ingress
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"yovision/bell/internal/event"
)
const (
Path = "/internal/v1/event-candidates"
MaxBodyBytes = 1 << 20
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
)
var (
ErrReplayConflict = errors.New("event ingress replay conflict")
ErrSourceConflict = errors.New("event ingress source event conflict")
ErrIdentityDenied = errors.New("event ingress identity denied")
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
producerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
type Result struct {
SchemaVersion int `json:"schema_version"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventID string `json:"event_id"`
Status string `json:"status"`
HTTPStatus int `json:"-"`
}
type Repository interface {
Replay(
context.Context,
string,
string,
[sha256.Size]byte,
string,
string,
[sha256.Size]byte,
) (Result, bool, error)
ProcessEvent(
context.Context,
string,
string,
[sha256.Size]byte,
string,
[sha256.Size]byte,
event.Event,
) (Result, error)
}
type Handler struct {
repository Repository
keys map[string]Key
factory *event.Factory
now func() time.Time
}
func NewHandler(repository Repository, keys map[string]Key, factory *event.Factory) (*Handler, error) {
if repository == nil || factory == nil || len(keys) == 0 {
return nil, errors.New("event ingress handler dependencies are required")
}
copyKeys := make(map[string]Key, len(keys))
for id, key := range keys {
if !keyIDPattern.MatchString(id) || !producerIDPattern.MatchString(key.ProducerID) || len(key.Secret) < 32 {
return nil, errors.New("invalid event ingress handler key")
}
copyKeys[id] = Key{ProducerID: key.ProducerID, Secret: append([]byte(nil), key.Secret...)}
}
return &Handler{repository: repository, keys: copyKeys, factory: factory, now: time.Now}, nil
}
type envelope struct {
SchemaVersion int `json:"schema_version"`
ProducerID string `json:"producer_id"`
Candidate json.RawMessage `json:"candidate"`
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != Path {
writeError(writer, http.StatusNotFound, "not_found", "")
return
}
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
if err != nil || len(body) > MaxBodyBytes {
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
return
}
keyID := request.Header.Get(HeaderKeyID)
timestamp := request.Header.Get(HeaderTimestamp)
nonce := request.Header.Get(HeaderNonce)
provided := request.Header.Get(HeaderSignature)
key, ok := h.keys[keyID]
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
expected := signature(key.Secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
if !hmac.Equal(signatureBytes, expected) {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
var value envelope
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil || value.SchemaVersion != 1 || !producerIDPattern.MatchString(value.ProducerID) || len(value.Candidate) == 0 {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
if value.ProducerID != key.ProducerID {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
canonicalCandidate, err := canonicalJSON(value.Candidate)
if err != nil {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
sourceEventID, err := candidateSourceEventID(canonicalCandidate)
if err != nil {
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", "schema_invalid")
return
}
requestHash := sha256.Sum256(body)
candidateHash := sha256.Sum256(canonicalCandidate)
replay, found, err := h.repository.Replay(
request.Context(), keyID, nonce, requestHash, value.ProducerID, sourceEventID, candidateHash,
)
switch {
case errors.Is(err, ErrReplayConflict):
writeError(writer, http.StatusConflict, "replay_conflict", "")
return
case errors.Is(err, ErrSourceConflict):
writeError(writer, http.StatusConflict, "source_event_conflict", "")
return
case err != nil:
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
return
case found:
writeJSON(writer, replay.HTTPStatus, replay)
return
}
created, err := h.factory.Create(request.Context(), canonicalCandidate)
if err != nil {
handleFactoryError(writer, err)
return
}
result, err := h.repository.ProcessEvent(request.Context(), keyID, nonce, requestHash, value.ProducerID, candidateHash, created)
switch {
case errors.Is(err, ErrReplayConflict):
writeError(writer, http.StatusConflict, "replay_conflict", "")
case errors.Is(err, ErrSourceConflict):
writeError(writer, http.StatusConflict, "source_event_conflict", "")
case errors.Is(err, ErrIdentityDenied):
writeError(writer, http.StatusForbidden, "identity_denied", "")
case err != nil:
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
default:
writeJSON(writer, result.HTTPStatus, result)
}
}
func handleFactoryError(writer http.ResponseWriter, err error) {
var validation *event.ValidationError
if !errors.As(err, &validation) {
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
return
}
switch validation.Code {
case event.CodePayloadTooLarge:
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
case event.CodePrivacyDenied:
writeError(writer, http.StatusForbidden, "privacy_denied", "")
case event.CodePrivacyUnavailable:
writeError(writer, http.StatusServiceUnavailable, "privacy_unavailable", "")
default:
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", string(validation.Code))
}
}
func canonicalJSON(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil || value == nil {
return nil, errors.New("invalid JSON")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("multiple JSON values")
}
return json.Marshal(value)
}
var sourceEventIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
func candidateSourceEventID(raw []byte) (string, error) {
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return "", errors.New("candidate is not an object")
}
var value string
if err := json.Unmarshal(object["source_event_id"], &value); err != nil || !sourceEventIDPattern.MatchString(value) {
return "", errors.New("candidate source event ID is invalid")
}
return value, nil
}
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func signature(secret []byte, canonical string) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return mac.Sum(nil)
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}
func writeError(writer http.ResponseWriter, status int, code, detail string) {
value := map[string]string{"error": code}
if detail != "" {
value["detail_code"] = detail
}
writeJSON(writer, status, value)
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
+176
View File
@@ -0,0 +1,176 @@
package ingress
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"yovision/bell/contracts"
"yovision/bell/internal/event"
)
type ingressIDs struct{}
func (ingressIDs) NewEventID() (string, error) {
return "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", nil
}
type ingressPrivacy struct{}
func (ingressPrivacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
return true, nil
}
type fakeRepository struct {
replayResult Result
replayFound bool
replayErr error
processErr error
processed int
}
func (f *fakeRepository) Replay(context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte) (Result, bool, error) {
return f.replayResult, f.replayFound, f.replayErr
}
func (f *fakeRepository) ProcessEvent(_ context.Context, _, _ string, _ [sha256.Size]byte, producer string, _ [sha256.Size]byte, value event.Event) (Result, error) {
f.processed++
if f.processErr != nil {
return Result{}, f.processErr
}
return Result{SchemaVersion: 1, ProducerID: producer, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}, nil
}
func ingressCandidate(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json"))
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["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func newIngressHandler(t *testing.T, repository Repository) (*Handler, []byte) {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressIDs{}, ingressPrivacy{}, guard)
if err != nil {
t.Fatal(err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
handler, err := NewHandler(repository, map[string]Key{"brain-a": {ProducerID: "brain-main", Secret: secret}}, factory)
if err != nil {
t.Fatal(err)
}
handler.now = func() time.Time { return time.Unix(1_800_000_000, 0).UTC() }
return handler, secret
}
func signedRequest(t *testing.T, secret []byte, producer string, candidate []byte) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]any{"schema_version": 1, "producer_id": producer, "candidate": json.RawMessage(candidate)})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, Path, bytes.NewReader(body))
timestamp := strconv.FormatInt(1_800_000_000, 10)
nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef"))
request.Header.Set(HeaderKeyID, "brain-a")
request.Header.Set(HeaderTimestamp, timestamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, Path, timestamp, nonce, body))))
return request
}
func TestHandlerAcceptsSignedCandidateAndRejectsProducerSpoofing(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusCreated || repository.processed != 1 {
t.Fatalf("signed candidate: status=%d body=%s processed=%d", response.Code, response.Body.String(), repository.processed)
}
var result Result
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Status != "accepted" || result.EventID == "" {
t.Fatalf("invalid accepted response: %+v %v", result, err)
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-spoofed", ingressCandidate(t)))
if response.Code != http.StatusUnauthorized || repository.processed != 1 {
t.Fatalf("producer spoofing was not rejected: %d %s", response.Code, response.Body.String())
}
}
func TestHandlerReturnsDurableReplayAndStableConflicts(t *testing.T) {
repository := &fakeRepository{replayFound: true, replayResult: Result{
SchemaVersion: 1, ProducerID: "brain-main", SourceEventID: "source-1",
EventID: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", Status: "duplicate", HTTPStatus: 200,
}}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusOK || repository.processed != 0 {
t.Fatalf("durable replay did not bypass factory persistence: %d", response.Code)
}
repository.replayFound = false
repository.replayErr = ErrSourceConflict
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusConflict {
t.Fatalf("source conflict status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHandlerRejectsInvalidSignatureAndUpstreamPlatformID(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
request := signedRequest(t, secret, "brain-main", ingressCandidate(t))
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(make([]byte, 32)))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("invalid signature status=%d", response.Code)
}
var object map[string]any
if err := json.Unmarshal(ingressCandidate(t), &object); err != nil {
t.Fatal(err)
}
object["id"] = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
withID, _ := json.Marshal(object)
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", withID))
if response.Code != http.StatusUnprocessableEntity || repository.processed != 0 {
t.Fatalf("upstream ID status=%d body=%s", response.Code, response.Body.String())
}
}
+53
View File
@@ -0,0 +1,53 @@
package ingress
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
)
type keyDocument struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
ProducerID string `json:"producer_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
type Key struct {
ProducerID string
Secret []byte
}
func LoadKeys(path string) (map[string]Key, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Bell event ingress key file")
}
var document keyDocument
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid Bell event ingress key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid Bell event ingress key file")
}
values := make(map[string]Key, len(document.Keys))
for _, item := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
if err != nil || !keyIDPattern.MatchString(item.KeyID) || !producerIDPattern.MatchString(item.ProducerID) || len(secret) < 32 {
return nil, errors.New("invalid Bell event ingress key")
}
if _, exists := values[item.KeyID]; exists {
return nil, errors.New("duplicate Bell event ingress key ID")
}
values[item.KeyID] = Key{ProducerID: item.ProducerID, Secret: append([]byte(nil), secret...)}
}
return values, nil
}
@@ -0,0 +1,289 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
)
func (p *Postgres) EventIngressReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 5 {
return errors.New("postgres Bell schema migration v5 is required for event ingress")
}
var bindingSelect bool
var receiptSelect, receiptInsert, receiptUpdate, receiptDelete, receiptTruncate bool
var nonceSelect, nonceInsert, nonceUpdate, nonceDelete, nonceTruncate bool
var deviceID, deviceArea, deviceModality bool
var deviceEndpoint, deviceCredential, deviceProfile, devicePath bool
var siteID, areaPolicy bool
err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.event_ingress_bindings', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'INSERT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'UPDATE'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'DELETE'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'TRUNCATE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'INSERT'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'UPDATE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'DELETE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'TRUNCATE'),
has_column_privilege(current_user, 'sense.devices', 'id', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'area_id', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'modality', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'endpoint_ref', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'credential_ref', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'profile_token', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'path_name', 'SELECT'),
has_column_privilege(current_user, 'bell.sites', 'id', 'SELECT'),
has_column_privilege(current_user, 'bell.areas', 'capture_policy', 'SELECT')`).Scan(
&bindingSelect,
&receiptSelect, &receiptInsert, &receiptUpdate, &receiptDelete, &receiptTruncate,
&nonceSelect, &nonceInsert, &nonceUpdate, &nonceDelete, &nonceTruncate,
&deviceID, &deviceArea, &deviceModality,
&deviceEndpoint, &deviceCredential, &deviceProfile, &devicePath,
&siteID, &areaPolicy,
)
if err != nil {
return errors.New("verify Bell event ingress privileges")
}
if !bindingSelect || !receiptSelect || !receiptInsert || receiptUpdate || receiptDelete || receiptTruncate ||
!nonceSelect || !nonceInsert || nonceUpdate || !nonceDelete || nonceTruncate ||
!deviceID || !deviceArea || !deviceModality || deviceEndpoint || deviceCredential || deviceProfile || devicePath ||
!siteID || !areaPolicy {
return errors.New("Bell event ingress privileges violate append-only boundary")
}
return nil
}
// VideoAllowed implements event.PrivacyPolicy using current Bell/Sense facts.
// Exact producer ownership is checked again in ProcessEvent.
func (p *Postgres) VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error) {
return bindingAllowed(ctx, p.db, "", tenantID, siteID, deviceID, "video")
}
type rowQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func bindingAllowed(
ctx context.Context,
query rowQuerier,
producerID string,
tenantID, siteID, deviceID int64,
modality string,
) (bool, error) {
producerClause := ""
arguments := []any{tenantID, siteID, deviceID, modality}
if producerID != "" {
producerClause = " AND binding.producer_id=$5"
arguments = append(arguments, producerID)
}
statement := `SELECT EXISTS (
SELECT 1
FROM bell.event_ingress_bindings AS binding
JOIN bell.sites AS site
ON site.tenant_id=binding.logical_tenant_id
AND site.id=binding.logical_site_id
JOIN bell.areas AS area
ON area.tenant_id=binding.logical_tenant_id
AND area.site_id=binding.logical_site_id
AND area.id=binding.logical_area_id
JOIN sense.devices AS device
ON device.tenant_id=binding.logical_tenant_id
AND device.site_id=binding.logical_site_id
AND device.id=binding.logical_device_id
AND device.area_id=binding.logical_area_id
AND device.modality=binding.modality
WHERE binding.tenant_id=$1 AND binding.site_id=$2 AND binding.device_id=$3
AND binding.modality=$4 AND binding.enabled
AND site.deleted_at IS NULL AND area.deleted_at IS NULL
AND (binding.modality <> 'video' OR area.capture_policy='video_allowed')` + producerClause + `
)`
var allowed bool
if err := query.QueryRowContext(ctx, statement, arguments...).Scan(&allowed); err != nil {
return false, fmt.Errorf("resolve Bell event ingress binding: %w", err)
}
return allowed, nil
}
func (p *Postgres) Replay(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
producerID, sourceEventID string,
candidateHash [sha256.Size]byte,
) (ingress.Result, bool, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return ingress.Result{}, false, errors.New("begin Bell event replay check")
}
defer tx.Rollback()
if err := lockIngress(ctx, tx, keyID, nonce, producerID, sourceEventID); err != nil {
return ingress.Result{}, false, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
return ingress.Result{}, false, errors.New("expire Bell event ingress nonces")
}
if value, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
if err == nil {
err = tx.Commit()
}
return value, found, err
}
var storedHash []byte
var eventID string
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, sourceEventID).Scan(&storedHash, &eventID)
if err == nil {
if !bytes.Equal(storedHash, candidateHash[:]) {
return ingress.Result{}, false, ingress.ErrSourceConflict
}
value := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: sourceEventID, EventID: eventID, Status: "duplicate", HTTPStatus: 200}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, value); err != nil {
return ingress.Result{}, false, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, false, errors.New("commit Bell source replay")
}
return value, true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, false, errors.New("read Bell event source receipt")
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, false, errors.New("commit Bell event replay miss")
}
return ingress.Result{}, false, nil
}
func (p *Postgres) ProcessEvent(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
producerID string,
candidateHash [sha256.Size]byte,
value event.Event,
) (ingress.Result, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return ingress.Result{}, errors.New("begin Bell event ingress")
}
defer tx.Rollback()
if err := lockIngress(ctx, tx, keyID, nonce, producerID, value.SourceEventID()); err != nil {
return ingress.Result{}, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
return ingress.Result{}, errors.New("expire Bell event ingress nonces")
}
if replay, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
if err == nil {
err = tx.Commit()
}
return replay, err
}
var storedHash []byte
var storedEventID string
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, value.SourceEventID()).Scan(&storedHash, &storedEventID)
if err == nil {
if !bytes.Equal(storedHash, candidateHash[:]) {
return ingress.Result{}, ingress.ErrSourceConflict
}
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: storedEventID, Status: "duplicate", HTTPStatus: 200}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
return ingress.Result{}, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, errors.New("commit Bell source duplicate")
}
return result, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, errors.New("read Bell event source receipt")
}
for _, sensor := range value.Sensors() {
allowed, err := bindingAllowed(ctx, tx, producerID, value.TenantID(), value.SiteID(), sensor.DeviceID, sensor.Modality)
if err != nil {
return ingress.Result{}, err
}
if !allowed {
return ingress.Result{}, ingress.ErrIdentityDenied
}
}
digest := value.Digest()
if _, err := tx.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)`,
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(), value.SourceEventID(),
value.Kind(), value.Severity(), value.OccurredAt(), value.DetectedAt(), digest[:], value.JSON(),
); err != nil {
return ingress.Result{}, fmt.Errorf("insert Bell ingress event: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_receipts(
producer_id, source_event_id, candidate_hash, event_id
) VALUES ($1,$2,$3,$4)`, producerID, value.SourceEventID(), candidateHash[:], value.ID()); err != nil {
return ingress.Result{}, fmt.Errorf("insert Bell event source receipt: %w", err)
}
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
return ingress.Result{}, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, errors.New("commit Bell event ingress")
}
return result, nil
}
func lockIngress(ctx context.Context, tx *sql.Tx, keyID, nonce, producerID, sourceEventID string) error {
for _, value := range []string{"event-nonce:" + keyID + ":" + nonce, "event-source:" + producerID + ":" + sourceEventID} {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, value); err != nil {
return errors.New("lock Bell event ingress identity")
}
}
return nil
}
func readNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte) (ingress.Result, bool, error) {
var storedHash, body []byte
var status int
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text
FROM bell.event_ingress_nonces WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&storedHash, &status, &body)
if errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, false, nil
}
if err != nil {
return ingress.Result{}, false, errors.New("read Bell event ingress nonce")
}
if !bytes.Equal(storedHash, requestHash[:]) {
return ingress.Result{}, false, ingress.ErrReplayConflict
}
var value ingress.Result
if err := json.Unmarshal(body, &value); err != nil {
return ingress.Result{}, false, errors.New("decode Bell event ingress nonce")
}
value.HTTPStatus = status
return value, true, nil
}
func insertNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte, value ingress.Result) error {
body, err := json.Marshal(value)
if err != nil {
return errors.New("encode Bell event ingress response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_nonces(
key_id, nonce, request_hash, response_status, response_body, expires_at
) VALUES ($1,$2,$3,$4,$5::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], value.HTTPStatus, body); err != nil {
return errors.New("insert Bell event ingress nonce")
}
return nil
}
@@ -0,0 +1,197 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
)
type ingressStoreIDs struct{ id string }
func (value ingressStoreIDs) NewEventID() (string, error) { return value.id, nil }
func ingressStoreCandidate(t *testing.T, sourceEventID string) []byte {
t.Helper()
var object map[string]any
if err := json.Unmarshal(testCandidate(t, "brain-demo-v1"), &object); err != nil {
t.Fatal(err)
}
object["source_event_id"] = sourceEventID
object["tenant_id"] = float64(101)
object["site_id"] = float64(201)
object["device_id"] = float64(301)
object["sensors"] = []any{map[string]any{"device_id": float64(301), "modality": "video", "role": "primary"}}
object["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func ingressStoreEvent(t *testing.T, repository *Postgres, id, sourceEventID string) event.Event {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{id}, repository, guard)
if err != nil {
t.Fatal(err)
}
value, err := factory.Create(context.Background(), ingressStoreCandidate(t, sourceEventID))
if err != nil {
t.Fatal(err)
}
return value
}
func TestPostgresEventIngressAtomicSourceReceipt(t *testing.T) {
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
if dsn == "" || adminDSN == "" {
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
}
admin, err := sql.Open("pgx", adminDSN)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
ctx := context.Background()
now := time.Now().UTC()
statements := []struct {
query string
args []any
}{
{`INSERT INTO bell.sites(tenant_id,id,name) VALUES ('ingress-tenant','ingress-site','Ingress Site')`, nil},
{`INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('ingress-tenant','ingress-site','ingress-area','Ingress Area','video_allowed')`, nil},
{`INSERT INTO sense.devices(id,tenant_id,site_id,serial_number,name,modality,desired_state,actual_state,area_id,created_at,updated_at)
VALUES ('ingress-device','ingress-tenant','ingress-site','INGRESS-SERIAL','Ingress Device','video','enabled','online','ingress-area',$1,$1)`, []any{now}},
{`INSERT INTO bell.event_ingress_bindings(
producer_id,tenant_id,site_id,device_id,logical_tenant_id,logical_site_id,logical_device_id,logical_area_id,modality
) VALUES ('brain-main',101,201,301,'ingress-tenant','ingress-site','ingress-device','ingress-area','video')`, nil},
}
for _, statement := range statements {
if _, err := admin.ExecContext(ctx, statement.query, statement.args...); err != nil {
t.Fatal(err)
}
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := repository.EventIngressReady(ctx); err != nil {
t.Fatal(err)
}
candidate := ingressStoreCandidate(t, "BRN-ingress-0001")
candidateHash := sha256.Sum256(candidate)
value := ingressStoreEvent(t, repository, "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C", "BRN-ingress-0001")
requestHash := sha256.Sum256([]byte("first-request"))
result, err := repository.ProcessEvent(ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, "brain-main", candidateHash, value)
if err != nil || result.Status != "accepted" || result.HTTPStatus != 201 {
t.Fatalf("first ingress: %+v %v", result, err)
}
// This is the crash-after-Bell-commit case: Brain uses a new nonce before
// it has locally recorded the first response.
replayed, found, err := repository.Replay(
ctx, "brain-a", "BBBBBBBBBBBBBBBBBBBBBB", sha256.Sum256([]byte("retry-request")),
"brain-main", "BRN-ingress-0001", candidateHash,
)
if err != nil || !found || replayed.Status != "duplicate" || replayed.EventID != result.EventID {
t.Fatalf("durable source replay: %+v found=%v err=%v", replayed, found, err)
}
conflictHash := sha256.Sum256([]byte("changed-candidate"))
if _, _, err := repository.Replay(
ctx, "brain-a", "CCCCCCCCCCCCCCCCCCCCCC", sha256.Sum256([]byte("conflict-request")),
"brain-main", "BRN-ingress-0001", conflictHash,
); !errors.Is(err, ingress.ErrSourceConflict) {
t.Fatalf("expected source conflict, got %v", err)
}
if _, _, err := repository.Replay(
ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", sha256.Sum256([]byte("different-request")),
"brain-main", "BRN-ingress-0001", candidateHash,
); !errors.Is(err, ingress.ErrReplayConflict) {
t.Fatalf("expected nonce replay conflict, got %v", err)
}
var wait sync.WaitGroup
errorsSeen := make(chan error, 8)
concurrentValues := make([]event.Event, 8)
for index := range concurrentValues {
id := fmt.Sprintf("evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2%c", "DEFGHJKM"[index])
concurrentValues[index] = ingressStoreEvent(t, repository, id, "BRN-ingress-0001")
}
for index := 0; index < 8; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
nonce := base64Nonce(index)
response, err := repository.ProcessEvent(
ctx, "brain-a", nonce, sha256.Sum256([]byte(nonce)), "brain-main", candidateHash, concurrentValues[index],
)
if err != nil || response.Status != "duplicate" || response.EventID != result.EventID {
errorsSeen <- fmt.Errorf("concurrent duplicate %d: %+v %w", index, response, err)
}
}(index)
}
wait.Wait()
close(errorsSeen)
for err := range errorsSeen {
t.Error(err)
}
var eventCount, receiptCount int
if err := db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM bell.events WHERE tenant_id=101 AND site_id=201 AND source_event_id='BRN-ingress-0001'),
(SELECT count(*) FROM bell.event_ingress_receipts WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001')`).Scan(&eventCount, &receiptCount); err != nil {
t.Fatal(err)
}
if eventCount != 1 || receiptCount != 1 {
t.Fatalf("concurrent ingress created events=%d receipts=%d", eventCount, receiptCount)
}
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy='non_imaging_only'
WHERE tenant_id='ingress-tenant' AND id='ingress-area'`); err != nil {
t.Fatal(err)
}
allowed, err := repository.VideoAllowed(ctx, 101, 201, 301)
if err != nil || allowed {
t.Fatalf("privacy change did not fail closed: allowed=%v err=%v", allowed, err)
}
guard, _ := event.NewEvidenceGuard("private-customer")
factory, _ := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{"evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2N"}, repository, guard)
_, err = factory.Create(ctx, ingressStoreCandidate(t, "BRN-ingress-0002"))
var validation *event.ValidationError
if !errors.As(err, &validation) || validation.Code != event.CodePrivacyDenied {
t.Fatalf("privacy denial code drift: %v", err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.event_ingress_receipts SET event_id=event_id
WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001'`); err == nil {
t.Fatal("runtime updated immutable event source receipt")
}
}
func base64Nonce(index int) string {
return fmt.Sprintf("D%021d", index)
}