feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 专用数据库,禁止对共享实例做模糊或递归清理。
|
||||
|
||||
@@ -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$;
|
||||
|
||||
Reference in New Issue
Block a user