feat: persist immutable Bell events and receipts (#17)

This commit is contained in:
QiuSW
2026-08-12 17:48:48 +08:00
parent a5fc8f5490
commit 64a1d82db4
11 changed files with 376 additions and 1 deletions
+52
View File
@@ -0,0 +1,52 @@
package event
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
"github.com/jackc/pgx/v5"
)
type HTTP struct {
Service Service
Auth auth.HTTP
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/events", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.list)))
mux.Handle("GET /api/v1/events/{id}", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.get)))
}
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
return
}
next := ""
if len(items) > 0 {
next = items[len(items)-1].ID
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next": next})
}
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "事件不存在"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
return
}
writeJSON(w, http.StatusOK, item)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+134
View File
@@ -0,0 +1,134 @@
package event
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrConflict = errors.New("幂等键已用于不同事件载荷")
var ErrInvalid = errors.New("事件字段无效")
type Service struct{ DB *pgxpool.Pool }
func normalize(command Command) ([]byte, [32]byte, error) {
command.ProducerID = strings.TrimSpace(command.ProducerID)
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
command.EventType = strings.TrimSpace(command.EventType)
command.Location = strings.TrimSpace(command.Location)
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
command.OccurredAt = command.OccurredAt.UTC()
if command.ProducerID == "" || command.SourceEventID == "" || command.EventType == "" || command.Location == "" || command.OccurredAt.IsZero() {
return nil, [32]byte{}, ErrInvalid
}
if command.Severity != "low" && command.Severity != "medium" && command.Severity != "high" && command.Severity != "critical" {
return nil, [32]byte{}, ErrInvalid
}
if command.EvidenceRef != nil {
value := strings.TrimSpace(*command.EvidenceRef)
if value == "" || strings.Contains(value, "@") || strings.Contains(value, "\\") || strings.HasPrefix(strings.ToLower(value), "file:") {
return nil, [32]byte{}, fmt.Errorf("%w: evidence_ref must be a safe logical reference", ErrInvalid)
}
command.EvidenceRef = &value
}
if command.Attributes == nil {
command.Attributes = map[string]any{}
}
data, err := json.Marshal(command)
if err != nil {
return nil, [32]byte{}, fmt.Errorf("normalize event: %w", err)
}
return data, sha256.Sum256(data), nil
}
func (s Service) Ingest(ctx context.Context, command Command) (Result, error) {
payload, digest, err := normalize(command)
if err != nil {
return Result{}, err
}
var normalized Command
if err := json.Unmarshal(payload, &normalized); err != nil {
return Result{}, err
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return Result{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
var eventID string
err = tx.QueryRow(ctx, `INSERT INTO bell_events(producer_id,source_event_id,event_type,occurred_at,location,severity,evidence_ref,normalized_payload,payload_sha256) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(producer_id,source_event_id) DO NOTHING RETURNING id::text`, normalized.ProducerID, normalized.SourceEventID, normalized.EventType, normalized.OccurredAt, normalized.Location, normalized.Severity, normalized.EvidenceRef, payload, digest[:]).Scan(&eventID)
created := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Result{}, err
}
if created {
if _, err = tx.Exec(ctx, `INSERT INTO bell_event_receipts(event_id,producer_id,source_event_id,payload_sha256) VALUES($1,$2,$3,$4)`, eventID, normalized.ProducerID, normalized.SourceEventID, digest[:]); err != nil {
return Result{}, err
}
}
result, storedDigest, err := loadResult(ctx, tx, normalized.ProducerID, normalized.SourceEventID)
if err != nil {
return Result{}, err
}
if !created && !equalDigest(storedDigest, digest[:]) {
return Result{}, ErrConflict
}
result.Duplicate = !created
if err = tx.Commit(ctx); err != nil {
return Result{}, err
}
return result, nil
}
func loadResult(ctx context.Context, q interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, producerID, sourceEventID string) (Result, []byte, error) {
var result Result
var digest []byte
err := q.QueryRow(ctx, `SELECT e.id::text,e.producer_id,e.source_event_id,e.event_type,e.occurred_at::text,e.location,e.severity,e.evidence_ref,e.normalized_payload,e.created_at::text,e.payload_sha256,r.id::text,r.accepted_at::text FROM bell_events e JOIN bell_event_receipts r ON r.event_id=e.id WHERE e.producer_id=$1 AND e.source_event_id=$2`, producerID, sourceEventID).Scan(&result.Event.ID, &result.Event.ProducerID, &result.Event.SourceEventID, &result.Event.EventType, &result.Event.OccurredAt, &result.Event.Location, &result.Event.Severity, &result.Event.EvidenceRef, &result.Event.NormalizedPayload, &result.Event.CreatedAt, &digest, &result.Receipt.ID, &result.Receipt.AcceptedAt)
result.Receipt.EventID = result.Event.ID
result.Receipt.ProducerID = producerID
result.Receipt.SourceEventID = sourceEventID
return result, digest, err
}
func equalDigest(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var different byte
for i := range a {
different |= a[i] ^ b[i]
}
return different == 0
}
func (s Service) List(ctx context.Context, limit int, before string) ([]Event, error) {
if limit < 1 || limit > 100 {
limit = 25
}
rows, err := s.DB.Query(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE ($2='' OR (created_at,id)<((SELECT created_at FROM bell_events WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY created_at DESC,id DESC LIMIT $1`, limit, before)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Event{}
for rows.Next() {
var item Event
if err := rows.Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s Service) Get(ctx context.Context, id string) (Event, error) {
var item Event
err := s.DB.QueryRow(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE id=$1`, id).Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt)
return item, err
}
+99
View File
@@ -0,0 +1,99 @@
package event
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
func TestNormalizeRejectsUnsafeEvidence(t *testing.T) {
value := `file:C:\camera\secret.mp4`
_, _, err := normalize(Command{ProducerID: "test", SourceEventID: "1", EventType: "intrusion", OccurredAt: time.Now(), Location: "gate", Severity: "high", EvidenceRef: &value})
if !errors.Is(err, ErrInvalid) {
t.Fatalf("expected invalid evidence reference, got %v", err)
}
}
func TestPostgresIdempotencyAndImmutability(t *testing.T) {
url := os.Getenv("BELL_TEST_DATABASE_URL")
if url == "" {
t.Skip("BELL_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
db, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := migrations.Apply(ctx, db); err != nil {
t.Fatal(err)
}
service := Service{DB: db}
key := fmt.Sprintf("event-test-%d", time.Now().UnixNano())
command := Command{ProducerID: "integration-test", SourceEventID: key, EventType: "danger-zone", OccurredAt: time.Now().UTC().Truncate(time.Millisecond), Location: "test-zone", Severity: "high", Attributes: map[string]any{"track_id": "anonymous-1"}}
const count = 16
results := make(chan Result, count)
errorsFound := make(chan error, count)
var wg sync.WaitGroup
for range count {
wg.Add(1)
go func() {
defer wg.Done()
result, err := service.Ingest(ctx, command)
if err != nil {
errorsFound <- err
return
}
results <- result
}()
}
wg.Wait()
close(results)
close(errorsFound)
for err := range errorsFound {
t.Errorf("concurrent ingest: %v", err)
}
var eventID, receiptID string
seen := 0
for result := range results {
seen++
if eventID == "" {
eventID = result.Event.ID
receiptID = result.Receipt.ID
}
if result.Event.ID != eventID || result.Receipt.ID != receiptID {
t.Errorf("unstable identities: %#v", result)
}
}
if seen != count {
t.Fatalf("received %d results, want %d", seen, count)
}
conflict := command
conflict.Location = "different"
if _, err := service.Ingest(ctx, conflict); !errors.Is(err, ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
if _, err := db.Exec(ctx, `UPDATE bell_events SET location='tampered' WHERE id=$1`, eventID); err == nil {
t.Fatal("immutable Event accepted update")
}
db.Close()
reopened, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
afterRestart, err := (Service{DB: reopened}).Ingest(ctx, command)
if err != nil {
t.Fatal(err)
}
if afterRestart.Event.ID != eventID || afterRestart.Receipt.ID != receiptID || !afterRestart.Duplicate {
t.Fatalf("receipt changed after restart: %#v", afterRestart)
}
}
+38
View File
@@ -0,0 +1,38 @@
package event
import (
"encoding/json"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/receipt"
)
type Command struct {
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
Location string `json:"location"`
Severity string `json:"severity"`
EvidenceRef *string `json:"evidence_ref,omitempty"`
Attributes map[string]any `json:"attributes"`
}
type Event struct {
ID string `json:"id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventType string `json:"event_type"`
OccurredAt string `json:"occurred_at"`
Location string `json:"location"`
Severity string `json:"severity"`
EvidenceRef *string `json:"evidence_ref,omitempty"`
NormalizedPayload json.RawMessage `json:"normalized_payload"`
CreatedAt string `json:"created_at"`
}
type Result struct {
Event Event `json:"event"`
Receipt receipt.Receipt `json:"receipt"`
Duplicate bool `json:"duplicate"`
}
+9
View File
@@ -0,0 +1,9 @@
package receipt
type Receipt struct {
ID string `json:"id"`
EventID string `json:"event_id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
AcceptedAt string `json:"accepted_at"`
}
+4 -1
View File
@@ -14,6 +14,7 @@ import (
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"git.ilapage.cn/ila/yovision/Bell/server/config"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
@@ -63,7 +64,9 @@ func Run(ctx context.Context, args []string) error {
return authService.BootstrapAdministrator(ctx, username, displayName, password)
}
app := platform.NewHTTPApp(db)
auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}.Register(app.Router())
authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}
authHTTP.Register(app.Router())
event.HTTP{Service: event.Service{DB: db}, Auth: authHTTP}.Register(app.Router())
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -0,0 +1,29 @@
CREATE TABLE bell_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
producer_id text NOT NULL,
source_event_id text NOT NULL,
event_type text NOT NULL,
occurred_at timestamptz NOT NULL,
location text NOT NULL,
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
evidence_ref text,
normalized_payload jsonb NOT NULL,
payload_sha256 bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (producer_id, source_event_id)
);
CREATE INDEX bell_events_created_page_idx ON bell_events(created_at DESC,id DESC);
CREATE TABLE bell_event_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id uuid NOT NULL UNIQUE REFERENCES bell_events(id),
producer_id text NOT NULL,
source_event_id text NOT NULL,
payload_sha256 bytea NOT NULL,
accepted_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (producer_id, source_event_id)
);
CREATE OR REPLACE FUNCTION bell_reject_event_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'Bell Event and Receipt facts are immutable'; END $$;
CREATE TRIGGER bell_event_no_update BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
CREATE TRIGGER bell_receipt_no_update BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
+3
View File
@@ -0,0 +1,3 @@
import request from '../../bootstrap/request'
export const listEvents = params => request.get('/api/v1/events', { params })
export const getEvent = id => request.get(`/api/v1/events/${id}`)
+2
View File
@@ -3,6 +3,7 @@ import Dashboard from './Dashboard.vue'
import Login from '../views/login/Login.vue'
import Users from '../views/system/Users.vue'
import Audit from '../views/system/Audit.vue'
import Events from '../views/event/Events.vue'
import store from './store'
const router = createRouter({
@@ -10,6 +11,7 @@ const router = createRouter({
routes: [
{ path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } },
{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } },
{ path: '/events', name: 'events', component: Events, meta: { title: '事件查询', permission: 'events:read' } },
{ path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
]
+1
View File
@@ -4,6 +4,7 @@
<div class="brand"><span class="brand__mark">B</span><div><strong>Bell</strong><small>预警中心</small></div></div>
<nav>
<router-link to="/" class="nav-item">工作台</router-link>
<router-link v-if="has('events:read')" to="/events" class="nav-item">事件查询</router-link>
<router-link v-if="has('users:read')" to="/system/users" class="nav-item">用户与角色</router-link>
<router-link v-if="has('audit:read')" to="/system/audit" class="nav-item">认证审计</router-link>
</nav>
+5
View File
@@ -0,0 +1,5 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>事件查询</h1><p>原始事件只读保存;处理结果不会改写事件。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="occurred_at" label="发生时间" min-width="180"/><el-table-column prop="event_type" label="事件类型" min-width="140"/><el-table-column prop="location" label="地点" min-width="150"/><el-table-column label="风险"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{severityName(scope.row.severity)}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无事件"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="事件技术详情" size="min(560px, 100%)"><el-descriptions v-if="selected" :column="1" border><el-descriptions-item label="事件编号">{{selected.id}}</el-descriptions-item><el-descriptions-item label="来源">{{selected.producer_id}}</el-descriptions-item><el-descriptions-item label="来源事件编号">{{selected.source_event_id}}</el-descriptions-item><el-descriptions-item label="发生时间">{{selected.occurred_at}}</el-descriptions-item><el-descriptions-item label="证据引用">{{selected.evidence_ref||'无'}}</el-descriptions-item><el-descriptions-item label="规范化载荷"><pre>{{JSON.stringify(selected.normalized_payload,null,2)}}</pre></el-descriptions-item></el-descriptions></el-drawer></main></template>
<script setup>
import { onMounted,ref } from 'vue';import { ElMessage } from 'element-plus';import { getEvent,listEvents } from '../../api/event';const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const selected=ref(null);const severityType=value=>({critical:'danger',high:'warning',medium:'primary',low:'info'}[value]||'info');const severityName=value=>({critical:'紧急',high:'高',medium:'中',low:'低'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listEvents({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取事件失败')}finally{loading.value=false}}async function open(row){try{selected.value=await getEvent(row.id);drawer.value=true}catch(e){ElMessage.error(e.error||'读取事件失败')}}onMounted(()=>load())
</script>
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}pre{white-space:pre-wrap;word-break:break-word;margin:0}</style>