feat(store): add Area admission and audit outbox [T-010]
This commit is contained in:
+6
-4
@@ -1,6 +1,6 @@
|
||||
# Sense M1/M2 接入骨架
|
||||
|
||||
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009 增加 PostgreSQL 双 schema 生产路径。默认关闭真实 ONVIF,显式设置 `SENSE_ONVIF_MODE=standard` 后才启用标准 SOAP/WS-Security 适配器。T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
|
||||
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009/T-010 增加 PostgreSQL 双 schema、Area 准入和本地审计 Outbox 生产基础。默认关闭真实 ONVIF,显式设置 `SENSE_ONVIF_MODE=standard` 后才启用标准 SOAP/WS-Security 适配器。T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
|
||||
|
||||
## 常用命令
|
||||
|
||||
@@ -45,9 +45,11 @@ SENSE_CREDENTIAL_<KEY>_RTSP_PASSWORD
|
||||
|
||||
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
|
||||
|
||||
## T-009 PostgreSQL 17.10
|
||||
## T-009/T-010 PostgreSQL 17.10
|
||||
|
||||
初始化 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/配额和 `bell.site_quota_v1`,`sense_app` 只能读取该视图,不能读取或写入 Bell 源表。应用登录角色和密码由部署环境创建,不进入仓库。
|
||||
初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取 `bell.site_quota_v1` 和 `bell.area_policy_v1`,不能读取或写入 Bell 源表。应用登录角色和密码由部署环境创建,不进入仓库。
|
||||
|
||||
PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。设备创建和期望态受理与 `sense.device_operation_outbox` 在同一事务写入;Outbox 不保存 endpoint、credential、完整 URI 或 MediaMTX 配置。Bell relay 尚未实现。
|
||||
|
||||
Windows 本机集成测试从仓库根目录执行:
|
||||
|
||||
@@ -65,7 +67,7 @@ $env:SENSE_DB_DSN = '由部署环境私下设置'
|
||||
go run ./cmd/sense-api
|
||||
```
|
||||
|
||||
PostgreSQL 启动会检查 Sense migration 版本及当前角色的跨 schema 权限;权限过宽、配额视图不可读或 schema 未安装时 readiness 初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,T-009 不搬迁现有 SQLite 数据。
|
||||
PostgreSQL 启动会检查 Sense migration 版本及当前角色对两个 Bell 投影视图的最小权限;权限过宽、视图不可读或 schema 未安装时 readiness 初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox 一致性,后续公共控制 API 不得在 SQLite 上启用。
|
||||
|
||||
Windows 本地准备 MediaMTX(从仓库根目录执行):
|
||||
|
||||
|
||||
@@ -75,21 +75,24 @@ func (s Site) Validate() error {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
TenantID string
|
||||
SiteID string
|
||||
SerialNumber string
|
||||
Name string
|
||||
Modality Modality
|
||||
Capabilities []Capability
|
||||
DesiredState DesiredState
|
||||
ActualState ActualState
|
||||
EndpointRef string
|
||||
CredentialRef string
|
||||
PathName string
|
||||
Generation int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
TenantID string
|
||||
SiteID string
|
||||
AreaID string
|
||||
SerialNumber string
|
||||
Name string
|
||||
Modality Modality
|
||||
Capabilities []Capability
|
||||
DesiredState DesiredState
|
||||
ActualState ActualState
|
||||
EndpointRef string
|
||||
CredentialRef string
|
||||
PathName string
|
||||
Generation int64
|
||||
QuotaSourceVersion int64
|
||||
AreaPolicySourceVersion int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (d Device) Validate() error {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditActorType string
|
||||
|
||||
const (
|
||||
AuditActorUser AuditActorType = "user"
|
||||
AuditActorService AuditActorType = "service"
|
||||
AuditActorSystem AuditActorType = "system"
|
||||
)
|
||||
|
||||
// AuditContext is supplied by a verified caller boundary. Repository callers
|
||||
// without an HTTP principal use the explicit system/sense fallback.
|
||||
type AuditContext struct {
|
||||
ActorType AuditActorType
|
||||
ActorID string
|
||||
Reason string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
type auditContextKey struct{}
|
||||
|
||||
func WithAuditContext(ctx context.Context, value AuditContext) context.Context {
|
||||
return context.WithValue(ctx, auditContextKey{}, value)
|
||||
}
|
||||
|
||||
func auditFromContext(ctx context.Context) AuditContext {
|
||||
value, ok := ctx.Value(auditContextKey{}).(AuditContext)
|
||||
if !ok {
|
||||
return AuditContext{ActorType: AuditActorSystem, ActorID: "sense"}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateAudit(value AuditContext) error {
|
||||
if value.ActorType != AuditActorUser && value.ActorType != AuditActorService && value.ActorType != AuditActorSystem {
|
||||
return errors.New("invalid audit actor type")
|
||||
}
|
||||
if strings.TrimSpace(value.ActorID) == "" || len(value.ActorID) > 200 {
|
||||
return errors.New("invalid audit actor ID")
|
||||
}
|
||||
if len(value.Reason) > 500 {
|
||||
return errors.New("audit reason is too long")
|
||||
}
|
||||
if len(value.TraceID) > 128 {
|
||||
return errors.New("audit trace ID is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type postgresAuditEvent struct {
|
||||
EventType string
|
||||
TenantID string
|
||||
SiteID string
|
||||
DeviceID string
|
||||
Generation int64
|
||||
QuotaSourceVersion int64
|
||||
AreaPolicySourceVersion int64
|
||||
Payload any
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
func insertPostgresAudit(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
event postgresAuditEvent,
|
||||
) error {
|
||||
audit := auditFromContext(ctx)
|
||||
if err := validateAudit(audit); err != nil {
|
||||
return err
|
||||
}
|
||||
eventID, err := newAuditEventID()
|
||||
if err != nil {
|
||||
return errors.New("generate audit event ID")
|
||||
}
|
||||
payload, err := json.Marshal(event.Payload)
|
||||
if err != nil {
|
||||
return errors.New("encode audit payload")
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
return errors.New("invalid audit payload")
|
||||
}
|
||||
occurredAt := event.OccurredAt.UTC()
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now().UTC()
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense.device_operation_outbox(
|
||||
event_id, 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, available_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''),
|
||||
$10, $11, $12, $13, $14, $14)`,
|
||||
eventID, event.EventType, event.TenantID, event.SiteID, event.DeviceID,
|
||||
audit.ActorType, audit.ActorID, audit.Reason, audit.TraceID,
|
||||
event.Generation, nullableVersion(event.QuotaSourceVersion),
|
||||
nullableVersion(event.AreaPolicySourceVersion), payload, occurredAt)
|
||||
if err != nil {
|
||||
return errors.New("insert device operation audit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newAuditEventID() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "audit_" + hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func nullableVersion(value int64) any {
|
||||
if value < 1 {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// Postgres persists Sense state in the sense schema and consumes only Bell's
|
||||
// versioned quota view. Migrations are deliberately installed out of process.
|
||||
// versioned quota and Area views. Migrations are installed out of process.
|
||||
type Postgres struct {
|
||||
db *sql.DB
|
||||
}
|
||||
@@ -53,20 +53,29 @@ func (s *Postgres) Close() error {
|
||||
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
|
||||
var version sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 1 {
|
||||
return errors.New("postgres sense schema migration v1 is required")
|
||||
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 3 {
|
||||
return errors.New("postgres sense schema migration v3 is required")
|
||||
}
|
||||
var canReadView, canWriteView, canReadSource, canWriteSource bool
|
||||
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
|
||||
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'bell.site_quota_v1', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.site_quota_v1', 'INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.sites', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE')`).
|
||||
Scan(&canReadView, &canWriteView, &canReadSource, &canWriteSource); err != nil {
|
||||
return errors.New("verify postgres quota privileges")
|
||||
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.area_policy_v1', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.area_policy_v1', 'INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.areas', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.areas', 'INSERT,UPDATE,DELETE')`).
|
||||
Scan(
|
||||
&canReadQuotaView, &canWriteQuotaView, &canReadSiteSource, &canWriteSiteSource,
|
||||
&canReadAreaView, &canWriteAreaView, &canReadAreaSource, &canWriteAreaSource,
|
||||
); err != nil {
|
||||
return errors.New("verify postgres Bell projection privileges")
|
||||
}
|
||||
if !canReadView || canWriteView || canReadSource || canWriteSource {
|
||||
return errors.New("postgres role violates Bell quota privilege boundary")
|
||||
if !canReadQuotaView || canWriteQuotaView || canReadSiteSource || canWriteSiteSource ||
|
||||
!canReadAreaView || canWriteAreaView || canReadAreaSource || canWriteAreaSource {
|
||||
return errors.New("postgres role violates Bell projection privilege boundary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -92,7 +101,14 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
|
||||
return errors.New("begin postgres create device")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var quotaVersion any
|
||||
areaVersion, err := checkPostgresAreaPolicy(
|
||||
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
|
||||
value.HasCapability(device.CapabilityVideoCapture), now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var quotaVersion int64
|
||||
if value.ConsumesVideoChannel() {
|
||||
version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
|
||||
if quotaErr != nil {
|
||||
@@ -101,14 +117,15 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
|
||||
quotaVersion = version
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
|
||||
id, tenant_id, site_id, serial_number, name, modality,
|
||||
id, tenant_id, site_id, area_id, serial_number, name, modality,
|
||||
desired_state, actual_state, endpoint_ref, credential_ref,
|
||||
path_name, generation, quota_source_version, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name,
|
||||
path_name, generation, quota_source_version, area_policy_source_version,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
|
||||
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||
value.CredentialRef, value.PathName, value.Generation, quotaVersion,
|
||||
value.CreatedAt, value.UpdatedAt)
|
||||
value.CredentialRef, value.PathName, value.Generation, nullableVersion(quotaVersion),
|
||||
areaVersion, value.CreatedAt, value.UpdatedAt)
|
||||
if err != nil {
|
||||
return errors.New("insert postgres device")
|
||||
}
|
||||
@@ -123,12 +140,83 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
|
||||
VALUES ($1, $2)`, value.ID, now); err != nil {
|
||||
return errors.New("insert postgres reconcile state")
|
||||
}
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
|
||||
DeviceID: value.ID, Generation: value.Generation,
|
||||
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "device_created", "area_id": value.AreaID,
|
||||
"modality": value.Modality, "capabilities": sortedCapabilities(value.Capabilities),
|
||||
"desired_state": value.DesiredState,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres create device")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPostgresAreaPolicy(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
tenantID, siteID, areaID string,
|
||||
imaging bool,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
if strings.TrimSpace(areaID) == "" {
|
||||
return 0, areaPolicyUnavailable()
|
||||
}
|
||||
// Area projection observation is serialized before the Site quota lock.
|
||||
// No admission path acquires these locks in the opposite order.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext('area:' || $2))`, tenantID, areaID); err != nil {
|
||||
return 0, errors.New("lock postgres Area admission")
|
||||
}
|
||||
var capturePolicy string
|
||||
var sourceVersion int64
|
||||
var sourceUpdatedAt time.Time
|
||||
err := tx.QueryRowContext(ctx, `SELECT capture_policy, source_version, source_updated_at
|
||||
FROM bell.area_policy_v1
|
||||
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
||||
Scan(&capturePolicy, &sourceVersion, &sourceUpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, areaPolicyUnavailable()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, areaPolicyUnavailable()
|
||||
}
|
||||
if (capturePolicy != "video_allowed" && capturePolicy != "non_imaging_only") ||
|
||||
sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
||||
return 0, areaPolicyInvalid()
|
||||
}
|
||||
var previous sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `SELECT source_version
|
||||
FROM sense.area_policy_projection_state
|
||||
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
||||
Scan(&previous)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errors.New("read postgres Area projection state")
|
||||
}
|
||||
if previous.Valid && sourceVersion < previous.Int64 {
|
||||
return 0, areaPolicyInvalid()
|
||||
}
|
||||
if imaging && capturePolicy == "non_imaging_only" {
|
||||
return 0, areaPolicyDenied()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
|
||||
tenant_id, site_id, area_id, source_version, synced_at
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (tenant_id, site_id, area_id) DO UPDATE SET
|
||||
source_version = EXCLUDED.source_version,
|
||||
synced_at = EXCLUDED.synced_at`, tenantID, siteID, areaID, sourceVersion, now); err != nil {
|
||||
return 0, errors.New("record postgres Area projection state")
|
||||
}
|
||||
return sourceVersion, nil
|
||||
}
|
||||
|
||||
func checkPostgresVideoQuota(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
@@ -199,10 +287,17 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenantID, siteID, endpointRef, pathName string
|
||||
var areaID sql.NullString
|
||||
var current device.DesiredState
|
||||
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name
|
||||
var generation int64
|
||||
var storedQuotaVersion, storedAreaVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, area_id, desired_state,
|
||||
endpoint_ref, path_name, generation, quota_source_version, area_policy_source_version
|
||||
FROM sense.devices WHERE id = $1 FOR UPDATE`, id).
|
||||
Scan(&tenantID, &siteID, ¤t, &endpointRef, &pathName)
|
||||
Scan(
|
||||
&tenantID, &siteID, &areaID, ¤t, &endpointRef, &pathName,
|
||||
&generation, &storedQuotaVersion, &storedAreaVersion,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -210,9 +305,25 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
|
||||
return errors.New("read postgres device desired state")
|
||||
}
|
||||
if current == desired {
|
||||
return tx.Commit()
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||
DeviceID: id, Generation: generation,
|
||||
QuotaSourceVersion: storedQuotaVersion.Int64,
|
||||
AreaPolicySourceVersion: storedAreaVersion.Int64,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Payload: map[string]any{
|
||||
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||
"desired_state": desired, "changed": false,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres no-op desired-state audit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var quotaVersion any
|
||||
var quotaVersion, areaVersion int64
|
||||
if desired == device.DesiredEnabled {
|
||||
var hasVideo bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
@@ -225,6 +336,13 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
|
||||
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||
return errors.New("enabled video devices require endpoint ref and path name")
|
||||
}
|
||||
version, areaErr := checkPostgresAreaPolicy(
|
||||
ctx, tx, tenantID, siteID, areaID.String, true, time.Now().UTC(),
|
||||
)
|
||||
if areaErr != nil {
|
||||
return areaErr
|
||||
}
|
||||
areaVersion = version
|
||||
version, quotaErr := checkPostgresVideoQuota(ctx, tx, tenantID, siteID, time.Now().UTC())
|
||||
if quotaErr != nil {
|
||||
return quotaErr
|
||||
@@ -232,21 +350,41 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
|
||||
quotaVersion = version
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE sense.devices SET
|
||||
now := time.Now().UTC()
|
||||
var updatedQuotaVersion, updatedAreaVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
|
||||
desired_state = $1, actual_state = 'pending', generation = generation + 1,
|
||||
quota_source_version = COALESCE($2, quota_source_version), updated_at = $3
|
||||
WHERE id = $4`, desired, quotaVersion, time.Now().UTC(), id)
|
||||
quota_source_version = COALESCE($2, quota_source_version),
|
||||
area_policy_source_version = COALESCE($3, area_policy_source_version),
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
RETURNING generation, quota_source_version, area_policy_source_version`,
|
||||
desired, nullableVersion(quotaVersion), nullableVersion(areaVersion), now, id).
|
||||
Scan(&generation, &updatedQuotaVersion, &updatedAreaVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("update postgres desired state")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = $1
|
||||
WHERE device_id = $2`, time.Now().UTC(), id); err != nil {
|
||||
WHERE device_id = $2`, now, id); err != nil {
|
||||
return errors.New("reset postgres reconcile state")
|
||||
}
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||
DeviceID: id, Generation: generation,
|
||||
QuotaSourceVersion: updatedQuotaVersion.Int64,
|
||||
AreaPolicySourceVersion: updatedAreaVersion.Int64,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||
"desired_state": desired, "changed": true,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres desired-state update")
|
||||
}
|
||||
@@ -288,18 +426,24 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
|
||||
values := make([]ReconcileCandidate, 0)
|
||||
for rows.Next() {
|
||||
var candidate ReconcileCandidate
|
||||
var areaID sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var nextAttempt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||
&candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||
&candidate.Device.PathName, &candidate.Device.Generation,
|
||||
"aVersion, &areaVersion,
|
||||
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
||||
&candidate.FailureCount, &nextAttempt,
|
||||
); err != nil {
|
||||
return nil, errors.New("scan postgres due reconcile device")
|
||||
}
|
||||
candidate.Device.AreaID = areaID.String
|
||||
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
||||
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
||||
if nextAttempt.Valid {
|
||||
value := nextAttempt.Time
|
||||
candidate.NextAttempt = &value
|
||||
@@ -483,20 +627,26 @@ func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
|
||||
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.area_id, d.serial_number, d.name, d.modality,
|
||||
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
|
||||
d.path_name, d.generation, d.created_at, d.updated_at`
|
||||
d.path_name, d.generation, d.quota_source_version, d.area_policy_source_version,
|
||||
d.created_at, d.updated_at`
|
||||
|
||||
const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
|
||||
|
||||
func scanPostgresDevice(row scanner) (device.Device, error) {
|
||||
var value device.Device
|
||||
var areaID sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
err := row.Scan(
|
||||
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber,
|
||||
&value.ID, &value.TenantID, &value.SiteID, &areaID, &value.SerialNumber,
|
||||
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
||||
&value.EndpointRef, &value.CredentialRef, &value.PathName,
|
||||
&value.Generation, &value.CreatedAt, &value.UpdatedAt,
|
||||
&value.Generation, "aVersion, &areaVersion, &value.CreatedAt, &value.UpdatedAt,
|
||||
)
|
||||
value.AreaID = areaID.String
|
||||
value.QuotaSourceVersion = quotaVersion.Int64
|
||||
value.AreaPolicySourceVersion = areaVersion.Int64
|
||||
return value, err
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestPostgresDefaultAndMaximumQuota(t *testing.T) {
|
||||
}
|
||||
radar := device.Device{
|
||||
ID: "radar-default", TenantID: "tenant-default", SiteID: "site-default",
|
||||
AreaID: "area-default",
|
||||
SerialNumber: "radar-default", Name: "Radar", Modality: device.ModalityRadar,
|
||||
Capabilities: []device.Capability{device.CapabilityTelemetry},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
@@ -113,8 +114,8 @@ func TestPostgresProjectionFailureAndRollbackFailClosed(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
err := store.CreateDevice(ctx, videoDevice(1, "missing-tenant", "missing-site"))
|
||||
if !errors.Is(err, ErrQuotaProjectionUnavailable) {
|
||||
t.Fatalf("missing projection must fail closed, got %v", err)
|
||||
if !errors.Is(err, ErrAreaPolicyUnavailable) {
|
||||
t.Fatalf("missing Area projection must fail closed first, got %v", err)
|
||||
}
|
||||
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
@@ -285,12 +286,12 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t009_sense`); err != nil {
|
||||
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t010_sense`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t009_sense`)
|
||||
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t010_sense`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
@@ -302,6 +303,316 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAreaPolicyAllowsNonImagingAndDeniesImagingCreate(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy = 'non_imaging_only'
|
||||
WHERE tenant_id = 'tenant' AND id = 'area-default'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocked := videoDevice(1, "tenant", "site")
|
||||
blocked.DesiredState = device.DesiredDisabled
|
||||
err := store.CreateDevice(ctx, blocked)
|
||||
if !errors.Is(err, ErrAreaPolicyDenied) {
|
||||
t.Fatalf("disabled imaging create must still be denied, got %v", err)
|
||||
}
|
||||
|
||||
radar := device.Device{
|
||||
ID: "radar-001", TenantID: "tenant", SiteID: "site", AreaID: "area-default",
|
||||
SerialNumber: "radar-001", Name: "Radar", Modality: device.ModalityRadar,
|
||||
Capabilities: []device.Capability{device.CapabilityTelemetry},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
}
|
||||
if err := store.CreateDevice(ctx, radar); err != nil {
|
||||
t.Fatalf("non-imaging device must be allowed: %v", err)
|
||||
}
|
||||
stored, err := store.GetDevice(ctx, radar.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.AreaID != "area-default" || stored.AreaPolicySourceVersion != 2 {
|
||||
t.Fatalf("Area projection evidence was not stored: %+v", stored)
|
||||
}
|
||||
var devices, audits int
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if devices != 1 || audits != 1 {
|
||||
t.Fatalf("denied create left partial state: devices=%d audits=%d", devices, audits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAreaProjectionMissingAndRollbackFailClosed(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
|
||||
missing := videoDevice(1, "tenant", "site")
|
||||
missing.AreaID = "missing-area"
|
||||
if err := store.CreateDevice(ctx, missing); !errors.Is(err, ErrAreaPolicyUnavailable) {
|
||||
t.Fatalf("missing Area projection must fail closed, got %v", err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
|
||||
tenant_id, site_id, area_id, source_version, synced_at
|
||||
) VALUES ('tenant', 'site', 'area-default', 99, clock_timestamp())`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateDevice(ctx, videoDevice(2, "tenant", "site")); !errors.Is(err, ErrAreaPolicyInvalid) {
|
||||
t.Fatalf("Area source-version rollback must fail closed, got %v", err)
|
||||
}
|
||||
var devices, audits int
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if devices != 0 || audits != 0 {
|
||||
t.Fatalf("failed Area admissions persisted state: devices=%d audits=%d", devices, audits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentAreaObservationIsMonotonic(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
ctx := context.Background()
|
||||
start := make(chan struct{})
|
||||
errorsFound := make(chan error, 2)
|
||||
var wait sync.WaitGroup
|
||||
for index := 1; index <= 2; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
value := videoDevice(index, "tenant", "site")
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
<-start
|
||||
errorsFound <- store.CreateDevice(ctx, value)
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent Area observation failed: %v", err)
|
||||
}
|
||||
}
|
||||
var sourceVersion int64
|
||||
if err := admin.QueryRowContext(ctx, `SELECT source_version
|
||||
FROM sense.area_policy_projection_state
|
||||
WHERE tenant_id = 'tenant' AND site_id = 'site' AND area_id = 'area-default'`).
|
||||
Scan(&sourceVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sourceVersion != 1 {
|
||||
t.Fatalf("concurrent observation recorded version %d", sourceVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAreaProjectionCannotCrossTenantBoundary(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
insertBellSite(t, admin, "tenant-a", "site", 2)
|
||||
insertBellSite(t, admin, "tenant-b", "site", 2)
|
||||
insertBellArea(t, admin, "tenant-b", "site", "private-area", "video_allowed")
|
||||
value := videoDevice(1, "tenant-a", "site")
|
||||
value.AreaID = "private-area"
|
||||
if err := store.CreateDevice(ctx, value); !errors.Is(err, ErrAreaPolicyUnavailable) {
|
||||
t.Fatalf("cross-tenant Area was not hidden as unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresEnableRechecksAreaWithoutStoppingExistingDevice(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
disabled := videoDevice(1, "tenant", "site")
|
||||
disabled.DesiredState = device.DesiredDisabled
|
||||
if err := store.CreateDevice(ctx, disabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateDevice(ctx, videoDevice(2, "tenant", "site")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy = 'non_imaging_only'
|
||||
WHERE tenant_id = 'tenant' AND id = 'area-default'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetDesiredState(ctx, disabled.ID, device.DesiredEnabled); !errors.Is(err, ErrAreaPolicyDenied) {
|
||||
t.Fatalf("enable under non-imaging policy must be denied, got %v", err)
|
||||
}
|
||||
stillDisabled, err := store.GetDevice(ctx, disabled.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stillEnabled, err := store.GetDevice(ctx, "camera-002")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stillDisabled.DesiredState != device.DesiredDisabled ||
|
||||
stillEnabled.DesiredState != device.DesiredEnabled {
|
||||
t.Fatalf("policy change altered existing state: disabled=%s enabled=%s",
|
||||
stillDisabled.DesiredState, stillEnabled.DesiredState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAuditContextRedactionAndNoopDesiredState(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
ctx := WithAuditContext(context.Background(), AuditContext{
|
||||
ActorType: AuditActorUser, ActorID: "operator-7", Reason: "approved change", TraceID: "trace-7",
|
||||
})
|
||||
value := videoDevice(1, "tenant", "site")
|
||||
if err := store.CreateDevice(ctx, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetDesiredState(ctx, value.ID, device.DesiredEnabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := store.GetDevice(ctx, value.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Generation != 1 {
|
||||
t.Fatalf("no-op desired-state request changed generation to %d", stored.Generation)
|
||||
}
|
||||
rows, err := admin.QueryContext(ctx, `SELECT event_id, event_type, actor_type, actor_id,
|
||||
COALESCE(reason, ''), COALESCE(trace_id, ''), payload::text
|
||||
FROM sense.device_operation_outbox ORDER BY occurred_at, event_id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var count int
|
||||
for rows.Next() {
|
||||
var eventID, eventType, actorType, actorID, reason, traceID, payload string
|
||||
if err := rows.Scan(&eventID, &eventType, &actorType, &actorID, &reason, &traceID, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count++
|
||||
if !strings.HasPrefix(eventID, "audit_") || len(eventID) != 38 {
|
||||
t.Fatalf("invalid audit event ID %q", eventID)
|
||||
}
|
||||
if actorType != "user" || actorID != "operator-7" || reason != "approved change" || traceID != "trace-7" {
|
||||
t.Fatalf("audit principal/context drift: %s/%s %s %s", actorType, actorID, reason, traceID)
|
||||
}
|
||||
for _, secret := range []string{value.EndpointRef, value.CredentialRef, value.PathName} {
|
||||
if strings.Contains(payload, secret) {
|
||||
t.Fatalf("audit payload leaked sensitive runtime data for %s", eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected create and no-op audit facts, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOutboxFailureRollsBackAdmission(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
if _, err := admin.ExecContext(ctx, `CREATE FUNCTION sense.t010_reject_outbox()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $function$
|
||||
BEGIN RAISE EXCEPTION 'synthetic outbox failure'; END
|
||||
$function$;
|
||||
CREATE TRIGGER t010_reject_outbox BEFORE INSERT ON sense.device_operation_outbox
|
||||
FOR EACH ROW EXECUTE FUNCTION sense.t010_reject_outbox()`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`DROP TRIGGER IF EXISTS t010_reject_outbox ON sense.device_operation_outbox;
|
||||
DROP FUNCTION IF EXISTS sense.t010_reject_outbox()`)
|
||||
}()
|
||||
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err == nil {
|
||||
t.Fatal("synthetic Outbox failure did not reject device creation")
|
||||
}
|
||||
var devices, projections, audits int
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.area_policy_projection_state`).Scan(&projections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if devices != 0 || projections != 0 || audits != 0 {
|
||||
t.Fatalf("Outbox failure left partial transaction: devices=%d projections=%d audits=%d",
|
||||
devices, projections, audits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOutboxFailureRollsBackDesiredState(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
value := videoDevice(1, "tenant", "site")
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
if err := store.CreateDevice(ctx, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.ExecContext(ctx, `CREATE FUNCTION sense.t010_reject_state_audit()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $function$
|
||||
BEGIN RAISE EXCEPTION 'synthetic state-audit failure'; END
|
||||
$function$;
|
||||
CREATE TRIGGER t010_reject_state_audit BEFORE INSERT ON sense.device_operation_outbox
|
||||
FOR EACH ROW EXECUTE FUNCTION sense.t010_reject_state_audit()`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`DROP TRIGGER IF EXISTS t010_reject_state_audit ON sense.device_operation_outbox;
|
||||
DROP FUNCTION IF EXISTS sense.t010_reject_state_audit()`)
|
||||
}()
|
||||
if err := store.SetDesiredState(ctx, value.ID, device.DesiredEnabled); err == nil {
|
||||
t.Fatal("synthetic Outbox failure did not reject desired-state change")
|
||||
}
|
||||
stored, err := store.GetDevice(ctx, value.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.DesiredState != device.DesiredDisabled || stored.Generation != 1 {
|
||||
t.Fatalf("Outbox failure committed desired state: %+v", stored)
|
||||
}
|
||||
var audits int
|
||||
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Fatalf("failed desired-state transaction changed Outbox count to %d", audits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOpenRejectsAreaSourcePrivilege(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT SELECT ON bell.areas TO yovision_t010_sense`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE SELECT ON bell.areas FROM yovision_t010_sense`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
_ = value.Close()
|
||||
t.Fatal("Area-source privilege was accepted")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "privilege boundary") {
|
||||
t.Fatalf("expected Area privilege-boundary error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(postgresTestDSNEnv)
|
||||
@@ -318,10 +629,13 @@ func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
|
||||
t.Fatal("connect PostgreSQL test administrator")
|
||||
}
|
||||
if _, err := admin.ExecContext(context.Background(), `TRUNCATE
|
||||
sense.device_operation_outbox,
|
||||
sense.device_capabilities,
|
||||
sense.reconcile_state,
|
||||
sense.devices,
|
||||
sense.site_quota_projection_state,
|
||||
sense.area_policy_projection_state,
|
||||
bell.areas,
|
||||
bell.sites CASCADE`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatal(err)
|
||||
@@ -350,4 +664,18 @@ func insertBellSite(t *testing.T, admin *sql.DB, tenantID, siteID string, quota
|
||||
if _, err := admin.ExecContext(context.Background(), query, arguments...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertBellArea(t, admin, tenantID, siteID, "area-default", "video_allowed")
|
||||
}
|
||||
|
||||
func insertBellArea(
|
||||
t *testing.T,
|
||||
admin *sql.DB,
|
||||
tenantID, siteID, areaID, capturePolicy string,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, err := admin.ExecContext(context.Background(), `INSERT INTO bell.areas(
|
||||
tenant_id, site_id, id, name, capture_policy
|
||||
) VALUES ($1, $2, $3, $4, $5)`, tenantID, siteID, areaID, "Test Area", capturePolicy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ const (
|
||||
var (
|
||||
ErrQuotaProjectionUnavailable = errors.New("quota projection unavailable")
|
||||
ErrQuotaProjectionInvalid = errors.New("quota projection invalid")
|
||||
ErrAreaPolicyUnavailable = errors.New("area policy unavailable")
|
||||
ErrAreaPolicyInvalid = errors.New("area policy invalid")
|
||||
ErrAreaPolicyDenied = errors.New("area policy denies imaging device")
|
||||
)
|
||||
|
||||
// Repository is the storage boundary used by the Sense process. SQLite stays
|
||||
@@ -61,3 +64,22 @@ func projectionUnavailable() error {
|
||||
func projectionInvalid() error {
|
||||
return "aProjectionError{kind: ErrQuotaProjectionInvalid}
|
||||
}
|
||||
|
||||
type areaPolicyError struct {
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *areaPolicyError) Error() string { return e.kind.Error() }
|
||||
func (e *areaPolicyError) Unwrap() error { return e.kind }
|
||||
|
||||
func areaPolicyUnavailable() error {
|
||||
return &areaPolicyError{kind: ErrAreaPolicyUnavailable}
|
||||
}
|
||||
|
||||
func areaPolicyInvalid() error {
|
||||
return &areaPolicyError{kind: ErrAreaPolicyInvalid}
|
||||
}
|
||||
|
||||
func areaPolicyDenied() error {
|
||||
return &areaPolicyError{kind: ErrAreaPolicyDenied}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,8 @@ func openTestStore(t *testing.T) *SQLite {
|
||||
func videoDevice(index int, tenantID, siteID string) device.Device {
|
||||
id := fmt.Sprintf("camera-%03d", index)
|
||||
return device.Device{
|
||||
ID: id, TenantID: tenantID, SiteID: siteID, SerialNumber: id, Name: id,
|
||||
ID: id, TenantID: tenantID, SiteID: siteID, AreaID: "area-default",
|
||||
SerialNumber: id, Name: id,
|
||||
Modality: device.ModalityVideo,
|
||||
Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
|
||||
Reference in New Issue
Block a user