feat(bell): add immutable event store [T-015]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-10 23:53:11 +08:00
parent 5b42f58f23
commit 236964d242
28 changed files with 1434 additions and 27 deletions
+115
View File
@@ -0,0 +1,115 @@
-- Bell v3 immutable event facts and append-only outcomes.
-- bell_app remains the migration/object owner; bell_runtime is a NOLOGIN
-- least-privilege group for the Bell process.
DO $role$
DECLARE
role_record pg_roles%ROWTYPE;
BEGIN
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_runtime';
IF NOT FOUND THEN
CREATE ROLE bell_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
ELSIF role_record.rolcanlogin OR role_record.rolsuper OR role_record.rolcreatedb
OR role_record.rolcreaterole OR role_record.rolreplication THEN
RAISE EXCEPTION 'existing bell_runtime role has unsafe attributes';
END IF;
IF pg_has_role('bell_runtime', 'bell_app', 'MEMBER')
OR pg_has_role('bell_runtime', 'sense_app', 'MEMBER') THEN
RAISE EXCEPTION 'bell_runtime must not inherit migration or Sense roles';
END IF;
END
$role$;
CREATE OR REPLACE FUNCTION bell.reject_immutable_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, bell
AS $function$
BEGIN
RAISE EXCEPTION 'Bell immutable facts cannot be updated or deleted';
END
$function$;
ALTER FUNCTION bell.reject_immutable_change() OWNER TO bell_app;
CREATE TABLE IF NOT EXISTS bell.events (
id text PRIMARY KEY,
tenant_id bigint NOT NULL,
site_id bigint NOT NULL,
device_id bigint NOT NULL,
source_event_id text NOT NULL,
kind text NOT NULL,
severity text NOT NULL,
occurred_at timestamptz NOT NULL,
detected_at timestamptz NOT NULL,
payload_hash bytea NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT bell_event_id_format CHECK (id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'),
CONSTRAINT bell_event_ids_positive CHECK (tenant_id >= 1 AND site_id >= 1 AND device_id >= 1),
CONSTRAINT bell_event_source_id CHECK (source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'),
CONSTRAINT bell_event_kind CHECK (kind ~ '^[a-z][a-z0-9_]{2,63}$'),
CONSTRAINT bell_event_severity CHECK (severity IN ('low', 'medium', 'high', 'critical')),
CONSTRAINT bell_event_time_order CHECK (detected_at >= occurred_at),
CONSTRAINT bell_event_hash_length CHECK (octet_length(payload_hash) = 32),
CONSTRAINT bell_event_payload_object CHECK (jsonb_typeof(payload) = 'object'),
CONSTRAINT bell_event_payload_identity CHECK (
payload ->> 'id' = id
AND (payload ->> 'tenant_id')::bigint = tenant_id
AND (payload ->> 'site_id')::bigint = site_id
AND (payload ->> 'device_id')::bigint = device_id
AND payload ->> 'source_event_id' = source_event_id
AND payload ->> 'kind' = kind
AND payload ->> 'severity' = severity
)
);
ALTER TABLE bell.events OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_events_immutable ON bell.events;
CREATE TRIGGER bell_events_immutable
BEFORE UPDATE OR DELETE ON bell.events
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
CREATE INDEX IF NOT EXISTS bell_events_scope_time_idx
ON bell.events(tenant_id, site_id, occurred_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS bell_events_device_time_idx
ON bell.events(tenant_id, site_id, device_id, occurred_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS bell_events_kind_time_idx
ON bell.events(tenant_id, site_id, kind, occurred_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS bell_events_source_lookup_idx
ON bell.events(tenant_id, site_id, device_id, source_event_id);
CREATE TABLE IF NOT EXISTS bell.event_outcomes (
id text PRIMARY KEY,
event_id text NOT NULL REFERENCES bell.events(id),
outcome text NOT NULL,
outcome_source text NOT NULL,
reason text,
actor_type text NOT NULL,
actor_id text NOT NULL,
occurred_at timestamptz NOT NULL,
record_hash bytea NOT NULL,
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT bell_event_outcome_id_format CHECK (id ~ '^out_[0-9A-HJKMNP-TV-Z]{26}$'),
CONSTRAINT bell_event_outcome_value CHECK (
outcome IN ('unknown', 'true_positive', 'false_positive', 'subject_recovered', 'duplicate', 'test')
),
CONSTRAINT bell_event_outcome_source CHECK (outcome_source IN ('auto', 'manual')),
CONSTRAINT bell_event_outcome_actor CHECK (
actor_type IN ('user', 'service', 'system') AND btrim(actor_id) <> ''
),
CONSTRAINT bell_event_outcome_reason CHECK (reason IS NULL OR char_length(reason) <= 500),
CONSTRAINT bell_event_outcome_hash_length CHECK (octet_length(record_hash) = 32)
);
ALTER TABLE bell.event_outcomes OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_event_outcomes_immutable ON bell.event_outcomes;
CREATE TRIGGER bell_event_outcomes_immutable
BEFORE UPDATE OR DELETE ON bell.event_outcomes
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
CREATE INDEX IF NOT EXISTS bell_event_outcomes_event_time_idx
ON bell.event_outcomes(event_id, occurred_at, id);
INSERT INTO bell.schema_migrations(version) VALUES (3)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,15 @@
-- Runtime Bell can append/read event facts but cannot rewrite, delete or
-- truncate them. Object ownership stays with the offline migration role.
REVOKE ALL ON FUNCTION bell.reject_immutable_change() FROM PUBLIC;
REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM PUBLIC;
REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM bell_runtime;
GRANT USAGE ON SCHEMA bell TO bell_runtime;
GRANT SELECT ON TABLE bell.schema_migrations TO bell_runtime;
GRANT SELECT, INSERT ON TABLE bell.events, bell.event_outcomes TO bell_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell
REVOKE ALL ON TABLES FROM PUBLIC;
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell
REVOKE ALL ON FUNCTIONS FROM PUBLIC;
+2 -1
View File
@@ -1,6 +1,6 @@
# YoVision PostgreSQL 初始化
本目录实现 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`;仓库不保存登录凭据。
本目录实现 T-009~T-012、T-015 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果;`012`~`013` 增加 Bell 不可变事件、append-only outcome 和独立 `bell_runtime` 最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
@@ -21,6 +21,7 @@ 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 的传输、签名、确认和留存尚未实现。
- `bell.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。
- `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 的表和函数没有权限。
+23 -1
View File
@@ -71,11 +71,33 @@ BEGIN
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
END IF;
IF (SELECT max(version) FROM bell.schema_migrations) <> 2
IF (SELECT max(version) FROM bell.schema_migrations) <> 3
OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN
RAISE EXCEPTION 'schema migration version drift';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_roles
WHERE rolname = 'bell_runtime' AND NOT rolcanlogin AND NOT rolsuper
AND NOT rolcreatedb AND NOT rolcreaterole AND NOT rolreplication
) THEN
RAISE EXCEPTION 'bell_runtime role is missing or unsafe';
END IF;
IF NOT has_table_privilege('yovision_t015_bell', 'bell.events', 'SELECT,INSERT')
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'SELECT,INSERT')
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'UPDATE')
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'DELETE')
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'TRUNCATE')
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'UPDATE')
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'DELETE')
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'TRUNCATE') THEN
RAISE EXCEPTION 'Bell runtime violates immutable event privileges';
END IF;
IF has_table_privilege('public', 'bell.events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
OR has_table_privilege('public', 'bell.event_outcomes', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell event facts leaked to PUBLIC';
END IF;
INSERT INTO bell.sites(tenant_id, id, name)
VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site')
RETURNING max_video_channels, version INTO default_quota, initial_version;