# 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)
