feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
-- Sense Outbox relay fencing and Bell global audit facts.
|
||||
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
ADD COLUMN IF NOT EXISTS relay_lease_owner text,
|
||||
ADD COLUMN IF NOT EXISTS relay_lease_token bigint NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS relay_lease_until timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS last_error_code text,
|
||||
ADD COLUMN IF NOT EXISTS dead_lettered_at timestamptz;
|
||||
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
DROP CONSTRAINT IF EXISTS sense_outbox_relay_lease_pair;
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
ADD CONSTRAINT sense_outbox_relay_lease_pair CHECK (
|
||||
(relay_lease_owner IS NULL AND relay_lease_until IS NULL)
|
||||
OR (relay_lease_owner IS NOT NULL AND btrim(relay_lease_owner) <> ''
|
||||
AND relay_lease_until IS NOT NULL)
|
||||
);
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
DROP CONSTRAINT IF EXISTS sense_outbox_relay_token_nonnegative;
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
ADD CONSTRAINT sense_outbox_relay_token_nonnegative CHECK (relay_lease_token >= 0);
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
DROP CONSTRAINT IF EXISTS sense_outbox_relay_error_length;
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
ADD CONSTRAINT sense_outbox_relay_error_length CHECK (
|
||||
last_error_code IS NULL OR (
|
||||
char_length(last_error_code) BETWEEN 1 AND 64
|
||||
AND last_error_code ~ '^[a-z][a-z0-9_]*$'
|
||||
)
|
||||
);
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
DROP CONSTRAINT IF EXISTS sense_outbox_relay_terminal_state;
|
||||
ALTER TABLE sense.device_operation_outbox
|
||||
ADD CONSTRAINT sense_outbox_relay_terminal_state CHECK (
|
||||
delivered_at IS NULL OR dead_lettered_at IS NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sense_outbox_relay_due_idx
|
||||
ON sense.device_operation_outbox(
|
||||
COALESCE(next_attempt_at, available_at), event_id
|
||||
)
|
||||
WHERE delivered_at IS NULL AND dead_lettered_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.audit_events (
|
||||
source_system text NOT NULL,
|
||||
event_id text NOT NULL,
|
||||
schema_version smallint NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
tenant_id text NOT NULL,
|
||||
site_id text NOT NULL,
|
||||
device_id text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text NOT NULL,
|
||||
reason text,
|
||||
trace_id text,
|
||||
aggregate_generation bigint NOT NULL,
|
||||
quota_source_version bigint,
|
||||
area_policy_source_version bigint,
|
||||
payload jsonb NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
record_hash bytea NOT NULL,
|
||||
PRIMARY KEY (source_system, event_id),
|
||||
CONSTRAINT bell_audit_source_system CHECK (source_system = 'sense'),
|
||||
CONSTRAINT bell_audit_event_id CHECK (event_id ~ '^audit_[0-9a-f]{32}$'),
|
||||
CONSTRAINT bell_audit_schema_version CHECK (schema_version IN (1, 2)),
|
||||
CONSTRAINT bell_audit_event_type CHECK (event_type IN (
|
||||
'device.created',
|
||||
'device.desired_state.accepted',
|
||||
'device.configuration.accepted'
|
||||
)),
|
||||
CONSTRAINT bell_audit_identity_not_blank CHECK (
|
||||
btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(device_id) <> ''
|
||||
AND btrim(actor_id) <> ''
|
||||
),
|
||||
CONSTRAINT bell_audit_actor_type CHECK (actor_type IN ('user', 'service', 'system')),
|
||||
CONSTRAINT bell_audit_generation_positive CHECK (aggregate_generation >= 1),
|
||||
CONSTRAINT bell_audit_projection_versions CHECK (
|
||||
(quota_source_version IS NULL OR quota_source_version >= 1)
|
||||
AND (area_policy_source_version IS NULL OR area_policy_source_version >= 1)
|
||||
),
|
||||
CONSTRAINT bell_audit_reason_length CHECK (reason IS NULL OR char_length(reason) <= 500),
|
||||
CONSTRAINT bell_audit_trace_length CHECK (trace_id IS NULL OR char_length(trace_id) <= 128),
|
||||
CONSTRAINT bell_audit_payload_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||
CONSTRAINT bell_audit_record_hash_length CHECK (octet_length(record_hash) = 32)
|
||||
);
|
||||
ALTER TABLE bell.audit_events OWNER TO bell_app;
|
||||
|
||||
DROP TRIGGER IF EXISTS bell_audit_events_immutable ON bell.audit_events;
|
||||
CREATE TRIGGER bell_audit_events_immutable
|
||||
BEFORE UPDATE OR DELETE ON bell.audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bell_audit_scope_time_idx
|
||||
ON bell.audit_events(tenant_id, site_id, occurred_at DESC, event_id DESC);
|
||||
CREATE INDEX IF NOT EXISTS bell_audit_device_time_idx
|
||||
ON bell.audit_events(tenant_id, site_id, device_id, occurred_at DESC, event_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.audit_relay_receipts (
|
||||
key_id text NOT NULL,
|
||||
nonce text NOT NULL,
|
||||
request_hash bytea NOT NULL,
|
||||
response_status integer NOT NULL,
|
||||
response_body jsonb NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (key_id, nonce),
|
||||
CONSTRAINT bell_audit_receipt_key_id CHECK (
|
||||
char_length(key_id) BETWEEN 1 AND 64 AND key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||
),
|
||||
CONSTRAINT bell_audit_receipt_nonce CHECK (
|
||||
char_length(nonce) BETWEEN 22 AND 64 AND nonce ~ '^[A-Za-z0-9_-]+$'
|
||||
),
|
||||
CONSTRAINT bell_audit_receipt_hash_length CHECK (octet_length(request_hash) = 32),
|
||||
CONSTRAINT bell_audit_receipt_status CHECK (response_status = 200),
|
||||
CONSTRAINT bell_audit_receipt_body CHECK (jsonb_typeof(response_body) = 'object'),
|
||||
CONSTRAINT bell_audit_receipt_ttl CHECK (
|
||||
expires_at >= received_at + interval '10 minutes'
|
||||
AND expires_at <= received_at + interval '11 minutes'
|
||||
)
|
||||
);
|
||||
ALTER TABLE bell.audit_relay_receipts OWNER TO bell_app;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bell_audit_receipt_expiry_idx
|
||||
ON bell.audit_relay_receipts(expires_at, key_id, nonce);
|
||||
|
||||
INSERT INTO bell.schema_migrations(version) VALUES (4)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
INSERT INTO sense.schema_migrations(version) VALUES (6)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Bell runtime may append global audit facts and maintain only short-lived
|
||||
-- idempotency receipts. Sense keeps ownership of its local Outbox.
|
||||
|
||||
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM PUBLIC;
|
||||
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM bell_runtime;
|
||||
|
||||
GRANT SELECT, INSERT ON TABLE bell.audit_events TO bell_runtime;
|
||||
GRANT SELECT, INSERT, DELETE ON TABLE bell.audit_relay_receipts TO bell_runtime;
|
||||
|
||||
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM sense_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE sense.device_operation_outbox TO sense_app;
|
||||
@@ -1,6 +1,6 @@
|
||||
# YoVision PostgreSQL 初始化
|
||||
|
||||
本目录实现 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`,仓库不保存登录凭据。
|
||||
本目录实现 T-009~T-012、T-015~T-016 的 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` 最小权限;`014`~`015` 增加审计 Outbox relay fencing、Bell 全局审计事实、短期防重收据和双方最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
|
||||
|
||||
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||
|
||||
@@ -20,7 +20,8 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
||||
- `bell_app` 拥有 `bell.sites`/`bell.areas`、版本 trigger、`bell.site_quota_v1` 和 `bell.area_policy_v1`。
|
||||
- `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。
|
||||
- `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。
|
||||
- `sense.device_operation_outbox` 是本地持久化审计事实,不是 Bell 全局审计真相;relay 的传输、签名、确认和留存尚未实现。
|
||||
- `sense.device_operation_outbox` 是本地持久化队列,Bell 全局审计真相只写入 `bell.audit_events`。Sense 只领取/确认本地行,不获得 Bell 表权限;Bell 只通过签名 HTTP ingress 收取,不读取 Outbox。
|
||||
- `bell.audit_events` 与事件事实一样不可更新/删除;`bell.audit_relay_receipts` 只为 10 分钟 nonce 幂等窗口保留,Bell runtime 仅可在这张限定表中查询、插入和清理过期记录。
|
||||
- `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 写删除结果。
|
||||
|
||||
@@ -71,8 +71,8 @@ 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) <> 3
|
||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN
|
||||
IF (SELECT max(version) FROM bell.schema_migrations) <> 4
|
||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
|
||||
RAISE EXCEPTION 'schema migration version drift';
|
||||
END IF;
|
||||
|
||||
@@ -201,3 +201,30 @@ BEGIN
|
||||
END IF;
|
||||
END
|
||||
$reconcile_safety$;
|
||||
|
||||
DO $audit_relay$
|
||||
BEGIN
|
||||
IF NOT has_table_privilege('yovision_t012_sense', 'sense.device_operation_outbox', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
OR has_table_privilege('yovision_t012_sense', 'bell.audit_events', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
OR has_table_privilege('yovision_t012_sense', 'bell.audit_relay_receipts', 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||
RAISE EXCEPTION 'Sense runtime violates audit relay schema ownership';
|
||||
END IF;
|
||||
IF NOT has_table_privilege('yovision_t015_bell', 'bell.audit_events', 'SELECT,INSERT')
|
||||
OR has_table_privilege('yovision_t015_bell', 'bell.audit_events', 'UPDATE,DELETE,TRUNCATE')
|
||||
OR NOT has_table_privilege('yovision_t015_bell', 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')
|
||||
OR has_table_privilege('yovision_t015_bell', 'bell.audit_relay_receipts', 'UPDATE,TRUNCATE') THEN
|
||||
RAISE EXCEPTION 'Bell runtime violates audit relay privileges';
|
||||
END IF;
|
||||
IF has_table_privilege('public', 'bell.audit_events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||
OR has_table_privilege('public', 'bell.audit_relay_receipts', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
|
||||
RAISE EXCEPTION 'Bell audit relay tables leaked to PUBLIC';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='sense' AND table_name='device_operation_outbox'
|
||||
AND column_name='relay_lease_token'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Sense audit relay fencing columns are missing';
|
||||
END IF;
|
||||
END
|
||||
$audit_relay$;
|
||||
|
||||
Reference in New Issue
Block a user