当 AI Agent 可以直接查询或修改业务数据库时,权限边界不能只写在提示词里。提示词会漂移,应用过滤条件可能漏写,而 PostgreSQL 行级安全(Row-Level Security,RLS)能够把隔离规则放到数据真正落盘的位置。
一套可信的 RLS 配置不只是执行 ENABLE ROW LEVEL SECURITY。还需要让 Agent 使用非所有者角色、对表启用 FORCE RLS、分别声明读写策略,并用“应该失败”的测试证明越权确实被拒绝。
权限模型:身份必须来自数据库会话
下面采用一个明确假设:每个 Agent 以独立数据库角色连接,或者由可信连接层执行 SET ROLE。角色与租户的映射保存在受保护的表中,策略通过 current_user 获取当前角色。
不要让 Agent 自己提交 tenant_id,也不要直接信任它能随意设置的自定义会话变量。否则,攻击者只需把变量改成另一个租户的 ID,RLS 就会变成一扇没有锁的门。
可以用临时 PostgreSQL 实例运行完整示例:
docker run --rm --name rls-demo \
-e POSTGRES_PASSWORD=postgres \
-p 55432:5432 -d postgres:16
export PGURL='postgresql://postgres:postgres@localhost:55432/postgres'
接着保存并执行以下 setup.sql。示例创建两个 Agent、两个租户和一张任务表:
CREATE SCHEMA private;
CREATE SCHEMA app;
CREATE ROLE agent_alpha LOGIN PASSWORD 'alpha-dev-only';
CREATE ROLE agent_beta LOGIN PASSWORD 'beta-dev-only';
CREATE TABLE private.agent_identities (
role_name name PRIMARY KEY,
tenant_id uuid NOT NULL
);
INSERT INTO private.agent_identities VALUES
('agent_alpha', '11111111-1111-1111-1111-111111111111'),
('agent_beta', '22222222-2222-2222-2222-222222222222');
REVOKE ALL ON SCHEMA private FROM PUBLIC;
REVOKE ALL ON private.agent_identities FROM PUBLIC;
CREATE FUNCTION private.agent_tenant(p_role name)
RETURNS uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, private
AS $$
SELECT tenant_id
FROM private.agent_identities
WHERE role_name = p_role
$$;
REVOKE ALL ON FUNCTION private.agent_tenant(name) FROM PUBLIC;
GRANT USAGE ON SCHEMA private TO agent_alpha, agent_beta;
GRANT EXECUTE ON FUNCTION private.agent_tenant(name) TO agent_alpha, agent_beta;
CREATE TABLE app.tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
title text NOT NULL,
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'ready', 'published'))
);
INSERT INTO app.tasks (tenant_id, title, status) VALUES
('11111111-1111-1111-1111-111111111111', 'Alpha plan', 'draft'),
('22222222-2222-2222-2222-222222222222', 'Beta plan', 'draft');
ALTER TABLE app.tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.tasks FORCE ROW LEVEL SECURITY;
CREATE POLICY tasks_select ON app.tasks
FOR SELECT
USING (tenant_id = private.agent_tenant(current_user));
CREATE POLICY tasks_insert ON app.tasks
FOR INSERT
WITH CHECK (
tenant_id = private.agent_tenant(current_user)
AND status = 'draft'
);
CREATE POLICY tasks_update ON app.tasks
FOR UPDATE
USING (
tenant_id = private.agent_tenant(current_user)
AND status <> 'published'
)
WITH CHECK (
tenant_id = private.agent_tenant(current_user)
AND status <> 'published'
);
CREATE POLICY tasks_delete ON app.tasks
FOR DELETE
USING (
tenant_id = private.agent_tenant(current_user)
AND status = 'draft'
);
GRANT USAGE ON SCHEMA app TO agent_alpha, agent_beta;
GRANT SELECT, INSERT, UPDATE, DELETE ON app.tasks TO agent_alpha, agent_beta;
GRANT USAGE, SELECT ON SEQUENCE app.tasks_id_seq TO agent_alpha, agent_beta;
执行:
psql "$PGURL" -v ON_ERROR_STOP=1 -f setup.sql
这里有三个关键点。Agent 不是表所有者;FORCE RLS 让表所有者在普通访问路径中也不能无意绕过策略;每种写操作都有显式策略,而不是依赖一条宽泛的 ALL 策略。超级用户、拥有 BYPASSRLS 的角色以及某些维护路径仍可绕过 RLS,因此这些身份绝不能交给 Agent。
读隔离与写约束要分别验证
只测试“Alpha 能看到 Alpha 数据”是不够的。安全测试必须同时覆盖允许路径和拒绝路径。下面的命令使用管理员会话执行 SET ROLE,便于在本地重复测试:
psql "$PGURL" -v ON_ERROR_STOP=1 <<'SQL'
SET ROLE agent_alpha;
-- 只能返回 Alpha plan。
TABLE app.tasks;
-- 合法写入。
INSERT INTO app.tasks (tenant_id, title)
VALUES ('11111111-1111-1111-1111-111111111111', 'Alpha draft');
RESET ROLE;
SQL
拒绝测试应把失败当成成功信号。这个脚本检查 Alpha 无法向 Beta 租户写入:
if psql "$PGURL" -v ON_ERROR_STOP=1 <<'SQL'
SET ROLE agent_alpha;
INSERT INTO app.tasks (tenant_id, title)
VALUES ('22222222-2222-2222-2222-222222222222', 'Cross-tenant write');
SQL
then
echo 'FAIL: cross-tenant insert was accepted' >&2
exit 1
else
echo 'PASS: cross-tenant insert was denied'
fi
还应测试一个容易被忽略的行为:对不可见行执行 UPDATE 或 DELETE,PostgreSQL 通常返回“影响 0 行”,而不一定抛出错误。因此测试必须检查行数和最终状态,不能只检查退出码。
changed=$(psql "$PGURL" -At -v ON_ERROR_STOP=1 <<'SQL'
SET ROLE agent_alpha;
WITH changed AS (
UPDATE app.tasks
SET title = 'stolen'
WHERE tenant_id = '22222222-2222-2222-2222-222222222222'
RETURNING 1
)
SELECT count(*) FROM changed;
SQL
)
test "$changed" = "0" || { echo 'FAIL: cross-tenant update succeeded'; exit 1; }
echo 'PASS: cross-tenant update affected 0 rows'
为什么 USING 和 WITH CHECK 缺一不可
USING 决定当前角色能看见哪些旧行,也影响 SELECT、UPDATE 和 DELETE 的目标集合。WITH CHECK 检查写入后的新行,阻止 Agent 把一条合法记录的 tenant_id 改成别人的租户。
对 UPDATE 只写 USING,常常会留下“能不能把行移动到另一个租户”的模糊空间。把两侧都写清楚,更容易审计,也能让测试精确对应安全意图。示例还把业务状态纳入写策略:只能创建草稿,已发布任务不可修改,只有草稿可删除。实际系统可以把发布动作放进经过审计的存储过程,并只授予 Agent 执行该过程的权限。
上线前的检查清单
将 RLS 用于 AI Agent 时,建议把以下检查放进迁移评审和 CI:
- Agent 使用非所有者、非超级用户、无
BYPASSRLS的角色。 - 所有敏感表同时启用
ENABLE RLS和FORCE RLS。 - 身份由可信数据库角色或可信网关注入,Agent 不能伪造。
SELECT、INSERT、UPDATE、DELETE分别有可读的策略。- 测试覆盖同租户成功、跨租户读取为空、跨租户写入失败或影响 0 行。
- 新增表、分区和后台任务不会绕过现有策略。
- 连接池在请求结束时可靠执行
RESET ROLE或丢弃会话状态。
RLS 不是完整的 Agent 沙箱:它不限制昂贵查询,不阻止合法数据被发送到外部,也不能替代网络隔离、查询超时、审计日志和密钥管理。但它能把最关键的“这一行属于谁”变成数据库可执行、可回归测试的约束。对于会自主调用工具的 Agent,这道边界应当由数据库证明,而不是由提示词承诺。