Files
pn-new-crm/SECURITY-GUIDELINES.md
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00

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_own vs view_all enforced 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() with sql.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 dangerouslySetInnerHTML except 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_value for 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
  • 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 (.env files, never committed to git)
  • .env.example with placeholder values (no real secrets)
  • .gitignore includes: .env, .env.local, .env.production, *.pem, *.key
  • Docker Compose uses env_file directive
  • No secrets in Dockerfile build args
  • Database connection string: ?sslmode=disable acceptable for Docker-internal, ?sslmode=require for 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 audit run 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)