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 }