feat(store): add Area admission and audit outbox [T-010]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-07 18:23:24 +08:00
parent 8abff0a566
commit 36786723e3
23 changed files with 1346 additions and 99 deletions
+18 -15
View File
@@ -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 {
+127
View File
@@ -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
}
+182 -32
View File
@@ -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, &current, &endpointRef, &pathName)
Scan(
&tenantID, &siteID, &areaID, &current, &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,
&quotaVersion, &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, &quotaVersion, &areaVersion, &value.CreatedAt, &value.UpdatedAt,
)
value.AreaID = areaID.String
value.QuotaSourceVersion = quotaVersion.Int64
value.AreaPolicySourceVersion = areaVersion.Int64
return value, err
}
+332 -4
View File
@@ -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)
}
}
+22
View File
@@ -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 &quotaProjectionError{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}
}
+2 -1
View File
@@ -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,