Update Convex with no payload to be just like convex with payload but without payload

This commit is contained in:
Gabriel Brown
2026-06-21 15:35:42 -05:00
parent 13b8b36c4c
commit fba73a92ce
130 changed files with 15637 additions and 32018 deletions
@@ -0,0 +1,567 @@
---
name: convex-security-audit
displayName: Convex Security Audit
description: Deep security review patterns for authorization logic, data access boundaries, action isolation, rate limiting, and protecting sensitive operations
version: 1.0.0
author: Convex
tags: [convex, security, audit, authorization, rate-limiting, protection]
---
# Convex Security Audit
Comprehensive security review patterns for Convex applications including authorization logic, data access boundaries, action isolation, rate limiting, and protecting sensitive operations.
## Documentation Sources
Before implementing, do not assume; fetch the latest documentation:
- Primary: https://docs.convex.dev/auth/functions-auth
- Production Security: https://docs.convex.dev/production
- For broader context: https://docs.convex.dev/llms.txt
## Instructions
### Security Audit Areas
1. **Authorization Logic** - Who can do what
2. **Data Access Boundaries** - What data users can see
3. **Action Isolation** - Protecting external API calls
4. **Rate Limiting** - Preventing abuse
5. **Sensitive Operations** - Protecting critical functions
### Authorization Logic Audit
#### Role-Based Access Control (RBAC)
```typescript
// convex/lib/auth.ts
import { ConvexError } from 'convex/values';
import { Doc } from './_generated/dataModel';
import { MutationCtx, QueryCtx } from './_generated/server';
type UserRole = 'user' | 'moderator' | 'admin' | 'superadmin';
const roleHierarchy: Record<UserRole, number> = {
user: 0,
moderator: 1,
admin: 2,
superadmin: 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: 'Authentication required',
});
}
const userRoleLevel = roleHierarchy[user.role as UserRole] ?? 0;
const requiredLevel = roleHierarchy[minRole];
if (userRoleLevel < requiredLevel) {
throw new ConvexError({
code: 'FORBIDDEN',
message: `Role '${minRole}' or higher required`,
});
}
return user;
}
// Permission-based check
type Permission =
| 'read:users'
| 'write:users'
| 'delete:users'
| 'admin:system';
const rolePermissions: Record<UserRole, Permission[]> = {
user: ['read:users'],
moderator: ['read:users', 'write:users'],
admin: ['read:users', 'write:users', 'delete:users'],
superadmin: ['read:users', 'write:users', 'delete:users', 'admin:system'],
};
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: 'Authentication required',
});
}
const userRole = user.role as UserRole;
const permissions = rolePermissions[userRole] ?? [];
if (!permissions.includes(permission)) {
throw new ConvexError({
code: 'FORBIDDEN',
message: `Permission '${permission}' required`,
});
}
return user;
}
```
### Data Access Boundaries Audit
```typescript
// convex/data.ts
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { getUser, requireRole } from './lib/auth';
// Audit: Users can only see their own data
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 [];
// SECURITY: Filter by userId
return await ctx.db
.query('userData')
.withIndex('by_user', (q) => q.eq('userId', user._id))
.collect();
},
});
// Audit: Verify ownership before returning sensitive data
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);
// SECURITY: Verify ownership
if (!item || item.ownerId !== user._id) {
return null; // Don't reveal if item exists
}
return item;
},
});
// Audit: Shared resources with access list
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;
// Public documents
if (doc.visibility === 'public') {
return { ...doc, accessLevel: 'public' };
}
// Must be authenticated for non-public
if (!user) return null;
// Owner has full access
if (doc.ownerId === user._id) {
return { ...doc, accessLevel: 'owner' };
}
// Check shared access
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 };
},
});
```
### Action Isolation Audit
```typescript
// convex/actions.ts
'use node';
import { ConvexError, v } from 'convex/values';
import { api, internal } from './_generated/api';
import { action, internalAction } from './_generated/server';
// SECURITY: Never expose API keys in responses
export const callExternalAPI = action({
args: { query: v.string() },
returns: v.object({ result: v.string() }),
handler: async (ctx, args) => {
// Verify user is authenticated
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError('Authentication required');
}
// Get API key from environment (not hardcoded)
const apiKey = process.env.EXTERNAL_API_KEY;
if (!apiKey) {
throw new Error('API key not configured');
}
// Log usage for audit trail
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) {
// Don't expose external API error details
throw new ConvexError('External service unavailable');
}
const data = await response.json();
// Sanitize response before returning
return { result: sanitizeResponse(data) };
},
});
// Internal action - not exposed to clients
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;
// Process payment with Stripe
// This should NEVER be exposed as a public action
return { success: true, transactionId: 'txn_xxx' };
},
});
```
### Rate Limiting Audit
```typescript
// convex/rateLimit.ts
import { ConvexError, v } from 'convex/values';
import { mutation, query } from './_generated/server';
const RATE_LIMITS = {
message: { requests: 10, windowMs: 60000 }, // 10 per minute
upload: { requests: 5, windowMs: 300000 }, // 5 per 5 minutes
api: { requests: 100, windowMs: 3600000 }, // 100 per hour
};
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;
// Count requests in window
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 };
}
// Record this request
await ctx.db.insert('rateLimits', {
userId: args.userId,
action: args.action,
timestamp: now,
});
return { allowed: true };
},
});
// Use in mutations
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('Authentication required');
// Check rate limit
const rateCheck = await checkRateLimit(ctx, {
userId: identity.tokenIdentifier,
action: 'message',
});
if (!rateCheck.allowed) {
throw new ConvexError({
code: 'RATE_LIMITED',
message: `Too many requests. Try again in ${Math.ceil(rateCheck.retryAfter! / 1000)} seconds`,
});
}
return await ctx.db.insert('messages', {
content: args.content,
authorId: identity.tokenIdentifier,
createdAt: Date.now(),
});
},
});
```
### Sensitive Operations Protection
```typescript
// convex/admin.ts
import { v } from 'convex/values';
import { internal } from './_generated/api';
import { internalMutation, mutation } from './_generated/server';
import { requirePermission, requireRole } from './lib/auth';
// Two-factor confirmation for dangerous operations
export const deleteAllUserData = mutation({
args: {
userId: v.id('users'),
confirmationCode: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Require superadmin
const admin = await requireRole(ctx, 'superadmin');
// Verify confirmation code
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('Invalid or expired confirmation code');
}
// Delete confirmation to prevent reuse
await ctx.db.delete(confirmation._id);
// Schedule deletion (don't do it inline)
await ctx.scheduler.runAfter(0, internal.admin._performDeletion, {
userId: args.userId,
requestedBy: admin._id,
});
// Audit log
await ctx.db.insert('auditLogs', {
action: 'delete_user_data',
targetUserId: args.userId,
performedBy: admin._id,
timestamp: Date.now(),
});
return null;
},
});
// Generate confirmation code for sensitive action
export const requestDeletionConfirmation = mutation({
args: { userId: v.id('users') },
returns: v.string(),
handler: async (ctx, args) => {
const admin = await requireRole(ctx, 'superadmin');
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 minutes
});
// In production, send code via secure channel (email, SMS)
return code;
},
});
```
## Examples
### Complete Audit Trail System
```typescript
// convex/audit.ts
import { v } from 'convex/values';
import { internalMutation, mutation, query } from './_generated/server';
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(),
});
// Internal: Log audit event
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(),
});
},
});
// Admin: View audit logs
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, 'admin');
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);
},
});
```
## Best Practices
- Never run `npx convex deploy` unless explicitly instructed
- Never run any git commands unless explicitly instructed
- Implement defense in depth (multiple security layers)
- Log all sensitive operations for audit trails
- Use confirmation codes for destructive actions
- Rate limit all user-facing endpoints
- Never expose internal API keys or errors
- Review access patterns regularly
## Common Pitfalls
1. **Single point of failure** - Implement multiple auth checks
2. **Missing audit logs** - Log all sensitive operations
3. **Trusting client data** - Always validate server-side
4. **Exposing error details** - Sanitize error messages
5. **No rate limiting** - Always implement rate limits
## References
- Convex Documentation: https://docs.convex.dev/
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
- Functions Auth: https://docs.convex.dev/auth/functions-auth
- Production Security: https://docs.convex.dev/production