Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
16 KiB
Port Nimara CRM — Security Guidelines
Status: Mandatory for all development. Every checklist item applies to every layer. Context: This system stores personally identifiable information for ultra-high-net-worth individuals — yacht owners, their brokers, legal counsel, and family members. A data breach would be catastrophic for the business and for the individuals involved. Security is not a nice-to-have; it is a hard requirement on every line of code.
1. Authentication & Session Security
1.1 Password Handling
- Passwords hashed with Argon2id via Better Auth defaults (never bcrypt, never SHA-*)
- Minimum password length: 12 characters (enforced in Zod schema AND server-side)
- Password complexity: at least 1 uppercase, 1 lowercase, 1 digit, 1 special character
- Password set/reset tokens: UUID + HMAC, 48-hour expiry, single-use, stored hashed in DB
- No password in URL parameters, query strings, or logs
- Rate limit login: 5 failed attempts per email per 15 minutes → temporary lockout with generic error
- Rate limit password reset requests: 3 per email per hour
1.2 Session Management
- Sessions stored in PostgreSQL via Better Auth (not JWT, not localStorage)
- Session cookie:
httpOnly,secure,sameSite=strict - Session duration: 24 hours, refresh on activity within last 25%
- CSRF token on every state-mutating request (Better Auth provides this)
- Logout destroys server-side session AND clears cookie
- Admin can revoke all sessions for a user
1.3 Authentication Responses
- Failed login returns generic "Invalid credentials" — never reveal whether email exists
- Password reset for non-existent email returns same success response as valid email
- No user enumeration via any endpoint (signup, reset, login)
2. Authorization & Access Control
2.1 Port Scoping (Multi-Tenancy Isolation)
- Every database query includes
WHERE port_id = :currentPortId - Port ID comes from authenticated session context, never from request body/params
- No endpoint allows cross-port data access (except super_admin)
- Port scoping enforced at the service layer, not just the API layer
- Drizzle queries use a helper:
withPortScope(query, portId)or equivalent pattern
2.2 RBAC Enforcement
- Permission checks on every API endpoint via middleware
- Middleware pattern:
authenticate → extractPort → checkPermission → handler - Permission checks use the role's JSON permission map, not hardcoded role names
- Super admin bypasses permission checks but is still subject to audit logging
- Port role overrides are applied after global role permissions
2.3 Resource-Level Access
- Users can only access records within their assigned port
- Reminder
view_ownvsview_allenforced at query level - File downloads verify the requesting user has access to the parent entity (client, expense, etc.)
- Document signing URLs are time-limited and single-use per signer
3. Input Validation & Sanitization
3.1 API Input Validation
- Every API endpoint validates input with a Zod schema — no exceptions
- Zod schemas validate types, lengths, formats, and allowed values
- Validation happens before any database query or business logic
- Validation errors return 400 with field-level error messages (no internal details)
- File upload validation: check MIME type (allowlist), max size (10MB default), filename sanitization
3.2 SQL Injection Prevention
- All queries use Drizzle ORM parameterized queries — no raw SQL string concatenation
- If raw SQL is ever needed (full-text search, complex aggregations): use
sql.raw()withsql.placeholder()only - Search inputs passed through
sanitizeSearchTerm()before inclusion in tsvector queries
3.3 XSS Prevention
- Rich text content (TipTap output, email bodies) sanitized with DOMPurify before storage AND before rendering
- DOMPurify allowlist: basic formatting tags only (b, i, u, em, strong, p, br, ul, ol, li, a, h1-h6, table, tr, td, th, blockquote, code, pre)
- Links in rich text:
rel="noopener noreferrer"enforced - User-generated content rendered via React (auto-escaped by default) — no
dangerouslySetInnerHTMLexcept for DOMPurify-sanitized content - File names displayed in UI are always escaped
3.4 Path Traversal Prevention
- MinIO object keys constructed from UUIDs, never from user input
- File upload original names stored in DB but never used as storage paths
- Storage path format:
{portSlug}/{entity}/{entityId}/{uuid}.{ext}
4. Data Protection
4.1 Encryption at Rest
- PostgreSQL: disk-level encryption via Docker volume (host responsibility)
- MinIO: SSE-S3 encryption enabled on all buckets
- Email account credentials (SMTP/IMAP passwords): encrypted with AES-256-GCM before storage in
email_accounts.credentials_enc - Google Calendar OAuth tokens: encrypted with pgcrypto in
google_calendar_tokens - Webhook signing secrets: stored encrypted, decrypted only at delivery time
4.2 Encryption in Transit
- TLS 1.3 on all external connections (nginx terminates)
- Internal Docker network: services communicate over Docker bridge (not exposed to host)
- MinIO connections from app: use HTTPS if MinIO has TLS configured, otherwise Docker-internal HTTP is acceptable
- HSTS header:
max-age=31536000; includeSubDomains
4.3 Sensitive Data Handling
- Never log passwords, tokens, API keys, or email credentials
- Never log full client PII (names, emails, phone numbers, addresses) — log entity IDs only
- Error responses to clients: generic messages only, no stack traces, no internal paths
- Database error messages caught and replaced with generic 500 responses
- Audit log
old_value/new_valuefor sensitive fields (email, phone): store hashed or masked versions
4.4 PII Minimization
- API responses return only fields the requesting user is authorized to see
- List endpoints return summary fields; detail endpoints return full records
- Export endpoints respect the user's permission level
- Search results do not include archived records unless explicitly requested
5. Audit Logging
5.1 What Must Be Logged
- Every create, update, delete, archive, restore operation on every entity
- Client merge operations with full merge details
- Login, logout, failed login attempts
- Permission changes (role assignments, role modifications)
- Document signing events (sent, viewed, signed, completed, expired)
- File uploads and downloads
- Export operations (who exported what, when)
- Admin settings changes
- Bulk operations (what was selected, what action was taken)
5.2 Audit Log Format
{
port_id: UUID, // null for system events
user_id: string, // null for system-generated
action: string, // create, update, delete, archive, restore, merge, login, logout, revert
entity_type: string, // client, interest, berth, expense, invoice, file, user, role, etc.
entity_id: UUID,
field_changed: string, // for updates: which field
old_value: JSONB, // previous value (masked for sensitive fields)
new_value: JSONB, // new value (masked for sensitive fields)
ip_address: string, // from request
user_agent: string, // from request
metadata: JSONB // extra context (e.g., source: "nocodb_migration")
}
5.3 Audit Helper Pattern
// Every service function should use this pattern:
async function updateClient(portId: string, clientId: string, data: UpdateClientInput, userId: string) {
const existing = await getClient(portId, clientId);
const updated = await db.update(clients).set(data).where(...).returning();
await auditLog({
portId,
userId,
action: 'update',
entityType: 'client',
entityId: clientId,
changes: diffFields(existing, updated), // generates field_changed + old_value + new_value per field
});
return updated;
}
6. API Security
6.1 Rate Limiting
- nginx layer: global rate limits per IP
- Auth endpoints: 5 requests/minute
- Public API: 30 requests/minute
- Authenticated API: 120 requests/minute
- File upload: 10 requests/minute
- Application layer: Redis sliding window per authenticated user
- Configurable per-endpoint (default: 60 requests/minute)
- Rate limit headers:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
6.2 Security Headers (nginx)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' wss:; frame-ancestors 'none';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
6.3 CORS
- Allowed origins: only the CRM domain and the public website domain
- No wildcard (
*) origins - Credentials:
true(for cookie-based auth) - Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
- Preflight cache: 1 hour
6.4 Request Size Limits
- JSON body: 1MB max
- File upload: 50MB max (configurable per endpoint)
- URL length: 2048 characters max
7. External Service Security
7.1 MinIO
- Access credentials in environment variables only (never in code, never in DB)
- Bucket policy: private by default, pre-signed URLs for client access (15-minute expiry)
- Object key format: no user-controllable path components
- SSE-S3 encryption enabled
7.2 Documenso
- API key in environment variable
- Webhook signature verification on every inbound webhook (reject if invalid)
- Signing URLs: generated per-signer, time-limited by Documenso
- Document status changes only accepted via verified webhooks (not client requests)
7.3 Google Calendar OAuth
- OAuth tokens encrypted at rest (pgcrypto)
- Refresh tokens rotated on each use
- Token scope: calendar read/write only (minimum scope)
- Revoke tokens on user request or account deactivation
7.4 OpenAI Vision API
- API key in environment variable
- Receipt images sent for OCR only — no PII fields sent to OpenAI
- Response validation: don't trust extracted values blindly, show user for confirmation
7.5 Frankfurter API
- No API key needed (public)
- Cache responses (24 hours) — don't hit on every request
- Fallback to last cached rate if API is down
8. Socket.io Security
- Socket connections require valid session cookie (same auth as HTTP)
- Max 10 connections per user (prevent resource exhaustion)
- Message size limit: 1MB
- Room authorization: user can only join rooms for their assigned port
- Room naming:
port:{portId},user:{userId}— portId verified against user's assignments - No sensitive data in Socket.io payloads — send event type + entity ID, client fetches details via API
- Connection timeout: 30 seconds idle → disconnect
9. BullMQ Security
- Job data: never include raw credentials or tokens — reference by encrypted DB record ID
- Failed job data: scrub sensitive fields before storing in Redis
- Dead letter queue: alert on accumulation (>10 jobs) via notification system
- Job retry limits: max 3 retries with exponential backoff
- Bull Board admin dashboard: protected by super_admin or director role check
10. File Upload Security
- MIME type validation: allowlist only
- Images:
image/jpeg,image/png,image/gif,image/webp - Documents:
application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document - Spreadsheets:
application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
- Images:
- File extension validation: must match MIME type
- Max file size: 50MB (configurable)
- Filename sanitization: strip path separators, null bytes, unicode control characters
- Storage filename: UUID-based (original name stored in DB only)
- Virus scanning: defer to V2 (document in risk register)
- No direct file serving — always through pre-signed URLs or API proxy with auth check
11. Environment & Secrets Management
- All secrets in environment variables (
.envfiles, never committed to git) .env.examplewith placeholder values (no real secrets).gitignoreincludes:.env,.env.local,.env.production,*.pem,*.key- Docker Compose uses
env_filedirective - No secrets in Dockerfile build args
- Database connection string:
?sslmode=disableacceptable for Docker-internal,?sslmode=requirefor any external connection
Required Environment Variables
# Database
DATABASE_URL=postgresql://user:pass@postgres:5432/portnimara
# Redis
REDIS_URL=redis://redis:6379
# Auth
BETTER_AUTH_SECRET=<32+ random bytes, base64>
CSRF_SECRET=<32+ random bytes, base64>
# MinIO
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=<access key>
MINIO_SECRET_KEY=<secret key>
# Documenso
DOCUMENSO_API_URL=https://documenso.portnimara.com/api/v1
DOCUMENSO_API_KEY=<api key>
DOCUMENSO_WEBHOOK_SECRET=<webhook signing secret>
# Email encryption
EMAIL_CREDENTIAL_KEY=<32-byte AES key, hex>
# Google OAuth
GOOGLE_CLIENT_ID=<client id>
GOOGLE_CLIENT_SECRET=<client secret>
# OpenAI (receipt OCR only)
OPENAI_API_KEY=<api key>
# App
APP_URL=https://crm.portnimara.com
PUBLIC_SITE_URL=https://portnimara.com
NODE_ENV=production
12. Dependency Security
pnpm auditrun in CI — fail build on high/critical vulnerabilities- Lockfile (
pnpm-lock.yaml) always committed - No
*version ranges in package.json — pin major+minor (^is OK,*is not) - Docker base image:
node:20-alpine(minimal attack surface) - No unnecessary OS packages in Docker image
13. Error Handling Security
What the client sees:
// 400 — Validation error
{ "error": "Validation failed", "details": [{ "field": "email", "message": "Invalid email format" }] }
// 401 — Not authenticated
{ "error": "Authentication required" }
// 403 — Not authorized
{ "error": "Insufficient permissions" }
// 404 — Not found (also used for unauthorized access to existing resources)
{ "error": "Resource not found" }
// 429 — Rate limited
{ "error": "Too many requests", "retryAfter": 60 }
// 500 — Server error
{ "error": "Internal server error" }
What the client NEVER sees:
- Stack traces
- Database error messages
- Internal file paths
- SQL queries
- Environment variable values
- Other users' IDs or data
14. Pre-Deployment Security Checklist
Before ANY deployment:
- All environment variables set (no defaults that work in production)
NODE_ENV=production- Debug/dev endpoints disabled
- API docs endpoint (
/api/docs) disabled in production (or protected behind auth) - Default/seed admin password changed
- HTTPS enforced (HTTP redirects to HTTPS)
- Database backups configured and tested
- Audit log writing verified (test a mutation, check the log)
- Rate limiting verified (test with rapid requests)
- CORS verified (test from unauthorized origin)
- File upload limits verified (test with oversized file)