feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
@@ -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