feat(store): add Area admission and audit outbox [T-010]
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
-- Bell owns Area and capture-policy truth. Sense records only the highest
|
||||
-- projection version it has observed and the version used for admission.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bell.areas (
|
||||
tenant_id text NOT NULL,
|
||||
site_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
capture_policy text NOT NULL DEFAULT 'video_allowed',
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
deleted_at timestamptz,
|
||||
PRIMARY KEY (tenant_id, id),
|
||||
CONSTRAINT bell_areas_site_fk FOREIGN KEY (tenant_id, site_id)
|
||||
REFERENCES bell.sites(tenant_id, id),
|
||||
CONSTRAINT bell_areas_capture_policy CHECK (
|
||||
capture_policy IN ('video_allowed', 'non_imaging_only')
|
||||
),
|
||||
CONSTRAINT bell_areas_version_positive CHECK (version >= 1),
|
||||
CONSTRAINT bell_areas_identity_not_blank CHECK (
|
||||
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||
AND btrim(id) <> '' AND btrim(name) <> ''
|
||||
)
|
||||
);
|
||||
ALTER TABLE bell.areas OWNER TO bell_app;
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell.bump_area_version()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = pg_catalog, bell
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
|
||||
OR NEW.site_id IS DISTINCT FROM OLD.site_id
|
||||
OR NEW.id IS DISTINCT FROM OLD.id THEN
|
||||
RAISE EXCEPTION 'Bell Area identity and Site are immutable';
|
||||
END IF;
|
||||
NEW.version := OLD.version + 1;
|
||||
NEW.updated_at := clock_timestamp();
|
||||
RETURN NEW;
|
||||
END
|
||||
$function$;
|
||||
ALTER FUNCTION bell.bump_area_version() OWNER TO bell_app;
|
||||
|
||||
DROP TRIGGER IF EXISTS bell_areas_bump_version ON bell.areas;
|
||||
CREATE TRIGGER bell_areas_bump_version
|
||||
BEFORE UPDATE ON bell.areas
|
||||
FOR EACH ROW EXECUTE FUNCTION bell.bump_area_version();
|
||||
|
||||
CREATE OR REPLACE VIEW bell.area_policy_v1 (
|
||||
tenant_id,
|
||||
site_id,
|
||||
area_id,
|
||||
capture_policy,
|
||||
source_version,
|
||||
source_updated_at
|
||||
) AS
|
||||
SELECT
|
||||
area.tenant_id,
|
||||
area.site_id,
|
||||
area.id,
|
||||
area.capture_policy,
|
||||
area.version,
|
||||
area.updated_at
|
||||
FROM bell.areas AS area
|
||||
JOIN bell.sites AS site
|
||||
ON site.tenant_id = area.tenant_id AND site.id = area.site_id
|
||||
WHERE area.deleted_at IS NULL AND site.deleted_at IS NULL;
|
||||
ALTER VIEW bell.area_policy_v1 OWNER TO bell_app;
|
||||
COMMENT ON VIEW bell.area_policy_v1 IS
|
||||
'v1 read-only Area capture-policy projection owned by Bell and consumed by Sense';
|
||||
|
||||
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS area_id text;
|
||||
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS area_policy_source_version bigint;
|
||||
|
||||
DO $constraints$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'sense.devices'::regclass
|
||||
AND conname = 'sense_devices_area_not_blank'
|
||||
) THEN
|
||||
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_area_not_blank
|
||||
CHECK (area_id IS NULL OR btrim(area_id) <> '');
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'sense.devices'::regclass
|
||||
AND conname = 'sense_devices_area_version_positive'
|
||||
) THEN
|
||||
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_area_version_positive
|
||||
CHECK (area_policy_source_version IS NULL OR area_policy_source_version >= 1);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'sense.devices'::regclass
|
||||
AND conname = 'sense_devices_tenant_site_id_unique'
|
||||
) THEN
|
||||
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_tenant_site_id_unique
|
||||
UNIQUE (tenant_id, site_id, id);
|
||||
END IF;
|
||||
END
|
||||
$constraints$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.area_policy_projection_state (
|
||||
tenant_id text NOT NULL,
|
||||
site_id text NOT NULL,
|
||||
area_id text NOT NULL,
|
||||
source_version bigint NOT NULL,
|
||||
synced_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (tenant_id, site_id, area_id),
|
||||
CONSTRAINT sense_area_projection_identity_not_blank CHECK (
|
||||
btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(area_id) <> ''
|
||||
),
|
||||
CONSTRAINT sense_area_projection_version_positive CHECK (source_version >= 1)
|
||||
);
|
||||
ALTER TABLE sense.area_policy_projection_state OWNER TO sense_app;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sense_devices_area_idx
|
||||
ON sense.devices(tenant_id, site_id, area_id);
|
||||
|
||||
INSERT INTO bell.schema_migrations(version) VALUES (2)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
INSERT INTO sense.schema_migrations(version) VALUES (2)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Local durable audit facts. Transport, signatures, acknowledgements and
|
||||
-- retention belong to a later Bell relay contract.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sense.device_operation_outbox (
|
||||
event_id text PRIMARY KEY,
|
||||
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,
|
||||
available_at timestamptz NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz,
|
||||
delivered_at timestamptz,
|
||||
CONSTRAINT sense_outbox_device_fk FOREIGN KEY (tenant_id, site_id, device_id)
|
||||
REFERENCES sense.devices(tenant_id, site_id, id),
|
||||
CONSTRAINT sense_outbox_event_id_format CHECK (event_id ~ '^audit_[0-9a-f]{32}$'),
|
||||
CONSTRAINT sense_outbox_event_type CHECK (
|
||||
event_type IN ('device.created', 'device.desired_state.accepted')
|
||||
),
|
||||
CONSTRAINT sense_outbox_identity_not_blank CHECK (
|
||||
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||
AND btrim(device_id) <> '' AND btrim(actor_id) <> ''
|
||||
),
|
||||
CONSTRAINT sense_outbox_actor_type CHECK (
|
||||
actor_type IN ('user', 'service', 'system')
|
||||
),
|
||||
CONSTRAINT sense_outbox_generation_positive CHECK (aggregate_generation >= 1),
|
||||
CONSTRAINT sense_outbox_quota_version_positive CHECK (
|
||||
quota_source_version IS NULL OR quota_source_version >= 1
|
||||
),
|
||||
CONSTRAINT sense_outbox_area_version_positive CHECK (
|
||||
area_policy_source_version IS NULL OR area_policy_source_version >= 1
|
||||
),
|
||||
CONSTRAINT sense_outbox_attempt_nonnegative CHECK (attempt_count >= 0),
|
||||
CONSTRAINT sense_outbox_reason_length CHECK (reason IS NULL OR char_length(reason) <= 500),
|
||||
CONSTRAINT sense_outbox_trace_length CHECK (trace_id IS NULL OR char_length(trace_id) <= 128),
|
||||
CONSTRAINT sense_outbox_payload_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||
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')
|
||||
),
|
||||
CONSTRAINT sense_outbox_payload_redacted CHECK (
|
||||
NOT (payload ?| ARRAY[
|
||||
'endpoint_ref', 'credential_ref', 'profile_token', 'path_name',
|
||||
'password', 'stream_uri', 'mediamtx_config'
|
||||
])
|
||||
)
|
||||
);
|
||||
ALTER TABLE sense.device_operation_outbox OWNER TO sense_app;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sense_outbox_delivery_idx
|
||||
ON sense.device_operation_outbox(delivered_at, next_attempt_at, available_at, event_id);
|
||||
CREATE INDEX IF NOT EXISTS sense_outbox_device_idx
|
||||
ON sense.device_operation_outbox(tenant_id, site_id, device_id, occurred_at, event_id);
|
||||
|
||||
INSERT INTO sense.schema_migrations(version) VALUES (3)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Extend the T-009 least-privilege boundary to the Area projection and new
|
||||
-- Sense-owned state. Reapplying this file is intentional and safe.
|
||||
|
||||
REVOKE ALL ON TABLE bell.area_policy_v1 FROM PUBLIC;
|
||||
REVOKE ALL ON TABLE bell.areas FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION bell.bump_area_version() FROM PUBLIC;
|
||||
|
||||
GRANT USAGE ON SCHEMA bell TO sense_app;
|
||||
GRANT SELECT ON TABLE bell.area_policy_v1 TO sense_app;
|
||||
REVOKE ALL ON TABLE bell.areas FROM sense_app;
|
||||
REVOKE ALL ON FUNCTION bell.bump_area_version() FROM sense_app;
|
||||
|
||||
REVOKE ALL ON TABLE sense.area_policy_projection_state FROM PUBLIC;
|
||||
REVOKE ALL ON TABLE sense.device_operation_outbox FROM PUBLIC;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
|
||||
sense.area_policy_projection_state,
|
||||
sense.device_operation_outbox
|
||||
TO sense_app;
|
||||
@@ -1,6 +1,6 @@
|
||||
# YoVision PostgreSQL 初始化
|
||||
|
||||
本目录实现 T-009 的 PostgreSQL `17.10` 初始 schema。SQL 按文件名前缀顺序执行:先创建 NOLOGIN 权限角色,再创建 Bell、Sense 对象,最后收窄跨 schema 权限。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。
|
||||
本目录实现 T-009/T-010 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 策略投影、设备操作审计 Outbox 和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。
|
||||
|
||||
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||
|
||||
@@ -15,11 +15,12 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
||||
|
||||
## 权限边界
|
||||
|
||||
- `bell_app` 拥有 `bell.sites`、版本 trigger 和 `bell.site_quota_v1`。
|
||||
- `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和配额视图的 `SELECT`。
|
||||
- `sense_app` 对 `bell.sites`、Bell migration 表和 trigger function 没有权限。
|
||||
- `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 的传输、签名、确认和留存尚未实现。
|
||||
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
||||
|
||||
## 恢复
|
||||
|
||||
T-009 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。若 PostgreSQL 切换验证失败,停止 PostgreSQL 模式的 Sense 进程并恢复 `SENSE_DB_DRIVER=sqlite` 与原 SQLite DSN。初始 schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
|
||||
T-009/T-010 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox 一致性,不能作为后续公共控制 API 的生产后端。若 PostgreSQL 切换验证失败,停止 PostgreSQL 模式的 Sense 进程并恢复原实验室 SQLite DSN;不要把此回退误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
|
||||
|
||||
@@ -6,10 +6,12 @@ DECLARE
|
||||
default_quota integer;
|
||||
initial_version bigint;
|
||||
updated_version bigint;
|
||||
area_initial_version bigint;
|
||||
area_updated_version bigint;
|
||||
BEGIN
|
||||
IF current_setting('server_version_num')::integer < 170000
|
||||
OR current_setting('server_version_num')::integer >= 180000 THEN
|
||||
RAISE EXCEPTION 'T-009 requires PostgreSQL 17.x';
|
||||
RAISE EXCEPTION 'YoVision requires PostgreSQL 17.x';
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(column_name ORDER BY ordinal_position)
|
||||
@@ -37,6 +39,43 @@ BEGIN
|
||||
RAISE EXCEPTION 'sense_app has forbidden Bell source-table privileges';
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(column_name ORDER BY ordinal_position)
|
||||
INTO actual_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'bell' AND table_name = 'area_policy_v1';
|
||||
IF actual_columns IS DISTINCT FROM ARRAY[
|
||||
'tenant_id', 'site_id', 'area_id', 'capture_policy',
|
||||
'source_version', 'source_updated_at'
|
||||
] THEN
|
||||
RAISE EXCEPTION 'area_policy_v1 signature drift: %', actual_columns;
|
||||
END IF;
|
||||
|
||||
IF NOT has_table_privilege('sense_app', 'bell.area_policy_v1', 'SELECT') THEN
|
||||
RAISE EXCEPTION 'sense_app lacks SELECT on Area policy view';
|
||||
END IF;
|
||||
IF has_table_privilege('sense_app', 'bell.area_policy_v1', 'INSERT')
|
||||
OR has_table_privilege('sense_app', 'bell.area_policy_v1', 'UPDATE')
|
||||
OR has_table_privilege('sense_app', 'bell.area_policy_v1', 'DELETE')
|
||||
OR has_table_privilege('sense_app', 'bell.areas', 'SELECT')
|
||||
OR has_table_privilege('sense_app', 'bell.areas', 'INSERT')
|
||||
OR has_table_privilege('sense_app', 'bell.areas', 'UPDATE')
|
||||
OR has_table_privilege('sense_app', 'bell.areas', 'DELETE') THEN
|
||||
RAISE EXCEPTION 'sense_app violates the Area projection boundary';
|
||||
END IF;
|
||||
IF has_function_privilege('sense_app', 'bell.bump_area_version()', 'EXECUTE') THEN
|
||||
RAISE EXCEPTION 'sense_app can execute the Bell Area version trigger function';
|
||||
END IF;
|
||||
IF NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'SELECT')
|
||||
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'INSERT')
|
||||
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'UPDATE')
|
||||
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
|
||||
OR (SELECT max(version) FROM sense.schema_migrations) <> 3 THEN
|
||||
RAISE EXCEPTION 'schema migration version drift';
|
||||
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;
|
||||
@@ -51,6 +90,28 @@ BEGIN
|
||||
RAISE EXCEPTION 'Bell site source_version is not monotonic';
|
||||
END IF;
|
||||
|
||||
INSERT INTO bell.areas(tenant_id, site_id, id, name, capture_policy)
|
||||
VALUES (
|
||||
'assertion-tenant', 'assertion-site', 'assertion-area',
|
||||
'Assertion Area', 'video_allowed'
|
||||
)
|
||||
RETURNING version INTO area_initial_version;
|
||||
|
||||
UPDATE bell.areas SET capture_policy = 'non_imaging_only'
|
||||
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area'
|
||||
RETURNING version INTO area_updated_version;
|
||||
IF area_initial_version <> 1 OR area_updated_version <> area_initial_version + 1 THEN
|
||||
RAISE EXCEPTION 'Bell Area source_version is not monotonic';
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO bell.areas(tenant_id, site_id, id, name, capture_policy)
|
||||
VALUES ('assertion-tenant', 'assertion-site', 'invalid-area', 'Invalid', 'unknown');
|
||||
RAISE EXCEPTION 'unknown capture_policy unexpectedly accepted';
|
||||
EXCEPTION
|
||||
WHEN check_violation THEN NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO bell.sites(tenant_id, id, name, max_video_channels)
|
||||
VALUES ('assertion-tenant', 'invalid-site', 'Invalid', 129);
|
||||
@@ -59,6 +120,8 @@ BEGIN
|
||||
WHEN check_violation THEN NULL;
|
||||
END;
|
||||
|
||||
DELETE FROM bell.areas
|
||||
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area';
|
||||
DELETE FROM bell.sites
|
||||
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site';
|
||||
END
|
||||
|
||||
Reference in New Issue
Block a user