feat(db): A5 审计表分区、append-only 与种子
audit_logs 按月 RANGE 分区、复合主键与 6 组索引;ensure_partitions 幂等建分区;UPDATE/DELETE/TRUNCATE 触发器与 lingxi_app 最小权限;audit_export_jobs;种子新增 3 项配置与 6 条演示审计。
This commit is contained in:
1 parent
408dc5e81b
commit
6215f204ce
6 files changed
+441
-4
No files matched your search
@@ -0,0 +1,178 @@
|
||||
-- A5-BE-01 · 审计表(月分区 + append-only)与异步导出任务表
|
||||
--
|
||||
-- Prisma 不支持分区 / 触发器 / 角色授权 DDL,本迁移在生成骨架后手写以下部分:
|
||||
-- 1. `audit_logs` 按月 RANGE 分区(复合主键 id + occurred_at),无 default 分区;
|
||||
-- 2. `audit_logs_ensure_partitions()` 幂等建分区,应用启动 + 每日定时调用;
|
||||
-- 3. BEFORE UPDATE / DELETE 行触发器 + BEFORE TRUNCATE 语句触发器拒绝篡改;
|
||||
-- 4. 创建 NOLOGIN 角色 `lingxi_app`,仅授予审计表 SELECT / INSERT(生产 DATABASE_URL 应使用该角色)。
|
||||
--
|
||||
-- 回滚方式:`audit_logs` / `audit_export_jobs` 为 A5 新增表,回滚可删除表与函数;
|
||||
-- 角色 `lingxi_app` 与授权保留(重跑迁移幂等)。禁止对 append-only 表执行 UPDATE / DELETE。
|
||||
|
||||
-- CreateTable(分区键 occurred_at 必须包含在唯一约束中)
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" CHAR(26) NOT NULL,
|
||||
"occurred_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"actor_id" CHAR(26),
|
||||
"actor_name" VARCHAR(64),
|
||||
"actor_account" VARCHAR(64),
|
||||
"action_code" VARCHAR(64) NOT NULL,
|
||||
"category" VARCHAR(32) NOT NULL,
|
||||
"target_type" VARCHAR(64),
|
||||
"target_id" VARCHAR(128),
|
||||
"target_name" VARCHAR(160),
|
||||
"customer_id" CHAR(26),
|
||||
"customer_name" VARCHAR(128),
|
||||
"result" VARCHAR(16) NOT NULL,
|
||||
"http_method" VARCHAR(8),
|
||||
"http_path" VARCHAR(255),
|
||||
"trace_id" VARCHAR(64),
|
||||
"ip" VARCHAR(64),
|
||||
"user_agent" VARCHAR(255),
|
||||
"before_json" JSONB,
|
||||
"after_json" JSONB,
|
||||
"changes_json" JSONB,
|
||||
"reason" VARCHAR(255),
|
||||
"error_code" VARCHAR(32),
|
||||
"error_message" VARCHAR(500),
|
||||
"duration_ms" INTEGER,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id","occurred_at")
|
||||
) PARTITION BY RANGE ("occurred_at");
|
||||
|
||||
-- CreateTable(普通表:导出任务需要更新状态 / 进度 / 下载计数)
|
||||
CREATE TABLE "audit_export_jobs" (
|
||||
"id" CHAR(26) NOT NULL,
|
||||
"status" VARCHAR(16) NOT NULL DEFAULT 'queued',
|
||||
"format" VARCHAR(16) NOT NULL DEFAULT 'csv',
|
||||
"filters_json" JSONB NOT NULL DEFAULT '{}',
|
||||
"requested_by" CHAR(26) NOT NULL,
|
||||
"requested_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"started_at" TIMESTAMPTZ(3),
|
||||
"finished_at" TIMESTAMPTZ(3),
|
||||
"total_rows" INTEGER,
|
||||
"row_count" INTEGER,
|
||||
"processed_rows" INTEGER,
|
||||
"truncated" BOOLEAN NOT NULL DEFAULT false,
|
||||
"file_path" VARCHAR(500),
|
||||
"file_size" INTEGER,
|
||||
"error_message" VARCHAR(500),
|
||||
"expires_at" TIMESTAMPTZ(3),
|
||||
"download_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_downloaded_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "audit_export_jobs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 分区自动创建:从当月创建到 +months_ahead 月,幂等;应用启动 / 每日定时调用。
|
||||
-- 不使用 default 分区(避免后续 ATTACH 冲突;写入范围由 ensure 保证)。
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION audit_logs_ensure_partitions(months_ahead int DEFAULT 13)
|
||||
RETURNS int
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
i int;
|
||||
start_ts timestamptz;
|
||||
end_ts timestamptz;
|
||||
part_name text;
|
||||
created int := 0;
|
||||
BEGIN
|
||||
IF months_ahead IS NULL OR months_ahead < 0 OR months_ahead > 120 THEN
|
||||
RAISE EXCEPTION 'audit_logs_ensure_partitions: months_ahead 必须在 0..120 之间(收到 %)', months_ahead
|
||||
USING ERRCODE = 'invalid_parameter_value';
|
||||
END IF;
|
||||
|
||||
FOR i IN 0..months_ahead LOOP
|
||||
start_ts := date_trunc('month', now()) + make_interval(months => i);
|
||||
end_ts := start_ts + interval '1 month';
|
||||
part_name := 'audit_logs_' || to_char(start_ts, 'YYYYMM');
|
||||
|
||||
IF to_regclass(format('%I.%I', current_schema(), part_name)) IS NULL THEN
|
||||
EXECUTE format(
|
||||
'CREATE TABLE %I PARTITION OF audit_logs FOR VALUES FROM (%L) TO (%L)',
|
||||
part_name, start_ts, end_ts
|
||||
);
|
||||
created := created + 1;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN created;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION audit_logs_ensure_partitions(int) IS
|
||||
'幂等创建 audit_logs 月分区(当月 ~ +months_ahead 月);返回本次新建分区数。应用启动与每日定时调用。';
|
||||
|
||||
-- 迁移时立即创建 14 个月分区,保证 seed / 首次启动即可写入
|
||||
SELECT audit_logs_ensure_partitions(13);
|
||||
|
||||
-- CreateIndex(父表索引会递归创建到已有分区,未来分区自动继承)
|
||||
CREATE INDEX "audit_logs_occurred_at_id_idx" ON "audit_logs"("occurred_at" DESC, "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_actor_id_occurred_at_idx" ON "audit_logs"("actor_id", "occurred_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_customer_id_occurred_at_idx" ON "audit_logs"("customer_id", "occurred_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_action_code_occurred_at_idx" ON "audit_logs"("action_code", "occurred_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_target_type_target_id_idx" ON "audit_logs"("target_type", "target_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_result_occurred_at_idx" ON "audit_logs"("result", "occurred_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_export_jobs_requested_by_requested_at_idx" ON "audit_export_jobs"("requested_by", "requested_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_export_jobs_status_idx" ON "audit_export_jobs"("status");
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- append-only:任何 UPDATE / DELETE / TRUNCATE 都在 DB 层拒绝(含表 owner)。
|
||||
-- 触发器是本地 postgres 账号的兜底;生产应用账号使用 lingxi_app(仅 SELECT / INSERT)。
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION audit_logs_block_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_logs 为 append-only 审计表,禁止 % 操作', TG_OP
|
||||
USING ERRCODE = 'insufficient_privilege',
|
||||
HINT = '审计日志仅允许 INSERT;归档 / 清理不得原地 UPDATE 或 DELETE(A5-BE-04 归档仅做只读搬迁)。';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER audit_logs_no_update_delete
|
||||
BEFORE UPDATE OR DELETE ON audit_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_logs_block_mutation();
|
||||
|
||||
CREATE TRIGGER audit_logs_no_truncate
|
||||
BEFORE TRUNCATE ON audit_logs
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION audit_logs_block_mutation();
|
||||
|
||||
-- 显式收回 PUBLIC 的写权限(新表默认无授权,此处表达 intent,便于审计授权差异)
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON "audit_logs" FROM PUBLIC;
|
||||
|
||||
-- 应用角色:生产 DATABASE_URL 应使用 lingxi_app(NOLOGIN 角色由部署侧授予登录或使用 SET ROLE);
|
||||
-- 本地继续使用 postgres,由触发器兜底 append-only 约束。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'lingxi_app') THEN
|
||||
CREATE ROLE lingxi_app NOLOGIN;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT SELECT, INSERT ON "audit_logs" TO lingxi_app;
|
||||
-- 导出任务表是应用可更新的普通表(队列状态 / 进度 / 下载计数)
|
||||
GRANT SELECT, INSERT, UPDATE ON "audit_export_jobs" TO lingxi_app;
|
||||
|
||||
COMMENT ON TABLE audit_logs IS
|
||||
'审计日志(append-only,按月 RANGE 分区;仅 INSERT,禁止 UPDATE / DELETE / TRUNCATE)。分区由 audit_logs_ensure_partitions 保证。';
|
||||
COMMENT ON TABLE audit_export_jobs IS
|
||||
'审计异步导出任务;文件短时效签名下载,任务本身可更新进度(A5-BE-03)。';
|
||||
Reference in new issue
Block a user