Files
yovision/Bell/internal/store/event_ingress_postgres.go
T
QiuSW bd964e8831
Harness governance / validate (pull_request) Has been cancelled
feat: implement T-019 reliable event ingress
2026-08-11 15:41:07 +08:00

290 lines
12 KiB
Go

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
}