From 20b06884ad91d0ea1a2f4be7b263a4566ccf70e2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 7 Aug 2026 20:14:37 +0800 Subject: [PATCH 1/3] chore(task): claim T-011 --- docs/tasks/T-011.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/tasks/T-011.md b/docs/tasks/T-011.md index b355500..46de86a 100644 --- a/docs/tasks/T-011.md +++ b/docs/tasks/T-011.md @@ -3,12 +3,12 @@ id: T-011 title: 实现 Sense Control API v1 与 PostgreSQL 一致性边界 phase: 2 deps: [T-010] -status: TODO +status: DOING created: 2026-08-07 issue: 39 -context_ref: null -claim_branch: null -work_branch: null +context_ref: 6402d4384d5c2b1c0f3852858c3cf0d63d948262 +claim_branch: claims/T-011 +work_branch: agent/codex/T-011 write_paths: - docs/tasks/T-011.md - docs/contracts/ @@ -95,6 +95,11 @@ T-008 已冻结 Sense Control API v1 的 7 个 endpoint,T-009/T-010 已提供 ## 执行记录 +### 2026-08-07 领取任务 + +- dispatcher `ila` 将 Issue #39 分配给 `codex`;`context_ref` 为 `6402d4384d5c2b1c0f3852858c3cf0d63d948262`,claim 为 `claims/T-011`,工作分支为 `agent/codex/T-011`。 +- 已读回 Issue `status/doing`、assignee、dispatcher 发布的结构化 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 仍为 waiting,当前没有活跃写路径冲突。 + ### 2026-08-07 Gitea 映射 - 任务规格先合入默认分支,再创建唯一主 Issue #39;本提交只回填双向映射,映射合入并读回前不领取任务。 From 0d3d7a22ed2d6812594af26170ddebffd0047d36 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 7 Aug 2026 20:39:59 +0800 Subject: [PATCH 2/3] chore(task): renew T-011 write paths --- docs/tasks/T-011.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tasks/T-011.md b/docs/tasks/T-011.md index 46de86a..2736374 100644 --- a/docs/tasks/T-011.md +++ b/docs/tasks/T-011.md @@ -26,6 +26,7 @@ write_paths: - Sense/internal/reconcile/ - Sense/internal/store/ - Sense/README.md + - docs/00-ai-start-here.md - docs/03-tech-stack.md - docs/04-architecture.md - docs/06-tasks.md @@ -99,6 +100,7 @@ T-008 已冻结 Sense Control API v1 的 7 个 endpoint,T-009/T-010 已提供 - dispatcher `ila` 将 Issue #39 分配给 `codex`;`context_ref` 为 `6402d4384d5c2b1c0f3852858c3cf0d63d948262`,claim 为 `claims/T-011`,工作分支为 `agent/codex/T-011`。 - 已读回 Issue `status/doing`、assignee、dispatcher 发布的结构化 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 仍为 waiting,当前没有活跃写路径冲突。 +- 实现中发现标准生成入口还需同步 `docs/00-ai-start-here.md`;dispatcher 复查无活跃冲突后发布完整 CLAIM RENEWAL,本任务据此增加该精确路径。 ### 2026-08-07 Gitea 映射 From a0d239811f17cfe7326df934ac70950e7d5d0ecc Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 7 Aug 2026 20:54:41 +0800 Subject: [PATCH 3/3] feat(sense): implement Control API v1 [T-011] --- Sense/README.md | 45 +- Sense/api/control-auth.example.json | 13 + Sense/cmd/sense-api/main.go | 24 +- Sense/internal/auth/auth.go | 181 +++ Sense/internal/auth/auth_test.go | 57 + Sense/internal/config/config.go | 81 +- Sense/internal/config/config_test.go | 45 +- Sense/internal/controlapi/generate.go | 5 + Sense/internal/controlapi/generated.gen.go | 1126 +++++++++++++++++ Sense/internal/controlapi/ids.go | 42 + Sense/internal/controlapi/mapping.go | 66 + Sense/internal/controlapi/oapi-codegen.yaml | 7 + Sense/internal/controlapi/security.go | 108 ++ Sense/internal/controlapi/security_test.go | 58 + Sense/internal/controlapi/server.go | 601 +++++++++ Sense/internal/controlapi/server_test.go | 272 ++++ Sense/internal/controlapi/validation.go | 153 +++ Sense/internal/controlapi/wire.go | 19 + Sense/internal/device/model.go | 2 + Sense/internal/mtx/client.go | 4 +- Sense/internal/reconcile/reconciler.go | 17 + Sense/internal/reconcile/reconciler_test.go | 30 + Sense/internal/store/control.go | 164 +++ Sense/internal/store/control_postgres.go | 889 +++++++++++++ Sense/internal/store/postgres.go | 58 +- Sense/internal/store/postgres_test.go | 367 +++++- Sense/internal/store/sqlite.go | 10 +- deploy/postgres/008_control_api.sql | 149 +++ .../postgres/009_privileges_control_api.sql | 11 + deploy/postgres/README.md | 7 +- deploy/postgres/tests/assertions.sql | 27 +- docs/00-ai-start-here.md | 2 +- docs/03-tech-stack.md | 6 +- docs/04-architecture.md | 2 +- docs/06-tasks.md | 2 +- docs/api.md | 6 +- docs/contracts/README.md | 9 +- .../sense-device-audit-v2.schema.json | 124 ++ docs/current-state.md | 6 +- docs/tasks/T-011.md | 11 +- init.ps1 | 2 +- init.sh | 2 +- scripts/test_postgres.ps1 | 14 +- tests/test_postgres_contract.py | 4 +- tests/test_sense_control_implementation.py | 99 ++ 45 files changed, 4844 insertions(+), 83 deletions(-) create mode 100644 Sense/api/control-auth.example.json create mode 100644 Sense/internal/auth/auth.go create mode 100644 Sense/internal/auth/auth_test.go create mode 100644 Sense/internal/controlapi/generate.go create mode 100644 Sense/internal/controlapi/generated.gen.go create mode 100644 Sense/internal/controlapi/ids.go create mode 100644 Sense/internal/controlapi/mapping.go create mode 100644 Sense/internal/controlapi/oapi-codegen.yaml create mode 100644 Sense/internal/controlapi/security.go create mode 100644 Sense/internal/controlapi/security_test.go create mode 100644 Sense/internal/controlapi/server.go create mode 100644 Sense/internal/controlapi/server_test.go create mode 100644 Sense/internal/controlapi/validation.go create mode 100644 Sense/internal/controlapi/wire.go create mode 100644 Sense/internal/store/control.go create mode 100644 Sense/internal/store/control_postgres.go create mode 100644 deploy/postgres/008_control_api.sql create mode 100644 deploy/postgres/009_privileges_control_api.sql create mode 100644 docs/contracts/sense-device-audit-v2.schema.json create mode 100644 tests/test_sense_control_implementation.py diff --git a/Sense/README.md b/Sense/README.md index 1647822..5f45216 100644 --- a/Sense/README.md +++ b/Sense/README.md @@ -1,20 +1,20 @@ # Sense M1/M2 接入骨架 -本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009/T-010 增加 PostgreSQL 双 schema、Area 准入和本地审计 Outbox 生产基础。默认关闭真实 ONVIF,显式设置 `SENSE_ONVIF_MODE=standard` 后才启用标准 SOAP/WS-Security 适配器。T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。 +本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-011 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox 和 Control API v1。默认关闭真实 ONVIF 与公共业务路由;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。 ## 常用命令 ```powershell cd Sense go mod download -go generate ./internal/mtx +go generate ./internal/mtx ./internal/controlapi go test ./... go vet ./... go build -o bin/sense-api.exe ./cmd/sense-api go run ./cmd/sense-api ``` -Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。当前运行代码只有 `/healthz` 与 `/readyz`;Sense Control API v1 虽已冻结,但 HTTP handler 和认证尚未实现。 +Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行只暴露 `/healthz` 与 `/readyz`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由。 常用环境变量: @@ -31,6 +31,11 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080` | `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 | | `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 | | `SENSE_ONVIF_RTSP_STRIP_QUERY` | `false` | 仅在已验证设备返回不可用查询串时显式移除;默认保留标准 URI 语义 | +| `SENSE_CONTROL_API_ENABLED` | `false` | 显式开启 Control API v1;只允许与 PostgreSQL 一起使用 | +| `SENSE_CONTROL_AUTH_MODE` | `static-sha256` | 首版外部摘要注册表适配器;token 格式不属于公共 API 契约 | +| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 | +| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url | +| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS | 设备台账只保存 `env://` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误: @@ -45,11 +50,11 @@ SENSE_CREDENTIAL__RTSP_PASSWORD MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。 -## T-009/T-010 PostgreSQL 17.10 +## T-009~T-011 PostgreSQL 17.10 -初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取 `bell.site_quota_v1` 和 `bell.area_policy_v1`,不能读取或写入 Bell 源表。应用登录角色和密码由部署环境创建,不进入仓库。 +初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取两个视图,不能读取或写入 Bell 源表。T-011 的 v4 schema 另有资源版本、24 小时幂等收据和 batch operation 表;收据只保存 scope/body 摘要与脱敏响应快照。应用登录角色和密码由部署环境创建,不进入仓库。 -PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。设备创建和期望态受理与 `sense.device_operation_outbox` 在同一事务写入;Outbox 不保存 endpoint、credential、完整 URI 或 MediaMTX 配置。Bell relay 尚未实现。 +PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建、移动 Area 和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。创建、配置修改和期望态受理都与对应脱敏 Outbox 事实同事务;停用后调和器只删除该设备的精确 MediaMTX path 并收敛为 offline,不枚举未知 path。Bell relay 尚未实现。 Windows 本机集成测试从仓库根目录执行: @@ -67,7 +72,33 @@ $env:SENSE_DB_DSN = '由部署环境私下设置' go run ./cmd/sense-api ``` -PostgreSQL 启动会检查 Sense migration 版本及当前角色对两个 Bell 投影视图的最小权限;权限过宽、视图不可读或 schema 未安装时 readiness 初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox 一致性,后续公共控制 API 不得在 SQLite 上启用。 +PostgreSQL 启动会检查 Sense migration 版本及当前角色对两个 Bell 投影视图的最小权限;权限过宽、视图不可读或 schema 未安装时 readiness 初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox 一致性,Control API feature flag 在 SQLite 下会直接拒绝启动。 + +### 开启 Control API + +复制 [`api/control-auth.example.json`](api/control-auth.example.json) 到仓库外受限目录并替换占位项。`token_sha256` 是至少 128 bit 随机 Bearer token 的 64 位小写 SHA-256,不是 token 明文;`site_ids` 支持精确 ID 或 `"*"`,权限只接受 `sense.devices.read`、`sense.devices.write`。注册表在启动时读取,轮换后需要受控重启。 + +另在仓库外生成 cursor key。PowerShell 示例只把 key 写入指定秘密文件,不把值打印到日志: + +```powershell +$cursorBytes = [byte[]]::new(32) +[Security.Cryptography.RandomNumberGenerator]::Fill($cursorBytes) +$cursorKey = [Convert]::ToBase64String($cursorBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +Set-Content -LiteralPath 'D:\private\sense-cursor.key' -Value $cursorKey -NoNewline +``` + +完成 PostgreSQL migration、专用登录角色和外部文件权限后,以私有环境开启: + +```powershell +$env:SENSE_DB_DRIVER = 'postgres' +$env:SENSE_DB_DSN = '由部署环境私下设置' +$env:SENSE_CONTROL_API_ENABLED = 'true' +$env:SENSE_CONTROL_AUTH_FILE = 'D:\private\sense-auth.json' +$env:SENSE_CONTROL_CURSOR_KEY_FILE = 'D:\private\sense-cursor.key' +go run ./cmd/sense-api +``` + +业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。 Windows 本地准备 MediaMTX(从仓库根目录执行): diff --git a/Sense/api/control-auth.example.json b/Sense/api/control-auth.example.json new file mode 100644 index 0000000..1725498 --- /dev/null +++ b/Sense/api/control-auth.example.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "principals": [ + { + "token_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "subject_id": "replace-with-service-id", + "actor_type": "service", + "tenant_id": "tenant-demo", + "site_ids": ["site-demo"], + "permissions": ["sense.devices.read", "sense.devices.write"] + } + ] +} diff --git a/Sense/cmd/sense-api/main.go b/Sense/cmd/sense-api/main.go index 7196feb..6a8ce20 100644 --- a/Sense/cmd/sense-api/main.go +++ b/Sense/cmd/sense-api/main.go @@ -12,7 +12,9 @@ import ( "syscall" "time" + "yovision/sense/internal/auth" "yovision/sense/internal/config" + "yovision/sense/internal/controlapi" "yovision/sense/internal/mtx" "yovision/sense/internal/onvif" "yovision/sense/internal/probe" @@ -43,6 +45,22 @@ func run(logger *slog.Logger) error { return err } defer repository.Close() + var controlHandler http.Handler + if cfg.ControlAPIEnabled { + controlStore, ok := repository.(store.ControlRepository) + if !ok { + return errors.New("selected repository does not support Sense Control API") + } + authenticator, err := auth.LoadStaticSHA256(cfg.ControlAuthFile) + if err != nil { + return err + } + cursors, err := controlapi.LoadCursorCodec(cfg.ControlCursorKeyFile) + if err != nil { + return err + } + controlHandler = controlapi.NewHTTPHandler(controlStore, authenticator, cursors) + } mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil) if err != nil { return err @@ -86,6 +104,9 @@ func run(logger *slog.Logger) error { writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte(`{"status":"ready"}`)) }) + if cfg.ControlAPIEnabled { + mux.Handle("/api/v1/", controlHandler) + } server := &http.Server{ Addr: cfg.HTTPAddress, Handler: mux, @@ -96,7 +117,8 @@ func run(logger *slog.Logger) error { } serverErrors := make(chan error, 1) go func() { - logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version) + logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version, + "control_api_enabled", cfg.ControlAPIEnabled) serverErrors <- server.ListenAndServe() }() diff --git a/Sense/internal/auth/auth.go b/Sense/internal/auth/auth.go new file mode 100644 index 0000000..5e382ce --- /dev/null +++ b/Sense/internal/auth/auth.go @@ -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 +} diff --git a/Sense/internal/auth/auth_test.go b/Sense/internal/auth/auth_test.go new file mode 100644 index 0000000..9193dab --- /dev/null +++ b/Sense/internal/auth/auth_test.go @@ -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) + } + } +} diff --git a/Sense/internal/config/config.go b/Sense/internal/config/config.go index 0ae54ac..860a323 100644 --- a/Sense/internal/config/config.go +++ b/Sense/internal/config/config.go @@ -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 } diff --git a/Sense/internal/config/config_test.go b/Sense/internal/config/config_test.go index 1c60ade..9555a15 100644 --- a/Sense/internal/config/config_test.go +++ b/Sense/internal/config/config_test.go @@ -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) + } +} diff --git a/Sense/internal/controlapi/generate.go b/Sense/internal/controlapi/generate.go new file mode 100644 index 0000000..9bc8445 --- /dev/null +++ b/Sense/internal/controlapi/generate.go @@ -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 diff --git a/Sense/internal/controlapi/generated.gen.go b/Sense/internal/controlapi/generated.gen.go new file mode 100644 index 0000000..5585ec4 --- /dev/null +++ b/Sense/internal/controlapi/generated.gen.go @@ -0,0 +1,1126 @@ +//go:build go1.22 + +// Package controlapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package controlapi + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/oapi-codegen/runtime" +) + +// Defines values for ActualState. +const ( + ActualStateFailed ActualState = "failed" + ActualStateOffline ActualState = "offline" + ActualStateOnline ActualState = "online" + ActualStatePending ActualState = "pending" +) + +// Valid indicates whether the value is a known member of the ActualState enum. +func (e ActualState) Valid() bool { + switch e { + case ActualStateFailed: + return true + case ActualStateOffline: + return true + case ActualStateOnline: + return true + case ActualStatePending: + return true + default: + return false + } +} + +// Defines values for AdapterStatus. +const ( + AdapterStatusAdapterNotReady AdapterStatus = "adapter_not_ready" + AdapterStatusAuthenticationFailed AdapterStatus = "authentication_failed" + AdapterStatusPending AdapterStatus = "pending" + AdapterStatusReady AdapterStatus = "ready" + AdapterStatusUnavailable AdapterStatus = "unavailable" +) + +// Valid indicates whether the value is a known member of the AdapterStatus enum. +func (e AdapterStatus) Valid() bool { + switch e { + case AdapterStatusAdapterNotReady: + return true + case AdapterStatusAuthenticationFailed: + return true + case AdapterStatusPending: + return true + case AdapterStatusReady: + return true + case AdapterStatusUnavailable: + return true + default: + return false + } +} + +// Defines values for BatchItemResultStatus. +const ( + BatchItemResultStatusAccepted BatchItemResultStatus = "accepted" + BatchItemResultStatusFailed BatchItemResultStatus = "failed" + BatchItemResultStatusRejected BatchItemResultStatus = "rejected" + BatchItemResultStatusSucceeded BatchItemResultStatus = "succeeded" +) + +// Valid indicates whether the value is a known member of the BatchItemResultStatus enum. +func (e BatchItemResultStatus) Valid() bool { + switch e { + case BatchItemResultStatusAccepted: + return true + case BatchItemResultStatusFailed: + return true + case BatchItemResultStatusRejected: + return true + case BatchItemResultStatusSucceeded: + return true + default: + return false + } +} + +// Defines values for BatchOperationStatus. +const ( + BatchOperationStatusFailed BatchOperationStatus = "failed" + BatchOperationStatusPartiallySucceeded BatchOperationStatus = "partially_succeeded" + BatchOperationStatusQueued BatchOperationStatus = "queued" + BatchOperationStatusRunning BatchOperationStatus = "running" + BatchOperationStatusSucceeded BatchOperationStatus = "succeeded" +) + +// Valid indicates whether the value is a known member of the BatchOperationStatus enum. +func (e BatchOperationStatus) Valid() bool { + switch e { + case BatchOperationStatusFailed: + return true + case BatchOperationStatusPartiallySucceeded: + return true + case BatchOperationStatusQueued: + return true + case BatchOperationStatusRunning: + return true + case BatchOperationStatusSucceeded: + return true + default: + return false + } +} + +// Defines values for Capability. +const ( + AudioCapture Capability = "audio_capture" + SpatialRule Capability = "spatial_rule" + Telemetry Capability = "telemetry" + VideoCapture Capability = "video_capture" +) + +// Valid indicates whether the value is a known member of the Capability enum. +func (e Capability) Valid() bool { + switch e { + case AudioCapture: + return true + case SpatialRule: + return true + case Telemetry: + return true + case VideoCapture: + return true + default: + return false + } +} + +// Defines values for DesiredState. +const ( + Disabled DesiredState = "disabled" + Enabled DesiredState = "enabled" +) + +// Valid indicates whether the value is a known member of the DesiredState enum. +func (e DesiredState) Valid() bool { + switch e { + case Disabled: + return true + case Enabled: + return true + default: + return false + } +} + +// Defines values for ErrorCode. +const ( + ErrorCodeAdapterNotReady ErrorCode = "adapter_not_ready" + ErrorCodeAreaPolicyDenied ErrorCode = "area_policy_denied" + ErrorCodeAreaPolicyUnavailable ErrorCode = "area_policy_unavailable" + ErrorCodeAuthenticationFailed ErrorCode = "authentication_failed" + ErrorCodeBatchTooLarge ErrorCode = "batch_too_large" + ErrorCodeConflict ErrorCode = "conflict" + ErrorCodeDuplicateSerialNumber ErrorCode = "duplicate_serial_number" + ErrorCodeEndpointCredentialsForbidden ErrorCode = "endpoint_credentials_forbidden" + ErrorCodeEtagMismatch ErrorCode = "etag_mismatch" + ErrorCodeForbidden ErrorCode = "forbidden" + ErrorCodeIdempotencyConflict ErrorCode = "idempotency_conflict" + ErrorCodeInternalError ErrorCode = "internal_error" + ErrorCodeInvalidRequest ErrorCode = "invalid_request" + ErrorCodeNotFound ErrorCode = "not_found" + ErrorCodePreconditionRequired ErrorCode = "precondition_required" + ErrorCodeQuotaExceeded ErrorCode = "quota_exceeded" + ErrorCodeQuotaProjectionInvalid ErrorCode = "quota_projection_invalid" + ErrorCodeQuotaProjectionUnavailable ErrorCode = "quota_projection_unavailable" + ErrorCodeServiceUnavailable ErrorCode = "service_unavailable" + ErrorCodeUnauthenticated ErrorCode = "unauthenticated" +) + +// Valid indicates whether the value is a known member of the ErrorCode enum. +func (e ErrorCode) Valid() bool { + switch e { + case ErrorCodeAdapterNotReady: + return true + case ErrorCodeAreaPolicyDenied: + return true + case ErrorCodeAreaPolicyUnavailable: + return true + case ErrorCodeAuthenticationFailed: + return true + case ErrorCodeBatchTooLarge: + return true + case ErrorCodeConflict: + return true + case ErrorCodeDuplicateSerialNumber: + return true + case ErrorCodeEndpointCredentialsForbidden: + return true + case ErrorCodeEtagMismatch: + return true + case ErrorCodeForbidden: + return true + case ErrorCodeIdempotencyConflict: + return true + case ErrorCodeInternalError: + return true + case ErrorCodeInvalidRequest: + return true + case ErrorCodeNotFound: + return true + case ErrorCodePreconditionRequired: + return true + case ErrorCodeQuotaExceeded: + return true + case ErrorCodeQuotaProjectionInvalid: + return true + case ErrorCodeQuotaProjectionUnavailable: + return true + case ErrorCodeServiceUnavailable: + return true + case ErrorCodeUnauthenticated: + return true + default: + return false + } +} + +// Defines values for Modality. +const ( + Button Modality = "button" + Contact Modality = "contact" + Other Modality = "other" + Radar Modality = "radar" + Video Modality = "video" + Wearable Modality = "wearable" +) + +// Valid indicates whether the value is a known member of the Modality enum. +func (e Modality) Valid() bool { + switch e { + case Button: + return true + case Contact: + return true + case Other: + return true + case Radar: + return true + case Video: + return true + case Wearable: + return true + default: + return false + } +} + +// Defines values for SiteQuotaStatusStatus. +const ( + SiteQuotaStatusStatusCurrent SiteQuotaStatusStatus = "current" + SiteQuotaStatusStatusInvalid SiteQuotaStatusStatus = "invalid" + SiteQuotaStatusStatusUnavailable SiteQuotaStatusStatus = "unavailable" +) + +// Valid indicates whether the value is a known member of the SiteQuotaStatusStatus enum. +func (e SiteQuotaStatusStatus) Valid() bool { + switch e { + case SiteQuotaStatusStatusCurrent: + return true + case SiteQuotaStatusStatusInvalid: + return true + case SiteQuotaStatusStatusUnavailable: + return true + default: + return false + } +} + +// ActualState defines model for ActualState. +type ActualState string + +// AdapterStatus defines model for AdapterStatus. +type AdapterStatus string + +// BatchDesiredStateItem defines model for BatchDesiredStateItem. +type BatchDesiredStateItem struct { + DesiredState DesiredState `json:"desired_state"` + DeviceId LogicalID `json:"device_id"` + Etag string `json:"etag"` +} + +// BatchDesiredStateRequest defines model for BatchDesiredStateRequest. +type BatchDesiredStateRequest struct { + Items []BatchDesiredStateItem `json:"items"` + Reason string `json:"reason"` +} + +// BatchItemResult defines model for BatchItemResult. +type BatchItemResult struct { + DeviceId LogicalID `json:"device_id"` + ErrorCode *ErrorCode `json:"error_code"` + Generation *int64 `json:"generation"` + Message *string `json:"message"` + Status BatchItemResultStatus `json:"status"` +} + +// BatchItemResultStatus defines model for BatchItemResult.Status. +type BatchItemResultStatus string + +// BatchOperation defines model for BatchOperation. +type BatchOperation struct { + CompletedAt *time.Time `json:"completed_at"` + Id string `json:"id"` + Results []BatchItemResult `json:"results"` + Status BatchOperationStatus `json:"status"` + SubmittedAt time.Time `json:"submitted_at"` + TraceId string `json:"trace_id"` +} + +// BatchOperationStatus defines model for BatchOperation.Status. +type BatchOperationStatus string + +// Capability defines model for Capability. +type Capability string + +// DesiredState defines model for DesiredState. +type DesiredState string + +// DesiredStateChange defines model for DesiredStateChange. +type DesiredStateChange struct { + DesiredState DesiredState `json:"desired_state"` + Reason string `json:"reason"` +} + +// Device defines model for Device. +type Device struct { + ActualState ActualState `json:"actual_state"` + AdapterStatus AdapterStatus `json:"adapter_status"` + AreaId LogicalID `json:"area_id"` + Capabilities *[]Capability `json:"capabilities,omitempty"` + + // Converged 期望代已被观察且当前无待重试;不等价于所有外部系统健康。 + Converged *bool `json:"converged,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + CredentialConfigured *bool `json:"credential_configured,omitempty"` + DesiredState DesiredState `json:"desired_state"` + EndpointConfigured *bool `json:"endpoint_configured,omitempty"` + FailureCount *int `json:"failure_count,omitempty"` + Generation *int64 `json:"generation,omitempty"` + Id LogicalID `json:"id"` + LastErrorCode *string `json:"last_error_code,omitempty"` + Modality Modality `json:"modality"` + Name string `json:"name"` + NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` + ObservedGeneration *int64 `json:"observed_generation,omitempty"` + ProjectionVersions ProjectionVersions `json:"projection_versions"` + SerialNumber string `json:"serial_number"` + SiteId LogicalID `json:"site_id"` + + // TenantId 只用于回显已授权上下文,不得由客户端写入。 + TenantId *LogicalID `json:"tenant_id,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// DeviceCreate defines model for DeviceCreate. +type DeviceCreate struct { + AreaId LogicalID `json:"area_id"` + + // Capabilities 请求接入的能力集合;适配器探测不支持时以稳定错误拒绝或标记 adapter_not_ready。 + Capabilities []Capability `json:"capabilities"` + + // CredentialRef 不透明密钥引用,不是用户名、密码或 token。 + CredentialRef *string `json:"credential_ref,omitempty"` + DesiredState *DesiredState `json:"desired_state,omitempty"` + + // EndpointRef 绝对 URI,禁止 userinfo;只在受控写路径使用。 + EndpointRef *string `json:"endpoint_ref,omitempty"` + Modality Modality `json:"modality"` + Name string `json:"name"` + ProfileToken *string `json:"profile_token,omitempty"` + SerialNumber string `json:"serial_number"` +} + +// DevicePage defines model for DevicePage. +type DevicePage struct { + Items []Device `json:"items"` + Page PageInfo `json:"page"` + Quota SiteQuotaStatus `json:"quota"` +} + +// DevicePatch defines model for DevicePatch. +type DevicePatch struct { + AreaId *LogicalID `json:"area_id,omitempty"` + CredentialRef *string `json:"credential_ref,omitempty"` + + // EndpointRef 绝对 URI,禁止 userinfo。 + EndpointRef *string `json:"endpoint_ref,omitempty"` + Name *string `json:"name,omitempty"` + ProfileToken *string `json:"profile_token,omitempty"` +} + +// ErrorCode defines model for ErrorCode. +type ErrorCode string + +// FieldError defines model for FieldError. +type FieldError struct { + Code string `json:"code"` + Field string `json:"field"` + Message string `json:"message"` +} + +// LogicalID defines model for LogicalID. +type LogicalID = string + +// Modality defines model for Modality. +type Modality string + +// MutationReceipt defines model for MutationReceipt. +type MutationReceipt struct { + AcceptedAt time.Time `json:"accepted_at"` + Device Device `json:"device"` + TraceId string `json:"trace_id"` +} + +// PageInfo defines model for PageInfo. +type PageInfo struct { + HasMore bool `json:"has_more"` + Limit int `json:"limit"` + NextCursor *string `json:"next_cursor"` +} + +// Problem defines model for Problem. +type Problem struct { + Code ErrorCode `json:"code"` + FieldErrors []FieldError `json:"field_errors"` + Message string `json:"message"` + Status int `json:"status"` + Title string `json:"title"` + TraceId string `json:"trace_id"` + Type string `json:"type"` +} + +// ProjectionVersions defines model for ProjectionVersions. +type ProjectionVersions struct { + AreaPolicySourceVersion *int64 `json:"area_policy_source_version,omitempty"` + QuotaSourceVersion *int64 `json:"quota_source_version,omitempty"` + SyncedAt *time.Time `json:"synced_at,omitempty"` +} + +// SiteQuotaStatus defines model for SiteQuotaStatus. +type SiteQuotaStatus struct { + AvailableVideoChannels *int `json:"available_video_channels"` + MaxVideoChannels *int `json:"max_video_channels"` + OverLimit bool `json:"over_limit"` + SourceVersion *int64 `json:"source_version"` + Status SiteQuotaStatusStatus `json:"status"` + SyncedAt *time.Time `json:"synced_at"` + UsedVideoChannels int `json:"used_video_channels"` +} + +// SiteQuotaStatusStatus defines model for SiteQuotaStatus.Status. +type SiteQuotaStatusStatus string + +// ActualStateFilter defines model for ActualStateFilter. +type ActualStateFilter = ActualState + +// CapabilityFilter defines model for CapabilityFilter. +type CapabilityFilter = Capability + +// Cursor defines model for Cursor. +type Cursor = string + +// DesiredStateFilter defines model for DesiredStateFilter. +type DesiredStateFilter = DesiredState + +// DeviceID defines model for DeviceID. +type DeviceID = LogicalID + +// IdempotencyKey defines model for IdempotencyKey. +type IdempotencyKey = string + +// IfMatch defines model for IfMatch. +type IfMatch = string + +// Limit defines model for Limit. +type Limit = int + +// ModalityFilter defines model for ModalityFilter. +type ModalityFilter = Modality + +// OperationID defines model for OperationID. +type OperationID = string + +// SiteID defines model for SiteID. +type SiteID = LogicalID + +// AcceptedMutation defines model for AcceptedMutation. +type AcceptedMutation = MutationReceipt + +// CreatedDevice defines model for CreatedDevice. +type CreatedDevice = Device + +// CurrentDevice defines model for CurrentDevice. +type CurrentDevice = Device + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse = Problem + +// ListDevicesParams defines parameters for ListDevices. +type ListDevicesParams struct { + // Cursor 不透明游标;客户端不得解析或构造 + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + Modality *ModalityFilter `form:"modality,omitempty" json:"modality,omitempty"` + Capability *CapabilityFilter `form:"capability,omitempty" json:"capability,omitempty"` + DesiredState *DesiredStateFilter `form:"desired_state,omitempty" json:"desired_state,omitempty"` + ActualState *ActualStateFilter `form:"actual_state,omitempty" json:"actual_state,omitempty"` +} + +// CreateDeviceParams defines parameters for CreateDevice. +type CreateDeviceParams struct { + // IdempotencyKey 同一认证主体、站点、operationId、key 和规范化 body 在 24 小时内返回同一结果;key 相同而 body 不同返回 idempotency_conflict。 + IdempotencyKey IdempotencyKey `json:"Idempotency-Key"` +} + +// UpdateDeviceParams defines parameters for UpdateDevice. +type UpdateDeviceParams struct { + // IfMatch 使用最近一次 GET/写响应的 opaque ETag;不匹配返回 412 etag_mismatch。 + IfMatch IfMatch `json:"If-Match"` +} + +// SetDeviceDesiredStateParams defines parameters for SetDeviceDesiredState. +type SetDeviceDesiredStateParams struct { + // IfMatch 使用最近一次 GET/写响应的 opaque ETag;不匹配返回 412 etag_mismatch。 + IfMatch IfMatch `json:"If-Match"` +} + +// BatchSetDeviceDesiredStateParams defines parameters for BatchSetDeviceDesiredState. +type BatchSetDeviceDesiredStateParams struct { + // IdempotencyKey 同一认证主体、站点、operationId、key 和规范化 body 在 24 小时内返回同一结果;key 相同而 body 不同返回 idempotency_conflict。 + IdempotencyKey IdempotencyKey `json:"Idempotency-Key"` +} + +// CreateDeviceJSONRequestBody defines body for CreateDevice for application/json ContentType. +type CreateDeviceJSONRequestBody = DeviceCreate + +// UpdateDeviceApplicationMergePatchPlusJSONRequestBody defines body for UpdateDevice for application/merge-patch+json ContentType. +type UpdateDeviceApplicationMergePatchPlusJSONRequestBody = DevicePatch + +// SetDeviceDesiredStateJSONRequestBody defines body for SetDeviceDesiredState for application/json ContentType. +type SetDeviceDesiredStateJSONRequestBody = DesiredStateChange + +// BatchSetDeviceDesiredStateJSONRequestBody defines body for BatchSetDeviceDesiredState for application/json ContentType. +type BatchSetDeviceDesiredStateJSONRequestBody = BatchDesiredStateRequest + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // GetOperation 查询批量写入和逐项结果 + // (GET /api/v1/operations/{operation_id}) + GetOperation(w http.ResponseWriter, r *http.Request, operationId OperationID) + // ListDevices 分页查询当前认证租户下的站点设备 + // (GET /api/v1/sites/{site_id}/devices) + ListDevices(w http.ResponseWriter, r *http.Request, siteId SiteID, params ListDevicesParams) + // CreateDevice 创建待探测设备 + // (POST /api/v1/sites/{site_id}/devices) + CreateDevice(w http.ResponseWriter, r *http.Request, siteId SiteID, params CreateDeviceParams) + // GetDevice 读取设备期望态、实际态和收敛进度 + // (GET /api/v1/sites/{site_id}/devices/{device_id}) + GetDevice(w http.ResponseWriter, r *http.Request, siteId SiteID, deviceId DeviceID) + // UpdateDevice 按 ETag 修改设备可编辑配置 + // (PATCH /api/v1/sites/{site_id}/devices/{device_id}) + UpdateDevice(w http.ResponseWriter, r *http.Request, siteId SiteID, deviceId DeviceID, params UpdateDeviceParams) + // SetDeviceDesiredState 启用或停用设备接入期望态 + // (PUT /api/v1/sites/{site_id}/devices/{device_id}/desired-state) + SetDeviceDesiredState(w http.ResponseWriter, r *http.Request, siteId SiteID, deviceId DeviceID, params SetDeviceDesiredStateParams) + // BatchSetDeviceDesiredState 批量提交最多 128 个设备期望态变更 + // (POST /api/v1/sites/{site_id}/devices:batchDesiredState) + BatchSetDeviceDesiredState(w http.ResponseWriter, r *http.Request, siteId SiteID, params BatchSetDeviceDesiredStateParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetOperation operation middleware +func (siw *ServerInterfaceWrapper) GetOperation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "operation_id" ------------- + var operationId OperationID + + err = runtime.BindStyledParameterWithOptions("simple", "operation_id", r.PathValue("operation_id"), &operationId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "operation_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetOperation(w, r, operationId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListDevices operation middleware +func (siw *ServerInterfaceWrapper) ListDevices(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "site_id" ------------- + var siteId SiteID + + err = runtime.BindStyledParameterWithOptions("simple", "site_id", r.PathValue("site_id"), &siteId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "site_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ListDevicesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + // ------------- Optional query parameter "modality" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "modality", r.URL.Query(), ¶ms.Modality, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "modality"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "modality", Err: err}) + } + return + } + + // ------------- Optional query parameter "capability" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "capability", r.URL.Query(), ¶ms.Capability, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "capability"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "capability", Err: err}) + } + return + } + + // ------------- Optional query parameter "desired_state" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "desired_state", r.URL.Query(), ¶ms.DesiredState, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "desired_state"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "desired_state", Err: err}) + } + return + } + + // ------------- Optional query parameter "actual_state" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "actual_state", r.URL.Query(), ¶ms.ActualState, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "actual_state"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "actual_state", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDevices(w, r, siteId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateDevice operation middleware +func (siw *ServerInterfaceWrapper) CreateDevice(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "site_id" ------------- + var siteId SiteID + + err = runtime.BindStyledParameterWithOptions("simple", "site_id", r.PathValue("site_id"), &siteId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "site_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params CreateDeviceParams + + headers := r.Header + + // ------------- Required header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKey + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = IdempotencyKey + + } else { + err := fmt.Errorf("Header parameter Idempotency-Key is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "Idempotency-Key", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateDevice(w, r, siteId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetDevice operation middleware +func (siw *ServerInterfaceWrapper) GetDevice(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "site_id" ------------- + var siteId SiteID + + err = runtime.BindStyledParameterWithOptions("simple", "site_id", r.PathValue("site_id"), &siteId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "site_id", Err: err}) + return + } + + // ------------- Path parameter "device_id" ------------- + var deviceId DeviceID + + err = runtime.BindStyledParameterWithOptions("simple", "device_id", r.PathValue("device_id"), &deviceId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "device_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetDevice(w, r, siteId, deviceId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateDevice operation middleware +func (siw *ServerInterfaceWrapper) UpdateDevice(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "site_id" ------------- + var siteId SiteID + + err = runtime.BindStyledParameterWithOptions("simple", "site_id", r.PathValue("site_id"), &siteId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "site_id", Err: err}) + return + } + + // ------------- Path parameter "device_id" ------------- + var deviceId DeviceID + + err = runtime.BindStyledParameterWithOptions("simple", "device_id", r.PathValue("device_id"), &deviceId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "device_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params UpdateDeviceParams + + headers := r.Header + + // ------------- Required header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = IfMatch + + } else { + err := fmt.Errorf("Header parameter If-Match is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "If-Match", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateDevice(w, r, siteId, deviceId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SetDeviceDesiredState operation middleware +func (siw *ServerInterfaceWrapper) SetDeviceDesiredState(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "site_id" ------------- + var siteId SiteID + + err = runtime.BindStyledParameterWithOptions("simple", "site_id", r.PathValue("site_id"), &siteId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "site_id", Err: err}) + return + } + + // ------------- Path parameter "device_id" ------------- + var deviceId DeviceID + + err = runtime.BindStyledParameterWithOptions("simple", "device_id", r.PathValue("device_id"), &deviceId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "device_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params SetDeviceDesiredStateParams + + headers := r.Header + + // ------------- Required header parameter "If-Match" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("If-Match")]; found { + var IfMatch IfMatch + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "If-Match", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "If-Match", valueList[0], &IfMatch, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "If-Match", Err: err}) + return + } + + params.IfMatch = IfMatch + + } else { + err := fmt.Errorf("Header parameter If-Match is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "If-Match", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetDeviceDesiredState(w, r, siteId, deviceId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// BatchSetDeviceDesiredState operation middleware +func (siw *ServerInterfaceWrapper) BatchSetDeviceDesiredState(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "site_id" ------------- + var siteId SiteID + + err = runtime.BindStyledParameterWithOptions("simple", "site_id", r.PathValue("site_id"), &siteId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "site_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params BatchSetDeviceDesiredStateParams + + headers := r.Header + + // ------------- Required header parameter "Idempotency-Key" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Idempotency-Key")]; found { + var IdempotencyKey IdempotencyKey + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Idempotency-Key", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Idempotency-Key", valueList[0], &IdempotencyKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Idempotency-Key", Err: err}) + return + } + + params.IdempotencyKey = IdempotencyKey + + } else { + err := fmt.Errorf("Header parameter Idempotency-Key is required, but not found") + siw.ErrorHandlerFunc(w, r, &RequiredHeaderError{ParamName: "Idempotency-Key", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.BatchSetDeviceDesiredState(w, r, siteId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/v1/operations/{operation_id}", wrapper.GetOperation) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/v1/sites/{site_id}/devices", wrapper.ListDevices) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/v1/sites/{site_id}/devices", wrapper.CreateDevice) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/v1/sites/{site_id}/devices/{device_id}", wrapper.GetDevice) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/api/v1/sites/{site_id}/devices/{device_id}", wrapper.UpdateDevice) + m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/v1/sites/{site_id}/devices/{device_id}/desired-state", wrapper.SetDeviceDesiredState) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/v1/sites/{site_id}/devices:batchDesiredState", wrapper.BatchSetDeviceDesiredState) + + return m +} diff --git a/Sense/internal/controlapi/ids.go b/Sense/internal/controlapi/ids.go new file mode 100644 index 0000000..069d982 --- /dev/null +++ b/Sense/internal/controlapi/ids.go @@ -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 +} diff --git a/Sense/internal/controlapi/mapping.go b/Sense/internal/controlapi/mapping.go new file mode 100644 index 0000000..7f5104d --- /dev/null +++ b/Sense/internal/controlapi/mapping.go @@ -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, + } +} diff --git a/Sense/internal/controlapi/oapi-codegen.yaml b/Sense/internal/controlapi/oapi-codegen.yaml new file mode 100644 index 0000000..d9c79fb --- /dev/null +++ b/Sense/internal/controlapi/oapi-codegen.yaml @@ -0,0 +1,7 @@ +package: controlapi +output: generated.gen.go +generate: + models: true + std-http-server: true +output-options: + skip-prune: false diff --git a/Sense/internal/controlapi/security.go b/Sense/internal/controlapi/security.go new file mode 100644 index 0000000..aca782d --- /dev/null +++ b/Sense/internal/controlapi/security.go @@ -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)) +} diff --git a/Sense/internal/controlapi/security_test.go b/Sense/internal/controlapi/security_test.go new file mode 100644 index 0000000..d2238a8 --- /dev/null +++ b/Sense/internal/controlapi/security_test.go @@ -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) + } +} diff --git a/Sense/internal/controlapi/server.go b/Sense/internal/controlapi/server.go new file mode 100644 index 0000000..b7b2d6a --- /dev/null +++ b/Sense/internal/controlapi/server.go @@ -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, "aError) { + 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) diff --git a/Sense/internal/controlapi/server_test.go b/Sense/internal/controlapi/server_test.go new file mode 100644 index 0000000..d438d99 --- /dev/null +++ b/Sense/internal/controlapi/server_test.go @@ -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: "aVersion, 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) + } + } +} diff --git a/Sense/internal/controlapi/validation.go b/Sense/internal/controlapi/validation.go new file mode 100644 index 0000000..3737f7c --- /dev/null +++ b/Sense/internal/controlapi/validation.go @@ -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, ",") +} diff --git a/Sense/internal/controlapi/wire.go b/Sense/internal/controlapi/wire.go new file mode 100644 index 0000000..bcc65f9 --- /dev/null +++ b/Sense/internal/controlapi/wire.go @@ -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, + }) +} diff --git a/Sense/internal/device/model.go b/Sense/internal/device/model.go index 5cc9266..d70a2f4 100644 --- a/Sense/internal/device/model.go +++ b/Sense/internal/device/model.go @@ -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 diff --git a/Sense/internal/mtx/client.go b/Sense/internal/mtx/client.go index 4505168..9e683f0 100644 --- a/Sense/internal/mtx/client.go +++ b/Sense/internal/mtx/client.go @@ -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()} diff --git a/Sense/internal/reconcile/reconciler.go b/Sense/internal/reconcile/reconciler.go index 22d79e4..8af902b 100644 --- a/Sense/internal/reconcile/reconciler.go +++ b/Sense/internal/reconcile/reconciler.go @@ -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)) diff --git a/Sense/internal/reconcile/reconciler_test.go b/Sense/internal/reconcile/reconciler_test.go index 4efa0f0..a06abad 100644 --- a/Sense/internal/reconcile/reconciler_test.go +++ b/Sense/internal/reconcile/reconciler_test.go @@ -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)) diff --git a/Sense/internal/store/control.go b/Sense/internal/store/control.go new file mode 100644 index 0000000..de7affe --- /dev/null +++ b/Sense/internal/store/control.go @@ -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]) + `"` +} diff --git a/Sense/internal/store/control_postgres.go b/Sense/internal/store/control_postgres.go new file mode 100644 index 0000000..8457351 --- /dev/null +++ b/Sense/internal/store/control_postgres.go @@ -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, "aVersion, &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, "aVersion, &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, ¤t, &endpointRef, &pathName, &generation, &resourceVersion, + "aVersion, &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("aVersion, &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, "aError) { + 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 +} diff --git a/Sense/internal/store/postgres.go b/Sense/internal/store/postgres.go index a72e2e4..980c59b 100644 --- a/Sense/internal/store/postgres.go +++ b/Sense/internal/store/postgres.go @@ -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, "aVersion, &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, "aVersion, &areaVersion, &value.CreatedAt, &value.UpdatedAt, + &value.EndpointRef, &value.CredentialRef, &value.ProfileToken, &value.PathName, + &value.Generation, &value.ResourceVersion, "aVersion, &areaVersion, + &value.CreatedAt, &value.UpdatedAt, ) value.AreaID = areaID.String value.QuotaSourceVersion = quotaVersion.Int64 diff --git a/Sense/internal/store/postgres_test.go b/Sense/internal/store/postgres_test.go index c2daaf3..ac7fa45 100644 --- a/Sense/internal/store/postgres_test.go +++ b/Sense/internal/store/postgres_test.go @@ -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, diff --git a/Sense/internal/store/sqlite.go b/Sense/internal/store/sqlite.go index cc36ea6..9c9fc95 100644 --- a/Sense/internal/store/sqlite.go +++ b/Sense/internal/store/sqlite.go @@ -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) } diff --git a/deploy/postgres/008_control_api.sql b/deploy/postgres/008_control_api.sql new file mode 100644 index 0000000..bd2caf4 --- /dev/null +++ b/deploy/postgres/008_control_api.sql @@ -0,0 +1,149 @@ +-- Sense Control API v1 durable concurrency, idempotency and batch-operation state. + +ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS profile_token text NOT NULL DEFAULT ''; +ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS resource_version bigint NOT NULL DEFAULT 1; + +DO $area_preflight$ +BEGIN + IF EXISTS (SELECT 1 FROM sense.devices WHERE area_id IS NULL OR btrim(area_id) = '') THEN + RAISE EXCEPTION 'assign every legacy Sense device to a valid Area before installing Control API v4'; + END IF; +END +$area_preflight$; +ALTER TABLE sense.devices ALTER COLUMN area_id SET NOT NULL; + +DO $constraints$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'sense.devices'::regclass + AND conname = 'sense_devices_resource_version_positive' + ) THEN + ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_resource_version_positive + CHECK (resource_version >= 1); + END IF; +END +$constraints$; + +CREATE TABLE IF NOT EXISTS sense.control_idempotency_receipts ( + scope_hash bytea PRIMARY KEY, + request_hash bytea NOT NULL, + operation_name text NOT NULL, + principal_id text NOT NULL, + tenant_id text NOT NULL, + site_id text NOT NULL, + response_status integer NOT NULL, + response_body jsonb NOT NULL, + response_etag text, + response_location text, + trace_id text NOT NULL, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + CONSTRAINT sense_control_receipt_hash_lengths CHECK ( + octet_length(scope_hash) = 32 AND octet_length(request_hash) = 32 + ), + CONSTRAINT sense_control_receipt_identity_not_blank CHECK ( + btrim(operation_name) <> '' AND btrim(principal_id) <> '' + AND btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(trace_id) <> '' + ), + CONSTRAINT sense_control_receipt_status CHECK (response_status BETWEEN 200 AND 299), + CONSTRAINT sense_control_receipt_operation CHECK ( + operation_name IN ('createDevice', 'batchSetDeviceDesiredState') + ), + CONSTRAINT sense_control_receipt_lengths CHECK ( + char_length(principal_id) <= 200 AND char_length(trace_id) <= 128 + AND (response_etag IS NULL OR char_length(response_etag) <= 128) + AND (response_location IS NULL OR char_length(response_location) <= 512) + ), + CONSTRAINT sense_control_receipt_body_object CHECK (jsonb_typeof(response_body) = 'object'), + CONSTRAINT sense_control_receipt_ttl CHECK (expires_at >= created_at + interval '24 hours') +); +ALTER TABLE sense.control_idempotency_receipts OWNER TO sense_app; + +CREATE INDEX IF NOT EXISTS sense_control_receipts_expiry_idx + ON sense.control_idempotency_receipts(expires_at, scope_hash); + +CREATE TABLE IF NOT EXISTS sense.batch_operations ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + site_id text NOT NULL, + principal_id text NOT NULL, + status text NOT NULL, + trace_id text NOT NULL, + submitted_at timestamptz NOT NULL, + completed_at timestamptz, + CONSTRAINT sense_batch_operation_id_format CHECK ( + id ~ '^op_[0-9A-HJKMNP-TV-Z]{26}$' + ), + CONSTRAINT sense_batch_operation_identity_not_blank CHECK ( + btrim(tenant_id) <> '' AND btrim(site_id) <> '' + AND btrim(principal_id) <> '' AND btrim(trace_id) <> '' + ), + CONSTRAINT sense_batch_operation_status CHECK ( + status IN ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed') + ), + CONSTRAINT sense_batch_operation_completion CHECK ( + (status IN ('queued', 'running') AND completed_at IS NULL) + OR (status IN ('succeeded', 'partially_succeeded', 'failed') AND completed_at IS NOT NULL) + ) +); +ALTER TABLE sense.batch_operations OWNER TO sense_app; + +CREATE TABLE IF NOT EXISTS sense.batch_operation_items ( + operation_id text NOT NULL REFERENCES sense.batch_operations(id) ON DELETE CASCADE, + ordinal integer NOT NULL, + device_id text NOT NULL, + status text NOT NULL, + error_code text, + message text, + generation bigint, + PRIMARY KEY (operation_id, ordinal), + CONSTRAINT sense_batch_item_ordinal CHECK (ordinal BETWEEN 0 AND 127), + CONSTRAINT sense_batch_item_device_not_blank CHECK (btrim(device_id) <> ''), + CONSTRAINT sense_batch_item_status CHECK ( + status IN ('accepted', 'rejected', 'succeeded', 'failed') + ), + CONSTRAINT sense_batch_item_error_pair CHECK ( + (status IN ('accepted', 'succeeded') AND error_code IS NULL AND message IS NULL) + OR (status IN ('rejected', 'failed') AND error_code IS NOT NULL AND message IS NOT NULL) + ), + CONSTRAINT sense_batch_item_error_code CHECK ( + error_code IS NULL OR error_code IN ( + 'invalid_request', 'unauthenticated', 'forbidden', 'not_found', + 'conflict', 'precondition_required', 'etag_mismatch', + 'idempotency_conflict', 'duplicate_serial_number', 'quota_exceeded', + 'quota_projection_unavailable', 'quota_projection_invalid', + 'area_policy_denied', 'area_policy_unavailable', 'adapter_not_ready', + 'authentication_failed', 'endpoint_credentials_forbidden', + 'batch_too_large', 'service_unavailable', 'internal_error' + ) + ), + CONSTRAINT sense_batch_item_message_length CHECK ( + message IS NULL OR char_length(message) <= 500 + ), + CONSTRAINT sense_batch_item_generation CHECK (generation IS NULL OR generation >= 1) +); +ALTER TABLE sense.batch_operation_items OWNER TO sense_app; + +CREATE INDEX IF NOT EXISTS sense_batch_operations_scope_idx + ON sense.batch_operations(tenant_id, site_id, submitted_at DESC, id); + +ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_event_type; +ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_event_type CHECK ( + event_type IN ( + 'device.created', + 'device.desired_state.accepted', + 'device.configuration.accepted' + ) +); +ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_payload_kind; +ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_payload_kind CHECK ( + (event_type = 'device.created' AND payload ->> 'kind' = 'device_created') + OR (event_type = 'device.desired_state.accepted' + AND payload ->> 'kind' = 'desired_state_accepted') + OR (event_type = 'device.configuration.accepted' + AND payload ->> 'kind' = 'configuration_accepted') +); + +INSERT INTO sense.schema_migrations(version) VALUES (4) +ON CONFLICT (version) DO NOTHING; diff --git a/deploy/postgres/009_privileges_control_api.sql b/deploy/postgres/009_privileges_control_api.sql new file mode 100644 index 0000000..00e9c41 --- /dev/null +++ b/deploy/postgres/009_privileges_control_api.sql @@ -0,0 +1,11 @@ +-- Extend the least-privilege boundary to Control API durable state. + +REVOKE ALL ON TABLE sense.control_idempotency_receipts FROM PUBLIC; +REVOKE ALL ON TABLE sense.batch_operations FROM PUBLIC; +REVOKE ALL ON TABLE sense.batch_operation_items FROM PUBLIC; + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + sense.control_idempotency_receipts, + sense.batch_operations, + sense.batch_operation_items +TO sense_app; diff --git a/deploy/postgres/README.md b/deploy/postgres/README.md index a678c4e..8f8160b 100644 --- a/deploy/postgres/README.md +++ b/deploy/postgres/README.md @@ -1,9 +1,11 @@ # YoVision PostgreSQL 初始化 -本目录实现 T-009/T-010 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 策略投影、设备操作审计 Outbox 和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。 +本目录实现 T-009~T-011 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 的资源版本、write-only profile token、24 小时幂等收据、batch operation 和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。 生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志: +安装 `008` 前必须先查询并为全部历史 `sense.devices.area_id IS NULL/blank` 记录完成经批准的 Area 归属;migration 会失败关闭而不会猜测或自动搬迁这些设备。 + ```powershell $env:YOVISION_PG_ADMIN_DSN = '由管理员私下设置' Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql | @@ -19,8 +21,9 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql | - `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。 - `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。 - `sense.device_operation_outbox` 是本地持久化审计事实,不是 Bell 全局审计真相;relay 的传输、签名、确认和留存尚未实现。 +- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。 - PUBLIC 对两个业务 schema 的表和函数没有权限。 ## 恢复 -T-009/T-010 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox 一致性,不能作为后续公共控制 API 的生产后端。若 PostgreSQL 切换验证失败,停止 PostgreSQL 模式的 Sense 进程并恢复原实验室 SQLite DSN;不要把此回退误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。 +T-009~T-011 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox/Control API 一致性,业务路由不会在 SQLite 上注册。若 PostgreSQL 切换验证失败,先关闭 Control API feature flag;需要实验室回退时停止 PostgreSQL 模式进程并恢复 SQLite DSN,不得误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。 diff --git a/deploy/postgres/tests/assertions.sql b/deploy/postgres/tests/assertions.sql index 8079490..c9e89ab 100644 --- a/deploy/postgres/tests/assertions.sql +++ b/deploy/postgres/tests/assertions.sql @@ -72,7 +72,7 @@ BEGIN RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox'; END IF; IF (SELECT max(version) FROM bell.schema_migrations) <> 2 - OR (SELECT max(version) FROM sense.schema_migrations) <> 3 THEN + OR (SELECT max(version) FROM sense.schema_migrations) <> 4 THEN RAISE EXCEPTION 'schema migration version drift'; END IF; @@ -126,3 +126,28 @@ BEGIN WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site'; END $assertions$; + +DO $control_api$ +BEGIN + IF NOT has_table_privilege('yovision_t011_sense', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE') + OR NOT has_table_privilege('yovision_t011_sense', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE') + OR NOT has_table_privilege('yovision_t011_sense', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE') THEN + RAISE EXCEPTION 'Sense runtime lacks Control API table privileges'; + END IF; + IF has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT') + OR has_table_privilege('public', 'sense.batch_operations', 'SELECT') THEN + RAISE EXCEPTION 'Control API durable state leaked to PUBLIC'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'sense' AND table_name = 'devices' + AND column_name = 'resource_version' + ) OR NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'sense' AND table_name = 'devices' + AND column_name = 'profile_token' + ) THEN + RAISE EXCEPTION 'Control API device columns are missing'; + END IF; +END +$control_api$; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 1fa19b7..7c08673 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -85,7 +85,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI python scripts/validate_agent_context.py python -m unittest discover -s tests -p "test_*.py" python scripts/validate_harness_governance.py -go -C Sense generate ./internal/mtx +go -C Sense generate ./internal/mtx ./internal/controlapi go -C Sense test ./... go -C Sense vet ./... go -C Sense build ./... diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 3afc0a3..5d23684 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -50,6 +50,8 @@ MediaMTX 官方 `v1.19.3` OpenAPI 固定保存于 `Sense/api/vendor/mediamtx-v1. M2 仍保留 SQLite 作为单机开发与 T-006 回归入口,但生产真相源切换目标是 PostgreSQL。运行时必须显式设置 `SENSE_DB_DRIVER=postgres` 和私有 `SENSE_DB_DSN` 才使用 PostgreSQL;仓库不提供带密码 DSN。初始 schema 由 `deploy/postgres/` 的顺序 migration 创建,不在 Sense 进程启动时使用高权限自动迁移。 +T-011 复用同一套冻结的 Go、PostgreSQL、pgx 与 `oapi-codegen`,未增加第三方依赖。Sense Control API v1 从仓库 OpenAPI 生成 `net/http` server glue;首版认证是可替换 port 下的外部静态 SHA-256 注册表,公共路由默认关闭且只在 PostgreSQL v4 schema 上启用。静态适配器是私有部署启动方案,不替代后续 Bell/JWT/OIDC 身份服务。 + ## 2. 外部项目边界 - MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。 @@ -86,7 +88,7 @@ Sense 单独执行: ```powershell go -C Sense mod download -go -C Sense generate ./internal/mtx +go -C Sense generate ./internal/mtx ./internal/controlapi go -C Sense test ./... go -C Sense vet ./... go -C Sense build ./... @@ -107,7 +109,7 @@ python scripts/validate_harness_governance.py | --- | --- | --- | --- | | Harness 文档/任务/Gitea 模板 | 上述三条 Python 命令 | 任一治理协议、清单或任务 schema 变化 | 不适用 | | `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 | -| Sense Go | `go -C Sense generate ./internal/mtx`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 | +| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 | | PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 | | Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 | | Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index c0a13c1..3173dd8 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -95,7 +95,7 @@ Sense ── 视频流/触发信号 ──> Brain ## 8. 数据与契约 - Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及只记录已观察版本的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 为五列,Area v1 固定为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at` 六列。 -- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、cursor 分页、幂等键与 ETag;敏感连接引用只写不读。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。T-009/T-010 已实现 PostgreSQL 准入与审计事务基础,不等于 handler、认证、Bell 管理服务或 Outbox relay 已实现。 +- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。正式签名和兼容规则以 [`contracts/`](contracts/) 为准,Bell 管理服务、JWT/OIDC 与 Outbox relay 仍未实现。 - 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。 - Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。 - 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index bf63f34..bbac273 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -25,7 +25,7 @@ - T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。 - T-009:使用 PostgreSQL 17.10 建立 `sense`/`bell` schema、Bell 配额源表/只读视图、最小权限和 Sense PostgreSQL repository;SQLite 保留为 M1 本地路径。 - T-010:建立 Bell Area/`capture_policy` 只读投影、Sense 成像准入与设备操作审计 Outbox 原子写入;relay 传输协议留给后续任务。 -- 后续 T-011 按 T-008 契约实现认证 tenant 上下文、设备管理 handler、幂等/ETag/cursor 与批量操作;公共控制 API 只在 PostgreSQL 路径启用,不得把 T-009/T-010 数据基础误报为 API 已交付。 +- T-011:按 T-008 契约实现认证 tenant 上下文、7 个设备/operation handler、PostgreSQL 幂等收据、ETag/HMAC cursor、最多 128 项批量操作和停用精确 path 收敛;公共控制 API 默认关闭且只在 PostgreSQL 路径启用。 - 对账器幂等/退避/并发/10% 安全闸。 - WireGuard 边缘隧道与断网恢复。 - 16 路批量开通、停用和容量基准。 diff --git a/docs/api.md b/docs/api.md index 60ca343..02b4d9b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # API 与契约 -> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1 与 Sense 本地设备审计事件 v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。 +> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1 与 Sense 本地设备审计事件 v1/v2 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。 ## 1. 已冻结:Brain → Bell 事件契约 @@ -45,7 +45,7 @@ - `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。 - v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。 -T-008 冻结公共控制契约;T-009/T-010 已实现 PostgreSQL Site/Area 源表、两个只读视图、最小权限、Sense 准入 repository 和本地审计 Outbox,但尚未实现 Sense HTTP handler、认证中间件、Bell 管理服务或 Outbox relay。 +T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入和本地审计基础;T-011 已实现 7 个 HTTP handler、外部静态摘要认证适配器、tenant/Site scope、幂等收据、ETag/HMAC cursor 和持久化 batch operation。业务路由默认关闭且仅可在 PostgreSQL 上开启;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。 ## 4. 待冻结的 Bell 公共 API @@ -83,7 +83,7 @@ MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch ### 5.3 Sense 进程 HTTP 面 -T-003 只实现了运维探针:`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成。两者返回 JSON,均不等价于摄像头、MediaMTX path 或里程碑健康。T-008 已冻结站点作用域的 `/api/v1/sites/{site_id}/devices` 等设备管理契约,但尚未实现对应 handler;当前可运行进程仍只暴露探针,后续实现不得发布 `/api/v1/devices` 等无站点边界的临时接口。 +`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成;两者不要求认证,也不等价于摄像头、MediaMTX path 或里程碑健康。T-011 在 `SENSE_CONTROL_API_ENABLED=true`、PostgreSQL v4 schema 和外部安全文件全部有效时注册冻结的 `/api/v1` 路由;默认 SQLite 运行仍只暴露探针。业务路由不提供无 Site 边界的 `/api/v1/devices` 临时接口。 ## 6. 变更流程 diff --git a/docs/contracts/README.md b/docs/contracts/README.md index 9a27735..669498a 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -1,6 +1,6 @@ # Sense 控制面、准入投影与本地审计契约 v1 -> 冻结日期:2026-08-07。契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-009/T-010 已实现两个只读投影和本地审计事务基础;HTTP handler、Bell 管理服务和 Outbox relay 仍未实现。 +> 冻结日期:2026-08-07。Control API 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-011 已实现 Control API handler 与 PostgreSQL 一致性边界;Bell 管理服务、JWT/OIDC 和 Outbox relay 仍未实现。 ## 契约文件 @@ -10,6 +10,7 @@ | [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 | | [`area-policy-v1.sql`](area-policy-v1.sql) | Bell | Sense | Area 归属与 `capture_policy` 只读投影 | | [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;未来 Bell relay | 脱敏设备操作审计事实,不包含传输协议 | +| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;未来 Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 | OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。 @@ -54,7 +55,7 @@ T-010 冻结 `bell.area_policy_v1` 的列顺序为 `tenant_id/site_id/area_id/ca ## 本地设备操作审计 -`sense-device-audit-v1.schema.json` 冻结本地审计事实的逻辑 envelope。当前事件只有 `device.created` 和 `device.desired_state.accepted`;主体类型为 `user | service | system`,投影版本与 generation 随事实保存。`data` 只包含 Area、模态、能力和状态变化等脱敏字段,禁止 endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置。 +`sense-device-audit-v1.schema.json` 继续冻结创建与期望态两类事实且不原地扩展严格枚举。T-011 新增 v2 后继,兼容 v1 两类事件并增加 `device.configuration.accepted`;该 payload 只保存是否变化、字段名和 Area 逻辑 ID,不保存字段值。主体类型为 `user | service | system`,投影版本与 generation 随事实保存;endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置始终禁止进入审计。 PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。Schema 不是 Bell relay 协议:传输端点、签名、批量确认、重放窗口和留存由后续任务冻结。 @@ -71,7 +72,9 @@ PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_ ```powershell python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null +python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null python -m unittest discover -s tests -p "test_sense_control_contract.py" +python -m unittest discover -s tests -p "test_sense_control_implementation.py" ``` -测试校验本仓库依赖的 OpenAPI 结构与安全不变量,并不替代后续实现任务对完整 OpenAPI 标准验证器、HTTP handler 和 PostgreSQL migration 的验证。 +测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。 diff --git a/docs/contracts/sense-device-audit-v2.schema.json b/docs/contracts/sense-device-audit-v2.schema.json new file mode 100644 index 0000000..368da6c --- /dev/null +++ b/docs/contracts/sense-device-audit-v2.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://yovision.local/contracts/sense-device-audit-v2.schema.json", + "title": "Sense Device Audit Event v2", + "description": "v1 的向后兼容后继:增加脱敏设备配置受理事实,仍不定义 Bell relay。", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", "event_type", "tenant_id", "site_id", "device_id", + "actor", "reason", "trace_id", "aggregate_generation", + "projection_versions", "data", "occurred_at" + ], + "properties": { + "event_id": {"type": "string", "pattern": "^audit_[0-9a-f]{32}$"}, + "event_type": { + "enum": [ + "device.created", + "device.desired_state.accepted", + "device.configuration.accepted" + ] + }, + "tenant_id": {"$ref": "#/$defs/logicalId"}, + "site_id": {"$ref": "#/$defs/logicalId"}, + "device_id": {"$ref": "#/$defs/logicalId"}, + "actor": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": {"enum": ["user", "service", "system"]}, + "id": {"type": "string", "minLength": 1, "maxLength": 200} + } + }, + "reason": {"type": ["string", "null"], "maxLength": 500}, + "trace_id": {"type": ["string", "null"], "maxLength": 128}, + "aggregate_generation": {"type": "integer", "minimum": 1}, + "projection_versions": { + "type": "object", + "additionalProperties": false, + "required": ["quota_source_version", "area_policy_source_version"], + "properties": { + "quota_source_version": {"type": ["integer", "null"], "minimum": 1}, + "area_policy_source_version": {"type": ["integer", "null"], "minimum": 1} + } + }, + "data": { + "oneOf": [ + {"$ref": "#/$defs/deviceCreatedData"}, + {"$ref": "#/$defs/desiredStateData"}, + {"$ref": "#/$defs/configurationData"} + ] + }, + "occurred_at": {"type": "string", "format": "date-time"} + }, + "allOf": [ + { + "if": {"properties": {"event_type": {"const": "device.created"}}}, + "then": {"properties": {"data": {"$ref": "#/$defs/deviceCreatedData"}}} + }, + { + "if": {"properties": {"event_type": {"const": "device.desired_state.accepted"}}}, + "then": {"properties": {"data": {"$ref": "#/$defs/desiredStateData"}}} + }, + { + "if": {"properties": {"event_type": {"const": "device.configuration.accepted"}}}, + "then": {"properties": {"data": {"$ref": "#/$defs/configurationData"}}} + } + ], + "$defs": { + "logicalId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "desiredState": {"enum": ["disabled", "enabled"]}, + "deviceCreatedData": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "area_id", "modality", "capabilities", "desired_state"], + "properties": { + "kind": {"const": "device_created"}, + "area_id": {"$ref": "#/$defs/logicalId"}, + "modality": {"enum": ["video", "radar", "contact", "button", "wearable", "other"]}, + "capabilities": { + "type": "array", + "maxItems": 16, + "uniqueItems": true, + "items": {"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]} + }, + "desired_state": {"$ref": "#/$defs/desiredState"} + } + }, + "desiredStateData": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "previous_desired_state", "desired_state", "changed"], + "properties": { + "kind": {"const": "desired_state_accepted"}, + "previous_desired_state": {"$ref": "#/$defs/desiredState"}, + "desired_state": {"$ref": "#/$defs/desiredState"}, + "changed": {"type": "boolean"} + } + }, + "configurationData": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "changed", "changed_fields", "area_id"], + "properties": { + "kind": {"const": "configuration_accepted"}, + "changed": {"type": "boolean"}, + "changed_fields": { + "type": "array", + "maxItems": 5, + "uniqueItems": true, + "items": { + "enum": ["name", "area_id", "endpoint_ref", "credential_ref", "profile_token"] + } + }, + "area_id": {"$ref": "#/$defs/logicalId"} + } + } + } +} diff --git a/docs/current-state.md b/docs/current-state.md index f30ad23..822c922 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -20,7 +20,7 @@ - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 - `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。 - `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。 -- `docs/contracts/sense-control-v1.openapi.json` 已冻结站点作用域的设备查询、创建、修改、启停与最多 128 项批量操作;配额/Area 只读视图和本地设备审计事件均已冻结并实现数据库基础。HTTP handler、认证、Bell 管理服务和 Outbox relay 尚未实现。 +- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现:外部静态 SHA-256 主体注册表、tenant/Site scope、HMAC cursor、ETag、PostgreSQL 24 小时幂等收据和最多 128 项 batch operation 均有代码与隔离集成测试。默认 SQLite 仍只暴露探针;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。 - harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。 - Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。 - T-002 已关闭架构影响型问题:首期 S2 民办寄宿学校、客户侧私有部署、ONVIF/RTSP、NVIDIA 主路径、自研 Bell、Web/H5 与客户侧证据存储等边界已批准。 @@ -66,12 +66,12 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz` 与 `/readyz` 运维探 - 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。 - 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。 - Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。 -- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009/T-010 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色与备份方案。 +- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-011 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色、外部 Control API 安全文件与备份方案。 - 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。 ## 下一步 -下一步按 Gitea 流程建立 T-011 Sense Control API v1 实现任务:基于 T-009/T-010 PostgreSQL repository 实现认证 tenant 上下文、7 个设备/operation handler、幂等收据、ETag/cursor 和最多 128 项批量操作;公共 API 不在 SQLite 路径启用。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。 +下一项建议创建 T-012,补齐 M2 对账安全闸、孤儿检测(只报告/受控处置)与多实例可观测性,再进入 WireGuard 和 16 路批量开通基准;不要把 Control API 完成误报为 M2 容量出口。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。 ## 已知风险 diff --git a/docs/tasks/T-011.md b/docs/tasks/T-011.md index 2736374..9c63ff8 100644 --- a/docs/tasks/T-011.md +++ b/docs/tasks/T-011.md @@ -3,7 +3,7 @@ id: T-011 title: 实现 Sense Control API v1 与 PostgreSQL 一致性边界 phase: 2 deps: [T-010] -status: DOING +status: DONE created: 2026-08-07 issue: 39 context_ref: 6402d4384d5c2b1c0f3852858c3cf0d63d948262 @@ -96,6 +96,15 @@ T-008 已冻结 Sense Control API v1 的 7 个 endpoint,T-009/T-010 已提供 ## 执行记录 +### 2026-08-07 完成 Sense Control API v1 + +- 使用冻结的 `oapi-codegen v2.8.0` 从 T-008 OpenAPI 生成 Go 1.22+ `net/http` server glue(生成 SHA-256 `39a52e4f54bb1a742f58b64dcf15ce98359385236da5ccc0dae77e8ded2b218b`),实现全部 7 个 endpoint、严格 JSON/1 MiB body、稳定 Problem、write-only 字段脱敏和 `Cache-Control: no-store`;生成漂移已进入 `init.ps1`/`init.sh`。 +- 新增可替换认证 port 与 `static-sha256` 私有部署适配器:外部注册表只保存 token 摘要、主体、tenant、Site scope 与两项权限,摘要 constant-time 比较;业务路由默认关闭,只能在 PostgreSQL v4 schema 与外部 32 字节 HMAC cursor key 有效时开启。SQLite 默认路径继续只暴露探针。 +- 新增 `008`/`009` migration:设备 `resource_version`/write-only profile token、24 小时摘要幂等收据、持久化 batch operation/逐项结果与最小权限。创建收据与设备/审计同事务;batch 以 savepoint 隔离逐项失败,并先按设备 ID 排序锁定目标,避免相反请求顺序死锁。ETag 只在配置/期望态实际变化时前进;相同期望态仍审计但不增加 generation/resource version。 +- 保留严格审计 v1 文件不变,新增 v2 后继和 `device.configuration.accepted` 脱敏事实;Area 修改重新准入。停用调和只对台账中的精确 path 做幂等删除,成功后 observed generation 收敛且 actual state 为 offline,不枚举或清理未知 path。 +- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:`001`~`009` 连续重放、SQL 权限断言和 25 个 `TestPostgres*` 全绿,覆盖列表稳定分页/过滤、创建并发幂等、收据脱敏/冲突、ETag/PATCH、审计 v2、逐项 batch、operation tenant 隐藏、相反顺序批量锁与 PUBLIC 权限负例;随机回环临时集群已停止并清理,现有 `D:\pgsql17\data`/5432 listener 未读取、停止或修改。 +- `./init.ps1` 通过;Python 契约/治理测试共 47 项通过;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、生成漂移和 `git diff --check` 均通过;`go test -race ./internal/auth ./internal/controlapi ./internal/reconcile ./internal/store` 通过。不需要摄像头或 UI,本结果不解除 T-007,也不形成 16/128 路容量承诺。 + ### 2026-08-07 领取任务 - dispatcher `ila` 将 Issue #39 分配给 `codex`;`context_ref` 为 `6402d4384d5c2b1c0f3852858c3cf0d63d948262`,claim 为 `claims/T-011`,工作分支为 `agent/codex/T-011`。 diff --git a/init.ps1 b/init.ps1 index 4849716..cdb7b75 100644 --- a/init.ps1 +++ b/init.ps1 @@ -12,7 +12,7 @@ Set-Location -Path $PSScriptRoot # Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 $InstallCmd = "go -C Sense mod download" -$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" +$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" $StartCmd = "go -C Sense run ./cmd/sense-api" function Assert-Configured { diff --git a/init.sh b/init.sh index 4b9ce6d..9a8ac5c 100644 --- a/init.sh +++ b/init.sh @@ -14,7 +14,7 @@ cd "$ROOT_DIR" # Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 INSTALL_CMD=(go -C Sense mod download) -VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...") +VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...") START_CMD=(go -C Sense run ./cmd/sense-api) ensure_configured() { diff --git a/scripts/test_postgres.ps1 b/scripts/test_postgres.ps1 index 9460399..376ae00 100644 --- a/scripts/test_postgres.ps1 +++ b/scripts/test_postgres.ps1 @@ -40,7 +40,7 @@ $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port $listener.Stop() $systemTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) -$clusterName = 'yovision-t010-pg-' + [guid]::NewGuid().ToString('N') +$clusterName = 'yovision-t011-pg-' + [guid]::NewGuid().ToString('N') $dataDir = Join-Path $systemTemp $clusterName $logPath = Join-Path $systemTemp ($clusterName + '.log') $started = $false @@ -69,9 +69,9 @@ try { $started = $true $adminRootDSN = "postgres://postgres@127.0.0.1:$port/postgres?sslmode=disable" - $databaseName = 'yovision_t010' + $databaseName = 'yovision_t011' $adminDatabaseDSN = "postgres://postgres@127.0.0.1:$port/${databaseName}?sslmode=disable" - $senseDSN = "postgres://yovision_t010_sense@127.0.0.1:$port/${databaseName}?sslmode=disable" + $senseDSN = "postgres://yovision_t011_sense@127.0.0.1:$port/${databaseName}?sslmode=disable" Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-f' (Join-Path $repoRoot 'deploy\postgres\001_roles.sql') Invoke-Checked $createdb '-h' '127.0.0.1' '-p' ([string]$port) '-U' 'postgres' $databaseName @@ -83,12 +83,14 @@ try { '004_privileges.sql', '005_area_policy.sql', '006_device_operation_outbox.sql', - '007_privileges_area_audit.sql' + '007_privileges_area_audit.sql', + '008_control_api.sql', + '009_privileges_control_api.sql' )) { Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot "deploy\postgres\$name") } } - Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t010_sense LOGIN IN ROLE sense_app' + Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t011_sense LOGIN IN ROLE sense_app' Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot 'deploy\postgres\tests\assertions.sql') $env:YOVISION_TEST_POSTGRES_DSN = $senseDSN @@ -103,7 +105,7 @@ finally { if (-not $started -or $stopped) { $resolvedData = [IO.Path]::GetFullPath($dataDir) if (-not $resolvedData.StartsWith($systemTemp, [StringComparison]::OrdinalIgnoreCase) -or - [IO.Path]::GetFileName($resolvedData) -notlike 'yovision-t010-pg-*') { + [IO.Path]::GetFileName($resolvedData) -notlike 'yovision-t011-pg-*') { throw "Refusing to clean unexpected temporary path." } if (Test-Path -LiteralPath $resolvedData) { diff --git a/tests/test_postgres_contract.py b/tests/test_postgres_contract.py index 6b2dd8c..ef84dc2 100644 --- a/tests/test_postgres_contract.py +++ b/tests/test_postgres_contract.py @@ -54,6 +54,8 @@ class PostgresContractTests(unittest.TestCase): "005_area_policy.sql", "006_device_operation_outbox.sql", "007_privileges_area_audit.sql", + "008_control_api.sql", + "009_privileges_control_api.sql", ], names, ) @@ -107,7 +109,7 @@ class PostgresContractTests(unittest.TestCase): "initdb.exe", "pg_ctl.exe", "127.0.0.1", - "yovision-t010-pg-", + "yovision-t011-pg-", "YOVISION_TEST_POSTGRES_DSN", "Get-NetTCPConnection", ): diff --git a/tests/test_sense_control_implementation.py b/tests/test_sense_control_implementation.py new file mode 100644 index 0000000..f5e21d7 --- /dev/null +++ b/tests/test_sense_control_implementation.py @@ -0,0 +1,99 @@ +"""Static safety checks for the T-011 Sense Control API implementation.""" + +from __future__ import annotations + +import json +import pathlib +import re +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def text(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def normalized(value: str) -> str: + return re.sub(r"\s+", " ", value.lower()) + + +class SenseControlImplementationTests(unittest.TestCase): + def test_generated_server_covers_all_frozen_operations(self) -> None: + generated = text("Sense/internal/controlapi/generated.gen.go") + self.assertIn("Code generated by github.com/oapi-codegen/oapi-codegen/v2", generated) + for operation in ( + "ListDevices", + "CreateDevice", + "GetDevice", + "UpdateDevice", + "SetDeviceDesiredState", + "BatchSetDeviceDesiredState", + "GetOperation", + ): + self.assertIn(operation, generated) + config = text("Sense/internal/controlapi/oapi-codegen.yaml") + self.assertIn("std-http-server: true", config) + self.assertIn("./internal/controlapi", text("init.ps1")) + + def test_control_api_is_feature_gated_to_postgres(self) -> None: + config = text("Sense/internal/config/config.go") + main = text("Sense/cmd/sense-api/main.go") + self.assertIn('SENSE_CONTROL_API_ENABLED', config) + self.assertIn('Control API requires SENSE_DB_DRIVER=postgres', config) + self.assertIn('mux.Handle("/api/v1/", controlHandler)', main) + self.assertNotIn('mux.Handle("/api/v1/"', text("Sense/internal/store/sqlite.go")) + + def test_static_authentication_stores_only_digests(self) -> None: + source = text("Sense/internal/auth/auth.go") + example = json.loads(text("Sense/api/control-auth.example.json")) + self.assertIn("subtle.ConstantTimeCompare", source) + self.assertIn("sha256.Sum256([]byte(token))", source) + self.assertNotIn('json:"token"', source) + for principal in example["principals"]: + self.assertRegex(principal["token_sha256"], r"^[0-9a-f]{64}$") + self.assertNotIn("token", principal.keys() - {"token_sha256"}) + + def test_postgres_receipts_are_hashed_durable_and_at_least_24_hours(self) -> None: + migration = normalized(text("deploy/postgres/008_control_api.sql")) + implementation = text("Sense/internal/store/control_postgres.go") + self.assertIn("create table if not exists sense.control_idempotency_receipts", migration) + self.assertIn("scope_hash bytea primary key", migration) + self.assertIn("expires_at >= created_at + interval '24 hours'", migration) + self.assertNotIn("idempotency_key", migration) + self.assertIn("pg_advisory_xact_lock", implementation) + self.assertIn("ConstantTimeCompare", implementation) + + def test_concurrency_batch_and_limits_are_not_hardcoded_to_16(self) -> None: + migration = normalized(text("deploy/postgres/008_control_api.sql")) + server = text("Sense/internal/controlapi/server.go") + store = text("Sense/internal/store/control_postgres.go") + self.assertIn("resource_version bigint not null default 1", migration) + self.assertIn("len(body.Items) > 128", server) + self.assertIn("limit > 100", server) + self.assertIn("SAVEPOINT ", store) + self.assertNotIn("len(body.Items) > 16", server) + + def test_audit_v1_is_unchanged_and_v2_is_a_strict_superset(self) -> None: + first = json.loads(text("docs/contracts/sense-device-audit-v1.schema.json")) + second = json.loads(text("docs/contracts/sense-device-audit-v2.schema.json")) + v1_events = set(first["properties"]["event_type"]["enum"]) + v2_events = set(second["properties"]["event_type"]["enum"]) + self.assertEqual( + {"device.created", "device.desired_state.accepted"}, v1_events + ) + self.assertEqual(v1_events | {"device.configuration.accepted"}, v2_events) + self.assertIn("configurationData", second["$defs"]) + + def test_disabled_reconciliation_deletes_only_exact_path(self) -> None: + reconciler = text("Sense/internal/reconcile/reconciler.go") + media = text("Sense/internal/mtx/client.go") + self.assertIn("candidate.Device.DesiredState == device.DesiredDisabled", reconciler) + self.assertIn("r.media.DeletePath(ctx, candidate.Device.PathName)", reconciler) + self.assertIn("Deletion is an idempotent convergence operation", media) + self.assertNotIn("ListPaths", reconciler) + + +if __name__ == "__main__": + unittest.main()