Convex安全审计Skill convex-security-audit

这个技能提供Convex应用程序的全面安全审计模式,涵盖授权逻辑、数据访问边界、动作隔离、速率限制和敏感操作保护。它帮助开发者在后端开发中实施多层次安全措施,确保应用免受滥用和攻击。适用于网络安全领域,提升应用安全性和合规性。关键词:Convex, 安全审计, 授权控制, 数据保护, 速率限制, 动作隔离, 敏感操作, 后端安全。

安全审计 0 次安装 0 次浏览 更新于 3/17/2026

name: convex-security-audit displayName: Convex安全审计 description: 授权逻辑、数据访问边界、动作隔离、速率限制和保护敏感操作的深入安全审查模式 version: 1.0.0 author: Convex tags: [convex, 安全, 审计, 授权, 速率限制, 保护]

Convex安全审计

用于Convex应用程序的全面安全审查模式,包括授权逻辑、数据访问边界、动作隔离、速率限制和保护敏感操作。

文档来源

在实施前,不要假设;获取最新文档:

说明

安全审计领域

  1. 授权逻辑 - 谁可以做什么
  2. 数据访问边界 - 用户可以看到什么数据
  3. 动作隔离 - 保护外部API调用
  4. 速率限制 - 防止滥用
  5. 敏感操作 - 保护关键功能

授权逻辑审计

基于角色的访问控制(RBAC)

// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "./_generated/server";
import { ConvexError } from "convex/values";
import { Doc } from "./_generated/dataModel";

type UserRole = "用户" | "版主" | "管理员" | "超级管理员";

const roleHierarchy: Record<UserRole, number> = {
  用户: 0,
  版主: 1,
  管理员: 2,
  超级管理员: 3,
};

export async function getUser(ctx: QueryCtx | MutationCtx): Promise<Doc<"users"> | null> {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) return null;
  
  return await ctx.db
    .query("users")
    .withIndex("by_tokenIdentifier", (q) => 
      q.eq("tokenIdentifier", identity.tokenIdentifier)
    )
    .unique();
}

export async function requireRole(
  ctx: QueryCtx | MutationCtx, 
  minRole: UserRole
): Promise<Doc<"users">> {
  const user = await getUser(ctx);
  
  if (!user) {
    throw new ConvexError({
      code: "UNAUTHENTICATED",
      message: "需要认证",
    });
  }
  
  const userRoleLevel = roleHierarchy[user.role as UserRole] ?? 0;
  const requiredLevel = roleHierarchy[minRole];
  
  if (userRoleLevel < requiredLevel) {
    throw new ConvexError({
      code: "FORBIDDEN",
      message: `需要角色 '${minRole}' 或更高`,
    });
  }
  
  return user;
}

// 基于权限的检查
type Permission = "读取:用户" | "写入:用户" | "删除:用户" | "管理:系统";

const rolePermissions: Record<UserRole, Permission[]> = {
  用户: ["读取:用户"],
  版主: ["读取:用户", "写入:用户"],
  管理员: ["读取:用户", "写入:用户", "删除:用户"],
  超级管理员: ["读取:用户", "写入:用户", "删除:用户", "管理:系统"],
};

export async function requirePermission(
  ctx: QueryCtx | MutationCtx,
  permission: Permission
): Promise<Doc<"users">> {
  const user = await getUser(ctx);
  
  if (!user) {
    throw new ConvexError({ code: "UNAUTHENTICATED", message: "需要认证" });
  }
  
  const userRole = user.role as UserRole;
  const permissions = rolePermissions[userRole] ?? [];
  
  if (!permissions.includes(permission)) {
    throw new ConvexError({
      code: "FORBIDDEN",
      message: `需要权限 '${permission}'`,
    });
  }
  
  return user;
}

数据访问边界审计

// convex/data.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { getUser, requireRole } from "./lib/auth";
import { ConvexError } from "convex/values";

// 审计:用户只能看到自己的数据
export const getMyData = query({
  args: {},
  returns: v.array(v.object({
    _id: v.id("userData"),
    content: v.string(),
  })),
  handler: async (ctx) => {
    const user = await getUser(ctx);
    if (!user) return [];
    
    // 安全:按用户ID过滤
    return await ctx.db
      .query("userData")
      .withIndex("by_user", (q) => q.eq("userId", user._id))
      .collect();
  },
});

// 审计:返回敏感数据前验证所有权
export const getSensitiveItem = query({
  args: { itemId: v.id("sensitiveItems") },
  returns: v.union(v.object({
    _id: v.id("sensitiveItems"),
    secret: v.string(),
  }), v.null()),
  handler: async (ctx, args) => {
    const user = await getUser(ctx);
    if (!user) return null;
    
    const item = await ctx.db.get(args.itemId);
    
    // 安全:验证所有权
    if (!item || item.ownerId !== user._id) {
      return null; // 不揭示项目是否存在
    }
    
    return item;
  },
});

// 审计:带访问列表的共享资源
export const getSharedDocument = query({
  args: { docId: v.id("documents") },
  returns: v.union(v.object({
    _id: v.id("documents"),
    content: v.string(),
    accessLevel: v.string(),
  }), v.null()),
  handler: async (ctx, args) => {
    const user = await getUser(ctx);
    const doc = await ctx.db.get(args.docId);
    
    if (!doc) return null;
    
    // 公共文档
    if (doc.visibility === "public") {
      return { ...doc, accessLevel: "公共" };
    }
    
    // 非公共必须认证
    if (!user) return null;
    
    // 所有者有完全访问
    if (doc.ownerId === user._id) {
      return { ...doc, accessLevel: "所有者" };
    }
    
    // 检查共享访问
    const access = await ctx.db
      .query("documentAccess")
      .withIndex("by_doc_and_user", (q) => 
        q.eq("documentId", args.docId).eq("userId", user._id)
      )
      .unique();
    
    if (!access) return null;
    
    return { ...doc, accessLevel: access.level };
  },
});

动作隔离审计

// convex/actions.ts
"use node";

import { action, internalAction } from "./_generated/server";
import { v } from "convex/values";
import { api, internal } from "./_generated/api";
import { ConvexError } from "convex/values";

// 安全:永远不要在响应中暴露API密钥
export const callExternalAPI = action({
  args: { query: v.string() },
  returns: v.object({ result: v.string() }),
  handler: async (ctx, args) => {
    // 验证用户已认证
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new ConvexError("需要认证");
    }
    
    // 从环境获取API密钥(不要硬编码)
    const apiKey = process.env.EXTERNAL_API_KEY;
    if (!apiKey) {
      throw new Error("API密钥未配置");
    }
    
    // 记录使用以进行审计跟踪
    await ctx.runMutation(internal.audit.logAPICall, {
      userId: identity.tokenIdentifier,
      endpoint: "external-api",
      timestamp: Date.now(),
    });
    
    const response = await fetch("https://api.example.com/query", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query: args.query }),
    });
    
    if (!response.ok) {
      // 不要暴露外部API错误详情
      throw new ConvexError("外部服务不可用");
    }
    
    const data = await response.json();
    
    // 返回前清理响应
    return { result: sanitizeResponse(data) };
  },
});

// 内部动作 - 不向客户端暴露
export const _processPayment = internalAction({
  args: {
    userId: v.id("users"),
    amount: v.number(),
    paymentMethodId: v.string(),
  },
  returns: v.object({ success: v.boolean(), transactionId: v.optional(v.string()) }),
  handler: async (ctx, args) => {
    const stripeKey = process.env.STRIPE_SECRET_KEY;
    
    // 使用Stripe处理付款
    // 这永远不应作为公共动作暴露
    
    return { success: true, transactionId: "txn_xxx" };
  },
});

速率限制审计

// convex/rateLimit.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError } from "convex/values";

const RATE_LIMITS = {
  message: { requests: 10, windowMs: 60000 }, // 每分钟10次
  upload: { requests: 5, windowMs: 300000 },  // 每5分钟5次
  api: { requests: 100, windowMs: 3600000 },  // 每小时100次
};

export const checkRateLimit = mutation({
  args: {
    userId: v.string(),
    action: v.union(v.literal("message"), v.literal("upload"), v.literal("api")),
  },
  returns: v.object({ allowed: v.boolean(), retryAfter: v.optional(v.number()) }),
  handler: async (ctx, args) => {
    const limit = RATE_LIMITS[args.action];
    const now = Date.now();
    const windowStart = now - limit.windowMs;
    
    // 计算窗口内的请求
    const requests = await ctx.db
      .query("rateLimits")
      .withIndex("by_user_and_action", (q) => 
        q.eq("userId", args.userId).eq("action", args.action)
      )
      .filter((q) => q.gt(q.field("timestamp"), windowStart))
      .collect();
    
    if (requests.length >= limit.requests) {
      const oldestRequest = requests[0];
      const retryAfter = oldestRequest.timestamp + limit.windowMs - now;
      
      return { allowed: false, retryAfter };
    }
    
    // 记录此请求
    await ctx.db.insert("rateLimits", {
      userId: args.userId,
      action: args.action,
      timestamp: now,
    });
    
    return { allowed: true };
  },
});

// 在变种中使用
export const sendMessage = mutation({
  args: { content: v.string() },
  returns: v.id("messages"),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new ConvexError("需要认证");
    
    // 检查速率限制
    const rateCheck = await checkRateLimit(ctx, {
      userId: identity.tokenIdentifier,
      action: "message",
    });
    
    if (!rateCheck.allowed) {
      throw new ConvexError({
        code: "RATE_LIMITED",
        message: `请求过多。请在 ${Math.ceil(rateCheck.retryAfter! / 1000)} 秒后重试`,
      });
    }
    
    return await ctx.db.insert("messages", {
      content: args.content,
      authorId: identity.tokenIdentifier,
      createdAt: Date.now(),
    });
  },
});

敏感操作保护

// convex/admin.ts
import { mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { requireRole, requirePermission } from "./lib/auth";
import { internal } from "./_generated/api";

// 危险操作的双因素确认
export const deleteAllUserData = mutation({
  args: {
    userId: v.id("users"),
    confirmationCode: v.string(),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    // 需要超级管理员
    const admin = await requireRole(ctx, "超级管理员");
    
    // 验证确认码
    const confirmation = await ctx.db
      .query("confirmations")
      .withIndex("by_admin_and_code", (q) => 
        q.eq("adminId", admin._id).eq("code", args.confirmationCode)
      )
      .filter((q) => q.gt(q.field("expiresAt"), Date.now()))
      .unique();
    
    if (!confirmation || confirmation.action !== "delete_user_data") {
      throw new ConvexError("无效或过期的确认码");
    }
    
    // 删除确认以防止重用
    await ctx.db.delete(confirmation._id);
    
    // 计划删除(不要内联执行)
    await ctx.scheduler.runAfter(0, internal.admin._performDeletion, {
      userId: args.userId,
      requestedBy: admin._id,
    });
    
    // 审计日志
    await ctx.db.insert("auditLogs", {
      action: "delete_user_data",
      targetUserId: args.userId,
      performedBy: admin._id,
      timestamp: Date.now(),
    });
    
    return null;
  },
});

// 为敏感操作生成确认码
export const requestDeletionConfirmation = mutation({
  args: { userId: v.id("users") },
  returns: v.string(),
  handler: async (ctx, args) => {
    const admin = await requireRole(ctx, "超级管理员");
    
    const code = generateSecureCode();
    
    await ctx.db.insert("confirmations", {
      adminId: admin._id,
      code,
      action: "delete_user_data",
      targetUserId: args.userId,
      expiresAt: Date.now() + 5 * 60 * 1000, // 5分钟
    });
    
    // 在生产中,通过安全渠道(电子邮件、SMS)发送代码
    return code;
  },
});

示例

完整审计跟踪系统

// convex/audit.ts
import { mutation, query, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { getUser, requireRole } from "./lib/auth";

const auditEventValidator = v.object({
  _id: v.id("auditLogs"),
  _creationTime: v.number(),
  action: v.string(),
  userId: v.optional(v.string()),
  resourceType: v.string(),
  resourceId: v.string(),
  details: v.optional(v.any()),
  ipAddress: v.optional(v.string()),
  timestamp: v.number(),
});

// 内部:记录审计事件
export const logEvent = internalMutation({
  args: {
    action: v.string(),
    userId: v.optional(v.string()),
    resourceType: v.string(),
    resourceId: v.string(),
    details: v.optional(v.any()),
  },
  returns: v.id("auditLogs"),
  handler: async (ctx, args) => {
    return await ctx.db.insert("auditLogs", {
      ...args,
      timestamp: Date.now(),
    });
  },
});

// 管理员:查看审计日志
export const getAuditLogs = query({
  args: {
    resourceType: v.optional(v.string()),
    userId: v.optional(v.string()),
    limit: v.optional(v.number()),
  },
  returns: v.array(auditEventValidator),
  handler: async (ctx, args) => {
    await requireRole(ctx, "管理员");
    
    let query = ctx.db.query("auditLogs");
    
    if (args.resourceType) {
      query = query.withIndex("by_resource_type", (q) => 
        q.eq("resourceType", args.resourceType)
      );
    }
    
    return await query
      .order("desc")
      .take(args.limit ?? 100);
  },
});

最佳实践

  • 除非明确指示,永远不要运行 npx convex deploy
  • 除非明确指示,永远不要运行任何git命令
  • 实施深度防御(多层安全)
  • 记录所有敏感操作以进行审计跟踪
  • 使用确认码进行破坏性操作
  • 对所有用户面向的端点进行速率限制
  • 永远不要暴露内部API密钥或错误
  • 定期审查访问模式

常见陷阱

  1. 单点故障 - 实施多个认证检查
  2. 缺少审计日志 - 记录所有敏感操作
  3. 信任客户端数据 - 始终在服务器端验证
  4. 暴露错误详情 - 清理错误消息
  5. 没有速率限制 - 始终实施速率限制

参考