568 lines
14 KiB
Markdown
568 lines
14 KiB
Markdown
---
|
|
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
|