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 实例或调用者被分配角色(如 readereditoradmin),通过 RBAC 策略限制其文件读写、API 调用范围;
  • 审计日志:所有关键操作(Skill 执行、Token 交换、权限拒绝)都会以结构化方式记录,满足 SOC2 或等保合规对审计追溯的要求。

这三层防护将让你的市场从“能用”跃迁到“安全可控”,也为后续多租户商业化打下基础。


步骤一:基于 OAuth2 的委托授权——让 Skill 代表用户访问第三方服务

当我们说“让 Skill 帮用户发邮件”“帮用户管理 GitHub 仓库”,本质上是 Skill 在运行时需要代表用户调用外部 API。OAuth2 授权码流程是标准解法:用户在我们平台点击“授权”,跳转到第三方登录页确认,第三方返回授权码,平台后端用授权码换取 access_tokenrefresh_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.confpolicy.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 平台逐层构建了安全三要素:

  1. OAuth2 委托授权:实现了用户授权 Skill 代表其访问 GitHub 的完整流程,并处理好 Token 存储与自动刷新;
  2. RBAC 最小权限:利用 Casbin 制定了基于角色的策略,在 API 网关和文件系统层强制访问控制,仅允许授权动作;
  3. 操作审计日志:通过结构化日志记录所有 Skill 调用与权限判决,为合规与溯源提供依据。

你现在已经拥有一个从开发栈转向生产栈所必需的安全骨架。这套机制还可以直接演进出更高级的特性——例如下章即将展开的多租户场景下的隔离与计费,正是建立在这种基于角色的权限隔离和可审计的调用记录之上,从而安全地将平台商业化。请在前往下一章前,确保你至少完成以下动作:

  • [ ] 实现至少一个第三方 OAuth 授权流程,并验证 Token 刷新逻辑;
  • [ ] 配置两条以上的 RBAC 策略,并编写测试用例验证越权拦截;
  • [ ] 运行平台并触发几次 Skill 调用,检查 audit.log 输出了结构完整的条目;
  • [ ] 将审计日志配置写入持久化存储,并设置轮转策略;
  • [ ] 在 README 中记录当前平台的安全模型与角色定义。

现在,你的 Skills 平台已经不再是“裸奔”状态。下一章,我们将迈向下一个里程碑:多租户场景下的隔离与计费是商业化的前提。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~