name: convex-security-audit displayName: Convex安全审计 description: 授权逻辑、数据访问边界、动作隔离、速率限制和保护敏感操作的深入安全审查模式 version: 1.0.0 author: Convex tags: [convex, 安全, 审计, 授权, 速率限制, 保护]
Convex安全审计
用于Convex应用程序的全面安全审查模式,包括授权逻辑、数据访问边界、动作隔离、速率限制和保护敏感操作。
文档来源
在实施前,不要假设;获取最新文档:
- 主要:https://docs.convex.dev/auth/functions-auth
- 生产安全:https://docs.convex.dev/production
- 更广泛的上下文:https://docs.convex.dev/llms.txt
说明
安全审计领域
- 授权逻辑 - 谁可以做什么
- 数据访问边界 - 用户可以看到什么数据
- 动作隔离 - 保护外部API调用
- 速率限制 - 防止滥用
- 敏感操作 - 保护关键功能
授权逻辑审计
基于角色的访问控制(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密钥或错误
- 定期审查访问模式
常见陷阱
- 单点故障 - 实施多个认证检查
- 缺少审计日志 - 记录所有敏感操作
- 信任客户端数据 - 始终在服务器端验证
- 暴露错误详情 - 清理错误消息
- 没有速率限制 - 始终实施速率限制
参考
- Convex文档:https://docs.convex.dev/
- Convex LLMs.txt:https://docs.convex.dev/llms.txt
- 函数认证:https://docs.convex.dev/auth/functions-auth
- 生产安全:https://docs.convex.dev/production