290 lines
12 KiB
Go
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
|
||
|
|
}
|