7.3. 认证、授权与审计缺一不可
时间回到三个月前,我刚把第一版 Skills 市场推到内网试运行,就收到团队安全审计的一封邮件:某个技能在未授权的情况下读取了服务器上的配置文件,虽然没有造成损失,但评审结论很明确——“未实现认证与授权的系统,不允许进入生产环境”。这条红线直接把我们从快速迭代的兴奋中拽了出来,也促使我认真补上 Skills 生态的安全底座。本章会带你从零开始,为已有的注册中心和运行环境加上 OAuth2 委托授权、RBAC 权限模型和操作审计日志,让 Skills 的能力与风险严格绑定。
你需要什么
- 一个已可运行的 Skills 注册中心与运行时(沿用上一章基于 Express/Node.js 的后端,数据库可切换为 PostgreSQL)
- Node.js 18+、npm,编辑器与终端
- 第三方服务测试账号(如 GitHub OAuth App,用于演示委托授权)
- 预计耗时:2~2.5 小时
最终成果
完成本章后,你将得到一套具备完整安全机制的 Skills 平台:
- 委托授权:用户可授权某个 Skill 代表自己访问 GitHub 等第三方服务,无需共享凭据,系统自动处理 Token 刷新;
- 角色权限:每个 Skill 实例或调用者被分配角色(如
reader、editor、admin),通过 RBAC 策略限制其文件读写、API 调用范围; - 审计日志:所有关键操作(Skill 执行、Token 交换、权限拒绝)都会以结构化方式记录,满足 SOC2 或等保合规对审计追溯的要求。
这三层防护将让你的市场从“能用”跃迁到“安全可控”,也为后续多租户商业化打下基础。
步骤一:基于 OAuth2 的委托授权——让 Skill 代表用户访问第三方服务
当我们说“让 Skill 帮用户发邮件”“帮用户管理 GitHub 仓库”,本质上是 Skill 在运行时需要代表用户调用外部 API。OAuth2 授权码流程是标准解法:用户在我们平台点击“授权”,跳转到第三方登录页确认,第三方返回授权码,平台后端用授权码换取 access_token 和 refresh_token,此后 Skill 的行动就用这些令牌代表用户。
我们在此并不从零实现 OAuth2 服务器,而是作为客户端接入第三方。以 GitHub 为例,这是一次典型的委托授权过程。
1.1 配置 OAuth2 客户端与数据库模型
先在 GitHub 创建一个 OAuth App,记录 Client ID 和 Client Secret,回调地址填 https://<你的平台>/oauth/callback。然后在后端配置环境变量:
# .env
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-secret
GITHUB_REDIRECT_URI=http://localhost:3000/oauth/callback
接下来需要存储每个用户授权给不同 Skill 的 Token。推荐使用一张 user_tokens 表(PostgreSQL),结构如下:
| 字段 | 说明 |
|---|---|
id |
主键 |
user_id |
平台用户 ID |
skill_id |
被授权的 Skill ID(可空,若无单独隔离可忽略) |
provider |
如 'github' |
access_token |
加密存储的 Access Token |
refresh_token |
Refresh Token(加密存储) |
expires_at |
Access Token 过期时间 |
scope |
已授权的权限范围字符串 |
created_at |
创建时间 |
注意:生产环境中所有 Token 必须使用 AES-256-GCM 等算法加密存储,绝不能明文入库。本章为聚焦流程,用简单示例展示逻辑,请在实际部署时替换加密层。
1.2 实现授权请求与回调处理
// oauth.js
const express = require('express');
const axios = require('axios');
const router = express.Router();
// 发起授权:将用户重定向到 GitHub
router.get('/authorize/:skillId?', (req, res) => {
const skillId = req.params.skillId || 'default';
const authUrl = `https://github.com/login/oauth/authorize?` +
`client_id=${process.env.GITHUB_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(process.env.GITHUB_REDIRECT_URI)}` +
`&scope=repo,user` +
`&state=${skillId}`; // 用 state 传递 skillId,防止 CSRF
res.redirect(authUrl);
});
// 回调处理:换取 Token 并存储
router.get('/oauth/callback', async (req, res, next) => {
const { code, state } = req.query;
const skillId = state; // 简化:假设 state 直接传 skillId
try {
// 用 code 换取 access_token
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code
},
{ headers: { Accept: 'application/json' } }
);
const { access_token, refresh_token, expires_in, scope } = tokenResponse.data;
// 存储到数据库(伪代码,实际需加密)
await db.query(
`INSERT INTO user_tokens (user_id, skill_id, provider, access_token, refresh_token, expires_at, scope)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[req.user.id, skillId, 'github', access_token, refresh_token,
new Date(Date.now() + expires_in * 1000), scope]
);
res.send('授权成功!Skill 已获得代表您访问 GitHub 的权限。');
} catch (error) {
next(error);
}
});
module.exports = router;
预期结果:用户访问 /authorize/<skillId> 后跳转 GitHub 授权页,确认后回调到我们的 /oauth/callback,Token 被安全存储。Skill 后续通过 user_id + skill_id 获取有效 Token 即可代表用户调用 API。
1.3 自动刷新 Token
当 expires_at 临近时,Skill 应使用 refresh_token 换取新的 access_token。可以封装一个工具函数:
async function getValidToken(userId, skillId, provider) {
let tokenRecord = await db.query(
'SELECT * FROM user_tokens WHERE user_id=$1 AND skill_id=$2 AND provider=$3',
[userId, skillId, provider]
);
if (!tokenRecord.rows.length) throw new Error('未授权');
let { access_token, refresh_token, expires_at } = tokenRecord.rows[0];
// 如果过期或即将过期(预留 5 分钟缓冲),则刷新
if (Date.now() > new Date(expires_at).getTime() - 5 * 60 * 1000) {
const refreshResponse = await axios.post('https://github.com/login/oauth/access_token', {
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: decrypt(refresh_token)
});
// 更新数据库...
access_token = refreshResponse.data.access_token;
}
return decrypt(access_token);
}
踩坑经验:不少第三方 OAuth 提供商(如 GitHub)的 refresh_token 仅在一次有效,刷新后会返回新的 refresh_token 或直接过期,务必做好错误重试和并发刷新控制。
步骤二:RBAC 与最小权限原则——为 Skills 分配角色,限定访问范围
OAuth2 解决了“能不能代用户调用 API”的问题,但 Skill 自身能访问哪些本地文件、调用哪些内部端点,同样需要严格控制。例如,一个“代码审查” Skill 不应该拥有删除仓库文件的权限。我们采用基于角色的访问控制(RBAC),将每个 Skill 实例与一系列角色绑定,在中间件层面进行策略裁决。
2.1 定义角色与权限
以文件系统和 API 操作为例,可先划分以下角色:
| 角色 | 权限描述 |
|---|---|
viewer |
只允许读取公开文件、调用 GET 类 API |
operator |
允许执行任务类操作(如运行脚本),读写受限目录 |
admin |
可管理配置、修改其他 Skill 属性,无文件系统限制 |
我们可以使用 Casbin 这类轻量级授权库来配置策略。在项目根目录创建 rbac_model.conf 和 policy.csv。
Casbin 模型文件 (rbac_model.conf):
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && keyMatch(r.act, p.act)
策略文件 (policy.csv):
p, viewer, /api/skills/*, GET
p, operator, /api/skills/run, POST
p, operator, /workspace/operator/*, read-write
p, admin, /api/*, *
p, admin, /workspace/*, *
g, user_alice, viewer
g, skill_code_reviewer, operator
g, skill_admin_bot, admin
策略读取后,Casbin 会根据角色定义和请求 (子、对象、动作) 判断是否允许。
2.2 在 Express 中间件中集成 RBAC
安装 casbin 和适配器(如 casbin-pg-adapter 可持久化策略到数据库):
npm install casbin casbin-pg-adapter
编写授权中间件:
// middleware/rbac.js
const { newEnforcer } = require('casbin');
const path = require('path');
let enforcer;
async function initEnforcer() {
// 使用适配器从 DB 加载策略,或从 CSV 文件初始化
enforcer = await newEnforcer('rbac_model.conf', 'policy.csv');
console.log('RBAC 策略已加载');
}
async function authorize(subject, object, action) {
if (!enforcer) await initEnforcer();
const allowed = await enforcer.enforce(subject, object, action);
return allowed;
}
function rbacMiddleware(subjectFn) {
return async (req, res, next) => {
const subject = subjectFn(req); // 例如取 skill_id 或 user_id
const obj = req.path;
const act = req.method;
const allowed = await authorize(subject, obj, act);
if (!allowed) {
console.log(`拒绝访问: sub=${subject}, obj=${obj}, act=${act}`);
return res.status(403).json({ error: '权限不足' });
}
next();
};
}
module.exports = { rbacMiddleware, initEnforcer };
应用中间件:针对 /api 路由,从请求头或 JWT 中解析出 Skill 的身份。
const { rbacMiddleware } = require('./middleware/rbac');
// 假设通过 JWT 解析出 sub 字段为 skill 名称
app.use('/api', rbacMiddleware(req => req.skill?.name || 'anonymous'));
预期结果:当一个 Skill 尝试调用 POST /api/skills/run 时,若其角色为 viewer,则返回 403 Forbidden;若为 operator,则通过。
踩坑经验:Casbin 策略中
keyMatch支持 URL 路径的通配符,例如/api/*匹配/api/foo/bar,但务必注意匹配深度。建议策略上线前用enforcer.enforce()编写单元测试,覆盖所有角色与边界路径。
2.3 文件系统级别的访问控制
对于 Skill 运行时的文件操作(如读写本地工作区),也可以在 Skill 执行器中拦截并调用授权函数。比如封装一个 safeFs 模块,在读写前检查 subject 是否有对应路径和操作的权限。
一个简化的示例:
const fs = require('fs').promises;
const restrictionMap = {
viewer: { dir: '/workspace/public', actions: ['read'] },
operator: { dir: '/workspace/operator', actions: ['read', 'write'] },
admin: { dir: '/', actions: ['read', 'write'] }
};
async function safeWriteFile(subject, filePath, content) {
const role = getUserRole(subject); // 从策略获取
const rule = restrictionMap[role];
if (!filePath.startsWith(rule.dir) || !rule.actions.includes('write')) {
throw new Error('无权写入该路径');
}
await fs.writeFile(filePath, content);
}
这样即便 Skill 内部的代码有漏洞,RBAC 层也能作为最后的防线阻止越权操作。
步骤三:操作审计与合规日志——记录每一次 Skill 调用详情
认证和授权是前置门槛,审计则是事后追溯的“黑匣子”。SOC2、GDPR 等合规框架都要求记录关键操作,包括谁、何时、对什么资源执行了什么动作、结果如何。
3.1 设计审计日志结构
对所有 Skill 调用、授权决定、Token 刷新等事件,采用统一的 JSON 格式记录。建议包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
timestamp |
ISO 8601 | 事件时间 |
event_type |
string | 如 skill_invoke, token_refresh, access_denied |
subject |
string | 调用者标识(Skill 名或用户 ID) |
object |
string | 操作对象(API 路径或文件路径) |
action |
string | 操作类型(GET, POST, read, write) |
result |
string | 成功/失败/权限拒绝 |
metadata |
object | 额外信息(如 IP、请求 ID、消耗时间) |
3.2 实现审计日志中间件
使用 Winston 将日志输出到文件和数据库,同时方便后续接入 ELK 或 Loki 等日志系统。
// middleware/auditLogger.js
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'audit.log' }),
new winston.transports.Console()
]
});
function auditLog(type, subject, object, action, result, metadata = {}) {
logger.info({
timestamp: new Date().toISOString(),
event_type: type,
subject,
object,
action,
result,
metadata
});
}
function auditMiddleware(req, res, next) {
const start = Date.now();
// 劫持 res.send 来获取最终状态码
const originalSend = res.send;
res.send = function(body) {
res.send = originalSend; // 恢复
const subject = req.skill?.name || req.user?.id || 'anonymous';
const object = req.originalUrl;
const action = req.method;
const result = res.statusCode < 400 ? 'success' : 'failure';
const metadata = { ip: req.ip, duration: Date.now() - start };
auditLog('api_request', subject, object, action, result, metadata);
return originalSend.call(this, body);
};
next();
}
应用中这样挂载:
app.use(auditMiddleware); // 放在所有路由之前
此外,对于 Token 刷新和权限拒绝等内部事件,也直接调用 auditLog 函数。
预期结果:所有 API 请求都会生成类似下面的审计日志(一行一条):
{"timestamp":"2026-06-02T10:20:30.123Z","event_type":"api_request","subject":"skill_code_reviewer","object":"/api/skills/run","action":"POST","result":"success","metadata":{"ip":"192.168.1.5","duration":220}}
{"timestamp":"2026-06-02T10:21:05.000Z","event_type":"access_denied","subject":"skill_code_reviewer","object":"/api/admin/config","action":"GET","result":"failure","metadata":{"ip":"192.168.1.5"}}
3.3 审计日志的持久化与合规
保证日志的完整性至关重要。Winston 的 File transport 可本地存储,但生产环境应转发到不可变存储(如 AWS CloudWatch、自建 Loki、或直接写入 append-only 数据库表)。额外注意:
- 日志文件权限设置为仅 root 可写,防止篡改。
- 使用
auditd或系统日志守护进程防止日志丢失。 - 保留时间至少满足合规要求(如 SOC2 建议不少于 12 个月)。
至此,一条完整的“认证 → 授权 → 审计”链路已闭环。任何 Skill 的行为都有了清晰的权限边界和事后可追溯的记录。
回顾
本章我们在 2 小时左右的时间里,从一个坦率的“安全红线”开始,为 Skills 平台逐层构建了安全三要素:
- OAuth2 委托授权:实现了用户授权 Skill 代表其访问 GitHub 的完整流程,并处理好 Token 存储与自动刷新;
- RBAC 最小权限:利用 Casbin 制定了基于角色的策略,在 API 网关和文件系统层强制访问控制,仅允许授权动作;
- 操作审计日志:通过结构化日志记录所有 Skill 调用与权限判决,为合规与溯源提供依据。
你现在已经拥有一个从开发栈转向生产栈所必需的安全骨架。这套机制还可以直接演进出更高级的特性——例如下章即将展开的多租户场景下的隔离与计费,正是建立在这种基于角色的权限隔离和可审计的调用记录之上,从而安全地将平台商业化。请在前往下一章前,确保你至少完成以下动作:
- [ ] 实现至少一个第三方 OAuth 授权流程,并验证 Token 刷新逻辑;
- [ ] 配置两条以上的 RBAC 策略,并编写测试用例验证越权拦截;
- [ ] 运行平台并触发几次 Skill 调用,检查 audit.log 输出了结构完整的条目;
- [ ] 将审计日志配置写入持久化存储,并设置轮转策略;
- [ ] 在
README中记录当前平台的安全模型与角色定义。
现在,你的 Skills 平台已经不再是“裸奔”状态。下一章,我们将迈向下一个里程碑:多租户场景下的隔离与计费是商业化的前提。
agent skills 入门到精通
关于 LearnKu