面向第三方 Agent 开发者。回答一个问题:你把 API Key 交出来之后,平台到底给了你什么、没给你什么。 单一事实源:
packages/types/src/capability-catalog.ts;机器可读产物:packages/types/generated/capabilities.json(共 62 个 scope,数字为实跑node scripts/check-capability-catalog.mjs --json与读取产物所得)。
平台的开放姿态是一句话:开放功能,不开放数据(设计原则见
capabilities.md 第 1 节)。落到实现上,是每条能力都带一个 dataClass 字段
(packages/types/src/capability-catalog.ts:114-132 的 CapabilityEntry,dataClass 在 :118),
它决定这次调用在数据面上能触达什么。
关键差别:
scope(如chat:write)决定能不能进这道门 —— 由能力闸裁决;dataClass决定进门之后能看见多少 —— 由数据闸裁决,与 scope 正交。
只授 scope 不看 dataClass,等于把"读全库"包装成"给了一个能力位"。这是本表存在的唯一理由。
| dataClass | 数量 | 一句话含义 | 数据可见范围 | 映射的 DB 访问模式 |
|---|---|---|---|---|
compute |
26 | 只消耗算力(推理/生成/检索临时输入) | 禁止读业务实体表,只允许写自有运行记录 | self-metadata |
scoped-read |
19 | 读该能力自带的数据视图 | 强制按凭据归属人(owner)过滤 | read-owned |
scoped-write |
14 | 写自有资源(建助手、传文件、存记忆) | 强制 owner 过滤 + 幂等键登记 | write-owned |
platform |
3 | 平台运营面(本机控制、对外发布、内部运维) | 机器凭据一律 403,永不开放 | unrestricted |
数量与映射均为实读:capabilities.json 的 capabilities[].dataClass 聚合
(compute 26 / scoped-read 19 / scoped-write 14 / platform 3 = 62),
dataClass → DbAccessMode 映射在 packages/types/src/capability-catalog.ts:71-76,
访问模式枚举在 :54-63(forbidden / self-metadata / read-owned / write-owned / unrestricted)。
unrestricted 只给 platform 类,而 platform 类对机器凭据根本进不来 ——
即"数据不受限"的那一档永远不会被第三方 key 命中,这是两闸串联的结果,不是 dataClass 单独保证的。
兼容说明:历史上存在 self-metadata-implicit 写法,由 effectiveDataClass() 归一为 compute
(packages/types/src/capability-catalog.ts:958-963,983-986);当前目录内无条目使用它,读产物时看到的都是四类规范值。
compute 不是"什么都不许写",而是"只许写自己这次调用的痕迹"。白名单
COMPUTE_ALLOWED_TABLES(packages/types/src/capability-catalog.ts:82-90):
| 表 | 为什么算"自有运行记录" |
|---|---|
llm_call_logs |
本次调用的 token/成本/时延流水 |
agent_runs |
本次 agent 执行的运行状态 |
agent_checkpoints |
运行中间态 |
api_key_usage_windows |
你自己 key 的用量窗口 |
webhook_delivery_logs |
你订阅的 webhook 投递结果 |
audit_logs |
归因审计(见第 6 节) |
security_logs |
安全事件 |
判定规则(apps/api/src/utils/scoped-guard.ts:154-159 注释即口径):
forbidden→ 一律DATA_ACCESS_DENIED;self-metadata→ 仅上表白名单可写,其余DATA_ACCESS_DENIED;read-owned/write-owned→ 语句必须带 owner 谓词,否则OWNER_FILTER_REQUIRED;- 表名不可判定(裸 SQL / CTE / 存储过程)→ 默认拒绝,绝不静默放行
(
apps/api/src/utils/scoped-guard.ts:24及:79-87)。
read-owned / write-owned 有一句硬前提:数据库连接不能是超级用户 —— 超级用户绕过
一切 RLS,"声明了行级隔离却给不出隔离"会被 fail-closed 断言直接判 503
DATA_ISOLATION_UNAVAILABLE(assertNonSuperuserForScopedMode)。这条断言在生产一直是
常亮状态,因为受控出口 dbScoped() / dbReadScoped() 与 db 同池,而那条池子是超级用户。
现在的落点(逐条可查,不靠承诺):
| 层次 | 现状 | 载体 |
|---|---|---|
| 应用层数据闸 | 生效中(唯一真正在按 dataClass 挡的一层) | apps/api/src/utils/scoped-guard.ts |
| 连接角色 | 新增非超级用户应用角色 ihui_app(NOSUPERUSER NOBYPASSRLS NOINHERIT),配 DATABASE_APP_URL 后受控出口与超级用户探针一起切到它 |
迁移 packages/database/drizzle/20260921160000_scoped_app_role_owner_rls.sql + apps/api/src/db/index.ts |
| 表级权限 | 只授"开放面实际触达的 4 张表 × 实际用到的 DML"(见下表);不在清单里的表,DB 层直接 permission denied | 同上 |
| 行级策略 | 已建好(owner 维度),但未 ENABLE ROW LEVEL SECURITY |
同上 |
| 会话变量 | app.user_id 等写在主池;受控出口那条池拿不到,行级强制因此还不能开 |
apps/api/src/plugins/rls-context.ts |
GRANT 清单(不是一把梭,GRANT ALL ON SCHEMA 一律不许):
| 表 | 归属列 | 授给 ihui_app 的 DML |
调用点 |
|---|---|---|---|
content_generation_tasks |
user_id |
SELECT, INSERT | db/content-generation-queries.ts |
webhook_subscriptions |
user_id |
SELECT, INSERT, UPDATE, DELETE | routes/developer/webhooks.ts |
zhs_ai_user_model_chat_config |
user_id |
SELECT, INSERT, UPDATE, DELETE | routes/v1-ai-core.ts(/v1/user/models 族) |
messages |
sender_id / receiver_id(无 user_id 列) |
仅 SELECT | routes/other/v1-customer-service-routes.ts |
拿不到权限、也没有策略的表(有意为之):
- 无 owner 列的全站共享面
content_generation_templates、zhs_faq—— 压根不走受控出口, 给它们授权等于把"读全库"的口子重新开给应用角色,给它们写策略更没有归属列可依据; compute白名单的自有运行记录表(llm_call_logs/agent_runs/agent_checkpoints/api_key_usage_windows/webhook_delivery_logs/audit_logs/security_logs)—— 今天的写入走db而不是dbScoped(),所以一张都不授;哪天把 compute 写路径接到受控 出口,必须连同 GRANT 一起做,否则那条路会在应用层放行、在 DB 层 permission denied;users及其余一切表 —— 一张都不授。content_generation_tasks.user_id等虽然 FK 到users,但 PostgreSQL 的外键完整性检查走被引用表的属主上下文,不做表级 ACL 也不套 RLS, 所以授权给users既不必要也绝不该有。
行级强制为什么先不开,以及怎么开:
- 策略读
app.user_id,而它只写在主池 —— 应用池上的策略恒为假,开了就是全量 0 行; - 即便给应用池补一次 SET,池化 session 变量与后续语句不保证同一条物理连接
(
plugins/rls-context.ts已自述),要钉死必须事务级SET LOCAL,那需要改调用点。
失败方向顺带说清楚:策略与语句自带的 WHERE / 写入值取交集,所以上下文丢了/串了只会
"更窄"(0 行、写入被拒),不会把别人的行放行 —— 最坏是可用性事故,不是越权事故。
激活由 node packages/database/scripts/owner-rls.mjs enable 承担:它先查 catalog 前置
(角色非超级用户、非 BYPASSRLS、不是表 owner、策略齐),再 ENABLE,最后拿一条
"陌生主体必须读到 0 行"的负向断言验收(只 SELECT、事务必回滚),断言不过当场回退。
回滚是同一条命令的 disable(DISABLE ROW LEVEL SECURITY + DROP POLICY IF EXISTS)。
上线顺序清单(O13,按序执行,缺一不可):
- 跑迁移:
packages/database/drizzle/20260921160000_scoped_app_role_owner_rls.sql(部署时自动执行,幂等)—— 建ihui_app角色 + 逐表 GRANT + owner 策略;迁移刻意不 `ENABLE ROW LEVEL SECURITY(见该文件 :22 与 :188-197 的说明),真正挡数据的是应用闸。 - 运维在库上设密码:
ALTER ROLE ihui_app PASSWORD '<随机强密码>';—— 密码不落仓 (不进.env提交、不进 git),与 §5d 密钥引导同口径。 - 配
DATABASE_APP_URL:apps/api 的环境变量,DSN 指向ihui_app角色。启动期会做一次 有界超时(≤2s)的SELECT 1探测,配错只 warn 不 crash(见apps/api/src/db/index.ts)。 - 验证:
node packages/database/scripts/owner-rls.mjs status(全程只读,不写库)—— 角色非超级用户 / 非 BYPASSRLS / 策略齐 / GRANT 齐时 exit 0;任何漂移 exit 1。 CI 侧自动化同口径:.github/workflows/db-owner-rls.yml(临时 PG,绝不碰生产库)。 - 后置阶段才考虑
ENABLE ROW LEVEL SECURITY:前置是应用池侧app.user_id会话变量 改为事务级SET LOCAL(见上文 1、2 两条原因),届时走node packages/database/scripts/owner-rls.mjs enable(断言 +--apply两段式)。
⚠️ 未做 1–3 时,开放面scoped-*能力返回 503DATA_ISOLATION_UNAVAILABLE—— 这是 有意的 fail-closed("给不出隔离就拒绝服务"),不是缺陷,更不是回退到"假装隔离"; 第一方链路(db/dbRead)全程不受影响。
标注格式:scope(risk / 是否计费 / 是否要求幂等键 / 第三方可申请)。
| scope | risk | billable | idem | thirdParty | 端点形态 |
|---|---|---|---|---|---|
models:read |
low | 否 | 否 | ✅ | GET /v1/models(元数据) |
chat:write |
high | ✅ | 是 | ✅ | POST /v1/chat/completions |
responses:write |
high | ✅ | 是 | ✅ | POST /v1/responses |
embeddings:write |
medium | ✅ | 否 | ✅ | POST /v1/embeddings |
images:write |
medium | ✅ | 否 | ✅ | 图像生成 |
tools:call |
high | ✅ | 是 | ✅ | POST /v1/mcp/tools/call |
realtime:connect |
high | ✅ | 否 | ✅ | WS /v1/realtime |
web:fetch |
high | ✅ | 否 | ✅ | URL 抓取(输入是你给的 URL) |
sandbox:run |
critical | 否 | 否 | ❌ | 沙箱执行(红线,见第 7 节) |
im:send |
high | 否 | 是 | ❌ | 以你身份触达真人(红线) |
注意 compute 里也混着 thirdPartyEligible=false 的红线能力 —— dataClass 管"数据面",
thirdPartyEligible 管"给不给你用",两者都要过。
| scope | risk | 说明 |
|---|---|---|
assistants:read |
low | 只列你归属人名下的助手 |
threads:read |
medium | 会话线程,owner 过滤 |
files:read |
medium | 你上传的文件 |
memory:read |
high | 记忆条目(高危:内容敏感,风险档最高) |
batches:read |
low | 你提交的批任务 |
billing:read |
medium | 你自己的账单/额度 |
codebase:read |
medium | 代码库语义检索(billable,检索结果限 owner) |
connectors:read / skills:read / edu:read |
low | 清单类只读 |
14 个 scope 全部 idempotencyRequired=true(实读产物),典型:assistants:write、threads:write、
files:write(risk high)、memory:write(risk high + billable)、knowledge:write、webhooks:manage。
其中四样不对第三方开放:diff:apply(critical)、connectors:write、skills:write、oauth:manage。
幂等键的实际语义要如实说清:能力闸登记 Idempotency-Key 供重放层消费,但当前不会因为缺键而返回 400 ——
理由写在代码注释里:OpenAI 官方 SDK 不发这个头,硬性要求会打断标准客户端互操作性
(apps/api/src/utils/capability-guard.ts:109-115)。idempotencyRequired 目前是声明 + 登记,
不是强制。幂等重放层本身在源码里标注为后续项(:109 "供重放层(O10)消费")。
| scope | risk | 原因 |
|---|---|---|
computer:operate |
critical | 本机 GUI / 键鼠 / 剪贴板控制 |
publish:operate |
critical | 向第三方社媒发布(对外可见、不可撤回) |
ops:execute |
critical | 平台内部运维(db_query / git / 定时任务 / PR) |
三者全部 thirdPartyEligible=false(枚举定义 packages/types/src/api-key.ts:142,166,174,
注释即此意)。
| 层 | 组件 | 干什么 | 当前状态 |
|---|---|---|---|
| 授权闸 | apps/api/src/utils/capability-guard.ts(requireCapability / requireAnyCapability / requireCapabilityRules / declareCapability) |
默认拒绝:未登记、platform、不可对第三方 → 403;命中则把 entry 注入 request.capability |
✅ 已铺开:/v1 178 个 handler 全部覆盖,其中 164 个强制 scope、14 个迁移期 declareCapability(仅登记不强制) |
| 数据闸 | apps/api/src/utils/scoped-guard.ts(assertDbAccessAllowed:220 / createScopedDb:747 / scopedWhere:809) |
按 dataClass 拦截 SQL;owner 谓词缺失即拒;表名不可判定即拒 | |
| 归属绑定 | apps/api/src/utils/open-capability-gate.ts:51-56(bindKeyOwner) |
机器调用必须绑定归属人,不得脱离用户持有数据 | ✅ /api 开放登记表(8 条)已挂根级 preHandler(apps/api/src/routes/index.ts:485) |
数据闸"接入面为 0"的具体含义(必须讲清,否则容易被误读成"数据闸在保护你"):
- 拦截器只对新出口生效:
dbScoped()/dbReadScoped()(apps/api/src/db/index.ts:81,83,由createScopedDb包装既有db/dbRead,同一连接池,不改连接语义); - 启动条件:
principal.capability存在且principal.kind === 'apiKey'(apps/api/src/utils/scoped-guard.ts:195-197),principal 由全局插件归一 (apps/api/src/server.ts:438-441); - 全仓检索
dbScoped/dbReadScoped,除定义/导出/注释外没有一处业务调用 (apps/api/src/db/index.ts、apps/api/src/plugins/principal.ts、apps/api/src/server.ts之外为零), 已有的是行为单测apps/api/tests/o4-data-scope-gate.test.ts。
结论:当前"不开放数据"主要靠第 1 层(能力闸 + 红线 scope)与端点自身的 owner 过滤实现;
第 2 层是它的机械加固,机制就绪但还没被路由接上。 因此第 3 节的 DATA_ACCESS_DENIED 等三条码
现在不会在开放面出现(见 error-codes.md 第 2.3 节)。
- 审计覆盖从「仅
/api/」扩到「/api/+/v1/、/v1beta/」,OPEN_SURFACE_PREFIXES定义在apps/api/src/plugins/audit-logger.ts:44,记录范围判定在:309-315; 此前开放面一条审计都不落,这正是"哪把 key 调了哪个端点"无法回答的根因(该文件头注释:17-25)。 - 每行审计带
apiKeyId/ 归属 userId / capability(scope·dataClass·risk·billable·domain)/ method / url / routePattern / statusCode / durationMs:buildRequestAttribution()apps/api/src/plugins/audit-logger.ts:225-250。 - 绝不记录凭据原文:注释明确"不读
request.apiKey.key"(那是凭据原文,落库等同密钥泄露),apps/api/src/plugins/audit-logger.ts:232-233。 - 计费流水
llm_call_logs也在顶层列写apiKeyId+clientIp+costCents+httpStatus(packages/database/src/schema/llm-call-logs.ts:61,69,71,73;写入点apps/api/src/services/relay-billing-service.ts:1148-1174)。 - 未鉴权流量(无 key 无 JWT)同样落审计,
userId/apiKeyId为 null 但带 IP + path, 可回答"谁在探测哪个端点"(apps/api/src/plugins/audit-logger.ts:24-25)。 - GET 采样默认 100%,可用
AUDIT_LOG_GET_SAMPLE_RATE调低;4xx 强制全量记录以便复盘 (apps/api/src/plugins/audit-logger.ts:84-87,314-316)。
正文级留存与清除口径见 compliance.md 第 4 节。
实读产物 thirdPartyEligible=false 共 10 项(不是"大概几项"):
| scope | dataClass | risk | 为什么给不了 |
|---|---|---|---|
computer:operate |
platform | critical | 本机 GUI/键鼠/剪贴板 |
publish:operate |
platform | critical | 对外发布,不可撤回 |
ops:execute |
platform | critical | 平台内部运维 |
sandbox:run |
compute | critical | 命令执行;开放前提是强制隔离网络且由管理员显式签发 |
diff:apply |
scoped-write | critical | 把生成内容全量写盘 |
browser:operate |
compute | high | 浏览器自动化 |
im:send |
compute | high | 以调用者身份触达真人 |
connectors:write |
scoped-write | high | 注册外部 MCP server = 注入可执行行为 |
skills:write |
scoped-write | high | 安装技能 = 注入可执行行为 |
oauth:manage |
scoped-write | high | 应用注册与密钥轮转(改走自助 DCR) |
其中 diff:apply 有一个额外事实值得注意:守门脚本报告它被端点闸口引用了
(node scripts/check-capability-catalog.mjs 的 C 类警告 M2M_FORBIDDEN_GATE),
即 apps/api/src/routes/v1-apply-diff.ts 挂了闸,但该 scope 对机器凭据恒 403 M2M_FORBIDDEN ——
这是"有意只给人 JWT"(见该文件 :36 注释),不是漏登记。
'*' 通配救不了你:它只在"目录已登记且 isM2MAllowed=true"的范围内生效
(packages/types/src/capability-catalog.ts:1032-1037、
apps/api/src/utils/capability-guard.ts:99-100、apps/api/src/plugins/api-key-auth.ts:1115-1132)。
pnpm capabilities:export # 重新生成 packages/types/generated/capabilities.json
pnpm capabilities:check # 防漂移:产物必须与目录逐字段一致
node scripts/check-capability-catalog.mjs --json # 四项检查(产物一致/端点覆盖/scope 语义/反向核对)改目录的流程(顺序不可换):scope 先加进 packages/types/src/api-key.ts 的枚举 →
在 capability-catalog.ts 登记 dataClass/risk/billable/thirdPartyEligible/routes →
pnpm capabilities:export → CI 的 capabilities:check + 守门脚本(guardian id 51)必须绿。
未登记的 scope 在服务启动期就会抛错(requireCapabilityOrThrow,
packages/types/src/capability-catalog.ts:992-999),目录与路由漂移不可能静默上线。
全量守门当前报 22 条 D 类警告(目录声明了 routes 但找不到注册点)。逐条核对后原因是确定的:
这些 scope 的端点不在 apps/api/src/routes/ 下(扫描器只扫这个目录,
scripts/check-capability-catalog.mjs 的 ROUTES_DIR_REL / V1_ROUTE_FILE_RE),
例如 POST /api/mcp、POST /api/sandbox/run、POST /api/web/fetch 属 ai-service(Python),
/v1/billing/* 等尚未落地。它们是扫描范围局限,不等于端点不存在;
但确实意味着"目录声明 ↔ 真实路由"的这一半只在 apps/api 侧被机械校验过。
- 能力总览:capabilities.md
- 速率与配额:rate-limits.md
- 错误码:error-codes.md
- 滥用与封禁:abuse-policy.md
- 合规与数据留存:compliance.md