feat(store): add PostgreSQL foundation [T-009]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-07 17:52:50 +08:00
parent 04231d006a
commit 2ff7ff5618
23 changed files with 1593 additions and 24 deletions
+24
View File
@@ -0,0 +1,24 @@
-- T-009 cluster roles. Runtime login roles are deployment-specific members of
-- these NOLOGIN roles; passwords and login principals never belong in Git.
DO $roles$
DECLARE
role_record pg_roles%ROWTYPE;
BEGIN
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_app';
IF NOT FOUND THEN
CREATE ROLE bell_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
ELSIF role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole
OR role_record.rolreplication OR role_record.rolcanlogin THEN
RAISE EXCEPTION 'existing bell_app role has unsafe attributes';
END IF;
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'sense_app';
IF NOT FOUND THEN
CREATE ROLE sense_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
ELSIF role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole
OR role_record.rolreplication OR role_record.rolcanlogin THEN
RAISE EXCEPTION 'existing sense_app role has unsafe attributes';
END IF;
END
$roles$;
+73
View File
@@ -0,0 +1,73 @@
-- Bell owns Tenant/Site/quota truth. Run as the cluster administrator or a
-- migration role that can SET ROLE to bell_app.
CREATE SCHEMA IF NOT EXISTS bell AUTHORIZATION bell_app;
ALTER SCHEMA bell OWNER TO bell_app;
CREATE TABLE IF NOT EXISTS bell.schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
ALTER TABLE bell.schema_migrations OWNER TO bell_app;
CREATE TABLE IF NOT EXISTS bell.sites (
tenant_id text NOT NULL,
id text NOT NULL,
name text NOT NULL,
max_video_channels smallint NOT NULL DEFAULT 16,
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_sites_video_quota_range
CHECK (max_video_channels BETWEEN 1 AND 128),
CONSTRAINT bell_sites_version_positive CHECK (version >= 1),
CONSTRAINT bell_sites_identity_not_blank
CHECK (btrim(tenant_id) <> '' AND btrim(id) <> '' AND btrim(name) <> '')
);
ALTER TABLE bell.sites OWNER TO bell_app;
CREATE OR REPLACE FUNCTION bell.bump_site_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.id IS DISTINCT FROM OLD.id THEN
RAISE EXCEPTION 'Bell site identity is immutable';
END IF;
NEW.version := OLD.version + 1;
NEW.updated_at := clock_timestamp();
RETURN NEW;
END
$function$;
ALTER FUNCTION bell.bump_site_version() OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_sites_bump_version ON bell.sites;
CREATE TRIGGER bell_sites_bump_version
BEFORE UPDATE ON bell.sites
FOR EACH ROW EXECUTE FUNCTION bell.bump_site_version();
CREATE OR REPLACE VIEW bell.site_quota_v1 (
tenant_id,
site_id,
max_video_channels,
source_version,
source_updated_at
) AS
SELECT
site.tenant_id,
site.id,
site.max_video_channels,
site.version,
site.updated_at
FROM bell.sites AS site
WHERE site.deleted_at IS NULL;
ALTER VIEW bell.site_quota_v1 OWNER TO bell_app;
COMMENT ON VIEW bell.site_quota_v1 IS
'v1 read-only site video quota projection owned by Bell and consumed by Sense';
INSERT INTO bell.schema_migrations(version) VALUES (1)
ON CONFLICT (version) DO NOTHING;
+86
View File
@@ -0,0 +1,86 @@
-- Sense owns device desired state and reconciliation progress. It deliberately
-- has no writable Site or quota truth table.
CREATE SCHEMA IF NOT EXISTS sense AUTHORIZATION sense_app;
ALTER SCHEMA sense OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
ALTER TABLE sense.schema_migrations OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.devices (
id text PRIMARY KEY,
tenant_id text NOT NULL,
site_id text NOT NULL,
serial_number text NOT NULL,
name text NOT NULL,
modality text NOT NULL,
desired_state text NOT NULL,
actual_state text NOT NULL,
endpoint_ref text NOT NULL DEFAULT '',
credential_ref text NOT NULL DEFAULT '',
path_name text NOT NULL DEFAULT '',
generation bigint NOT NULL DEFAULT 1,
quota_source_version bigint,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT sense_devices_identity_not_blank CHECK (
btrim(id) <> '' AND btrim(tenant_id) <> '' AND btrim(site_id) <> ''
AND btrim(serial_number) <> '' AND btrim(name) <> ''
),
CONSTRAINT sense_devices_modality CHECK (
modality IN ('video', 'radar', 'contact', 'button', 'wearable', 'other')
),
CONSTRAINT sense_devices_desired_state CHECK (desired_state IN ('disabled', 'enabled')),
CONSTRAINT sense_devices_actual_state CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
CONSTRAINT sense_devices_generation_positive CHECK (generation >= 1),
CONSTRAINT sense_devices_quota_version_positive CHECK (
quota_source_version IS NULL OR quota_source_version >= 1
),
UNIQUE (tenant_id, site_id, serial_number)
);
ALTER TABLE sense.devices OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.device_capabilities (
device_id text NOT NULL REFERENCES sense.devices(id) ON DELETE CASCADE,
capability text NOT NULL,
PRIMARY KEY (device_id, capability),
CONSTRAINT sense_device_capability_known CHECK (
capability IN ('video_capture', 'audio_capture', 'spatial_rule', 'telemetry')
)
);
ALTER TABLE sense.device_capabilities OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.reconcile_state (
device_id text PRIMARY KEY REFERENCES sense.devices(id) ON DELETE CASCADE,
failure_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz,
last_error_code text,
observed_generation bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL,
CONSTRAINT sense_reconcile_failure_nonnegative CHECK (failure_count >= 0),
CONSTRAINT sense_reconcile_generation_nonnegative CHECK (observed_generation >= 0)
);
ALTER TABLE sense.reconcile_state OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.site_quota_projection_state (
tenant_id text NOT NULL,
site_id text NOT NULL,
source_version bigint NOT NULL,
synced_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, site_id),
CONSTRAINT sense_quota_projection_version_positive CHECK (source_version >= 1)
);
ALTER TABLE sense.site_quota_projection_state OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
ON sense.devices(tenant_id, site_id, desired_state);
CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
ON sense.devices(path_name) WHERE path_name <> '';
CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
ON sense.reconcile_state(next_attempt_at);
INSERT INTO sense.schema_migrations(version) VALUES (1)
ON CONFLICT (version) DO NOTHING;
+21
View File
@@ -0,0 +1,21 @@
-- Cross-schema privileges are intentionally one-way: Bell publishes one view;
-- Sense cannot read Bell source tables or write any Bell object.
REVOKE ALL ON SCHEMA bell FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA bell FROM PUBLIC;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA bell FROM PUBLIC;
REVOKE ALL ON SCHEMA sense FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA sense FROM PUBLIC;
GRANT USAGE ON SCHEMA bell TO sense_app;
GRANT SELECT ON TABLE bell.site_quota_v1 TO sense_app;
REVOKE ALL ON TABLE bell.sites FROM sense_app;
REVOKE ALL ON TABLE bell.schema_migrations FROM sense_app;
REVOKE ALL ON FUNCTION bell.bump_site_version() FROM sense_app;
GRANT USAGE ON SCHEMA sense TO sense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA sense TO sense_app;
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;
ALTER DEFAULT PRIVILEGES FOR ROLE sense_app IN SCHEMA sense REVOKE ALL ON TABLES FROM PUBLIC;
+25
View File
@@ -0,0 +1,25 @@
# YoVision PostgreSQL 初始化
本目录实现 T-009 的 PostgreSQL `17.10` 初始 schema。SQL 按文件名前缀顺序执行:先创建 NOLOGIN 权限角色,再创建 Bell、Sense 对象,最后收窄跨 schema 权限。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
```powershell
$env:YOVISION_PG_ADMIN_DSN = '由管理员私下设置'
Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
Sort-Object Name |
ForEach-Object { & 'D:\pgsql17\bin\psql.exe' -X -v ON_ERROR_STOP=1 -d $env:YOVISION_PG_ADMIN_DSN -f $_.FullName }
```
不要把该示例替换成真实连接串后提交。自动验收使用 [`scripts/test_postgres.ps1`](../../scripts/test_postgres.ps1) 启动隔离临时集群,不读取或修改 `D:\pgsql17\data`。
## 权限边界
- `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 没有权限。
- PUBLIC 对两个业务 schema 的表和函数没有权限。
## 恢复
T-009 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。若 PostgreSQL 切换验证失败,停止 PostgreSQL 模式的 Sense 进程并恢复 `SENSE_DB_DRIVER=sqlite` 与原 SQLite DSN。初始 schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
+65
View File
@@ -0,0 +1,65 @@
\set ON_ERROR_STOP on
DO $assertions$
DECLARE
actual_columns text[];
default_quota integer;
initial_version bigint;
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';
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 = 'site_quota_v1';
IF actual_columns IS DISTINCT FROM ARRAY[
'tenant_id', 'site_id', 'max_video_channels', 'source_version', 'source_updated_at'
] THEN
RAISE EXCEPTION 'site_quota_v1 signature drift: %', actual_columns;
END IF;
IF NOT has_table_privilege('sense_app', 'bell.site_quota_v1', 'SELECT') THEN
RAISE EXCEPTION 'sense_app lacks SELECT on quota view';
END IF;
IF has_table_privilege('sense_app', 'bell.site_quota_v1', 'INSERT')
OR has_table_privilege('sense_app', 'bell.site_quota_v1', 'UPDATE')
OR has_table_privilege('sense_app', 'bell.site_quota_v1', 'DELETE') THEN
RAISE EXCEPTION 'sense_app has forbidden quota-view write privileges';
END IF;
IF has_table_privilege('sense_app', 'bell.sites', 'SELECT')
OR has_table_privilege('sense_app', 'bell.sites', 'INSERT')
OR has_table_privilege('sense_app', 'bell.sites', 'UPDATE')
OR has_table_privilege('sense_app', 'bell.sites', 'DELETE') THEN
RAISE EXCEPTION 'sense_app has forbidden Bell source-table privileges';
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;
IF default_quota <> 16 OR initial_version <> 1 THEN
RAISE EXCEPTION 'Bell site default quota/version drift';
END IF;
UPDATE bell.sites SET name = 'Assertion Site Updated'
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site'
RETURNING version INTO updated_version;
IF updated_version <> initial_version + 1 THEN
RAISE EXCEPTION 'Bell site source_version is not monotonic';
END IF;
BEGIN
INSERT INTO bell.sites(tenant_id, id, name, max_video_channels)
VALUES ('assertion-tenant', 'invalid-site', 'Invalid', 129);
RAISE EXCEPTION 'quota 129 unexpectedly accepted';
EXCEPTION
WHEN check_violation THEN NULL;
END;
DELETE FROM bell.sites
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site';
END
$assertions$;