feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
This commit is contained in:
+33
-4
@@ -1,15 +1,17 @@
|
||||
# Bell 事件存储与内部审计入口
|
||||
# Bell 事件存储、审计与 Brain 事件入口
|
||||
|
||||
Bell 当前实现 M3 的事件域基础及 Sense 审计 relay 的最小内部 HTTP 服务:
|
||||
Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的 Brain 事件 ingress:
|
||||
|
||||
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
|
||||
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
|
||||
- PostgreSQL `bell.events` 保存不可变事实;后续 outcome 追加到 `bell.event_outcomes`。
|
||||
- `bell_runtime` 对三张不可变事实表只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;仅可在短期 `audit_relay_receipts` 表查询、插入和清理过期收据。
|
||||
- `bell_runtime` 对事件、outcome、全局审计和 Brain 来源收据只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;只可清理两张短期 nonce 收据表。
|
||||
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
|
||||
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
|
||||
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
|
||||
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
|
||||
|
||||
Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。审计 relay 只服务 Sense,不得把 `internal/event` 的 Go 类型或该 HMAC 适配器当成公共协议。
|
||||
公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。两条 HMAC ingress 都是内部适配器,不得当成 Bell 公共协议或共用 key。
|
||||
|
||||
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
||||
|
||||
@@ -17,6 +19,33 @@ Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`017` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
|
||||
|
||||
```powershell
|
||||
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
|
||||
$env:BELL_EVENT_INGRESS_KEYS_FILE = 'D:\private\brain-event-keys.json'
|
||||
$env:BELL_EVIDENCE_FORBIDDEN_NAMES_FILE = 'D:\private\forbidden-evidence-names.txt'
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
forbidden-names 文件每行一个不得出现在证据 URI 的租户/客户标记,至少一行、最多 256 行。key 文件格式见 `docs/contracts/README.md`。仓库不提供真实 secret、DSN、绑定或客户名称;绑定只能引用已经存在且同 Area/modality 的设备。
|
||||
|
||||
管理员在事务中核对逻辑资源后,可按下列列签名配置一条视频绑定;数字 ID 是冻结事件契约使用的稳定正整数,不是把文本 ID 强转为数字:
|
||||
|
||||
```sql
|
||||
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, enabled
|
||||
) VALUES (
|
||||
'brain-main', 1, 1, 1,
|
||||
'tenant-logical-id', 'site-logical-id', 'device-logical-id', 'area-logical-id',
|
||||
'video', true
|
||||
);
|
||||
```
|
||||
|
||||
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
|
||||
+85
-10
@@ -11,24 +11,31 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/audit"
|
||||
"yovision/bell/internal/event"
|
||||
"yovision/bell/internal/ingress"
|
||||
"yovision/bell/internal/store"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
type configuration struct {
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
eventIngressEnabled bool
|
||||
eventKeyFile string
|
||||
forbiddenNamesFile string
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -41,11 +48,20 @@ func main() {
|
||||
|
||||
func loadConfiguration() (configuration, error) {
|
||||
value := configuration{
|
||||
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
||||
dsn: os.Getenv("BELL_DB_DSN"),
|
||||
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
||||
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
||||
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
||||
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
||||
dsn: os.Getenv("BELL_DB_DSN"),
|
||||
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
||||
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
||||
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
||||
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
||||
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
|
||||
}
|
||||
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
|
||||
case "", "false":
|
||||
case "true":
|
||||
value.eventIngressEnabled = true
|
||||
default:
|
||||
return configuration{}, errors.New("BELL_EVENT_INGRESS_ENABLED must be true or false")
|
||||
}
|
||||
if value.dsn == "" {
|
||||
return configuration{}, errors.New("BELL_DB_DSN is required")
|
||||
@@ -65,6 +81,14 @@ func loadConfiguration() (configuration, error) {
|
||||
if (value.tlsCert == "") != (value.tlsKey == "") {
|
||||
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
|
||||
}
|
||||
if value.eventIngressEnabled {
|
||||
if value.eventKeyFile == "" || !filepath.IsAbs(value.eventKeyFile) {
|
||||
return configuration{}, errors.New("BELL_EVENT_INGRESS_KEYS_FILE must be an absolute external path when event ingress is enabled")
|
||||
}
|
||||
if value.forbiddenNamesFile == "" || !filepath.IsAbs(value.forbiddenNamesFile) {
|
||||
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -106,6 +130,32 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(audit.RelayPath, handler)
|
||||
if cfg.eventIngressEnabled {
|
||||
if err := repository.EventIngressReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
eventKeys, err := ingress.LoadKeys(cfg.eventKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forbiddenNames, err := loadForbiddenNames(cfg.forbiddenNamesFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, err := event.NewEvidenceGuard(forbiddenNames...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
factory, err := event.NewFactory(contracts.EventV01Schema, event.ULIDGenerator{}, repository, guard)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventHandler, err := ingress.NewHandler(repository, eventKeys, factory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mux.Handle(ingress.Path, eventHandler)
|
||||
}
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatus(writer, http.StatusOK, "ok")
|
||||
})
|
||||
@@ -114,6 +164,12 @@ func run(logger *slog.Logger) error {
|
||||
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||
return
|
||||
}
|
||||
if cfg.eventIngressEnabled {
|
||||
if err := repository.EventIngressReady(request.Context()); err != nil {
|
||||
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeStatus(writer, http.StatusOK, "ready")
|
||||
})
|
||||
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
|
||||
@@ -138,6 +194,25 @@ func run(logger *slog.Logger) error {
|
||||
return server.Shutdown(shutdownContext)
|
||||
}
|
||||
|
||||
func loadForbiddenNames(path string) ([]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || len(raw) > 64<<10 {
|
||||
return nil, errors.New("read Bell evidence forbidden-names file")
|
||||
}
|
||||
var values []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, line)
|
||||
}
|
||||
if len(values) == 0 || len(values) > 256 {
|
||||
return nil, errors.New("Bell evidence forbidden-names file must contain 1 to 256 names")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func writeStatus(writer http.ResponseWriter, status int, value string) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
|
||||
@@ -31,3 +31,23 @@ func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
|
||||
t.Fatalf("remote TLS Bell bind rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventIngressIsDisabledByDefaultAndRequiresExternalPolicy(t *testing.T) {
|
||||
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "audit.json"))
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
|
||||
value, err := loadConfiguration()
|
||||
if err != nil || value.eventIngressEnabled {
|
||||
t.Fatalf("default event ingress configuration: %+v %v", value, err)
|
||||
}
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("event ingress without keys and evidence policy was accepted")
|
||||
}
|
||||
t.Setenv("BELL_EVENT_INGRESS_KEYS_FILE", filepath.Join(t.TempDir(), "event-keys.json"))
|
||||
t.Setenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE", filepath.Join(t.TempDir(), "names.txt"))
|
||||
value, err = loadConfiguration()
|
||||
if err != nil || !value.eventIngressEnabled {
|
||||
t.Fatalf("valid event ingress configuration rejected: %+v %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user