feat(store): add PostgreSQL foundation [T-009]
This commit is contained in:
@@ -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$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 专用数据库,禁止对共享实例做模糊或递归清理。
|
||||
@@ -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$;
|
||||
Reference in New Issue
Block a user