第 3 章把语义层补在旧库上之后,只读检索这件事算跑通了:字段有了业务含义,一句自然语言问句能落到一条能执行的 SQL。可第一次上线评审就卡住——同一个问句,普通员工和部门主管拿到的结果一模一样。语义层解决的是能不能查到,没解决谁能看到。这一章处理后者。

先说清本章的前提。存量系统里那套权限多半已经跑了五六年:菜单和功能是 RBAC,某些敏感单据额外挂了 ACL,部门之间还有写死的可见范围规则。为 AI 单独建一套权限模型是最贵的方案,也是最危险的——两套模型迟早会长歪,而 AI 这一套通常没人维护。下面三道做法都建立在复用上,AI 只做已有权限体系的消费者,不做第二套裁判。

AI 服务的运行身份

第一件事是把 AI 服务自己的身份定下来。它不该继承调用者的会话,也不该拿着某个人的账号直接连库。它用一个服务账号运行,可用的权限范围写死在部署配置里,业务库的连接串干脆不发给它。

# deploy/ai-service.env —— AI 服务的运行身份与上限
AI_SERVICE_ACCOUNT: svc-ai-reader
AI_SERVICE_SCOPES: "orders.read,customers.read"
AI_SERVICE_TOKEN: "${VAULT:ai-service/token}"
BIZ_GATEWAY: "biz-gateway.internal:8080"
OBO_HEADER: "X-On-Behalf-Of"          # 代行身份头,由网关签发并校验
PERM_ENDPOINT: "iam.internal:9000/internal/check"
BUSINESS_DB_DSN: ""                   # 留空:AI 服务不持有业务库连接串

这份配置的价值是把边界固化在部署层:只有两个读 scope,没有写权限,也没有绕过业务网关直连库的可能。评审时看这一份文件就知道它的上限在哪,不用去翻代码。

代行身份的传递

服务账号不是终点。业务网关收到请求时还得知道这次调用是替谁做的,否则审计日志里全是 svc-ai-reader,出事查不到人。做法是在服务账号凭据之外,额外带一个代表真实用户的代行头,由网关校验并解出用户身份。

import os
import requests

GATEWAY = os.environ["BIZ_GATEWAY"]

def fetch_orders(user_token: str, dept: str, limit: int = 20) -> list:
    """以服务账号身份调用业务网关,用代行头传递真实用户。"""
    resp = requests.get(
        f"{GATEWAY}/api/orders",
        params={"dept": dept, "limit": limit},
        headers={
            "Authorization": f"Bearer {os.environ['AI_SERVICE_TOKEN']}",
            "X-On-Behalf-Of": user_token,
        },
        timeout=5,
    )
    resp.raise_for_status()
    return resp.json()["items"]

网关侧的解析逻辑要拒绝代行头缺失或过期的请求,并把解出的真实用户写进审计记录,服务账号只作为经由留痕。

def resolve_actor(headers: dict) -> str:
    """网关入口:校验服务账号,解出被代行的真实用户。"""
    svc = verify_service_token(headers.get("Authorization"))
    if svc not in {"svc-ai-reader"}:
        raise PermissionError(f"service account not allowed: {svc}")

    user = verify_user_token(headers.get("X-On-Behalf-Of"))
    if user is None:
        raise PermissionError("missing or expired on-behalf-of token")

    audit.write(actor=user, via=svc, path=headers.get("X-Request-Path"))
    return user

这样一次查询落在日志里是"谁通过 AI 服务看了什么",而不是一次匿名调用。审计链没断,后面追责才追得下去。

检索出口的行级过滤

只读需求选好了、语义层也补上了,检索这一步最容易出的问题是:索引里什么都有,取回 top-k 直接塞进提示词。正确做法是在检索出口统一挂过滤器,先按用户可见范围裁剪,再把剩下的内容交给模型。能下推到 SQL 的尽量下推,索引里存了冗余字段的就拼进查询条件。

-- 语义层生成的检索 SQL 必须强制携带可见范围条件
SELECT o.id, o.amount, o.status, o.created_at
FROM orders o
JOIN dept_visible v ON v.dept_id = o.dept_id
WHERE v.user_id = :user_id
  AND o.deleted_at IS NULL
ORDER BY o.created_at DESC
LIMIT :limit;

可见范围来自 dept_visible 这张权限系统自己的表,不来自任何提示词约束。语义层拼 SQL 时把这一段作为不可省略的模板,等于给所有检索加了兜底;靠模型自觉加 where 条件是不可靠的。

向量检索那条路径同理,只是过滤发生在取回之后、拼上下文之前。这里有个细节:先多取一些再裁,否则裁完可能不够 k 条。

def retrieve(user_id: str, query: str, k: int = 8) -> list:
    """先检索,再按可见范围裁剪,只把裁剪后的结果交给模型。"""
    candidates = vector_index.search(query, k * 3)
    visible = iam.visible_ids(user_id, resource_type="order")
    kept = [c for c in candidates if c["id"] in visible][:k]
    return kept

kept 之外的行从头到尾没进过提示词,这是"先过滤后给模型"与"先给模型再过滤"的分界线。后者的问题是越权内容已经在上下文里,事后删不掉,日志和缓存里也可能留着。

权限判断的封装接口

读路径收口之后,写路径要更谨慎。模型要动数据之前,先问一次现有权限系统,拿到明确答复再执行。实现本身很薄:把 IAM 里已有的判断能力包成一个内部 HTTP 接口。

POST /internal/check
{"user": "u1024", "resource": "order:88231", "action": "write"}

200 OK
{"allow": false, "reason": "role=clerk exceeds scope", "ttl": 300}

返回值里带 reason 是为了排障时能直接看到拒绝原因,ttl 用于短时间缓存同一用户对同一资源的判断,避免每个工具调用都打一次 IAM。缓存时间别设长,权限变更要能及时生效。

再把这个接口注册成模型可调用的工具,并在写操作前强制调用,整条链才算闭环。

CHECK_TOOL = {
    "name": "check_permission",
    "description": "执行写操作前询问权限系统,allow 为 false 时必须停止",
    "parameters": {
        "type": "object",
        "properties": {
            "user": {"type": "string"},
            "resource": {"type": "string"},
            "action": {"type": "string"},
        },
        "required": ["user", "resource", "action"],
    },
}

def before_write(user: str, resource: str, action: str) -> dict:
    verdict = iam.check(user, resource, action)
    if not verdict["allow"]:
        raise PermissionError(verdict["reason"])
    return verdict

关键是执行顺序:先 check,再动作。模型自己"判断"用户是主管不算数,只有接口回 allow 才算数。注册成工具而不是写在提示词里,是因为工具调用可以被代码强制、被日志记录,提示词约束两者都做不到。

三类越权路径

落地过程中反复出现的越权,基本就这三种。

第一种是把权限规则写进提示词。提示词里那句"只回答用户有权限的数据",模型在长上下文里会忘,而且它根本不知道用户的可见范围是什么——那是权限系统的数据,不在模型输入里。

第二种是为 AI 单开一路"后门接口"。图省事让 AI 走一个跳过权限校验的内部接口,短期跑通了,代价是这类调用不进权限审计,等要查"上个月 AI 看过哪些客户资料"时查不出来。

第三种是过滤时机靠后。检索结果先拼进提示词、再在输出阶段做遮蔽,看着也有过滤,实际越权内容已经进了上下文,还可能被模型复述出来,或被记进会话日志。

这三种的共同点是权限判断脱离了权限系统。只要判断仍由 IAM 做,漏掉的部分就是可定位的;一旦判断交给提示词或旁路接口,就变成一个没人能说清的黑盒。

下一章

到这一步,AI 功能在权限上是安全的:身份有据、读有过滤、写有问询。但新功能一上线就是全量——所有部门同时看到新入口,出问题影响面太大。第 5 章处理这件事:怎么让新功能先只对一个部门生效,出问题能一键收回。