让 AI Agent 只看自己的数据:一套可验证的 PostgreSQL 行级安全方案

2026-08-07 59 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:8 分钟

当 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

还应测试一个容易被忽略的行为:对不可见行执行 UPDATEDELETE,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'

为什么 USINGWITH CHECK 缺一不可

USING 决定当前角色能看见哪些旧行,也影响 SELECTUPDATEDELETE 的目标集合。WITH CHECK 检查写入后的新行,阻止 Agent 把一条合法记录的 tenant_id 改成别人的租户。

UPDATE 只写 USING,常常会留下“能不能把行移动到另一个租户”的模糊空间。把两侧都写清楚,更容易审计,也能让测试精确对应安全意图。示例还把业务状态纳入写策略:只能创建草稿,已发布任务不可修改,只有草稿可删除。实际系统可以把发布动作放进经过审计的存储过程,并只授予 Agent 执行该过程的权限。

上线前的检查清单

将 RLS 用于 AI Agent 时,建议把以下检查放进迁移评审和 CI:

  • Agent 使用非所有者、非超级用户、无 BYPASSRLS 的角色。
  • 所有敏感表同时启用 ENABLE RLSFORCE RLS
  • 身份由可信数据库角色或可信网关注入,Agent 不能伪造。
  • SELECTINSERTUPDATEDELETE 分别有可读的策略。
  • 测试覆盖同租户成功、跨租户读取为空、跨租户写入失败或影响 0 行。
  • 新增表、分区和后台任务不会绕过现有策略。
  • 连接池在请求结束时可靠执行 RESET ROLE 或丢弃会话状态。

RLS 不是完整的 Agent 沙箱:它不限制昂贵查询,不阻止合法数据被发送到外部,也不能替代网络隔离、查询超时、审计日志和密钥管理。但它能把最关键的“这一行属于谁”变成数据库可执行、可回归测试的约束。对于会自主调用工具的 Agent,这道边界应当由数据库证明,而不是由提示词承诺。


相关推荐