使用哈希链在 PostgreSQL 中实现防篡改审计轨迹
通过只追加表和哈希链,在 PostgreSQL 中实现防篡改的审计轨迹,让审查和调查时的编辑易于发现。

为什么普通审计日志容易被质疑
审计轨迹是在某些事情看起来不对时你会依赖的记录:奇怪的退款、没人记得的权限变更,或“消失”的客户记录。如果审计轨迹可以被编辑,它就不再是证据,而变成了别人可以重写的数据。
很多“审计日志”只是普通表。如果行可以被更新或删除,那么故事也可以被更新或删除。
一个关键的区别是:阻止修改并不等同于让修改可检测。你可以通过权限减少改动,但任何拥有足够权限的人(或被盗的管理员凭据)仍然可以篡改历史。防篡改证据接受这一现实:你可能无法阻止所有改动,但可以让改动留下明显的指纹。
普通审计日志之所以容易被质疑,有一些可预测的原因。特权用户可以事后“修正”日志;被攻破的应用账号可以写出看起来合理的条目,伪装成正常流量;时间戳可以被补回以掩盖迟来的变更;或者有人只删除最有害的几行。
“防篡改”意味着你设计审计轨迹,使得即便是一次微小的修改(改动某个字段、删除一行、重新排列事件)也能在事后被检测到。你不是在做魔法,而是在承诺:当有人问“我们怎么知道这个日志是真实的?”时,你可以运行检查来显示日志是否被触碰过。
决定你需要证明的内容
只有在能回答未来会遇到的问题时,防篡改审计轨迹才有用:谁做了什么、什么时候做的、发生了哪些改变。
从对你业务重要的事件开始。数据变更(创建、更新、删除)是基础,但调查通常也依赖于安全与访问相关事件:登录、重置密码、权限更改和账户锁定。如果你处理支付、退款、信用或付款,把资金流动作为一类重要事件来对待,而不是作为更新行的副作用。
然后决定什么能让一条事件具有可信度。审计员通常期望看到执行者(用户或服务)、服务器端时间戳、所采取的动作和受影响的对象。对于更新,存储变更前后值(或至少敏感字段),再加上请求 id 或关联 id,以便把许多小的数据库变更关联回一次用户操作。
最后,要明确系统中“不可变”的含义。最简单的规则是:永远不要更新或删除审计行,只插入。如果出了问题,写一条新的事件来更正或覆盖旧的,并保留原始记录可见。
构建只追加的审计表
把审计数据和正常表分开。一个专用的 audit 模式可以减少意外修改,并使权限更容易管理。
目标很简单:允许添加行,但不允许更改或移除。在 PostgreSQL 中,你可以通过权限(谁能做什么)和在表设计中的一些安全约束来强制这一点。
这是一个实用的起始表:
CREATE SCHEMA IF NOT EXISTS audit;
CREATE TABLE audit.events (
id bigserial PRIMARY KEY,
entity_type text NOT NULL,
entity_id text NOT NULL,
event_type text NOT NULL CHECK (event_type IN ('INSERT','UPDATE','DELETE')),
actor_id text,
occurred_at timestamptz NOT NULL DEFAULT now(),
request_id text,
before_data jsonb,
after_data jsonb,
notes text
);
有几个字段在调查时特别有用:
occurred_at使用DEFAULT now(),以便时间由数据库而非客户端盖章。entity_type和entity_id,以便你能追踪同一记录的多次变更。request_id,让一次用户操作可以在多行之间被追溯。
用角色来锁定权限。你的应用角色应该只能对 audit.events 执行 INSERT 和 SELECT,而不能 UPDATE 或 DELETE。把模式变更和更强的权限留给不被应用直接使用的管理员角色。
用触发器捕获变更(简单且可预测)
如果你想要防篡改的审计轨迹,最可靠的捕获位置是数据库本身。应用日志可能被跳过、过滤或重写;而触发器无论哪个应用、脚本或管理工具触碰表都会触发。
让触发器保持简单。它们应该只做一件事:在每次对重要表的 INSERT、UPDATE 和 DELETE 时,追加一条审计事件。
实用的审计记录通常包含表名、操作类型、主键、变更前后值、时间戳和能把相关变更组合在一起的标识符(事务 id 和关联 id)。
关联 id 是把“更新了 20 行”变为“这是一次按钮点击”的关键。你的应用可以在每个请求中设置一次关联 id(例如在 DB 会话设置中),触发器可以读取它。也存储 txid_current(),这样当缺少关联 id 时仍能把变更分组。
下面是一个简单的触发器模式,它之所以可预测是因为它只向审计表插入(将名称调整为你的模式):
CREATE OR REPLACE FUNCTION audit_row_change() RETURNS trigger AS $$
DECLARE
corr_id text;
BEGIN
corr_id := current_setting('app.correlation_id', true);
INSERT INTO audit_events(
occurred_at, table_name, op, row_pk,
old_row, new_row, db_user, txid, correlation_id
) VALUES (
now(), TG_TABLE_NAME, TG_OP, COALESCE(NEW.id, OLD.id),
to_jsonb(OLD), to_jsonb(NEW), current_user, txid_current(), corr_id
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
抗拒在触发器中做更多事情的冲动。避免额外查询、网络调用或复杂分支。简短的触发器更容易测试、运行更快,也更难在审查时被质疑。
添加哈希链,让修改留下指纹
只追加表有帮助,但拥有足够权限的人仍然可以改写历史。哈希链能让这种篡改变得可见。
在每条审计行中增加两个列:prev_hash 和 row_hash(有时称为 chain_hash)。prev_hash 存储同一链中上一行的哈希。row_hash 存储当前行的哈希,它是由行数据加上 prev_hash 计算得出。
你要哈希的内容很重要。你需要一个稳定且可重复的输入,这样同一行总会产生相同的哈希。
一种实用的方法是哈希由固定列(时间戳、执行者、动作、实体 id)、规范化的负载(通常使用 jsonb,因为键的存储是一致的)和 prev_hash 拼接出的规范字符串。
要注意那些可能无意义地变化的细节,例如空白、纯文本 JSON 的键顺序或特定区域格式化。保持类型一致,并以一种可预测的方式序列化。
按流而不是全库建链
如果把每个审计事件串联成单一全局序列,写入可能成为瓶颈。许多系统在“流”内建链,比如按租户、按实体类型或按业务对象建链。
每条新行查找其流的最新 row_hash,把它存为 prev_hash,然后计算自己的 row_hash。
-- Requires pgcrypto
-- digest() returns bytea; store hashes as bytea
row_hash = digest(
concat_ws('|',
stream_key,
occurred_at::text,
actor_id::text,
action,
entity,
entity_id::text,
payload::jsonb::text,
encode(prev_hash, 'hex')
),
'sha256'
);
对链头做快照
为加快审查速度,定期(例如每天按流)在一个小的快照表里存储最新的 row_hash(“链头”)。在调查时,你可以验证到每个快照为止的链,而不必一次性扫描全部历史。快照也更方便比较导出并发现可疑缺口。
在不破坏链的情况下处理并发与排序
在真实流量下哈希链会变得棘手。如果两个事务同时写入审计行,并且都使用相同的 prev_hash,就可能出现分叉。这会削弱你证明单一、干净序列的能力。
首先决定链代表什么。一个全局链最容易解释,但竞争最高。多个链可以降低竞争,但你必须清楚每条链能证明什么。
无论采用哪种模型,都要用单调的事件 id(通常由序列支持)定义严格顺序。时间戳不足以保证顺序,因为它们可能碰撞并且可以被操纵。
为了避免在计算 prev_hash 时的竞态条件,对每个流序列化“获取最后哈希 + 插入下一行”操作。常见方法是锁定代表流头的单行,或使用以流 id 为键的 advisory lock。目标是:同一流的两个写入者不能同时读取到相同的最后哈希。
分区和分片会影响“最后一行”所在的位置。如果你预计要为审计数据做分区,使用与流键相同的分区键(例如租户 id),让每个链完全包含于一个分区内。这样,即便租户将来跨服务器迁移,租户链仍保持可验证。
在调查时如何验证链
哈希链只有在你能在有人询问时证明链仍然完整才有意义。最安全的方法是一个只读的验证查询(或作业),重新计算每行的哈希并与存储值比较。
一个可按需运行的简单验证器
验证器应该:重建每行的期望哈希,确认每行都链接到前一行,并标记任何异常。
下面是使用窗口函数的常见模式。请根据你的表调整列名。
WITH ordered AS (
SELECT
id,
created_at,
actor_id,
action,
entity,
entity_id,
payload,
prev_hash,
row_hash,
LAG(row_hash) OVER (ORDER BY created_at, id) AS expected_prev_hash,
/* expected row hash, computed the same way as in your insert trigger */
encode(
digest(
coalesce(prev_hash, '') || '|' ||
id::text || '|' ||
created_at::text || '|' ||
coalesce(actor_id::text, '') || '|' ||
action || '|' ||
entity || '|' ||
entity_id::text || '|' ||
payload::text,
'sha256'
),
'hex'
) AS expected_row_hash
FROM audit_log
)
SELECT
id,
created_at,
CASE
WHEN prev_hash IS DISTINCT FROM expected_prev_hash THEN 'BROKEN_LINK'
WHEN row_hash IS DISTINCT FROM expected_row_hash THEN 'HASH_MISMATCH'
ELSE 'OK'
END AS status
FROM ordered
WHERE prev_hash IS DISTINCT FROM expected_prev_hash
OR row_hash IS DISTINCT FROM expected_row_hash
ORDER BY created_at, id;
除了判断“是否破坏”,还值得检查缺口(范围内缺失的 id)、乱序的链接,以及不符合真实工作流程的可疑重复。
将验证结果记录为不可变事件
不要只是运行一个查询然后把输出丢进工单。把验证结果存到一个单独的只追加表(例如 audit_verification_runs)中,记录运行时间、验证器版本、触发人、检查范围,以及断链和哈希不匹配的计数。
这会给出第二条线索:不仅审计日志是否完整,你还可以证明你一直在做检查。
一个实用的频率是:每次部署影响审计逻辑后运行、对活跃系统每天运行一次,并且在计划审计前总是运行一次。
常见会破坏防篡改性的错误
大多数失败并非来自哈希算法本身,而是来自例外和缺口,让人有争论的余地。
丧失信任的最快方式是允许更新审计行。即便是“仅此一次”,你也创建了先例和一条可行路径来重写历史。如果需要更正,新增一条解释更正的审计事件,并保留原始记录。
当你哈希不稳定的数据时,哈希链也会失效。JSON 是常见的陷阱。如果你哈希 JSON 字符串,键顺序、空白、数字格式等微小差异都会改变哈希,使验证噪声增多。优先使用一种规范形式:字段归一化、jsonb 或其他一致的序列化方式。
其他会削弱可辩护轨迹的模式包括:
- 只哈希负载而跳过上下文(时间戳、执行者、对象 id、动作)。
- 仅在应用层捕获变更并假定数据库与之永远一致。
- 使用同一个数据库角色既能写业务数据又能修改审计历史。
- 在链内允许
prev_hash为 NULL 而没有清晰可记录的规则。
职责分离很重要。如果同一角色既能插入审计事件又能修改它们,防篡改就变成了一个承诺而非控制措施。
可辩护审计轨迹的快速清单
可辩护的审计轨迹应当难以更改且易于验证。
从访问控制开始:审计表在实际操作中必须是只追加的。应用角色应能插入(通常也能读取),但不能更新或删除。模式更改应严格受限。
确保每行能回答调查者会问的问题:谁做的、何时发生(服务器端时间)、发生了什么(清晰的事件名和操作)、触及了什么(实体名和 id),以及如何关联(请求/关联 id 和事务 id)。
然后验证完整性层。一个简单的测试是重放一段并确认每个 prev_hash 是否匹配前一行的哈希,以及每个存储的哈希是否与重新计算的一致。
在运营上,把验证当作常规作业:
- 运行定期完整性检查并记录通过/失败结果和检查范围。
- 对不匹配、缺口和断链发出告警。
- 保留备份时间足够覆盖你的保留窗口,并锁定保留策略以防早于预期地“清理”审计历史。
示例:在合规审查中发现可疑编辑
一个常见的测试用例是退款争议。客户声称他们被批准了 250 美元的退款,但系统现在显示 25 美元。支持认为批准是正确的,合规团队需要答案。
先用关联 id(订单 id、工单 id 或 refund_request_id)和时间窗口缩小搜索范围。提取该关联 id 对应的审计行,并把它们按时间括起来,围绕批准时间。
你要找的是完整的事件序列:请求创建、退款批准、退款金额设定以及任何后续更新。在防篡改设计下,你还会检查序列是否保持完整。
一个简单的调查流程:
- 拉取该关联 id 在时间序列内的所有审计行。
- 从存储字段(包括
prev_hash)重新计算每行的哈希。 - 将计算出的哈希与存储的哈希比对。
- 找到第一个不同的行,并查看之后的行是否也失败。
如果有人编辑了某一审计行(例如把金额从 250 改为 25),该行的哈希将不再匹配。因为下一行包含先前的哈希,差异通常会向后级联。该级联就是指纹:它显示审计记录在事后被修改过。
链能告诉你的:发生了修改、链首次断裂的位置以及受影响行的范围。链本身不能告诉你的有:谁进行了修改、若被覆盖原始值是什么,或其他表是否也被改动。
后续步骤:稳妥推出并保持可维护性
把审计轨迹当作其他安全控制来对待。分步骤推出,证明可行,然后再扩展。
从那些在被质疑时会对你造成最大伤害的动作开始:权限变更、支付、退款、数据导出和人工覆盖。一旦覆盖这些,就在不改变核心设计的前提下添加低风险事件。
把审计事件的契约写下来:记录哪些字段、每种事件类型的含义、哈希如何计算以及如何运行验证。把这些文档和数据库迁移放在一起,并保证验证过程可重复。
恢复演练很重要,因为调查通常会从备份开始而不是实时系统。定期恢复到测试数据库并验证链的端到端。如果在恢复后无法复现相同的验证结果,你的防篡改证据将难以辩护。
如果你在构建内部工具和管理工作流时使用 AppMaster (appmaster.io),通过一致的服务器端流程标准化审计事件写入有助于在各功能间保持事件架构和关联 id 的统一,从而让验证和调查更简单。
为该系统安排维护时间。审计轨迹通常在团队发布新功能但忘记添加事件、更新哈希输入或保持验证作业与恢复演练运行时悄然失效。


