跳到正文
3 分钟

身份与权限

使用 Transport、Endpoint 与 Service Middleware 接入应用已有的认证和授权系统。

Univer Collaboration SDK 不定义用户、角色或 ACL 数据模型。应用在 Transport Middleware 中建立可信 身份,再使用 Endpoint 与 Service Middleware 保护实时 Session 和权威协同数据。

本页是 Middleware 与 Event 的具体应用指南,重点介绍如何利用 Middleware 接入身份与权限。

身份模型

标识由谁提供含义与生命周期
userID应用稳定的业务身份,也是 confirmed changeset 的作者
memberIDEndpoint当前 WebSocket Session 的在线成员 ID,重连后变化
sid + reqIdClientChangeset 的提交幂等身份,重试时保持不变

浏览器提交的用户资料、memberID 或 revision 都不能代替应用认证。应用应把自己的稳定用户主键映射为 context.userID

在 Transport Middleware 中建立身份

Transport 会为每个 Collaboration HTTP request 重新运行 Middleware。应用可以读取 Cookie、Session 或 Bearer Token,并把认证结果挂到 Context:

TypeScript
transport.use(async (context, next) => {
  const user = await auth.requireUser(context.incomingMessage);

  context.userID = user.id;
  context.customData.user = user;
  context.customData.tenantID = user.tenantID;
  context.customData.traceID = readTraceID(context.incomingMessage);

  await next();
});

认证失败时,应直接结束 HTTP response,不再调用 next()

TypeScript
transport.use(async (context, next) => {
  const user = await auth.findUser(context.incomingMessage);
  if (!user) {
    context.response.statusCode = 401;
    context.response.end("Authentication required");
    return;
  }

  context.userID = user.id;
  await next();
});

SDK 不规定 Cookie 名称、Token 格式、用户表或登录流程;这些都属于应用。

身份如何进入 WebSocket Session

WebSocket 不信任客户端 payload 中的用户字段。Endpoint 使用一次性 Session Ticket 把已认证的 HTTP Context 延长到实时 Session:

Text
Session Ticket HTTP request
  → Transport Middleware 验证身份
  → Endpoint 保存 { userID, customData }
  → 返回 opaque one-time ticket
  → WebSocket open 消费 ticket
  → 创建 Session { userID, memberID, customData }

Ticket 字符串本身不包含 userIDcustomDatamemberID 只标识当前连接,不能单独作为身份凭据。

在正确边界执行授权

要保护的行为对应 Middleware
Collaboration HTTP 入口Transport use()
WebSocket 建连Endpoint connect
加入 Unit RoomEndpoint joinUnit
读取 Snapshot、Block 或 ChangesetService readUnitData
提交内容修改Service submitChangeset
创建 UnitService createUnit
删除 UnitService deleteUnits
恢复 UnitService recoverUnits

Endpoint joinUnit 只控制 Session 能否进入实时 Room。Snapshot 与 missing changeset 可以通过 HTTP 读取,因此 JOIN 检查不能替代 Service readUnitData。同样,客户端只读 UI 只是产品提示,不能替代 submitChangeset 的服务端规则。

保护读取、JOIN 与编辑

TypeScript
import { CollabError } from "@univerjs-pro/collaboration-service";

endpoint.use("joinUnit", async (context, next) => {
  const allowed = await acl.canRead(context.session.userID, context.unitID);
  if (!allowed) {
    throw new CollabError("PERMISSION_DENIED", "Cannot join this Unit");
  }
  await next();
});

service.use("readUnitData", async (context, next) => {
  const allowed = await acl.canRead(context.userID, context.request.unitID);
  if (!allowed) {
    throw new CollabError("PERMISSION_DENIED", "Unit is not accessible");
  }
  await next();
});

service.use("submitChangeset", async (context, next) => {
  const unitID = context.request.changeset.unitID;
  const allowed = await acl.canEdit(context.userID, unitID);
  if (!allowed) {
    throw new CollabError("PERMISSION_DENIED", "Unit is read-only");
  }
  await next();
});

真实应用通常同时安装这三项规则:读取与 JOIN 使用 read policy,提交使用 edit policy。

保护 Unit 生命周期

协同协议只打开已有 Unit;“新建文档”通常由应用 API 编排。应用先创建产品记录和 ACL,再调用 createUnitFromData()createUnitFromSnapshot()。同时应使用 Service Middleware 保护生命周期:

TypeScript
service.use("createUnit", async (context, next) => {
  if (!(await acl.canCreate(context.userID, context.request.snapshot.type))) {
    throw new CollabError("PERMISSION_DENIED", "Unit creation denied");
  }
  await next();
});

service.use("deleteUnits", async (context, next) => {
  for (const unitID of context.request.unitIDs) {
    if (!(await acl.canDelete(context.userID, unitID))) {
      throw new CollabError("PERMISSION_DENIED", `Cannot delete ${unitID}`);
    }
  }
  await next();
});

恢复 Unit 需要单独保护 recoverUnits。不要因为用户曾经拥有删除权限,就默认其永久拥有恢复权限。

在 customData 中复用查询结果

同一次 Service 调用中的 Middleware 可以用 customData 避免重复查询:

TypeScript
service.use("submitChangeset", async (context, next) => {
  const unitID = context.request.changeset.unitID;
  context.customData.role ??= await acl.getRole(context.userID, unitID);

  if (context.customData.role === "viewer") {
    throw new CollabError("PERMISSION_DENIED", "Unit is read-only");
  }
  await next();
});

customData 只属于当前调用或当前 Session,不会自动持久化。长期角色、ACL 或租户关系仍应保存在应用 数据库中。

扩展模块需要独立策略

History、Thread Comment 与 Worktree 拥有各自的 Service Middleware:

  • History list 和 changeset read 应检查 Unit 读取权限;
  • Comment list 应检查读取权限,add/reply/edit/delete 应检查评论策略;
  • Worktree 应分别保护可见性、draft 编辑、状态变化与正式 merge;
  • Worktree 最终写入 trunk 时,还会进入 trunk Service 自己的 Middleware。

主 Collaboration Service 上的规则不会自动保护这些模块。启用扩展模块时,应显式复用应用的同一套 policy service,而不是复制一份独立 ACL 数据。

完整可运行实现见 Permissions 示例