Initial commit: Port Nimara CRM (Layers 0-4)
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

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>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

409
SECURITY-GUIDELINES.md Normal file
View File

@@ -0,0 +1,409 @@
# 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
```typescript
{
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
```typescript
// 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)
```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:
```json
// 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)