feat(sense): implement Control API v1 [T-011]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-07 20:54:41 +08:00
parent 0d3d7a22ed
commit a0d239811f
45 changed files with 4844 additions and 83 deletions
+181
View File
@@ -0,0 +1,181 @@
// Package auth defines the verified principal boundary for Sense HTTP APIs.
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
)
const (
PermissionDevicesRead = "sense.devices.read"
PermissionDevicesWrite = "sense.devices.write"
)
var (
ErrUnauthenticated = errors.New("unauthenticated")
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
)
type Principal struct {
SubjectID string
ActorType string
TenantID string
SiteIDs []string
Permissions map[string]struct{}
}
func (p Principal) AllowsSite(siteID string) bool {
for _, allowed := range p.SiteIDs {
if allowed == "*" || allowed == siteID {
return true
}
}
return false
}
func (p Principal) Has(permission string) bool {
_, ok := p.Permissions[permission]
return ok
}
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
type staticFile struct {
Version int `json:"version"`
Principals []staticPrincipal `json:"principals"`
}
type staticPrincipal struct {
TokenSHA256 string `json:"token_sha256"`
SubjectID string `json:"subject_id"`
ActorType string `json:"actor_type"`
TenantID string `json:"tenant_id"`
SiteIDs []string `json:"site_ids"`
Permissions []string `json:"permissions"`
}
type staticEntry struct {
digest [sha256.Size]byte
principal Principal
}
// StaticSHA256 authenticates opaque tokens against externally provisioned
// SHA-256 digests. The source file is read only during process startup.
type StaticSHA256 struct {
entries []staticEntry
}
func LoadStaticSHA256(path string) (*StaticSHA256, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read static authentication registry")
}
decoder := json.NewDecoder(strings.NewReader(string(contents)))
decoder.DisallowUnknownFields()
var document staticFile
if err := decoder.Decode(&document); err != nil {
return nil, errors.New("decode static authentication registry")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, errors.New("static authentication registry has trailing JSON")
}
if document.Version != 1 || len(document.Principals) == 0 {
return nil, errors.New("static authentication registry must contain version 1 principals")
}
entries := make([]staticEntry, 0, len(document.Principals))
seenDigests := make(map[string]struct{}, len(document.Principals))
for index, value := range document.Principals {
entry, validationErr := parseStaticPrincipal(value)
if validationErr != nil {
return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr)
}
if _, exists := seenDigests[value.TokenSHA256]; exists {
return nil, errors.New("duplicate static authentication token digest")
}
seenDigests[value.TokenSHA256] = struct{}{}
entries = append(entries, entry)
}
return &StaticSHA256{entries: entries}, nil
}
func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) {
var entry staticEntry
if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) {
return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters")
}
digest, err := hex.DecodeString(value.TokenSHA256)
if err != nil {
return entry, errors.New("token_sha256 must be hexadecimal")
}
copy(entry.digest[:], digest)
if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 {
return entry, errors.New("subject_id must contain 1 to 200 characters")
}
if value.ActorType != "user" && value.ActorType != "service" {
return entry, errors.New("actor_type must be user or service")
}
if !logicalIDPattern.MatchString(value.TenantID) {
return entry, errors.New("tenant_id is invalid")
}
if len(value.SiteIDs) == 0 {
return entry, errors.New("site_ids must not be empty")
}
sites := make([]string, 0, len(value.SiteIDs))
seenSites := make(map[string]struct{}, len(value.SiteIDs))
for _, siteID := range value.SiteIDs {
if siteID != "*" && !logicalIDPattern.MatchString(siteID) {
return entry, errors.New("site_ids contains an invalid site")
}
if _, exists := seenSites[siteID]; exists {
return entry, errors.New("site_ids contains a duplicate")
}
seenSites[siteID] = struct{}{}
sites = append(sites, siteID)
}
permissions := make(map[string]struct{}, len(value.Permissions))
for _, permission := range value.Permissions {
if permission != PermissionDevicesRead && permission != PermissionDevicesWrite {
return entry, errors.New("permissions contains an unsupported value")
}
if _, exists := permissions[permission]; exists {
return entry, errors.New("permissions contains a duplicate")
}
permissions[permission] = struct{}{}
}
if len(permissions) == 0 {
return entry, errors.New("permissions must not be empty")
}
entry.principal = Principal{
SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID,
SiteIDs: sites, Permissions: permissions,
}
return entry, nil
}
func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) {
if len(token) < 22 || len(token) > 4096 {
return Principal{}, ErrUnauthenticated
}
digest := sha256.Sum256([]byte(token))
match := -1
for index := range a.entries {
if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 {
match = index
}
}
if match < 0 {
return Principal{}, ErrUnauthenticated
}
return a.entries[match].principal, nil
}
+57
View File
@@ -0,0 +1,57 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStaticSHA256AuthenticatesScopedPrincipal(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
digest := sha256.Sum256([]byte(token))
path := filepath.Join(t.TempDir(), "auth.json")
document := `{"version":1,"principals":[{` +
`"token_sha256":"` + hex.EncodeToString(digest[:]) + `",` +
`"subject_id":"operator-1","actor_type":"user","tenant_id":"tenant-a",` +
`"site_ids":["site-a"],"permissions":["sense.devices.read"]}]}`
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
authenticator, err := LoadStaticSHA256(path)
if err != nil {
t.Fatal(err)
}
principal, err := authenticator.Authenticate(context.Background(), token)
if err != nil {
t.Fatal(err)
}
if principal.TenantID != "tenant-a" || !principal.AllowsSite("site-a") ||
principal.AllowsSite("site-b") || !principal.Has(PermissionDevicesRead) {
t.Fatalf("unexpected principal: %+v", principal)
}
if _, err := authenticator.Authenticate(context.Background(), strings.Repeat("x", 32)); err == nil {
t.Fatal("unknown token authenticated")
}
}
func TestStaticSHA256RejectsUnsafeRegistry(t *testing.T) {
tests := []string{
`{"version":1,"principals":[]}`,
`{"version":1,"principals":[{"token_sha256":"ABC","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"system","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["admin"]}]}`,
}
for index, document := range tests {
path := filepath.Join(t.TempDir(), "auth.json")
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadStaticSHA256(path); err == nil {
t.Fatalf("unsafe registry %d was accepted", index)
}
}
}
+59 -22
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -19,20 +20,26 @@ const (
defaultReconcilePeriod = 5 * time.Second
defaultProbePeriod = 10 * time.Second
defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256"
)
type Config struct {
HTTPAddress string
AllowNonLoopback bool
DatabaseDriver string
DatabaseDSN string
MediaMTXURL string
ReconcileInterval time.Duration
ProbeInterval time.Duration
ONVIFMode string
RTSPRewriteHost string
RTSPRewritePort int
RTSPStripQuery bool
HTTPAddress string
AllowNonLoopback bool
DatabaseDriver string
DatabaseDSN string
MediaMTXURL string
ReconcileInterval time.Duration
ProbeInterval time.Duration
ONVIFMode string
RTSPRewriteHost string
RTSPRewritePort int
RTSPStripQuery bool
ControlAPIEnabled bool
ControlAuthMode string
ControlAuthFile string
ControlCursorKeyFile string
ControlAllowInsecureHTTP bool
}
func Load() (Config, error) {
@@ -56,19 +63,32 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
controlEnabled, err := boolEnv("SENSE_CONTROL_API_ENABLED", false)
if err != nil {
return Config{}, err
}
controlAllowInsecure, err := boolEnv("SENSE_CONTROL_ALLOW_INSECURE_HTTP", false)
if err != nil {
return Config{}, err
}
cfg := Config{
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
AllowNonLoopback: allow,
DatabaseDriver: stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver),
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
ReconcileInterval: reconcilePeriod,
ProbeInterval: probePeriod,
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
RTSPRewritePort: rewritePort,
RTSPStripQuery: stripQuery,
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
AllowNonLoopback: allow,
DatabaseDriver: stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver),
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
ReconcileInterval: reconcilePeriod,
ProbeInterval: probePeriod,
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
RTSPRewritePort: rewritePort,
RTSPStripQuery: stripQuery,
ControlAPIEnabled: controlEnabled,
ControlAuthMode: stringEnv("SENSE_CONTROL_AUTH_MODE", defaultControlAuthMode),
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
ControlAllowInsecureHTTP: controlAllowInsecure,
}
if err := cfg.Validate(); err != nil {
return Config{}, err
@@ -121,6 +141,23 @@ func (c Config) Validate() error {
return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST")
}
}
if c.ControlAPIEnabled {
if databaseDriver != "postgres" {
return fmt.Errorf("Sense Control API requires SENSE_DB_DRIVER=postgres")
}
if c.ControlAuthMode != defaultControlAuthMode {
return fmt.Errorf("SENSE_CONTROL_AUTH_MODE must be static-sha256")
}
if c.ControlAuthFile == "" || !filepath.IsAbs(c.ControlAuthFile) {
return fmt.Errorf("SENSE_CONTROL_AUTH_FILE must be an absolute external path")
}
if c.ControlCursorKeyFile == "" || !filepath.IsAbs(c.ControlCursorKeyFile) {
return fmt.Errorf("SENSE_CONTROL_CURSOR_KEY_FILE must be an absolute external path")
}
if !isLoopback && !c.ControlAllowInsecureHTTP {
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
}
}
return nil
}
+44 -1
View File
@@ -1,6 +1,9 @@
package config
import "testing"
import (
"path/filepath"
"testing"
)
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
t.Parallel()
@@ -93,3 +96,43 @@ func TestValidateDatabaseDriver(t *testing.T) {
t.Fatal("unknown database driver must be rejected")
}
}
func TestValidateControlAPIRequiresPostgresAndExternalSecurityFiles(t *testing.T) {
cfg := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "sqlite", DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
}
if err := cfg.Validate(); err == nil {
t.Fatal("Control API was accepted on SQLite")
}
cfg.DatabaseDriver = "postgres"
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
if err := cfg.Validate(); err != nil {
t.Fatalf("valid Control API configuration failed: %v", err)
}
cfg.ControlAuthFile = "relative-auth.json"
if err := cfg.Validate(); err == nil {
t.Fatal("repository-relative authentication file was accepted")
}
}
func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T) {
cfg := Config{
HTTPAddress: "0.0.0.0:8080", AllowNonLoopback: true,
DatabaseDriver: "postgres", DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
}
if err := cfg.Validate(); err == nil {
t.Fatal("non-loopback plaintext Control API was accepted without explicit risk acceptance")
}
cfg.ControlAllowInsecureHTTP = true
if err := cfg.Validate(); err != nil {
t.Fatalf("explicit non-loopback Control API risk acceptance failed: %v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package controlapi implements the frozen Sense Control API v1.
package controlapi
// The input is the repository-owned public contract frozen by T-008.
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../docs/contracts/sense-control-v1.openapi.json
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
package controlapi
import (
"crypto/rand"
"encoding/hex"
"errors"
"math/big"
"time"
)
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func newULID(prefix string, now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0] = byte(milliseconds >> 40)
value[1] = byte(milliseconds >> 32)
value[2] = byte(milliseconds >> 24)
value[3] = byte(milliseconds >> 16)
value[4] = byte(milliseconds >> 8)
value[5] = byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate identifier randomness")
}
number := new(big.Int).SetBytes(value)
base := big.NewInt(32)
remainder := new(big.Int)
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = crockford[remainder.Int64()]
}
return prefix + string(encoded), nil
}
func newTraceID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate trace identifier")
}
return "trace_" + hex.EncodeToString(value), nil
}
+66
View File
@@ -0,0 +1,66 @@
package controlapi
import (
"yovision/sense/internal/store"
)
func publicDevice(value store.ControlDevice) Device {
capabilities := make([]Capability, len(value.Capabilities))
for index := range value.Capabilities {
capabilities[index] = Capability(value.Capabilities[index])
}
tenantID := LogicalID(value.TenantID)
generation, observed := value.Generation, value.ObservedGeneration
converged, failureCount := value.Converged, value.FailureCount
endpointConfigured, credentialConfigured := value.EndpointConfigured, value.CredentialConfigured
createdAt, updatedAt := value.CreatedAt, value.UpdatedAt
result := Device{
Id: value.ID, TenantId: &tenantID, SiteId: value.SiteID,
SerialNumber: value.SerialNumber, Name: value.Name, Modality: Modality(value.Modality),
Capabilities: &capabilities, AreaId: value.AreaID,
DesiredState: DesiredState(value.DesiredState), ActualState: ActualState(value.ActualState),
AdapterStatus: AdapterStatus(value.AdapterStatus),
EndpointConfigured: &endpointConfigured, CredentialConfigured: &credentialConfigured,
Generation: &generation, ObservedGeneration: &observed, Converged: &converged,
FailureCount: &failureCount, NextAttemptAt: value.NextAttemptAt,
LastErrorCode: value.LastErrorCode, CreatedAt: &createdAt, UpdatedAt: &updatedAt,
}
if value.ProjectionVersions.QuotaSourceVersion != nil {
version := *value.ProjectionVersions.QuotaSourceVersion
result.ProjectionVersions.QuotaSourceVersion = &version
}
if value.ProjectionVersions.AreaPolicySourceVersion != nil {
version := *value.ProjectionVersions.AreaPolicySourceVersion
result.ProjectionVersions.AreaPolicySourceVersion = &version
}
result.ProjectionVersions.SyncedAt = value.ProjectionVersions.SyncedAt
return result
}
func publicQuota(value store.ControlSiteQuota) SiteQuotaStatus {
return SiteQuotaStatus{
Status: SiteQuotaStatusStatus(value.Status), UsedVideoChannels: value.UsedVideoChannels,
MaxVideoChannels: value.MaxVideoChannels,
AvailableVideoChannels: value.AvailableVideoChannels,
OverLimit: value.OverLimit, SourceVersion: value.SourceVersion, SyncedAt: value.SyncedAt,
}
}
func publicOperation(value store.ControlBatchOperation) BatchOperation {
results := make([]BatchItemResult, 0, len(value.Results))
for _, item := range value.Results {
var code *ErrorCode
if item.ErrorCode != nil {
converted := ErrorCode(*item.ErrorCode)
code = &converted
}
results = append(results, BatchItemResult{
DeviceId: item.DeviceID, Status: BatchItemResultStatus(item.Status),
ErrorCode: code, Message: item.Message, Generation: item.Generation,
})
}
return BatchOperation{
Id: value.ID, Status: BatchOperationStatus(value.Status), SubmittedAt: value.SubmittedAt,
CompletedAt: value.CompletedAt, Results: results, TraceId: value.TraceID,
}
}
@@ -0,0 +1,7 @@
package: controlapi
output: generated.gen.go
generate:
models: true
std-http-server: true
output-options:
skip-prune: false
+108
View File
@@ -0,0 +1,108 @@
package controlapi
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
)
var ErrInvalidCursor = errors.New("invalid cursor")
type CursorPosition struct {
CreatedAt time.Time
DeviceID string
}
type cursorPayload struct {
Version int `json:"v"`
TenantID string `json:"t"`
SiteID string `json:"s"`
FilterHash string `json:"f"`
CreatedAt string `json:"c"`
DeviceID string `json:"d"`
}
type CursorCodec struct {
key []byte
}
func LoadCursorCodec(path string) (*CursorCodec, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Control API cursor key")
}
key, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(contents)))
if err != nil || len(key) < 32 {
return nil, errors.New("Control API cursor key must be base64url for at least 32 bytes")
}
return NewCursorCodec(key), nil
}
func NewCursorCodec(key []byte) *CursorCodec {
copyOfKey := append([]byte(nil), key...)
return &CursorCodec{key: copyOfKey}
}
func (c *CursorCodec) Encode(tenantID, siteID, filterHash string, position CursorPosition) (string, error) {
payload, err := json.Marshal(cursorPayload{
Version: 1, TenantID: tenantID, SiteID: siteID, FilterHash: filterHash,
CreatedAt: position.CreatedAt.UTC().Format(time.RFC3339Nano), DeviceID: position.DeviceID,
})
if err != nil {
return "", errors.New("encode cursor payload")
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(encoded))
return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (c *CursorCodec) Decode(value, tenantID, siteID, filterHash string) (CursorPosition, error) {
var position CursorPosition
parts := strings.Split(value, ".")
if len(parts) != 2 || len(value) > 512 {
return position, ErrInvalidCursor
}
provided, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || len(provided) != sha256.Size {
return position, ErrInvalidCursor
}
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(parts[0]))
if subtle.ConstantTimeCompare(provided, mac.Sum(nil)) != 1 {
return position, ErrInvalidCursor
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return position, ErrInvalidCursor
}
var payload cursorPayload
decoder := json.NewDecoder(strings.NewReader(string(payloadBytes)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil || payload.Version != 1 ||
payload.TenantID != tenantID || payload.SiteID != siteID || payload.FilterHash != filterHash {
return position, ErrInvalidCursor
}
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
if err != nil || payload.DeviceID == "" {
return position, ErrInvalidCursor
}
position.CreatedAt = createdAt.UTC()
position.DeviceID = payload.DeviceID
return position, nil
}
func filterFingerprint(values ...string) string {
hash := sha256.New()
for _, value := range values {
_, _ = hash.Write([]byte{byte(len(value) >> 8), byte(len(value))})
_, _ = hash.Write([]byte(value))
}
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
@@ -0,0 +1,58 @@
package controlapi
import (
"bytes"
"strings"
"testing"
"time"
)
func TestCursorIsScopedAndTamperEvident(t *testing.T) {
codec := NewCursorCodec(bytes.Repeat([]byte{7}, 32))
position := CursorPosition{CreatedAt: time.Date(2026, 8, 7, 1, 2, 3, 4, time.UTC), DeviceID: "dev_1"}
value, err := codec.Encode("tenant-a", "site-a", "filters", position)
if err != nil {
t.Fatal(err)
}
decoded, err := codec.Decode(value, "tenant-a", "site-a", "filters")
if err != nil || decoded.DeviceID != position.DeviceID || !decoded.CreatedAt.Equal(position.CreatedAt) {
t.Fatalf("cursor did not round trip: %+v %v", decoded, err)
}
for name, candidate := range map[string]string{
"tenant": "tenant-b", "site": "site-b", "filter": "other",
} {
tenant, site, filter := "tenant-a", "site-a", "filters"
switch name {
case "tenant":
tenant = candidate
case "site":
site = candidate
case "filter":
filter = candidate
}
if _, err := codec.Decode(value, tenant, site, filter); err == nil {
t.Fatalf("cursor was accepted across %s scope", name)
}
}
tampered := value[:len(value)-1] + strings.ToUpper(value[len(value)-1:])
if tampered == value {
tampered = value[:len(value)-1] + "A"
}
if _, err := codec.Decode(tampered, "tenant-a", "site-a", "filters"); err == nil {
t.Fatal("tampered cursor was accepted")
}
}
func TestGeneratedIdentifiersMatchContractShape(t *testing.T) {
value, err := newULID("op_", time.Now())
if err != nil {
t.Fatal(err)
}
if len(value) != 29 || !strings.HasPrefix(value, "op_") {
t.Fatalf("invalid operation ID %q", value)
}
trace, err := newTraceID()
if err != nil || len(trace) != 38 {
t.Fatalf("invalid trace ID %q: %v", trace, err)
}
}
+601
View File
@@ -0,0 +1,601 @@
package controlapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
type principalContextKey struct{}
type traceContextKey struct{}
type Server struct {
store store.ControlRepository
authenticator auth.Authenticator
cursors *CursorCodec
now func() time.Time
}
func NewHTTPHandler(
repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec,
) http.Handler {
server := &Server{
store: repository, authenticator: authenticator, cursors: cursors, now: time.Now,
}
generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError})
return server.authenticate(generated)
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
traceID, err := newTraceID()
if err != nil {
http.Error(writer, "service unavailable", http.StatusServiceUnavailable)
return
}
ctx := context.WithValue(request.Context(), traceContextKey{}, traceID)
request = request.WithContext(ctx)
values := request.Header.Values("Authorization")
if len(values) != 1 {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
parts := strings.Fields(values[0])
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
principal, err := s.authenticator.Authenticate(request.Context(), parts[1])
if err != nil {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
ctx = context.WithValue(request.Context(), principalContextKey{}, principal)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func principalFromContext(ctx context.Context) auth.Principal {
value, _ := ctx.Value(principalContextKey{}).(auth.Principal)
return value
}
func traceFromContext(ctx context.Context) string {
value, _ := ctx.Value(traceContextKey{}).(string)
return value
}
func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) {
var required *RequiredHeaderError
if errors.As(err, &required) && required.ParamName == "If-Match" {
s.writeProblem(writer, request, http.StatusPreconditionRequired,
ErrorCodePreconditionRequired, "If-Match is required")
return
}
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid")
}
func (s *Server) requireSite(
writer http.ResponseWriter, request *http.Request, siteID, permission string,
) (auth.Principal, bool) {
principal := principalFromContext(request.Context())
if !validLogicalID(siteID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid")
return auth.Principal{}, false
}
if !principal.AllowsSite(siteID) {
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
return auth.Principal{}, false
}
if !principal.Has(permission) {
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
return auth.Principal{}, false
}
return principal, true
}
func (s *Server) writeProblem(
writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string,
) {
traceID := traceFromContext(request.Context())
writer.Header().Set("Content-Type", "application/problem+json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Trace-ID", traceID)
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(Problem{
Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status,
Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{},
})
}
func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Trace-ID", traceID)
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func (s *Server) ListDevices(
writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
if !ok {
return
}
limit := 50
if params.Limit != nil {
limit = *params.Limit
}
if limit < 1 || limit > 100 ||
(params.Modality != nil && !Modality(*params.Modality).Valid()) ||
(params.Capability != nil && !Capability(*params.Capability).Valid()) ||
(params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) ||
(params.ActualState != nil && !ActualState(*params.ActualState).Valid()) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid")
return
}
filterHash := filterFingerprint(
optionalString(params.Modality), optionalString(params.Capability),
optionalString(params.DesiredState), optionalString(params.ActualState),
)
filter := store.ControlListFilter{Limit: limit}
if params.Modality != nil {
value := device.Modality(*params.Modality)
filter.Modality = &value
}
if params.Capability != nil {
value := device.Capability(*params.Capability)
filter.Capability = &value
}
if params.DesiredState != nil {
value := device.DesiredState(*params.DesiredState)
filter.DesiredState = &value
}
if params.ActualState != nil {
value := device.ActualState(*params.ActualState)
filter.ActualState = &value
}
if params.Cursor != nil {
position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash)
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
return
}
filter.AfterCreated = &position.CreatedAt
filter.AfterDeviceID = position.DeviceID
}
page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
items := make([]Device, 0, len(page.Items))
for _, value := range page.Items {
items = append(items, publicDevice(value))
}
var nextCursor *string
if page.HasMore && len(page.Items) > 0 {
last := page.Items[len(page.Items)-1]
value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash,
CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID})
if err != nil {
s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created")
return
}
nextCursor = &value
}
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{
Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor},
Quota: publicQuota(page.Quota),
})
}
func optionalString[T ~string](value *T) string {
if value == nil {
return ""
}
return string(*value)
}
func (s *Server) CreateDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if err := rejectTopLevelNulls(contents); err != nil {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error())
return
}
var body DeviceCreate
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if code, message := validateDeviceCreate(body); code != "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
return
}
desired := Disabled
if body.DesiredState != nil {
desired = *body.DesiredState
}
body.DesiredState = &desired
canonical, _ := json.Marshal(body)
requestHash := sha256.Sum256(canonical)
now := s.now().UTC()
deviceID, err := newULID("dev_", now)
if err != nil {
s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated")
return
}
capabilities := make([]device.Capability, len(body.Capabilities))
for index := range body.Capabilities {
capabilities[index] = device.Capability(body.Capabilities[index])
}
value := device.Device{
ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId,
SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality),
Capabilities: capabilities, DesiredState: device.DesiredState(desired),
ActualState: device.ActualPending, PathName: "devices/" + deviceID,
Generation: 1, ResourceVersion: 1,
}
if body.EndpointRef != nil {
value.EndpointRef = *body.EndpointRef
}
if body.CredentialRef != nil {
value.CredentialRef = *body.CredentialRef
}
if body.ProfileToken != nil {
value.ProfileToken = *body.ProfileToken
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{
Scope: store.IdempotencyScope{
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
Operation: "createDevice", Key: params.IdempotencyKey,
RequestHash: requestHash, TraceID: traceID,
},
Device: value,
})
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writer.Header().Set("Location", result.Location)
writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device))
}
func validateDeviceCreate(body DeviceCreate) (string, string) {
if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) ||
!validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" ||
strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() ||
len(body.Capabilities) < 1 || len(body.Capabilities) > 16 {
return "invalid_request", "device fields are invalid"
}
seen := make(map[Capability]struct{}, len(body.Capabilities))
hasVideo := false
for _, capability := range body.Capabilities {
if !capability.Valid() {
return "invalid_request", "device capability is invalid"
}
if _, exists := seen[capability]; exists {
return "invalid_request", "device capabilities contain a duplicate"
}
seen[capability] = struct{}{}
hasVideo = hasVideo || capability == VideoCapture
}
if body.Modality == Video && !hasVideo {
return "invalid_request", "video modality requires video_capture"
}
if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) {
return "adapter_not_ready", "video capture requires endpoint and credential references"
}
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
}
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
return "endpoint_credentials_forbidden", "credential reference is invalid"
}
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
return "invalid_request", "profile token is invalid"
}
if body.DesiredState != nil && !body.DesiredState.Valid() {
return "invalid_request", "desired_state is invalid"
}
return "", ""
}
func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext {
actorType := store.AuditActorService
if principal.ActorType == "user" {
actorType = store.AuditActorUser
}
return store.AuditContext{
ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID,
}
}
func (s *Server) GetDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
if !ok {
return
}
if !validLogicalID(deviceID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid")
return
}
value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion))
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value))
}
func (s *Server) UpdateDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
params UpdateDeviceParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
return
}
contents, err := readRequestBody(request, "application/merge-patch+json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field")
return
}
for _, value := range fields {
if string(value) == "null" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null")
return
}
}
var body DevicePatch
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if code, message := validateDevicePatch(body); code != "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
return
}
patch := store.ControlPatch{
Name: body.Name, EndpointRef: body.EndpointRef,
CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken,
}
if body.AreaId != nil {
value := string(*body.AreaId)
patch.AreaID = &value
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
result, err := s.store.PatchControlDevice(
ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch,
)
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
})
}
func validateDevicePatch(body DevicePatch) (string, string) {
if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") {
return "invalid_request", "name is invalid"
}
if body.AreaId != nil && !validLogicalID(*body.AreaId) {
return "invalid_request", "area_id is invalid"
}
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
}
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
return "endpoint_credentials_forbidden", "credential reference is invalid"
}
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
return "invalid_request", "profile token is invalid"
}
return "", ""
}
func (s *Server) SetDeviceDesiredState(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
params SetDeviceDesiredStateParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var body DesiredStateChange
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid")
return
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
result, err := s.store.SetControlDesiredState(
ctx, principal.TenantID, siteID, deviceID, params.IfMatch,
device.DesiredState(body.DesiredState),
)
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
})
}
func (s *Server) BatchSetDeviceDesiredState(
writer http.ResponseWriter, request *http.Request, siteID SiteID,
params BatchSetDeviceDesiredStateParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var body BatchDesiredStateRequest
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if len(body.Items) > 128 {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items")
return
}
if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid")
return
}
items := make([]store.ControlBatchItem, 0, len(body.Items))
for _, item := range body.Items {
if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid")
return
}
items = append(items, store.ControlBatchItem{
DeviceID: item.DeviceId, ETag: item.Etag,
DesiredState: device.DesiredState(item.DesiredState),
})
}
canonical, _ := json.Marshal(body)
requestHash := sha256.Sum256(canonical)
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{
Scope: store.IdempotencyScope{
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey,
RequestHash: requestHash, TraceID: traceID,
},
Reason: body.Reason, Items: items,
})
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("Location", "/api/v1/operations/"+operation.ID)
writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation))
}
func (s *Server) GetOperation(
writer http.ResponseWriter, request *http.Request, operationID OperationID,
) {
principal := principalFromContext(request.Context())
if !principal.Has(auth.PermissionDevicesRead) {
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
return
}
if !operationIDRegex.MatchString(operationID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid")
return
}
operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
if !principal.AllowsSite(operation.SiteID) {
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
return
}
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation))
}
func (s *Server) writeStoreError(
writer http.ResponseWriter, request *http.Request, err error, mutation bool,
) {
status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed"
switch {
case errors.Is(err, store.ErrNotFound):
status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found"
case errors.Is(err, store.ErrETagMismatch):
status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match"
case errors.Is(err, store.ErrIdempotencyConflict):
status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request"
case errors.Is(err, store.ErrDuplicateSerialNumber):
status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site"
case errors.Is(err, store.ErrAreaPolicyDenied):
status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change"
case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid):
status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable"
case errors.Is(err, store.ErrQuotaProjectionUnavailable):
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable"
case errors.Is(err, store.ErrQuotaProjectionInvalid):
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid"
default:
var quotaError *device.QuotaExceededError
if errors.As(err, &quotaError) {
status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded"
} else if mutation {
status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted"
}
}
s.writeProblem(writer, request, status, code, message)
}
var _ ServerInterface = (*Server)(nil)
+272
View File
@@ -0,0 +1,272 @@
package controlapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
const testOperationID = "op_01K20Y8Q3E0000000000000000"
type fixedAuthenticator struct {
principal auth.Principal
err error
}
func (a fixedAuthenticator) Authenticate(context.Context, string) (auth.Principal, error) {
return a.principal, a.err
}
type fakeControlStore struct {
device store.ControlDevice
err error
}
func (f *fakeControlStore) ListControlDevices(context.Context, string, string, store.ControlListFilter) (store.ControlDevicePage, error) {
return store.ControlDevicePage{
Items: []store.ControlDevice{f.device}, Quota: store.ControlSiteQuota{Status: "current"},
}, f.err
}
func (f *fakeControlStore) CreateControlDevice(_ context.Context, request store.ControlCreateRequest) (store.ControlCreateResult, error) {
return store.ControlCreateResult{
Device: f.device, AcceptedAt: time.Now(), TraceID: request.Scope.TraceID,
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
Location: "/api/v1/sites/site-a/devices/" + f.device.ID,
}, f.err
}
func (f *fakeControlStore) GetControlDevice(context.Context, string, string, string) (store.ControlDevice, error) {
return f.device, f.err
}
func (f *fakeControlStore) PatchControlDevice(_ context.Context, _, _, _, _ string, _ store.ControlPatch) (store.ControlMutationResult, error) {
return f.mutation(), f.err
}
func (f *fakeControlStore) SetControlDesiredState(_ context.Context, _, _, _, _ string, _ device.DesiredState) (store.ControlMutationResult, error) {
return f.mutation(), f.err
}
func (f *fakeControlStore) mutation() store.ControlMutationResult {
return store.ControlMutationResult{
Device: f.device, AcceptedAt: time.Now(), TraceID: "trace_0123456789abcdef0123456789abcdef",
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
}
}
func (f *fakeControlStore) BatchSetControlDesiredState(_ context.Context, request store.ControlBatchRequest) (store.ControlBatchOperation, error) {
completed := time.Now().UTC()
generation := f.device.Generation
return store.ControlBatchOperation{
ID: testOperationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
Status: "succeeded", SubmittedAt: completed, CompletedAt: &completed,
TraceID: request.Scope.TraceID,
Results: []store.ControlBatchItemResult{{
DeviceID: f.device.ID, Status: "succeeded", Generation: &generation,
}},
}, f.err
}
func (f *fakeControlStore) GetControlOperation(context.Context, string, string) (store.ControlBatchOperation, error) {
completed := time.Now().UTC()
return store.ControlBatchOperation{
ID: testOperationID, TenantID: "tenant-a", SiteID: "site-a", Status: "succeeded",
SubmittedAt: completed, CompletedAt: &completed, Results: []store.ControlBatchItemResult{},
TraceID: "trace_0123456789abcdef0123456789abcdef",
}, f.err
}
func testHTTPHandler(repository *fakeControlStore, permissions ...string) http.Handler {
grants := make(map[string]struct{}, len(permissions))
for _, permission := range permissions {
grants[permission] = struct{}{}
}
principal := auth.Principal{
SubjectID: "operator-1", ActorType: "user", TenantID: "tenant-a",
SiteIDs: []string{"site-a"}, Permissions: grants,
}
return NewHTTPHandler(repository, fixedAuthenticator{principal: principal}, NewCursorCodec(bytes.Repeat([]byte{4}, 32)))
}
func testControlDevice() store.ControlDevice {
quotaVersion, areaVersion := int64(1), int64(2)
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
return store.ControlDevice{
ID: "dev_01K20Y8Q3E0000000000000000", TenantID: "tenant-a", SiteID: "site-a",
SerialNumber: "serial-1", Name: "Camera", Modality: device.ModalityVideo,
Capabilities: []device.Capability{device.CapabilityVideoCapture}, AreaID: "area-a",
DesiredState: device.DesiredDisabled, ActualState: device.ActualPending,
AdapterStatus: "pending", EndpointConfigured: true, CredentialConfigured: true,
Generation: 1, ResourceVersion: 1, FailureCount: 0,
ProjectionVersions: store.ControlProjectionVersions{
QuotaSourceVersion: &quotaVersion, AreaPolicySourceVersion: &areaVersion, SyncedAt: &now,
},
CreatedAt: now, UpdatedAt: now,
}
}
func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder
}
func TestAllFrozenControlRoutesReturnContractShapes(t *testing.T) {
value := testControlDevice()
repository := &fakeControlStore{device: value}
handler := testHTTPHandler(repository, auth.PermissionDevicesRead, auth.PermissionDevicesWrite)
etag := store.DeviceETag(value.ID, value.ResourceVersion)
list := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices", "", "")
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"quota"`) {
t.Fatalf("list route failed: %d %s", list.Code, list.Body.String())
}
createBody := `{"serial_number":"serial-1","name":"Camera","modality":"video",` +
`"capabilities":["video_capture"],"area_id":"area-a",` +
`"endpoint_ref":"onvif://camera","credential_ref":"env://CAMERA"}`
create := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(createBody))
create.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
create.Header.Set("Content-Type", "application/json")
create.Header.Set("Idempotency-Key", "create-request-0001")
createResult := httptest.NewRecorder()
handler.ServeHTTP(createResult, create)
if createResult.Code != http.StatusCreated || createResult.Header().Get("ETag") == "" ||
strings.Contains(createResult.Body.String(), "endpoint_ref") || strings.Contains(createResult.Body.String(), "credential_ref") {
t.Fatalf("create route failed or leaked write-only data: %d %s", createResult.Code, createResult.Body.String())
}
get := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices/"+value.ID, "", "")
if get.Code != http.StatusOK || get.Header().Get("ETag") != etag {
t.Fatalf("get route failed: %d %s", get.Code, get.Body.String())
}
patch := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"Updated"}`))
patch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
patch.Header.Set("Content-Type", "application/merge-patch+json")
patch.Header.Set("If-Match", etag)
patchResult := httptest.NewRecorder()
handler.ServeHTTP(patchResult, patch)
if patchResult.Code != http.StatusAccepted || !strings.Contains(patchResult.Body.String(), `"accepted_at"`) {
t.Fatalf("patch route failed: %d %s", patchResult.Code, patchResult.Body.String())
}
desired := httptest.NewRequest(http.MethodPut, "/api/v1/sites/site-a/devices/"+value.ID+"/desired-state", strings.NewReader(`{"desired_state":"enabled","reason":"test"}`))
desired.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
desired.Header.Set("Content-Type", "application/json")
desired.Header.Set("If-Match", etag)
desiredResult := httptest.NewRecorder()
handler.ServeHTTP(desiredResult, desired)
if desiredResult.Code != http.StatusAccepted {
t.Fatalf("desired-state route failed: %d %s", desiredResult.Code, desiredResult.Body.String())
}
batchBody := `{"items":[{"device_id":"` + value.ID + `","etag":"` + strings.ReplaceAll(etag, `"`, `\"`) +
`","desired_state":"enabled"}],"reason":"test"}`
batch := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices:batchDesiredState", strings.NewReader(batchBody))
batch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
batch.Header.Set("Content-Type", "application/json")
batch.Header.Set("Idempotency-Key", "batch-request-0001")
batchResult := httptest.NewRecorder()
handler.ServeHTTP(batchResult, batch)
if batchResult.Code != http.StatusAccepted || batchResult.Header().Get("Location") == "" {
t.Fatalf("batch route failed: %d %s", batchResult.Code, batchResult.Body.String())
}
operation := performRequest(handler, http.MethodGet, "/api/v1/operations/"+testOperationID, "", "")
if operation.Code != http.StatusOK || !strings.Contains(operation.Body.String(), `"results"`) {
t.Fatalf("operation route failed: %d %s", operation.Code, operation.Body.String())
}
}
func TestAuthenticationScopeAndPreconditionsUseStableProblems(t *testing.T) {
value := testControlDevice()
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesRead)
unauthenticated := httptest.NewRecorder()
handler.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/sites/site-a/devices", nil))
if unauthenticated.Code != http.StatusUnauthorized || unauthenticated.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("missing auth did not return 401: %d", unauthenticated.Code)
}
hidden := performRequest(handler, http.MethodGet, "/api/v1/sites/site-b/devices", "", "")
if hidden.Code != http.StatusNotFound {
t.Fatalf("out-of-scope site was not hidden: %d", hidden.Code)
}
missingPrecondition := performRequest(handler, http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
"application/merge-patch+json", `{"name":"Updated"}`)
if missingPrecondition.Code != http.StatusPreconditionRequired {
t.Fatalf("missing If-Match did not return 428: %d %s", missingPrecondition.Code, missingPrecondition.Body.String())
}
var problem Problem
if err := json.Unmarshal(missingPrecondition.Body.Bytes(), &problem); err != nil || problem.Code != ErrorCodePreconditionRequired || problem.TraceId == "" {
t.Fatalf("invalid Problem response: %+v %v", problem, err)
}
}
func TestStrictJSONAndStoreConflictsAreMapped(t *testing.T) {
value := testControlDevice()
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesWrite)
etag := store.DeviceETag(value.ID, value.ResourceVersion)
duplicate := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
strings.NewReader(`{"name":"one","name":"two"}`))
duplicate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
duplicate.Header.Set("Content-Type", "application/merge-patch+json")
duplicate.Header.Set("If-Match", etag)
result := httptest.NewRecorder()
handler.ServeHTTP(result, duplicate)
if result.Code != http.StatusBadRequest {
t.Fatalf("duplicate JSON property was accepted: %d %s", result.Code, result.Body.String())
}
nullCreate := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(
`{"serial_number":"serial","name":"Camera","modality":"radar",`+
`"capabilities":["telemetry"],"area_id":"area-a","profile_token":null}`))
nullCreate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
nullCreate.Header.Set("Content-Type", "application/json")
nullCreate.Header.Set("Idempotency-Key", "null-create-0001")
nullResult := httptest.NewRecorder()
handler.ServeHTTP(nullResult, nullCreate)
if nullResult.Code != http.StatusUnprocessableEntity {
t.Fatalf("explicit null was accepted: %d %s", nullResult.Code, nullResult.Body.String())
}
conflictHandler := testHTTPHandler(&fakeControlStore{device: value, err: store.ErrETagMismatch}, auth.PermissionDevicesWrite)
request := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"two"}`))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
request.Header.Set("Content-Type", "application/merge-patch+json")
request.Header.Set("If-Match", etag)
recorder := httptest.NewRecorder()
conflictHandler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "etag_mismatch") {
t.Fatalf("ETag mismatch was not mapped: %d %s", recorder.Code, recorder.Body.String())
}
}
func TestProjectionVersionsAlwaysEmitRequiredNullableKeys(t *testing.T) {
encoded, err := json.Marshal(ProjectionVersions{})
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"quota_source_version", "area_policy_source_version", "synced_at"} {
if !strings.Contains(string(encoded), `"`+key+`":null`) {
t.Fatalf("required nullable key %s was omitted: %s", key, encoded)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package controlapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"regexp"
"strings"
"unicode/utf8"
)
const maximumRequestBody = 1 << 20
var (
logicalIDRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
idempotencyKeyRegex = regexp.MustCompile(`^[A-Za-z0-9._:-]{16,128}$`)
operationIDRegex = regexp.MustCompile(`^op_[0-9A-HJKMNP-TV-Z]{26}$`)
strongETagRegex = regexp.MustCompile(`^"[A-Za-z0-9_-]{24}"$`)
)
func readRequestBody(request *http.Request, expectedMediaType string) ([]byte, error) {
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil || mediaType != expectedMediaType {
return nil, fmt.Errorf("Content-Type must be %s", expectedMediaType)
}
contents, err := io.ReadAll(io.LimitReader(request.Body, maximumRequestBody+1))
if err != nil {
return nil, errors.New("read request body")
}
if len(contents) == 0 || len(contents) > maximumRequestBody {
return nil, errors.New("request body is empty or too large")
}
if err := rejectDuplicateJSONKeys(contents); err != nil {
return nil, err
}
return contents, nil
}
func decodeStrictJSON(contents []byte, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return errors.New("request body does not match the API schema")
}
if decoder.Decode(&struct{}{}) != io.EOF {
return errors.New("request body contains trailing JSON")
}
return nil
}
func rejectTopLevelNulls(contents []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(contents, &fields); err != nil || fields == nil {
return errors.New("request body must be a JSON object")
}
for _, value := range fields {
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return errors.New("request body properties cannot be null")
}
}
return nil
}
func rejectDuplicateJSONKeys(contents []byte) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.UseNumber()
var visit func(int) error
visit = func(depth int) error {
if depth > 64 {
return errors.New("request body nesting is too deep")
}
token, err := decoder.Token()
if err != nil {
return errors.New("request body is not valid JSON")
}
delimiter, ok := token.(json.Delim)
if !ok {
return nil
}
switch delimiter {
case '{':
seen := make(map[string]struct{})
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return errors.New("request body is not valid JSON")
}
key, ok := keyToken.(string)
if !ok {
return errors.New("request body is not a JSON object")
}
if _, exists := seen[key]; exists {
return errors.New("request body contains a duplicate property")
}
seen[key] = struct{}{}
if err := visit(depth + 1); err != nil {
return err
}
}
end, err := decoder.Token()
if err != nil || end != json.Delim('}') {
return errors.New("request body is not valid JSON")
}
case '[':
for decoder.More() {
if err := visit(depth + 1); err != nil {
return err
}
}
end, err := decoder.Token()
if err != nil || end != json.Delim(']') {
return errors.New("request body is not valid JSON")
}
default:
return errors.New("request body is not valid JSON")
}
return nil
}
if err := visit(0); err != nil {
return err
}
if _, err := decoder.Token(); err != io.EOF {
return errors.New("request body contains trailing JSON")
}
return nil
}
func validLogicalID(value string) bool {
return logicalIDRegex.MatchString(value)
}
func validLength(value string, minimum, maximum int) bool {
length := utf8.RuneCountInString(value)
return utf8.ValidString(value) && length >= minimum && length <= maximum
}
func validateEndpoint(value string) bool {
if !validLength(value, 1, 2048) {
return false
}
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme != "" && parsed.User == nil &&
!strings.ContainsAny(value, "\r\n")
}
func validStrongETag(value string) bool {
return strongETagRegex.MatchString(value) && value != "*" && !strings.Contains(value, ",")
}
+19
View File
@@ -0,0 +1,19 @@
package controlapi
import "encoding/json"
// MarshalJSON preserves the OpenAPI-required nullable projection keys. The
// generator represents JSON null as nil pointers but marks read-only pointers
// omitempty, so the default encoder would otherwise violate the v1 wire shape.
func (value ProjectionVersions) MarshalJSON() ([]byte, error) {
type projectionWire struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
SyncedAt any `json:"synced_at"`
}
return json.Marshal(projectionWire{
QuotaSourceVersion: value.QuotaSourceVersion,
AreaPolicySourceVersion: value.AreaPolicySourceVersion,
SyncedAt: value.SyncedAt,
})
}
+2
View File
@@ -87,8 +87,10 @@ type Device struct {
ActualState ActualState
EndpointRef string
CredentialRef string
ProfileToken string
PathName string
Generation int64
ResourceVersion int64
QuotaSourceVersion int64
AreaPolicySourceVersion int64
CreatedAt time.Time
+3 -1
View File
@@ -92,7 +92,9 @@ func (c *Client) DeletePath(ctx context.Context, name string) error {
return fmt.Errorf("MediaMTX delete path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
return ErrPathNotFound
// Deletion is an idempotent convergence operation. A missing exact path
// already satisfies the disabled desired state.
return nil
}
if response.StatusCode() != http.StatusOK {
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
+17
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"time"
"yovision/sense/internal/device"
"yovision/sense/internal/onvif"
"yovision/sense/internal/store"
)
@@ -22,6 +23,7 @@ type Repository interface {
type MediaPaths interface {
EnsurePath(ctx context.Context, name, source string) (bool, error)
DeletePath(ctx context.Context, name string) error
}
type Reconciler struct {
@@ -60,6 +62,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) error {
}
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error {
if candidate.Device.DesiredState == device.DesiredDisabled {
if err := r.media.DeletePath(ctx, candidate.Device.PathName); err == nil {
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now)
} else if ctx.Err() != nil {
return ctx.Err()
} else {
return r.persistFailure(ctx, candidate, now, err)
}
}
result, err := r.discovery.Probe(ctx, onvif.Target{
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
})
@@ -75,6 +86,12 @@ func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.Reconcile
if ctx.Err() != nil {
return ctx.Err()
}
return r.persistFailure(ctx, candidate, now, err)
}
func (r *Reconciler) persistFailure(
ctx context.Context, candidate store.ReconcileCandidate, now time.Time, err error,
) error {
failureCount := candidate.FailureCount + 1
nextAttempt := now.Add(r.backoff(failureCount))
errorCode := string(onvif.CodeOf(err))
@@ -15,6 +15,7 @@ import (
type recordingMedia struct {
calls int
changed int
deleted int
paths map[string]string
}
@@ -31,6 +32,15 @@ func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (boo
return true, nil
}
func (m *recordingMedia) DeletePath(_ context.Context, name string) error {
m.calls++
if m.paths != nil {
delete(m.paths, name)
}
m.deleted++
return nil
}
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
@@ -132,6 +142,26 @@ func TestCancellationDoesNotPersistFailure(t *testing.T) {
}
}
func TestDisabledDeviceDeletesOnlyItsExactPath(t *testing.T) {
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
media := &recordingMedia{paths: map[string]string{"camera-1": "source", "other": "keep"}}
if err := repository.SetDesiredState(context.Background(), "camera-1", device.DesiredDisabled); err != nil {
t.Fatal(err)
}
reconciler := New(repository, onvif.NewFake(nil), media)
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.deleted != 1 || media.paths["other"] != "keep" {
t.Fatalf("disabled convergence touched the wrong paths: %+v", media.paths)
}
value, err := repository.GetDevice(context.Background(), "camera-1")
if err != nil || value.ActualState != device.ActualOffline {
t.Fatalf("disabled device did not converge offline: %+v %v", value, err)
}
}
func openRepository(t *testing.T, path string) *store.SQLite {
t.Helper()
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
+164
View File
@@ -0,0 +1,164 @@
package store
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"strconv"
"time"
"yovision/sense/internal/device"
)
var (
ErrETagMismatch = errors.New("device ETag mismatch")
ErrIdempotencyConflict = errors.New("idempotency key body conflict")
ErrDuplicateSerialNumber = errors.New("duplicate device serial number")
)
type ControlProjectionVersions struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
SyncedAt *time.Time `json:"synced_at"`
}
// ControlDevice is deliberately safe to serialize. It contains configured
// booleans, never endpoint, credential or profile-token values.
type ControlDevice struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
SerialNumber string `json:"serial_number"`
Name string `json:"name"`
Modality device.Modality `json:"modality"`
Capabilities []device.Capability `json:"capabilities"`
AreaID string `json:"area_id"`
DesiredState device.DesiredState `json:"desired_state"`
ActualState device.ActualState `json:"actual_state"`
AdapterStatus string `json:"adapter_status"`
EndpointConfigured bool `json:"endpoint_configured"`
CredentialConfigured bool `json:"credential_configured"`
Generation int64 `json:"generation"`
ObservedGeneration int64 `json:"observed_generation"`
Converged bool `json:"converged"`
FailureCount int `json:"failure_count"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
LastErrorCode *string `json:"last_error_code,omitempty"`
ProjectionVersions ControlProjectionVersions `json:"projection_versions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ResourceVersion int64 `json:"-"`
}
type ControlSiteQuota struct {
Status string
UsedVideoChannels int
MaxVideoChannels *int
AvailableVideoChannels *int
OverLimit bool
SourceVersion *int64
SyncedAt *time.Time
}
type ControlListFilter struct {
Limit int
AfterCreated *time.Time
AfterDeviceID string
Modality *device.Modality
Capability *device.Capability
DesiredState *device.DesiredState
ActualState *device.ActualState
}
type ControlDevicePage struct {
Items []ControlDevice
HasMore bool
Quota ControlSiteQuota
}
type IdempotencyScope struct {
PrincipalID string
TenantID string
SiteID string
Operation string
Key string
RequestHash [sha256.Size]byte
TraceID string
}
type ControlCreateRequest struct {
Scope IdempotencyScope
Device device.Device
}
type ControlCreateResult struct {
Device ControlDevice
AcceptedAt time.Time
TraceID string
ETag string
Location string
Replay bool
}
type ControlPatch struct {
Name *string
AreaID *string
EndpointRef *string
CredentialRef *string
ProfileToken *string
}
type ControlMutationResult struct {
Device ControlDevice
AcceptedAt time.Time
TraceID string
ETag string
}
type ControlBatchItem struct {
DeviceID string
ETag string
DesiredState device.DesiredState
}
type ControlBatchRequest struct {
Scope IdempotencyScope
Reason string
Items []ControlBatchItem
}
type ControlBatchItemResult struct {
DeviceID string `json:"device_id"`
Status string `json:"status"`
ErrorCode *string `json:"error_code"`
Message *string `json:"message"`
Generation *int64 `json:"generation"`
}
type ControlBatchOperation struct {
ID string `json:"id"`
TenantID string `json:"-"`
SiteID string `json:"-"`
Status string `json:"status"`
SubmittedAt time.Time `json:"submitted_at"`
CompletedAt *time.Time `json:"completed_at"`
Results []ControlBatchItemResult `json:"results"`
TraceID string `json:"trace_id"`
Replay bool `json:"-"`
}
type ControlRepository interface {
ListControlDevices(context.Context, string, string, ControlListFilter) (ControlDevicePage, error)
CreateControlDevice(context.Context, ControlCreateRequest) (ControlCreateResult, error)
GetControlDevice(context.Context, string, string, string) (ControlDevice, error)
PatchControlDevice(context.Context, string, string, string, string, ControlPatch) (ControlMutationResult, error)
SetControlDesiredState(context.Context, string, string, string, string, device.DesiredState) (ControlMutationResult, error)
BatchSetControlDesiredState(context.Context, ControlBatchRequest) (ControlBatchOperation, error)
GetControlOperation(context.Context, string, string) (ControlBatchOperation, error)
}
func DeviceETag(deviceID string, resourceVersion int64) string {
digest := sha256.Sum256([]byte(deviceID + "\x00" + strconv.FormatInt(resourceVersion, 10)))
return `"` + base64.RawURLEncoding.EncodeToString(digest[:18]) + `"`
}
+889
View File
@@ -0,0 +1,889 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/url"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"yovision/sense/internal/device"
)
const controlReceiptTTL = 24 * time.Hour
const controlDeviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
d.area_id, d.desired_state, d.actual_state,
(d.endpoint_ref <> ''), (d.credential_ref <> ''),
d.generation, d.resource_version,
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code,
d.quota_source_version, d.area_policy_source_version,
GREATEST(
(SELECT q.synced_at FROM sense.site_quota_projection_state q
WHERE q.tenant_id = d.tenant_id AND q.site_id = d.site_id
AND q.source_version = d.quota_source_version),
(SELECT a.synced_at FROM sense.area_policy_projection_state a
WHERE a.tenant_id = d.tenant_id AND a.site_id = d.site_id
AND a.area_id = d.area_id AND a.source_version = d.area_policy_source_version)
),
d.created_at, d.updated_at,
COALESCE((SELECT jsonb_agg(c.capability ORDER BY c.capability)
FROM sense.device_capabilities c WHERE c.device_id = d.id), '[]'::jsonb)::text`
const controlDeviceSelect = `SELECT ` + controlDeviceColumns + `
FROM sense.devices d JOIN sense.reconcile_state r ON r.device_id = d.id`
type controlScanner interface {
Scan(...any) error
}
func scanControlDevice(row controlScanner) (ControlDevice, error) {
var value ControlDevice
var nextAttempt, syncedAt sql.NullTime
var lastError sql.NullString
var quotaVersion, areaVersion sql.NullInt64
var capabilitiesJSON string
err := row.Scan(
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber, &value.Name,
&value.Modality, &value.AreaID, &value.DesiredState, &value.ActualState,
&value.EndpointConfigured, &value.CredentialConfigured,
&value.Generation, &value.ResourceVersion, &value.ObservedGeneration,
&value.FailureCount, &nextAttempt, &lastError, &quotaVersion, &areaVersion,
&syncedAt, &value.CreatedAt, &value.UpdatedAt, &capabilitiesJSON,
)
if err != nil {
return ControlDevice{}, err
}
if err := json.Unmarshal([]byte(capabilitiesJSON), &value.Capabilities); err != nil {
return ControlDevice{}, errors.New("decode postgres control device capabilities")
}
if value.Capabilities == nil {
value.Capabilities = make([]device.Capability, 0)
}
if nextAttempt.Valid {
point := nextAttempt.Time.UTC()
value.NextAttemptAt = &point
}
if lastError.Valid {
code := lastError.String
value.LastErrorCode = &code
}
if quotaVersion.Valid {
version := quotaVersion.Int64
value.ProjectionVersions.QuotaSourceVersion = &version
}
if areaVersion.Valid {
version := areaVersion.Int64
value.ProjectionVersions.AreaPolicySourceVersion = &version
}
if syncedAt.Valid {
point := syncedAt.Time.UTC()
value.ProjectionVersions.SyncedAt = &point
}
value.Converged = value.ObservedGeneration >= value.Generation && value.FailureCount == 0
value.AdapterStatus = controlAdapterStatus(value)
return value, nil
}
func controlAdapterStatus(value ControlDevice) string {
if value.LastErrorCode != nil {
switch *value.LastErrorCode {
case "authentication_failed":
return "authentication_failed"
case "adapter_not_ready":
return "adapter_not_ready"
default:
return "unavailable"
}
}
if value.Converged {
return "ready"
}
if value.ActualState == device.ActualFailed || value.ActualState == device.ActualOffline {
return "unavailable"
}
return "pending"
}
func (s *Postgres) GetControlDevice(
ctx context.Context, tenantID, siteID, deviceID string,
) (ControlDevice, error) {
value, err := scanControlDevice(s.db.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
if errors.Is(err, sql.ErrNoRows) {
return ControlDevice{}, ErrNotFound
}
if err != nil {
return ControlDevice{}, errors.New("get postgres control device")
}
return value, nil
}
func (s *Postgres) ListControlDevices(
ctx context.Context, tenantID, siteID string, filter ControlListFilter,
) (ControlDevicePage, error) {
quota, err := s.controlSiteQuota(ctx, tenantID, siteID)
if err != nil {
return ControlDevicePage{}, err
}
query := controlDeviceSelect + ` WHERE d.tenant_id = $1 AND d.site_id = $2`
arguments := []any{tenantID, siteID}
appendCondition := func(clause string, value any) {
arguments = append(arguments, value)
query += fmt.Sprintf(clause, len(arguments))
}
if filter.Modality != nil {
appendCondition(` AND d.modality = $%d`, *filter.Modality)
}
if filter.Capability != nil {
appendCondition(` AND EXISTS (SELECT 1 FROM sense.device_capabilities fc
WHERE fc.device_id = d.id AND fc.capability = $%d)`, *filter.Capability)
}
if filter.DesiredState != nil {
appendCondition(` AND d.desired_state = $%d`, *filter.DesiredState)
}
if filter.ActualState != nil {
appendCondition(` AND d.actual_state = $%d`, *filter.ActualState)
}
if filter.AfterCreated != nil {
arguments = append(arguments, filter.AfterCreated.UTC(), filter.AfterDeviceID)
query += fmt.Sprintf(` AND (d.created_at, d.id) > ($%d, $%d)`, len(arguments)-1, len(arguments))
}
arguments = append(arguments, filter.Limit+1)
query += fmt.Sprintf(` ORDER BY d.created_at ASC, d.id ASC LIMIT $%d`, len(arguments))
rows, err := s.db.QueryContext(ctx, query, arguments...)
if err != nil {
return ControlDevicePage{}, errors.New("list postgres control devices")
}
defer rows.Close()
values := make([]ControlDevice, 0, filter.Limit+1)
for rows.Next() {
value, scanErr := scanControlDevice(rows)
if scanErr != nil {
return ControlDevicePage{}, errors.New("scan postgres control device page")
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return ControlDevicePage{}, errors.New("iterate postgres control device page")
}
hasMore := len(values) > filter.Limit
if hasMore {
values = values[:filter.Limit]
}
return ControlDevicePage{Items: values, HasMore: hasMore, Quota: quota}, nil
}
func (s *Postgres) controlSiteQuota(ctx context.Context, tenantID, siteID string) (ControlSiteQuota, error) {
var maximum int
var sourceVersion int64
var syncedAt time.Time
err := s.db.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
Scan(&maximum, &sourceVersion, &syncedAt)
if errors.Is(err, sql.ErrNoRows) {
return ControlSiteQuota{}, ErrNotFound
}
if err != nil {
return ControlSiteQuota{}, errors.New("read postgres control site quota")
}
var used int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sense.devices d
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.desired_state = 'enabled'
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')`, tenantID, siteID).Scan(&used); err != nil {
return ControlSiteQuota{}, errors.New("count postgres control site video channels")
}
status := "current"
if maximum < 1 || maximum > device.MaximumVideoChannels || sourceVersion < 1 || syncedAt.IsZero() {
status = "invalid"
return ControlSiteQuota{Status: status, UsedVideoChannels: used, OverLimit: false}, nil
}
available := maximum - used
if available < 0 {
available = 0
}
point := syncedAt.UTC()
return ControlSiteQuota{
Status: status, UsedVideoChannels: used, MaxVideoChannels: &maximum,
AvailableVideoChannels: &available, OverLimit: used > maximum,
SourceVersion: &sourceVersion, SyncedAt: &point,
}, nil
}
type controlReceipt struct {
Status int
Body []byte
ETag string
Location string
TraceID string
CreatedAt time.Time
}
func controlScopeHash(scope IdempotencyScope) [sha256.Size]byte {
encoded, _ := json.Marshal([]string{
scope.PrincipalID, scope.TenantID, scope.SiteID, scope.Operation, scope.Key,
})
return sha256.Sum256(encoded)
}
func readControlReceipt(
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, now time.Time,
) (controlReceipt, bool, error) {
var receipt controlReceipt
scopeHash := controlScopeHash(scope)
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, hex.EncodeToString(scopeHash[:])); err != nil {
return receipt, false, errors.New("lock postgres Control API idempotency scope")
}
var storedRequestHash []byte
var etag, location sql.NullString
var expiresAt time.Time
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text,
response_etag, response_location, trace_id, created_at, expires_at
FROM sense.control_idempotency_receipts WHERE scope_hash = $1`, scopeHash[:]).
Scan(&storedRequestHash, &receipt.Status, &receipt.Body, &etag, &location,
&receipt.TraceID, &receipt.CreatedAt, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return receipt, false, nil
}
if err != nil {
return receipt, false, errors.New("read postgres Control API idempotency receipt")
}
if !expiresAt.After(now) {
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
WHERE scope_hash = $1`, scopeHash[:]); err != nil {
return receipt, false, errors.New("expire postgres Control API idempotency receipt")
}
return controlReceipt{}, false, nil
}
if subtle.ConstantTimeCompare(storedRequestHash, scope.RequestHash[:]) != 1 {
return receipt, false, ErrIdempotencyConflict
}
receipt.ETag = etag.String
receipt.Location = location.String
return receipt, true, nil
}
func writeControlReceipt(
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, receipt controlReceipt,
) error {
scopeHash := controlScopeHash(scope)
_, err := tx.ExecContext(ctx, `INSERT INTO sense.control_idempotency_receipts(
scope_hash, request_hash, operation_name, principal_id, tenant_id, site_id,
response_status, response_body, response_etag, response_location, trace_id,
created_at, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, NULLIF($9, ''), NULLIF($10, ''), $11, $12, $13)`,
scopeHash[:], scope.RequestHash[:], scope.Operation, scope.PrincipalID,
scope.TenantID, scope.SiteID, receipt.Status, string(receipt.Body),
receipt.ETag, receipt.Location, receipt.TraceID, receipt.CreatedAt,
receipt.CreatedAt.Add(controlReceiptTTL))
if err != nil {
return errors.New("write postgres Control API idempotency receipt")
}
// Bound opportunistic cleanup; never scans or deletes unexpired receipts.
_, _ = tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
WHERE scope_hash IN (SELECT scope_hash FROM sense.control_idempotency_receipts
WHERE expires_at <= $1 ORDER BY expires_at LIMIT 32)`, receipt.CreatedAt)
return nil
}
func (s *Postgres) CreateControlDevice(
ctx context.Context, request ControlCreateRequest,
) (ControlCreateResult, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlCreateResult{}, errors.New("begin postgres Control API device create")
}
defer tx.Rollback()
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
if err != nil {
return ControlCreateResult{}, err
}
if found {
var value ControlDevice
if err := json.Unmarshal(receipt.Body, &value); err != nil {
return ControlCreateResult{}, errors.New("decode postgres device creation receipt")
}
if err := tx.Commit(); err != nil {
return ControlCreateResult{}, errors.New("commit postgres device creation replay")
}
return ControlCreateResult{
Device: value, AcceptedAt: receipt.CreatedAt, TraceID: receipt.TraceID,
ETag: receipt.ETag, Location: receipt.Location, Replay: true,
}, nil
}
value := request.Device
if value.Generation == 0 {
value.Generation = 1
}
if value.ResourceVersion == 0 {
value.ResourceVersion = 1
}
if value.ActualState == "" {
value.ActualState = device.ActualPending
}
value.CreatedAt = now
value.UpdatedAt = now
if err := createControlDeviceTx(ctx, tx, value, now); err != nil {
return ControlCreateResult{}, err
}
created, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, value.TenantID, value.SiteID, value.ID))
if err != nil {
return ControlCreateResult{}, errors.New("read created postgres control device")
}
responseBody, err := json.Marshal(created)
if err != nil {
return ControlCreateResult{}, errors.New("encode created postgres control device")
}
etag := DeviceETag(created.ID, created.ResourceVersion)
location := "/api/v1/sites/" + url.PathEscape(created.SiteID) + "/devices/" + url.PathEscape(created.ID)
receipt = controlReceipt{
Status: 201, Body: responseBody, ETag: etag, Location: location,
TraceID: request.Scope.TraceID, CreatedAt: now,
}
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
return ControlCreateResult{}, err
}
if err := tx.Commit(); err != nil {
return ControlCreateResult{}, errors.New("commit postgres Control API device create")
}
return ControlCreateResult{
Device: created, AcceptedAt: now, TraceID: receipt.TraceID,
ETag: etag, Location: location,
}, nil
}
func createControlDeviceTx(ctx context.Context, tx *sql.Tx, value device.Device, now time.Time) error {
if err := value.Validate(); err != nil {
return fmt.Errorf("validate Control API device: %w", err)
}
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() {
quotaVersion, err = checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
if err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
id, tenant_id, site_id, area_id, serial_number, name, modality,
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
path_name, generation, resource_version, 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,$18,$19)`,
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
value.CreatedAt, value.UpdatedAt)
if err != nil {
var postgresError *pgconn.PgError
if errors.As(err, &postgresError) && postgresError.Code == "23505" &&
strings.Contains(postgresError.ConstraintName, "serial_number") {
return ErrDuplicateSerialNumber
}
return errors.New("insert postgres Control API device")
}
for _, capability := range sortedCapabilities(value.Capabilities) {
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.device_capabilities(device_id, capability)
VALUES ($1, $2)`, value.ID, capability); err != nil {
return errors.New("insert postgres Control API device capability")
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
VALUES ($1, $2)`, value.ID, now); err != nil {
return errors.New("insert postgres Control API reconcile state")
}
return 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,
},
})
}
var _ ControlRepository = (*Postgres)(nil)
func (s *Postgres) PatchControlDevice(
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, patch ControlPatch,
) (ControlMutationResult, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlMutationResult{}, errors.New("begin postgres Control API device patch")
}
defer tx.Rollback()
var name, areaID, endpointRef, credentialRef, profileToken string
var generation, resourceVersion int64
var desired device.DesiredState
var quotaVersion, areaVersion sql.NullInt64
var hasVideo bool
err = tx.QueryRowContext(ctx, `SELECT d.name, d.area_id, d.endpoint_ref,
d.credential_ref, d.profile_token, d.generation, d.resource_version,
d.desired_state, d.quota_source_version, d.area_policy_source_version,
EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
FROM sense.devices d
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
tenantID, siteID, deviceID).Scan(
&name, &areaID, &endpointRef, &credentialRef, &profileToken,
&generation, &resourceVersion, &desired, &quotaVersion, &areaVersion, &hasVideo,
)
if errors.Is(err, sql.ErrNoRows) {
return ControlMutationResult{}, ErrNotFound
}
if err != nil {
return ControlMutationResult{}, errors.New("read postgres Control API device patch state")
}
if DeviceETag(deviceID, resourceVersion) != expectedETag {
return ControlMutationResult{}, ErrETagMismatch
}
changedFields := make([]string, 0, 5)
reconcileChanged := false
if patch.Name != nil && *patch.Name != name {
name = *patch.Name
changedFields = append(changedFields, "name")
}
if patch.AreaID != nil && *patch.AreaID != areaID {
version, policyErr := checkPostgresAreaPolicy(
ctx, tx, tenantID, siteID, *patch.AreaID, hasVideo, now,
)
if policyErr != nil {
return ControlMutationResult{}, policyErr
}
areaID = *patch.AreaID
areaVersion = sql.NullInt64{Int64: version, Valid: true}
changedFields = append(changedFields, "area_id")
}
if patch.EndpointRef != nil && *patch.EndpointRef != endpointRef {
endpointRef = *patch.EndpointRef
changedFields = append(changedFields, "endpoint_ref")
reconcileChanged = true
}
if patch.CredentialRef != nil && *patch.CredentialRef != credentialRef {
credentialRef = *patch.CredentialRef
changedFields = append(changedFields, "credential_ref")
reconcileChanged = true
}
if patch.ProfileToken != nil && *patch.ProfileToken != profileToken {
profileToken = *patch.ProfileToken
changedFields = append(changedFields, "profile_token")
reconcileChanged = true
}
if len(changedFields) > 0 {
resourceVersion++
if reconcileChanged {
generation++
}
_, err = tx.ExecContext(ctx, `UPDATE sense.devices SET
name = $1, area_id = $2, endpoint_ref = $3, credential_ref = $4,
profile_token = $5, generation = $6, resource_version = $7,
area_policy_source_version = $8,
actual_state = CASE WHEN $9 THEN 'pending' ELSE actual_state END,
updated_at = $10
WHERE tenant_id = $11 AND site_id = $12 AND id = $13`,
name, areaID, endpointRef, credentialRef, profileToken, generation,
resourceVersion, nullableVersion(areaVersion.Int64), reconcileChanged, now,
tenantID, siteID, deviceID)
if err != nil {
return ControlMutationResult{}, errors.New("update postgres Control API device configuration")
}
if reconcileChanged {
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`, now, deviceID); err != nil {
return ControlMutationResult{}, errors.New("reset postgres Control API reconcile state")
}
}
}
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.configuration.accepted", TenantID: tenantID, SiteID: siteID,
DeviceID: deviceID, Generation: generation,
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
OccurredAt: now,
Payload: map[string]any{
"kind": "configuration_accepted", "changed": len(changedFields) > 0,
"changed_fields": changedFields, "area_id": areaID,
},
}); err != nil {
return ControlMutationResult{}, err
}
updated, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
if err != nil {
return ControlMutationResult{}, errors.New("read patched postgres control device")
}
if err := tx.Commit(); err != nil {
return ControlMutationResult{}, errors.New("commit postgres Control API device patch")
}
return ControlMutationResult{
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
}, nil
}
func (s *Postgres) SetControlDesiredState(
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, desired device.DesiredState,
) (ControlMutationResult, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlMutationResult{}, errors.New("begin postgres Control API desired-state update")
}
defer tx.Rollback()
updated, err := setControlDesiredStateTx(
ctx, tx, tenantID, siteID, deviceID, expectedETag, desired, now,
)
if err != nil {
return ControlMutationResult{}, err
}
if err := tx.Commit(); err != nil {
return ControlMutationResult{}, errors.New("commit postgres Control API desired-state update")
}
return ControlMutationResult{
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
}, nil
}
func setControlDesiredStateTx(
ctx context.Context, tx *sql.Tx, tenantID, siteID, deviceID, expectedETag string,
desired device.DesiredState, now time.Time,
) (ControlDevice, error) {
var areaID, endpointRef, pathName string
var current device.DesiredState
var generation, resourceVersion int64
var quotaVersion, areaVersion sql.NullInt64
var hasVideo bool
err := tx.QueryRowContext(ctx, `SELECT d.area_id, d.desired_state, d.endpoint_ref,
d.path_name, d.generation, d.resource_version, d.quota_source_version,
d.area_policy_source_version,
EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
FROM sense.devices d
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
tenantID, siteID, deviceID).Scan(
&areaID, &current, &endpointRef, &pathName, &generation, &resourceVersion,
&quotaVersion, &areaVersion, &hasVideo,
)
if errors.Is(err, sql.ErrNoRows) {
return ControlDevice{}, ErrNotFound
}
if err != nil {
return ControlDevice{}, errors.New("read postgres Control API desired state")
}
if DeviceETag(deviceID, resourceVersion) != expectedETag {
return ControlDevice{}, ErrETagMismatch
}
if current != desired {
var admittedQuota, admittedArea int64
if desired == device.DesiredEnabled && hasVideo {
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
return ControlDevice{}, errors.New("video adapter configuration is incomplete")
}
admittedArea, err = checkPostgresAreaPolicy(ctx, tx, tenantID, siteID, areaID, true, now)
if err != nil {
return ControlDevice{}, err
}
admittedQuota, err = checkPostgresVideoQuota(ctx, tx, tenantID, siteID, now)
if err != nil {
return ControlDevice{}, err
}
}
generation++
resourceVersion++
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
desired_state = $1, actual_state = 'pending', generation = $2,
resource_version = $3,
quota_source_version = COALESCE($4, quota_source_version),
area_policy_source_version = COALESCE($5, area_policy_source_version),
updated_at = $6
WHERE tenant_id = $7 AND site_id = $8 AND id = $9
RETURNING quota_source_version, area_policy_source_version`,
desired, generation, resourceVersion, nullableVersion(admittedQuota),
nullableVersion(admittedArea), now, tenantID, siteID, deviceID).
Scan(&quotaVersion, &areaVersion)
if err != nil {
return ControlDevice{}, errors.New("update postgres Control API desired state")
}
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`, now, deviceID); err != nil {
return ControlDevice{}, errors.New("reset postgres Control API desired-state reconciliation")
}
}
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
DeviceID: deviceID, Generation: generation,
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
OccurredAt: now,
Payload: map[string]any{
"kind": "desired_state_accepted", "previous_desired_state": current,
"desired_state": desired, "changed": current != desired,
},
}); err != nil {
return ControlDevice{}, err
}
value, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
if err != nil {
return ControlDevice{}, errors.New("read updated postgres control desired state")
}
return value, nil
}
func (s *Postgres) BatchSetControlDesiredState(
ctx context.Context, request ControlBatchRequest,
) (ControlBatchOperation, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlBatchOperation{}, errors.New("begin postgres Control API batch")
}
defer tx.Rollback()
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
if err != nil {
return ControlBatchOperation{}, err
}
if found {
var operation ControlBatchOperation
if err := json.Unmarshal(receipt.Body, &operation); err != nil {
return ControlBatchOperation{}, errors.New("decode postgres Control API batch receipt")
}
operation.TenantID = request.Scope.TenantID
operation.SiteID = request.Scope.SiteID
operation.Replay = true
if err := tx.Commit(); err != nil {
return ControlBatchOperation{}, errors.New("commit postgres Control API batch replay")
}
return operation, nil
}
operationID, err := newControlOperationID(now)
if err != nil {
return ControlBatchOperation{}, err
}
counts := make(map[string]int, len(request.Items))
for _, item := range request.Items {
counts[item.DeviceID]++
}
if err := lockControlBatchDevices(
ctx, tx, request.Scope.TenantID, request.Scope.SiteID, counts,
); err != nil {
return ControlBatchOperation{}, err
}
results := make([]ControlBatchItemResult, 0, len(request.Items))
succeeded := 0
for index, item := range request.Items {
if counts[item.DeviceID] > 1 {
code, message := "invalid_request", "device_id is duplicated in this request"
results = append(results, ControlBatchItemResult{
DeviceID: item.DeviceID, Status: "rejected", ErrorCode: &code, Message: &message,
})
continue
}
savepoint := fmt.Sprintf("control_batch_%d", index)
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+savepoint); err != nil {
return ControlBatchOperation{}, errors.New("create postgres Control API batch savepoint")
}
updated, itemErr := setControlDesiredStateTx(
ctx, tx, request.Scope.TenantID, request.Scope.SiteID,
item.DeviceID, item.ETag, item.DesiredState, now,
)
if itemErr != nil {
if _, rollbackErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+savepoint); rollbackErr != nil {
return ControlBatchOperation{}, errors.New("rollback postgres Control API batch item")
}
code, status, message := controlBatchError(itemErr)
results = append(results, ControlBatchItemResult{
DeviceID: item.DeviceID, Status: status, ErrorCode: &code, Message: &message,
})
} else {
generation := updated.Generation
results = append(results, ControlBatchItemResult{
DeviceID: item.DeviceID, Status: "succeeded", Generation: &generation,
})
succeeded++
}
if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+savepoint); err != nil {
return ControlBatchOperation{}, errors.New("release postgres Control API batch savepoint")
}
}
status := "partially_succeeded"
if succeeded == len(results) {
status = "succeeded"
} else if succeeded == 0 {
status = "failed"
}
completedAt := now
operation := ControlBatchOperation{
ID: operationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
Status: status, SubmittedAt: now, CompletedAt: &completedAt,
Results: results, TraceID: request.Scope.TraceID,
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operations(
id, tenant_id, site_id, principal_id, status, trace_id, submitted_at, completed_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, operation.ID, operation.TenantID,
operation.SiteID, request.Scope.PrincipalID, operation.Status, operation.TraceID,
operation.SubmittedAt, operation.CompletedAt); err != nil {
return ControlBatchOperation{}, errors.New("insert postgres Control API batch operation")
}
for index, result := range results {
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operation_items(
operation_id, ordinal, device_id, status, error_code, message, generation
) VALUES ($1,$2,$3,$4,$5,$6,$7)`, operation.ID, index, result.DeviceID,
result.Status, result.ErrorCode, result.Message, result.Generation); err != nil {
return ControlBatchOperation{}, errors.New("insert postgres Control API batch item")
}
}
body, err := json.Marshal(operation)
if err != nil {
return ControlBatchOperation{}, errors.New("encode postgres Control API batch operation")
}
receipt = controlReceipt{
Status: 202, Body: body,
Location: "/api/v1/operations/" + url.PathEscape(operation.ID),
TraceID: operation.TraceID, CreatedAt: now,
}
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
return ControlBatchOperation{}, err
}
if err := tx.Commit(); err != nil {
return ControlBatchOperation{}, errors.New("commit postgres Control API batch")
}
return operation, nil
}
func lockControlBatchDevices(
ctx context.Context, tx *sql.Tx, tenantID, siteID string, deviceIDs map[string]int,
) error {
ordered := make([]string, 0, len(deviceIDs))
for deviceID := range deviceIDs {
ordered = append(ordered, deviceID)
}
sort.Strings(ordered)
for _, deviceID := range ordered {
var locked string
err := tx.QueryRowContext(ctx, `SELECT id FROM sense.devices
WHERE tenant_id = $1 AND site_id = $2 AND id = $3 FOR UPDATE`,
tenantID, siteID, deviceID).Scan(&locked)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return errors.New("lock postgres Control API batch devices")
}
}
return nil
}
func controlBatchError(err error) (code, status, message string) {
switch {
case errors.Is(err, ErrNotFound):
return "not_found", "rejected", "device was not found"
case errors.Is(err, ErrETagMismatch):
return "etag_mismatch", "rejected", "device ETag does not match"
case errors.Is(err, ErrAreaPolicyDenied):
return "area_policy_denied", "rejected", "Area policy denies this change"
case errors.Is(err, ErrAreaPolicyUnavailable), errors.Is(err, ErrAreaPolicyInvalid):
return "area_policy_unavailable", "failed", "Area policy is unavailable"
case errors.Is(err, ErrQuotaProjectionUnavailable):
return "quota_projection_unavailable", "failed", "Site quota is unavailable"
case errors.Is(err, ErrQuotaProjectionInvalid):
return "quota_projection_invalid", "failed", "Site quota is invalid"
}
var quotaError *device.QuotaExceededError
if errors.As(err, &quotaError) {
return "quota_exceeded", "rejected", "Site video channel quota is exceeded"
}
return "service_unavailable", "failed", "device change could not be accepted"
}
func (s *Postgres) GetControlOperation(
ctx context.Context, tenantID, operationID string,
) (ControlBatchOperation, error) {
var value ControlBatchOperation
var completedAt sql.NullTime
err := s.db.QueryRowContext(ctx, `SELECT id, tenant_id, site_id, status,
submitted_at, completed_at, trace_id
FROM sense.batch_operations WHERE tenant_id = $1 AND id = $2`, tenantID, operationID).
Scan(&value.ID, &value.TenantID, &value.SiteID, &value.Status,
&value.SubmittedAt, &completedAt, &value.TraceID)
if errors.Is(err, sql.ErrNoRows) {
return ControlBatchOperation{}, ErrNotFound
}
if err != nil {
return ControlBatchOperation{}, errors.New("read postgres Control API batch operation")
}
if completedAt.Valid {
point := completedAt.Time.UTC()
value.CompletedAt = &point
}
rows, err := s.db.QueryContext(ctx, `SELECT device_id, status, error_code, message, generation
FROM sense.batch_operation_items WHERE operation_id = $1 ORDER BY ordinal`, operationID)
if err != nil {
return ControlBatchOperation{}, errors.New("list postgres Control API batch results")
}
defer rows.Close()
value.Results = make([]ControlBatchItemResult, 0)
for rows.Next() {
var item ControlBatchItemResult
var code, message sql.NullString
var generation sql.NullInt64
if err := rows.Scan(&item.DeviceID, &item.Status, &code, &message, &generation); err != nil {
return ControlBatchOperation{}, errors.New("scan postgres Control API batch result")
}
if code.Valid {
item.ErrorCode = &code.String
}
if message.Valid {
item.Message = &message.String
}
if generation.Valid {
item.Generation = &generation.Int64
}
value.Results = append(value.Results, item)
}
if err := rows.Err(); err != nil {
return ControlBatchOperation{}, errors.New("iterate postgres Control API batch results")
}
return value, nil
}
func newControlOperationID(now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0], value[1], value[2] = byte(milliseconds>>40), byte(milliseconds>>32), byte(milliseconds>>24)
value[3], value[4], value[5] = byte(milliseconds>>16), byte(milliseconds>>8), byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate Control API operation ID")
}
number := new(big.Int).SetBytes(value)
base, remainder := big.NewInt(32), new(big.Int)
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = alphabet[remainder.Int64()]
}
return "op_" + string(encoded), nil
}
+42 -16
View File
@@ -53,8 +53,8 @@ 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 < 3 {
return errors.New("postgres sense schema migration v3 is required")
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 4 {
return errors.New("postgres sense schema migration v4 is required")
}
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
@@ -77,6 +77,24 @@ func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
!canReadAreaView || canWriteAreaView || canReadAreaSource || canWriteAreaSource {
return errors.New("postgres role violates Bell projection privilege boundary")
}
var canUseReceipts, canUseOperations, canUseOperationItems bool
var publicReceipts, publicOperations, publicOperationItems bool
if err := s.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
&canUseReceipts, &canUseOperations, &canUseOperationItems,
&publicReceipts, &publicOperations, &publicOperationItems,
); err != nil {
return errors.New("verify postgres Control API state privileges")
}
if !canUseReceipts || !canUseOperations || !canUseOperationItems ||
publicReceipts || publicOperations || publicOperationItems {
return errors.New("postgres role violates Control API state privilege boundary")
}
return nil
}
@@ -87,6 +105,9 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
if value.ActualState == "" {
value.ActualState = device.ActualPending
}
if value.ResourceVersion == 0 {
value.ResourceVersion = 1
}
if err := value.Validate(); err != nil {
return fmt.Errorf("validate device: %w", err)
}
@@ -118,14 +139,15 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
}
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
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, area_policy_source_version,
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
path_name, generation, resource_version, 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)`,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
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, nullableVersion(quotaVersion),
areaVersion, value.CreatedAt, value.UpdatedAt)
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
value.CreatedAt, value.UpdatedAt)
if err != nil {
return errors.New("insert postgres device")
}
@@ -354,6 +376,7 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
var updatedQuotaVersion, updatedAreaVersion sql.NullInt64
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
desired_state = $1, actual_state = 'pending', generation = generation + 1,
resource_version = resource_version + 1,
quota_source_version = COALESCE($2, quota_source_version),
area_policy_source_version = COALESCE($3, area_policy_source_version),
updated_at = $4
@@ -413,10 +436,10 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense.devices d
JOIN sense.reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled'
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
AND (r.observed_generation < d.generation OR r.failure_count > 0)
AND (r.observed_generation < d.generation
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= $1)
ORDER BY d.updated_at, d.id LIMIT $2`, now, limit)
if err != nil {
@@ -434,7 +457,8 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
&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,
&candidate.Device.ProfileToken, &candidate.Device.PathName,
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
&quotaVersion, &areaVersion,
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
&candidate.FailureCount, &nextAttempt,
@@ -511,7 +535,8 @@ func (s *Postgres) MarkReconciled(ctx context.Context, id string, generation int
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = 'pending', updated_at = $1 WHERE id = $2`, now, id); err != nil {
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
updated_at = $1 WHERE id = $2`, now, id); err != nil {
return errors.New("mark postgres reconciled device pending")
}
if err := tx.Commit(); err != nil {
@@ -628,8 +653,8 @@ func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot
}
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.quota_source_version, d.area_policy_source_version,
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref, d.profile_token,
d.path_name, d.generation, d.resource_version, d.quota_source_version, d.area_policy_source_version,
d.created_at, d.updated_at`
const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
@@ -641,8 +666,9 @@ func scanPostgresDevice(row scanner) (device.Device, error) {
err := row.Scan(
&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, &quotaVersion, &areaVersion, &value.CreatedAt, &value.UpdatedAt,
&value.EndpointRef, &value.CredentialRef, &value.ProfileToken, &value.PathName,
&value.Generation, &value.ResourceVersion, &quotaVersion, &areaVersion,
&value.CreatedAt, &value.UpdatedAt,
)
value.AreaID = areaID.String
value.QuotaSourceVersion = quotaVersion.Int64
+363 -4
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
@@ -286,12 +287,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_t010_sense`); err != nil {
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t011_sense`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(),
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t010_sense`)
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t011_sense`)
}()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil {
@@ -596,12 +597,12 @@ 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 {
`GRANT SELECT ON bell.areas TO yovision_t011_sense`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(),
`REVOKE SELECT ON bell.areas FROM yovision_t010_sense`)
`REVOKE SELECT ON bell.areas FROM yovision_t011_sense`)
}()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil {
@@ -613,6 +614,361 @@ func TestPostgresOpenRejectsAreaSourcePrivilege(t *testing.T) {
}
}
func TestPostgresOpenRejectsPublicControlStatePrivilege(t *testing.T) {
_, admin := openPostgresTestStore(t)
ctx := context.Background()
if _, err := admin.ExecContext(ctx, `GRANT SELECT ON sense.batch_operations TO PUBLIC`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(), `REVOKE SELECT ON sense.batch_operations FROM PUBLIC`)
}()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil {
_ = value.Close()
t.Fatal("PUBLIC Control API table privilege was accepted")
}
if err == nil || !strings.Contains(err.Error(), "Control API state privilege boundary") {
t.Fatalf("expected Control API privilege-boundary error, got %v", err)
}
}
func TestPostgresControlCreateIdempotencyAndRedactedSnapshot(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
value := videoDevice(1, "tenant", "site")
value.ID = "dev_control_create_1"
value.DesiredState = device.DesiredDisabled
hash := sha256.Sum256([]byte("canonical-create"))
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorService, ActorID: "bell-control", TraceID: "trace-control-create",
})
request := ControlCreateRequest{
Scope: IdempotencyScope{
PrincipalID: "bell-control", TenantID: "tenant", SiteID: "site",
Operation: "createDevice", Key: "create-control-0001",
RequestHash: hash, TraceID: "trace-control-create",
},
Device: value,
}
first, err := postgres.CreateControlDevice(ctx, request)
if err != nil {
t.Fatal(err)
}
request.Device.ID = "dev_control_create_retry"
replayed, err := postgres.CreateControlDevice(ctx, request)
if err != nil {
t.Fatal(err)
}
if !replayed.Replay || replayed.Device.ID != first.Device.ID || replayed.TraceID != first.TraceID {
t.Fatalf("create replay drifted: first=%+v replay=%+v", first, replayed)
}
conflictHash := sha256.Sum256([]byte("different-create"))
request.Scope.RequestHash = conflictHash
if _, err := postgres.CreateControlDevice(ctx, request); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("same key with different body was not rejected: %v", err)
}
var devices, receipts int
var responseBody string
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*), min(response_body::text)
FROM sense.control_idempotency_receipts`).Scan(&receipts, &responseBody); err != nil {
t.Fatal(err)
}
if devices != 1 || receipts != 1 {
t.Fatalf("idempotent create counts drifted: devices=%d receipts=%d", devices, receipts)
}
for _, forbidden := range []string{value.EndpointRef, value.CredentialRef, "profile_token", "path_name"} {
if strings.Contains(responseBody, forbidden) {
t.Fatalf("idempotency response snapshot leaked %q", forbidden)
}
}
}
func TestPostgresControlListIsStableFilteredAndTenantScoped(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 4)
insertBellSite(t, admin, "other", "site", 4)
for index := 1; index <= 3; index++ {
value := videoDevice(index, "tenant", "site")
value.ID = fmt.Sprintf("dev_list_%d", index)
value.DesiredState = device.DesiredDisabled
if index == 3 {
value.DesiredState = device.DesiredEnabled
}
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
}
other := videoDevice(9, "other", "site")
other.ID, other.DesiredState = "dev_list_other", device.DesiredDisabled
if err := postgres.CreateDevice(context.Background(), other); err != nil {
t.Fatal(err)
}
first, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{Limit: 1})
if err != nil || len(first.Items) != 1 || !first.HasMore || first.Quota.UsedVideoChannels != 1 {
t.Fatalf("unexpected first page: %+v %v", first, err)
}
second, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{
Limit: 1, AfterCreated: &first.Items[0].CreatedAt, AfterDeviceID: first.Items[0].ID,
})
if err != nil || len(second.Items) != 1 || second.Items[0].ID == first.Items[0].ID {
t.Fatalf("stable cursor position failed: %+v %v", second, err)
}
desired := device.DesiredEnabled
filtered, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{
Limit: 100, DesiredState: &desired,
})
if err != nil || len(filtered.Items) != 1 || filtered.Items[0].ID != "dev_list_3" {
t.Fatalf("desired-state filter or tenant scope failed: %+v %v", filtered, err)
}
if _, err := postgres.ListControlDevices(context.Background(), "tenant", "missing", ControlListFilter{Limit: 50}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing Site did not return not found: %v", err)
}
}
func TestPostgresControlPatchUsesETagAndAuditV2(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
value := videoDevice(1, "tenant", "site")
value.ID = "dev_control_patch_1"
value.DesiredState = device.DesiredDisabled
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
current, err := postgres.GetControlDevice(context.Background(), "tenant", "site", value.ID)
if err != nil {
t.Fatal(err)
}
etag := DeviceETag(current.ID, current.ResourceVersion)
newName, newProfile := "Updated camera", "profile-main"
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorUser, ActorID: "operator-1", TraceID: "trace-control-patch",
})
updated, err := postgres.PatchControlDevice(ctx, "tenant", "site", value.ID, etag, ControlPatch{
Name: &newName, ProfileToken: &newProfile,
})
if err != nil {
t.Fatal(err)
}
if updated.Device.ResourceVersion != current.ResourceVersion+1 ||
updated.Device.Generation != current.Generation+1 || updated.ETag == etag {
t.Fatalf("patch did not advance versions: before=%+v after=%+v", current, updated)
}
if _, err := postgres.PatchControlDevice(ctx, "tenant", "site", value.ID, etag, ControlPatch{Name: &newName}); !errors.Is(err, ErrETagMismatch) {
t.Fatalf("stale ETag was accepted: %v", err)
}
var profileToken, eventType, payload string
if err := admin.QueryRow(`SELECT profile_token FROM sense.devices WHERE id = $1`, value.ID).Scan(&profileToken); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT event_type, payload::text
FROM sense.device_operation_outbox WHERE event_type = 'device.configuration.accepted'`).
Scan(&eventType, &payload); err != nil {
t.Fatal(err)
}
if profileToken != newProfile || eventType != "device.configuration.accepted" ||
strings.Contains(payload, newProfile) || !strings.Contains(payload, "profile_token") {
t.Fatalf("configuration persistence/audit mismatch: profile=%q event=%q payload=%s", profileToken, eventType, payload)
}
}
func TestPostgresControlBatchIsPerItemDurableAndReplayable(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 4)
first := videoDevice(1, "tenant", "site")
first.ID, first.DesiredState = "dev_batch_1", device.DesiredDisabled
second := videoDevice(2, "tenant", "site")
second.ID, second.DesiredState = "dev_batch_2", device.DesiredDisabled
for _, value := range []device.Device{first, second} {
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
}
hash := sha256.Sum256([]byte("canonical-batch"))
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorUser, ActorID: "operator-1", Reason: "approved", TraceID: "trace-batch",
})
request := ControlBatchRequest{
Scope: IdempotencyScope{
PrincipalID: "operator-1", TenantID: "tenant", SiteID: "site",
Operation: "batchSetDeviceDesiredState", Key: "batch-control-0001",
RequestHash: hash, TraceID: "trace-batch",
},
Reason: "approved",
Items: []ControlBatchItem{
{DeviceID: first.ID, ETag: DeviceETag(first.ID, 1), DesiredState: device.DesiredEnabled},
{DeviceID: first.ID, ETag: DeviceETag(first.ID, 1), DesiredState: device.DesiredEnabled},
{DeviceID: second.ID, ETag: DeviceETag(second.ID, 1), DesiredState: device.DesiredEnabled},
},
}
operation, err := postgres.BatchSetControlDesiredState(ctx, request)
if err != nil {
t.Fatal(err)
}
if operation.Status != "partially_succeeded" || len(operation.Results) != 3 ||
operation.Results[0].Status != "rejected" || operation.Results[1].Status != "rejected" ||
operation.Results[2].Status != "succeeded" {
t.Fatalf("unexpected batch result: %+v", operation)
}
replayed, err := postgres.BatchSetControlDesiredState(ctx, request)
if err != nil || !replayed.Replay || replayed.ID != operation.ID {
t.Fatalf("batch replay drifted: %+v %v", replayed, err)
}
read, err := postgres.GetControlOperation(context.Background(), "tenant", operation.ID)
if err != nil || read.SiteID != "site" || len(read.Results) != 3 {
t.Fatalf("stored operation could not be read: %+v %v", read, err)
}
if _, err := postgres.GetControlOperation(context.Background(), "other-tenant", operation.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("cross-tenant operation was visible: %v", err)
}
var enabled, operations, receipts int
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices WHERE desired_state = 'enabled'`).Scan(&enabled); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.batch_operations`).Scan(&operations); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.control_idempotency_receipts`).Scan(&receipts); err != nil {
t.Fatal(err)
}
if enabled != 1 || operations != 1 || receipts != 1 {
t.Fatalf("batch durability counts drifted: enabled=%d operations=%d receipts=%d", enabled, operations, receipts)
}
}
func TestPostgresConcurrentControlCreateExecutesOnce(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
hash := sha256.Sum256([]byte("concurrent-create"))
start := make(chan struct{})
results := make(chan ControlCreateResult, 2)
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.ID = fmt.Sprintf("dev_concurrent_%d", index)
value.SerialNumber = "same-semantic-serial"
value.DesiredState = device.DesiredDisabled
request := ControlCreateRequest{
Scope: IdempotencyScope{
PrincipalID: "service", TenantID: "tenant", SiteID: "site",
Operation: "createDevice", Key: "concurrent-create-0001",
RequestHash: hash, TraceID: fmt.Sprintf("trace-concurrent-%d", index),
}, Device: value,
}
<-start
result, err := postgres.CreateControlDevice(context.Background(), request)
if err != nil {
errorsFound <- err
return
}
results <- result
}(index)
}
close(start)
wait.Wait()
close(results)
close(errorsFound)
for err := range errorsFound {
t.Fatalf("concurrent idempotent create failed: %v", err)
}
ids := make(map[string]struct{})
for result := range results {
ids[result.Device.ID] = struct{}{}
}
if len(ids) != 1 {
t.Fatalf("concurrent create returned multiple resources: %+v", ids)
}
var devices, audits, receipts int
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.control_idempotency_receipts`).Scan(&receipts); err != nil {
t.Fatal(err)
}
if devices != 1 || audits != 1 || receipts != 1 {
t.Fatalf("concurrent create executed more than once: devices=%d audits=%d receipts=%d", devices, audits, receipts)
}
}
func TestPostgresConcurrentControlBatchesUseStableDeviceLockOrder(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 4)
ids := []string{"dev_lock_a", "dev_lock_b"}
for index, id := range ids {
value := videoDevice(index+1, "tenant", "site")
value.ID, value.DesiredState = id, device.DesiredDisabled
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
operations := make(chan ControlBatchOperation, 2)
errorsFound := make(chan error, 2)
var wait sync.WaitGroup
for index := 0; index < 2; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
order := ids
if index == 1 {
order = []string{ids[1], ids[0]}
}
hash := sha256.Sum256([]byte(fmt.Sprintf("batch-order-%d", index)))
request := ControlBatchRequest{
Scope: IdempotencyScope{
PrincipalID: "operator", TenantID: "tenant", SiteID: "site",
Operation: "batchSetDeviceDesiredState",
Key: fmt.Sprintf("batch-lock-order-%04d", index), RequestHash: hash,
TraceID: fmt.Sprintf("trace-lock-order-%d", index),
},
Reason: "concurrency test",
Items: []ControlBatchItem{
{DeviceID: order[0], ETag: DeviceETag(order[0], 1), DesiredState: device.DesiredEnabled},
{DeviceID: order[1], ETag: DeviceETag(order[1], 1), DesiredState: device.DesiredEnabled},
},
}
<-start
operation, err := postgres.BatchSetControlDesiredState(context.Background(), request)
if err != nil {
errorsFound <- err
return
}
operations <- operation
}(index)
}
close(start)
wait.Wait()
close(operations)
close(errorsFound)
for err := range errorsFound {
t.Fatalf("opposite-order batch failed or deadlocked: %v", err)
}
var succeeded, failed int
for operation := range operations {
switch operation.Status {
case "succeeded":
succeeded++
case "failed":
failed++
default:
t.Fatalf("unexpected concurrent batch status: %+v", operation)
}
}
if succeeded != 1 || failed != 1 {
t.Fatalf("expected one winner and one stale loser, got succeeded=%d failed=%d", succeeded, failed)
}
}
func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
t.Helper()
dsn := os.Getenv(postgresTestDSNEnv)
@@ -629,6 +985,9 @@ func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
t.Fatal("connect PostgreSQL test administrator")
}
if _, err := admin.ExecContext(context.Background(), `TRUNCATE
sense.control_idempotency_receipts,
sense.batch_operation_items,
sense.batch_operations,
sense.device_operation_outbox,
sense.device_capabilities,
sense.reconcile_state,
+6 -4
View File
@@ -366,12 +366,12 @@ func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int)
rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense_devices d
JOIN sense_reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled'
AND EXISTS (
WHERE EXISTS (
SELECT 1 FROM sense_device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
AND (r.observed_generation < d.generation OR r.failure_count > 0)
AND (r.observed_generation < d.generation
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
ORDER BY d.updated_at, d.id
LIMIT ?`, formatTime(now), limit)
@@ -487,7 +487,9 @@ func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
UPDATE sense_devices SET actual_state = 'pending', updated_at = ? WHERE id = ?`,
UPDATE sense_devices SET
actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
updated_at = ? WHERE id = ?`,
formatTime(now), id); err != nil {
return fmt.Errorf("mark reconciled device pending: %w", err)
}