feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
+12
-4
@@ -1,13 +1,21 @@
|
||||
# Bell 事件存储基础
|
||||
# Bell 事件存储与内部审计入口
|
||||
|
||||
Bell 当前只实现 M3 的事件域基础,不包含可部署 HTTP 服务:
|
||||
Bell 当前实现 M3 的事件域基础及 Sense 审计 relay 的最小内部 HTTP 服务:
|
||||
|
||||
- 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 权限。
|
||||
- `bell_runtime` 对三张不可变事实表只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;仅可在短期 `audit_relay_receipts` 表查询、插入和清理过期收据。
|
||||
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
|
||||
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
|
||||
|
||||
Brain→Bell transport、认证、公共事件 API、规则、Alert 和证据对象存储仍需后续任务冻结,不能把 `internal/event` 的 Go 类型当成公共网络协议。
|
||||
Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。审计 relay 只服务 Sense,不得把 `internal/event` 的 Go 类型或该 HMAC 适配器当成公共协议。
|
||||
|
||||
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
||||
|
||||
```powershell
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/internal/audit"
|
||||
"yovision/bell/internal/store"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
type configuration struct {
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("Bell stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
if value.dsn == "" {
|
||||
return configuration{}, errors.New("BELL_DB_DSN is required")
|
||||
}
|
||||
if value.keyFile == "" || !filepath.IsAbs(value.keyFile) {
|
||||
return configuration{}, errors.New("BELL_AUDIT_KEYS_FILE must be an absolute external path")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(value.address)
|
||||
if err != nil {
|
||||
return configuration{}, errors.New("invalid BELL_HTTP_ADDR")
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||
if !loopback && (value.tlsCert == "" || value.tlsKey == "" || !filepath.IsAbs(value.tlsCert) || !filepath.IsAbs(value.tlsKey)) {
|
||||
return configuration{}, errors.New("non-loopback Bell bind requires absolute TLS certificate and key paths")
|
||||
}
|
||||
if (value.tlsCert == "") != (value.tlsKey == "") {
|
||||
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := loadConfiguration()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pgConfig, err := pgx.ParseConfig(cfg.dsn)
|
||||
if err != nil {
|
||||
return errors.New("invalid Bell postgres DSN")
|
||||
}
|
||||
if pgConfig.RuntimeParams == nil {
|
||||
pgConfig.RuntimeParams = make(map[string]string)
|
||||
}
|
||||
pgConfig.RuntimeParams["application_name"] = "yovision-bell"
|
||||
db := stdlib.OpenDB(*pgConfig)
|
||||
db.SetMaxOpenConns(16)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
defer db.Close()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
repository, err := store.OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repository.AuditRelayReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := audit.LoadKeys(cfg.keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handler, err := audit.NewHandler(repository, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(audit.RelayPath, handler)
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatus(writer, http.StatusOK, "ok")
|
||||
})
|
||||
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := repository.AuditRelayReady(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}}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Bell listening", "address", cfg.address, "version", version, "tls_enabled", cfg.tlsCert != "")
|
||||
if cfg.tlsCert != "" {
|
||||
serverErrors <- server.ListenAndServeTLS(cfg.tlsCert, cfg.tlsKey)
|
||||
return
|
||||
}
|
||||
serverErrors <- server.ListenAndServe()
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case serverErr := <-serverErrors:
|
||||
if !errors.Is(serverErr, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serve Bell HTTP: %w", serverErr)
|
||||
}
|
||||
}
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdownContext)
|
||||
}
|
||||
|
||||
func writeStatus(writer http.ResponseWriter, status int, value string) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(writer, `{"status":%q}`, value)
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
|
||||
t.Setenv("BELL_DB_DSN", "")
|
||||
t.Setenv("BELL_AUDIT_KEYS_FILE", "")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("missing Bell database was accepted")
|
||||
}
|
||||
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||
t.Setenv("BELL_AUDIT_KEYS_FILE", "relative.json")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("relative Bell key file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationRequiresTLSOutsideLoopback(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(), "keys.json"))
|
||||
t.Setenv("BELL_HTTP_ADDR", "0.0.0.0:8081")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("remote plaintext Bell bind was accepted")
|
||||
}
|
||||
t.Setenv("BELL_TLS_CERT_FILE", filepath.Join(t.TempDir(), "server.crt"))
|
||||
t.Setenv("BELL_TLS_KEY_FILE", filepath.Join(t.TempDir(), "server.key"))
|
||||
if _, err := loadConfiguration(); err != nil {
|
||||
t.Fatalf("remote TLS Bell bind rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Package audit authenticates and validates Sense audit relay batches.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
RelayPath = "/internal/v1/audit-events:batch"
|
||||
MaxBatchSize = 100
|
||||
MaxBodyBytes = 1 << 20
|
||||
HeaderKeyID = "X-YoVision-Key-Id"
|
||||
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||
HeaderNonce = "X-YoVision-Nonce"
|
||||
HeaderSignature = "X-YoVision-Signature"
|
||||
)
|
||||
|
||||
var ErrReplayConflict = errors.New("audit relay replay conflict")
|
||||
|
||||
type Actor struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type ProjectionVersions struct {
|
||||
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Actor Actor `json:"actor"`
|
||||
Reason *string `json:"reason"`
|
||||
TraceID *string `json:"trace_id"`
|
||||
AggregateGeneration int64 `json:"aggregate_generation"`
|
||||
ProjectionVersions ProjectionVersions `json:"projection_versions"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Envelope struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Event Event `json:"event"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
EventID string `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
ErrorCode *string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResponse struct {
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
Envelope Envelope
|
||||
RecordHash [sha256.Size]byte
|
||||
ErrorCode string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
ProcessAuditBatch(context.Context, string, string, [sha256.Size]byte, []Candidate) ([]Result, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository Repository
|
||||
keys map[string][]byte
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHandler(repository Repository, keys map[string][]byte) (*Handler, error) {
|
||||
if repository == nil || len(keys) == 0 {
|
||||
return nil, errors.New("audit handler dependencies are required")
|
||||
}
|
||||
copyKeys := make(map[string][]byte, len(keys))
|
||||
for id, secret := range keys {
|
||||
if !keyIDPattern.MatchString(id) || len(secret) < 32 {
|
||||
return nil, errors.New("invalid audit handler key")
|
||||
}
|
||||
copyKeys[id] = append([]byte(nil), secret...)
|
||||
}
|
||||
return &Handler{repository: repository, keys: copyKeys, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != RelayPath {
|
||||
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)
|
||||
secret, 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(secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
|
||||
if !hmac.Equal(signatureBytes, expected) {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
candidates, err := decodeCandidates(body)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_batch")
|
||||
return
|
||||
}
|
||||
requestHash := sha256.Sum256(body)
|
||||
results, err := h.repository.ProcessAuditBatch(request.Context(), keyID, nonce, requestHash, candidates)
|
||||
if errors.Is(err, ErrReplayConflict) {
|
||||
writeError(writer, http.StatusConflict, "replay_conflict")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, BatchResponse{Results: results})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type rawBatch struct {
|
||||
Events []json.RawMessage `json:"events"`
|
||||
}
|
||||
|
||||
func decodeCandidates(body []byte) ([]Candidate, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var batch rawBatch
|
||||
if err := decoder.Decode(&batch); err != nil || len(batch.Events) < 1 || len(batch.Events) > MaxBatchSize {
|
||||
return nil, errors.New("invalid audit batch")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid audit batch trailing data")
|
||||
}
|
||||
values := make([]Candidate, len(batch.Events))
|
||||
for index, raw := range batch.Events {
|
||||
values[index].RecordHash = sha256.Sum256(raw)
|
||||
if !hasExactEnvelopeShape(raw) {
|
||||
values[index].ErrorCode = "schema_invalid"
|
||||
continue
|
||||
}
|
||||
itemDecoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
itemDecoder.DisallowUnknownFields()
|
||||
if err := itemDecoder.Decode(&values[index].Envelope); err != nil {
|
||||
values[index].ErrorCode = "schema_invalid"
|
||||
continue
|
||||
}
|
||||
values[index].ErrorCode = validateEnvelope(values[index].Envelope)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
var (
|
||||
eventIDPattern = regexp.MustCompile(`^audit_[0-9a-f]{32}$`)
|
||||
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
)
|
||||
|
||||
func validateEnvelope(value Envelope) string {
|
||||
event := value.Event
|
||||
if (value.SchemaVersion != 1 && value.SchemaVersion != 2) || !eventIDPattern.MatchString(event.EventID) ||
|
||||
!logicalIDPattern.MatchString(event.TenantID) || !logicalIDPattern.MatchString(event.SiteID) || !logicalIDPattern.MatchString(event.DeviceID) ||
|
||||
event.AggregateGeneration < 1 || event.OccurredAt.IsZero() || strings.TrimSpace(event.Actor.ID) == "" || utf8.RuneCountInString(event.Actor.ID) > 200 ||
|
||||
(event.Actor.Type != "user" && event.Actor.Type != "service" && event.Actor.Type != "system") ||
|
||||
(event.Reason != nil && utf8.RuneCountInString(*event.Reason) > 500) || (event.TraceID != nil && utf8.RuneCountInString(*event.TraceID) > 128) ||
|
||||
(event.ProjectionVersions.QuotaSourceVersion != nil && *event.ProjectionVersions.QuotaSourceVersion < 1) ||
|
||||
(event.ProjectionVersions.AreaPolicySourceVersion != nil && *event.ProjectionVersions.AreaPolicySourceVersion < 1) {
|
||||
return "schema_invalid"
|
||||
}
|
||||
if event.EventType != "device.created" && event.EventType != "device.desired_state.accepted" &&
|
||||
(event.EventType != "device.configuration.accepted" || value.SchemaVersion != 2) {
|
||||
return "schema_invalid"
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(event.Data, &data); err != nil || data == nil {
|
||||
return "schema_invalid"
|
||||
}
|
||||
expected := map[string]string{
|
||||
"device.created": "device_created",
|
||||
"device.desired_state.accepted": "desired_state_accepted",
|
||||
"device.configuration.accepted": "configuration_accepted",
|
||||
}[event.EventType]
|
||||
if data["kind"] != expected || !validateData(event.EventType, data) || containsSensitiveKey(data) {
|
||||
return "payload_invalid"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasExactEnvelopeShape(raw []byte) bool {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || !exactRawKeys(envelope, "schema_version", "event") {
|
||||
return false
|
||||
}
|
||||
var event map[string]json.RawMessage
|
||||
if err := json.Unmarshal(envelope["event"], &event); err != nil || !exactRawKeys(event,
|
||||
"event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id",
|
||||
"aggregate_generation", "projection_versions", "data", "occurred_at",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
var actor, projections map[string]json.RawMessage
|
||||
return json.Unmarshal(event["actor"], &actor) == nil && exactRawKeys(actor, "type", "id") &&
|
||||
json.Unmarshal(event["projection_versions"], &projections) == nil && exactRawKeys(projections, "quota_source_version", "area_policy_source_version")
|
||||
}
|
||||
|
||||
func exactRawKeys(value map[string]json.RawMessage, expected ...string) bool {
|
||||
if len(value) != len(expected) {
|
||||
return false
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, exists := value[key]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateData(eventType string, data map[string]any) bool {
|
||||
switch eventType {
|
||||
case "device.created":
|
||||
if !exactAnyKeys(data, "kind", "area_id", "modality", "capabilities", "desired_state") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
|
||||
return false
|
||||
}
|
||||
if !member(stringValue(data["modality"]), "video", "radar", "contact", "button", "wearable", "other") || !member(stringValue(data["desired_state"]), "disabled", "enabled") {
|
||||
return false
|
||||
}
|
||||
return validStringSet(data["capabilities"], 16, "video_capture", "audio_capture", "spatial_rule", "telemetry")
|
||||
case "device.desired_state.accepted":
|
||||
if !exactAnyKeys(data, "kind", "previous_desired_state", "desired_state", "changed") {
|
||||
return false
|
||||
}
|
||||
_, changed := data["changed"].(bool)
|
||||
return changed && member(stringValue(data["previous_desired_state"]), "disabled", "enabled") && member(stringValue(data["desired_state"]), "disabled", "enabled")
|
||||
case "device.configuration.accepted":
|
||||
if !exactAnyKeys(data, "kind", "changed", "changed_fields", "area_id") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
|
||||
return false
|
||||
}
|
||||
_, changed := data["changed"].(bool)
|
||||
return changed && validStringSet(data["changed_fields"], 5, "name", "area_id", "endpoint_ref", "credential_ref", "profile_token")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func exactAnyKeys(value map[string]any, expected ...string) bool {
|
||||
if len(value) != len(expected) {
|
||||
return false
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, exists := value[key]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
result, _ := value.(string)
|
||||
return result
|
||||
}
|
||||
|
||||
func member(value string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validStringSet(value any, maximum int, allowed ...string) bool {
|
||||
items, ok := value.([]any)
|
||||
if !ok || len(items) > maximum {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
text, ok := item.(string)
|
||||
if !ok || !member(text, allowed...) || seen[text] {
|
||||
return false
|
||||
}
|
||||
seen[text] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsSensitiveKey(value any) bool {
|
||||
forbidden := map[string]bool{"password": true, "stream_uri": true, "mediamtx_config": true}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
if forbidden[strings.ToLower(key)] || containsSensitiveKey(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if containsSensitiveKey(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, code string) {
|
||||
writeJSON(writer, status, map[string]string{"error": code})
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingRepository struct {
|
||||
candidates []Candidate
|
||||
results []Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingRepository) ProcessAuditBatch(_ context.Context, _, _ string, _ [sha256.Size]byte, values []Candidate) ([]Result, error) {
|
||||
r.candidates = values
|
||||
return r.results, r.err
|
||||
}
|
||||
|
||||
func validBody(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
value := map[string]any{"events": []any{map[string]any{
|
||||
"schema_version": 1,
|
||||
"event": map[string]any{
|
||||
"event_id": "audit_00000000000000000000000000000001", "event_type": "device.created",
|
||||
"tenant_id": "tenant", "site_id": "site", "device_id": "camera-1",
|
||||
"actor": map[string]any{"type": "system", "id": "sense"}, "reason": nil, "trace_id": nil,
|
||||
"aggregate_generation": 1,
|
||||
"projection_versions": map[string]any{"quota_source_version": 1, "area_policy_source_version": 1},
|
||||
"data": map[string]any{"kind": "device_created", "area_id": "area", "modality": "video", "capabilities": []any{"video_capture"}, "desired_state": "enabled"},
|
||||
"occurred_at": "2026-08-11T00:00:00Z",
|
||||
},
|
||||
}}}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func signedRequest(t *testing.T, body, secret []byte, timestamp time.Time, nonce string) *http.Request {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, RelayPath, bytes.NewReader(body))
|
||||
stamp := strconv.FormatInt(timestamp.Unix(), 10)
|
||||
request.Header.Set(HeaderKeyID, "sense-a")
|
||||
request.Header.Set(HeaderTimestamp, stamp)
|
||||
request.Header.Set(HeaderNonce, nonce)
|
||||
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, RelayPath, stamp, nonce, body))))
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandlerAuthenticatesAndReturnsPerItemResults(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{3}, 32)
|
||||
repository := &recordingRepository{results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}}
|
||||
handler, err := NewHandler(repository, map[string][]byte{"sense-a": secret})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 11, 0, 1, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, validBody(t), secret, now, "AAAAAAAAAAAAAAAAAAAAAA"))
|
||||
if response.Code != http.StatusOK || len(repository.candidates) != 1 || repository.candidates[0].ErrorCode != "" {
|
||||
t.Fatalf("valid batch rejected: status=%d candidates=%+v", response.Code, repository.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsStaleOrTamperedRequests(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{4}, 32)
|
||||
repository := &recordingRepository{}
|
||||
handler, _ := NewHandler(repository, map[string][]byte{"sense-a": secret})
|
||||
now := time.Date(2026, 8, 11, 0, 10, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
for _, request := range []*http.Request{
|
||||
signedRequest(t, validBody(t), secret, now.Add(-301*time.Second), "BBBBBBBBBBBBBBBBBBBBBB"),
|
||||
signedRequest(t, append(validBody(t), ' '), bytes.Repeat([]byte{5}, 32), now, "CCCCCCCCCCCCCCCCCCCCCC"),
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unsafe request returned %d", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCandidatesRejectsSensitiveItemWithoutRejectingBatch(t *testing.T) {
|
||||
body := validBody(t)
|
||||
var value map[string]any
|
||||
_ = json.Unmarshal(body, &value)
|
||||
events := value["events"].([]any)
|
||||
event := events[0].(map[string]any)["event"].(map[string]any)
|
||||
event["data"].(map[string]any)["password"] = "must-not-persist"
|
||||
body, _ = json.Marshal(value)
|
||||
candidates, err := decodeCandidates(body)
|
||||
if err != nil || len(candidates) != 1 || candidates[0].ErrorCode != "payload_invalid" {
|
||||
t.Fatalf("unexpected per-item validation: %+v %v", candidates, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type keyDocument struct {
|
||||
Version int `json:"version"`
|
||||
Keys []struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Secret string `json:"secret_base64url"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
func LoadKeys(path string) (map[string][]byte, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read Bell audit 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 audit key file")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid Bell audit key file")
|
||||
}
|
||||
values := make(map[string][]byte, len(document.Keys))
|
||||
for _, item := range document.Keys {
|
||||
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
|
||||
if err != nil || !keyIDPattern.MatchString(item.KeyID) || len(secret) < 32 {
|
||||
return nil, errors.New("invalid Bell audit key")
|
||||
}
|
||||
if _, exists := values[item.KeyID]; exists {
|
||||
return nil, errors.New("duplicate Bell audit key ID")
|
||||
}
|
||||
values[item.KeyID] = secret
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"yovision/bell/internal/audit"
|
||||
)
|
||||
|
||||
func (p *Postgres) AuditRelayReady(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 < 4 {
|
||||
return errors.New("postgres Bell schema migration v4 is required for audit relay")
|
||||
}
|
||||
var auditSelect, auditInsert, auditUpdate, auditDelete, auditTruncate bool
|
||||
var receiptUse bool
|
||||
if err := p.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'INSERT'),
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'UPDATE'),
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'DELETE'),
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'TRUNCATE'),
|
||||
has_table_privilege(current_user, 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')`).Scan(
|
||||
&auditSelect, &auditInsert, &auditUpdate, &auditDelete, &auditTruncate, &receiptUse,
|
||||
); err != nil {
|
||||
return errors.New("verify Bell audit relay privileges")
|
||||
}
|
||||
if !auditSelect || !auditInsert || auditUpdate || auditDelete || auditTruncate || !receiptUse {
|
||||
return errors.New("Bell audit relay privileges violate append-only boundary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ProcessAuditBatch(
|
||||
ctx context.Context,
|
||||
keyID, nonce string,
|
||||
requestHash [sha256.Size]byte,
|
||||
candidates []audit.Candidate,
|
||||
) ([]audit.Result, error) {
|
||||
if len(candidates) < 1 || len(candidates) > audit.MaxBatchSize {
|
||||
return nil, errors.New("invalid audit candidate batch")
|
||||
}
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("begin Bell audit batch")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, keyID, nonce); err != nil {
|
||||
return nil, errors.New("lock Bell audit receipt")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.audit_relay_receipts WHERE expires_at <= clock_timestamp()`); err != nil {
|
||||
return nil, errors.New("expire Bell audit receipts")
|
||||
}
|
||||
var existingHash, existingBody []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT request_hash, response_body::text
|
||||
FROM bell.audit_relay_receipts WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&existingHash, &existingBody)
|
||||
if err == nil {
|
||||
if !bytes.Equal(existingHash, requestHash[:]) {
|
||||
return nil, audit.ErrReplayConflict
|
||||
}
|
||||
var response audit.BatchResponse
|
||||
if err := json.Unmarshal(existingBody, &response); err != nil {
|
||||
return nil, errors.New("decode stored Bell audit receipt")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.New("commit Bell audit replay")
|
||||
}
|
||||
return response.Results, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("read Bell audit receipt")
|
||||
}
|
||||
results := make([]audit.Result, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.ErrorCode != "" {
|
||||
code := candidate.ErrorCode
|
||||
results = append(results, audit.Result{EventID: candidate.Envelope.Event.EventID, Status: "rejected", ErrorCode: &code})
|
||||
continue
|
||||
}
|
||||
event := candidate.Envelope.Event
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, errors.New("encode Bell audit fact")
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_events(
|
||||
source_system, event_id, schema_version, event_type, tenant_id, site_id,
|
||||
device_id, actor_type, actor_id, reason, trace_id, aggregate_generation,
|
||||
quota_source_version, area_policy_source_version, payload, occurred_at, record_hash
|
||||
) VALUES ('sense',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)
|
||||
ON CONFLICT (source_system,event_id) DO NOTHING`,
|
||||
event.EventID, candidate.Envelope.SchemaVersion, event.EventType, event.TenantID,
|
||||
event.SiteID, event.DeviceID, event.Actor.Type, event.Actor.ID, event.Reason,
|
||||
event.TraceID, event.AggregateGeneration,
|
||||
event.ProjectionVersions.QuotaSourceVersion,
|
||||
event.ProjectionVersions.AreaPolicySourceVersion,
|
||||
payload, event.OccurredAt, candidate.RecordHash[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert Bell audit fact: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, errors.New("read Bell audit insert result")
|
||||
}
|
||||
if affected == 1 {
|
||||
results = append(results, audit.Result{EventID: event.EventID, Status: "accepted"})
|
||||
continue
|
||||
}
|
||||
var storedHash []byte
|
||||
if err := tx.QueryRowContext(ctx, `SELECT record_hash FROM bell.audit_events
|
||||
WHERE source_system='sense' AND event_id=$1`, event.EventID).Scan(&storedHash); err != nil {
|
||||
return nil, errors.New("read existing Bell audit fact")
|
||||
}
|
||||
if bytes.Equal(storedHash, candidate.RecordHash[:]) {
|
||||
results = append(results, audit.Result{EventID: event.EventID, Status: "duplicate"})
|
||||
} else {
|
||||
code := "id_conflict"
|
||||
results = append(results, audit.Result{EventID: event.EventID, Status: "rejected", ErrorCode: &code})
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(audit.BatchResponse{Results: results})
|
||||
if err != nil {
|
||||
return nil, errors.New("encode Bell audit response")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_relay_receipts(
|
||||
key_id, nonce, request_hash, response_status, response_body, expires_at
|
||||
) VALUES ($1,$2,$3,200,$4::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], encoded); err != nil {
|
||||
return nil, errors.New("insert Bell audit receipt")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.New("commit Bell audit batch")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/internal/audit"
|
||||
)
|
||||
|
||||
func auditCandidate(t *testing.T, eventID, actorID string) audit.Candidate {
|
||||
t.Helper()
|
||||
data := json.RawMessage(`{"kind":"device_created","area_id":"area","modality":"video","capabilities":["video_capture"],"desired_state":"enabled"}`)
|
||||
value := audit.Envelope{SchemaVersion: 1, Event: audit.Event{
|
||||
EventID: eventID, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
|
||||
Actor: audit.Actor{Type: "system", ID: actorID}, AggregateGeneration: 1,
|
||||
ProjectionVersions: audit.ProjectionVersions{}, Data: data, OccurredAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC),
|
||||
}}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return audit.Candidate{Envelope: value, RecordHash: sha256.Sum256(raw)}
|
||||
}
|
||||
|
||||
func TestPostgresAuditBatchReceiptAndImmutableFact(t *testing.T) {
|
||||
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
repository, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.AuditRelayReady(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestHash := sha256.Sum256([]byte("request-one"))
|
||||
eventID := "audit_10000000000000000000000000000001"
|
||||
results, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "sense")})
|
||||
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
|
||||
t.Fatalf("first batch: %+v %v", results, err)
|
||||
}
|
||||
replayed, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "ignored-by-receipt")})
|
||||
if err != nil || replayed[0].Status != "accepted" {
|
||||
t.Fatalf("receipt replay: %+v %v", replayed, err)
|
||||
}
|
||||
different := sha256.Sum256([]byte("request-two"))
|
||||
if _, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", different, []audit.Candidate{auditCandidate(t, eventID, "sense")}); !errors.Is(err, audit.ErrReplayConflict) {
|
||||
t.Fatalf("expected replay conflict, got %v", err)
|
||||
}
|
||||
|
||||
duplicate, err := repository.ProcessAuditBatch(ctx, "sense-a", "BBBBBBBBBBBBBBBBBBBBBB", different, []audit.Candidate{auditCandidate(t, eventID, "sense")})
|
||||
if err != nil || duplicate[0].Status != "duplicate" {
|
||||
t.Fatalf("event duplicate: %+v %v", duplicate, err)
|
||||
}
|
||||
conflictHash := sha256.Sum256([]byte("request-three"))
|
||||
conflict, err := repository.ProcessAuditBatch(ctx, "sense-a", "CCCCCCCCCCCCCCCCCCCCCC", conflictHash, []audit.Candidate{auditCandidate(t, eventID, "other")})
|
||||
if err != nil || conflict[0].Status != "rejected" || conflict[0].ErrorCode == nil || *conflict[0].ErrorCode != "id_conflict" {
|
||||
t.Fatalf("event conflict: %+v %v", conflict, err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE bell.audit_events SET actor_id='mutated' WHERE event_id=$1`, eventID); err == nil {
|
||||
t.Fatal("runtime updated immutable audit fact")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM bell.audit_events WHERE event_id=$1`, eventID); err == nil {
|
||||
t.Fatal("runtime deleted immutable audit fact")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user