feat(sense): add reconciliation safety controls [T-012]
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
-- Add fenced reconciliation claims and durable, secret-free MediaMTX path
|
||||
-- ownership/orphan reports. Runtime migrations are installed out of process.
|
||||
|
||||
ALTER TABLE sense.reconcile_state
|
||||
ADD COLUMN IF NOT EXISTS lease_owner text,
|
||||
ADD COLUMN IF NOT EXISTS lease_token text,
|
||||
ADD COLUMN IF NOT EXISTS lease_until timestamptz;
|
||||
|
||||
DO $constraints$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'sense.reconcile_state'::regclass
|
||||
AND conname = 'sense_reconcile_lease_all_or_none'
|
||||
) THEN
|
||||
ALTER TABLE sense.reconcile_state
|
||||
ADD CONSTRAINT sense_reconcile_lease_all_or_none CHECK (
|
||||
(lease_owner IS NULL AND lease_token IS NULL AND lease_until IS NULL)
|
||||
OR (btrim(lease_owner) <> '' AND btrim(lease_token) <> '' AND lease_until IS NOT NULL)
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$constraints$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sense_reconcile_lease_due_idx
|
||||
ON sense.reconcile_state(lease_until, next_attempt_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.media_path_ownership (
|
||||
path_name text PRIMARY KEY,
|
||||
device_id text NOT NULL,
|
||||
tenant_id text NOT NULL,
|
||||
site_id text NOT NULL,
|
||||
first_claimed_at timestamptz NOT NULL,
|
||||
last_confirmed_at timestamptz NOT NULL,
|
||||
CONSTRAINT sense_media_path_ownership_not_blank CHECK (
|
||||
btrim(path_name) <> '' AND btrim(device_id) <> ''
|
||||
AND btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||
)
|
||||
);
|
||||
ALTER TABLE sense.media_path_ownership OWNER TO sense_app;
|
||||
|
||||
-- Keep the historical row independent from device lifecycle so a later device
|
||||
-- deletion cannot erase the evidence needed to classify an owned stale path.
|
||||
INSERT INTO sense.media_path_ownership(
|
||||
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
|
||||
)
|
||||
SELECT d.path_name, d.id, d.tenant_id, d.site_id, d.created_at, d.updated_at
|
||||
FROM sense.devices d
|
||||
WHERE btrim(d.path_name) <> ''
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
ON CONFLICT (path_name) DO UPDATE SET
|
||||
device_id = EXCLUDED.device_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
site_id = EXCLUDED.site_id,
|
||||
last_confirmed_at = GREATEST(
|
||||
sense.media_path_ownership.last_confirmed_at,
|
||||
EXCLUDED.last_confirmed_at
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.operational_leases (
|
||||
lease_name text PRIMARY KEY,
|
||||
owner_id text NOT NULL,
|
||||
fencing_token text NOT NULL,
|
||||
lease_until timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CONSTRAINT sense_operational_lease_not_blank CHECK (
|
||||
btrim(lease_name) <> '' AND btrim(owner_id) <> '' AND btrim(fencing_token) <> ''
|
||||
)
|
||||
);
|
||||
ALTER TABLE sense.operational_leases OWNER TO sense_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.orphan_scan_runs (
|
||||
id text PRIMARY KEY,
|
||||
instance_id text NOT NULL,
|
||||
observed_count integer NOT NULL,
|
||||
owned_stale_count integer NOT NULL,
|
||||
unowned_count integer NOT NULL,
|
||||
safety_allowed boolean NOT NULL,
|
||||
safety_reason text NOT NULL,
|
||||
completed_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
CONSTRAINT sense_orphan_scan_id CHECK (id ~ '^scan_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
CONSTRAINT sense_orphan_scan_counts CHECK (
|
||||
observed_count >= 0 AND owned_stale_count >= 0 AND unowned_count >= 0
|
||||
AND owned_stale_count + unowned_count <= observed_count
|
||||
),
|
||||
CONSTRAINT sense_orphan_scan_not_blank CHECK (
|
||||
btrim(instance_id) <> '' AND btrim(safety_reason) <> ''
|
||||
),
|
||||
CONSTRAINT sense_orphan_scan_expiry CHECK (expires_at > completed_at)
|
||||
);
|
||||
ALTER TABLE sense.orphan_scan_runs OWNER TO sense_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.orphan_scan_findings (
|
||||
scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id) ON DELETE CASCADE,
|
||||
path_name text NOT NULL,
|
||||
classification text NOT NULL,
|
||||
device_id text,
|
||||
PRIMARY KEY (scan_id, path_name),
|
||||
UNIQUE (scan_id, path_name, classification),
|
||||
CONSTRAINT sense_orphan_finding_classification CHECK (
|
||||
classification IN ('owned_stale', 'unowned')
|
||||
),
|
||||
CONSTRAINT sense_orphan_finding_not_blank CHECK (
|
||||
btrim(path_name) <> '' AND (device_id IS NULL OR btrim(device_id) <> '')
|
||||
),
|
||||
CONSTRAINT sense_orphan_finding_owner_shape CHECK (
|
||||
(classification = 'owned_stale' AND device_id IS NOT NULL)
|
||||
OR (classification = 'unowned' AND device_id IS NULL)
|
||||
)
|
||||
);
|
||||
ALTER TABLE sense.orphan_scan_findings OWNER TO sense_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.orphan_cleanup_actions (
|
||||
scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id),
|
||||
path_name text NOT NULL,
|
||||
classification text NOT NULL DEFAULT 'owned_stale',
|
||||
actor_id text NOT NULL,
|
||||
status text NOT NULL,
|
||||
error_code text,
|
||||
attempted_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (scan_id, path_name),
|
||||
FOREIGN KEY (scan_id, path_name, classification)
|
||||
REFERENCES sense.orphan_scan_findings(scan_id, path_name, classification),
|
||||
CONSTRAINT sense_orphan_cleanup_owned_only CHECK (classification = 'owned_stale'),
|
||||
CONSTRAINT sense_orphan_cleanup_status CHECK (status IN ('deleted', 'failed')),
|
||||
CONSTRAINT sense_orphan_cleanup_not_blank CHECK (
|
||||
btrim(path_name) <> '' AND btrim(actor_id) <> ''
|
||||
AND (error_code IS NULL OR btrim(error_code) <> '')
|
||||
),
|
||||
CONSTRAINT sense_orphan_cleanup_error_shape CHECK (
|
||||
(status = 'deleted' AND error_code IS NULL)
|
||||
OR (status = 'failed' AND error_code IS NOT NULL)
|
||||
)
|
||||
);
|
||||
ALTER TABLE sense.orphan_cleanup_actions OWNER TO sense_app;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sense_orphan_scan_completed_idx
|
||||
ON sense.orphan_scan_runs(completed_at);
|
||||
CREATE INDEX IF NOT EXISTS sense_orphan_cleanup_status_idx
|
||||
ON sense.orphan_cleanup_actions(scan_id, status);
|
||||
|
||||
INSERT INTO sense.schema_migrations(version) VALUES (5)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Extend the least-privilege boundary to reconciliation and orphan state.
|
||||
|
||||
REVOKE ALL ON TABLE
|
||||
sense.media_path_ownership,
|
||||
sense.operational_leases,
|
||||
sense.orphan_scan_runs,
|
||||
sense.orphan_scan_findings,
|
||||
sense.orphan_cleanup_actions
|
||||
FROM PUBLIC;
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
|
||||
sense.media_path_ownership,
|
||||
sense.operational_leases,
|
||||
sense.orphan_scan_runs,
|
||||
sense.orphan_scan_findings,
|
||||
sense.orphan_cleanup_actions
|
||||
TO sense_app;
|
||||
@@ -1,6 +1,6 @@
|
||||
# YoVision PostgreSQL 初始化
|
||||
|
||||
本目录实现 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`;仓库不保存登录凭据。
|
||||
本目录实现 T-009~T-012 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。
|
||||
|
||||
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||
|
||||
@@ -22,8 +22,9 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
||||
- `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、状态和稳定错误,不保存连接秘密。
|
||||
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
|
||||
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
||||
|
||||
## 恢复
|
||||
|
||||
T-009~T-011 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox/Control API 一致性,业务路由不会在 SQLite 上注册。若 PostgreSQL 切换验证失败,先关闭 Control API feature flag;需要实验室回退时停止 PostgreSQL 模式进程并恢复 SQLite DSN,不得误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
|
||||
T-009~T-012 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox/Control API、多实例租约或孤儿处置一致性,业务路由不会在 SQLite 上注册。若 PostgreSQL 切换验证失败,先关闭 Control API 和孤儿扫描;需要实验室回退时停止 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) <> 4 THEN
|
||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN
|
||||
RAISE EXCEPTION 'schema migration version drift';
|
||||
END IF;
|
||||
|
||||
@@ -129,9 +129,9 @@ $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
|
||||
IF NOT has_table_privilege('yovision_t012_sense', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
OR NOT has_table_privilege('yovision_t012_sense', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
OR NOT has_table_privilege('yovision_t012_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')
|
||||
@@ -151,3 +151,31 @@ BEGIN
|
||||
END IF;
|
||||
END
|
||||
$control_api$;
|
||||
|
||||
DO $reconcile_safety$
|
||||
DECLARE
|
||||
target text;
|
||||
BEGIN
|
||||
FOREACH target IN ARRAY ARRAY[
|
||||
'sense.media_path_ownership',
|
||||
'sense.operational_leases',
|
||||
'sense.orphan_scan_runs',
|
||||
'sense.orphan_scan_findings',
|
||||
'sense.orphan_cleanup_actions'
|
||||
] LOOP
|
||||
IF NOT has_table_privilege('yovision_t012_sense', target, 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||
RAISE EXCEPTION 'Sense runtime lacks reconciliation safety privilege on %', target;
|
||||
END IF;
|
||||
IF has_table_privilege('public', target, 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||
RAISE EXCEPTION 'Reconciliation safety state leaked to PUBLIC on %', target;
|
||||
END IF;
|
||||
END LOOP;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'sense' AND table_name = 'reconcile_state'
|
||||
AND column_name = 'lease_token'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'reconcile fencing columns are missing';
|
||||
END IF;
|
||||
END
|
||||
$reconcile_safety$;
|
||||
|
||||
Reference in New Issue
Block a user