Skip to content

Commit fd2382a

Browse files
authored
Merge pull request #34 from AskExe/tom-audit-cf
fix: Track C audit remediation — security hardening
2 parents 0372f9b + 704c0f9 commit fd2382a

4 files changed

Lines changed: 203 additions & 17 deletions

File tree

docs/SCHEMA-CONTRACT.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# exe-crm Schema Contract
2+
3+
This document defines the database schema contract that external systems (exe-os
4+
gateway, exe-erp projections) MUST respect when querying CRM data.
5+
6+
## Architecture
7+
8+
Twenty CRM uses **per-workspace PostgreSQL schemas**. There is no single `crm`
9+
schema or top-level `people`/`companies` table.
10+
11+
### Schema naming
12+
13+
```
14+
workspace_<base36(workspace_uuid)>
15+
```
16+
17+
Generated by `getWorkspaceSchemaName()` in
18+
`src/engine/workspace-datasource/utils/get-workspace-schema-name.util.ts`:
19+
20+
```ts
21+
export const getWorkspaceSchemaName = (workspaceId: string): string => {
22+
return `workspace_${uuidToBase36(workspaceId)}`;
23+
};
24+
```
25+
26+
### Table naming
27+
28+
Tables use the **singular** `nameSingular` from Twenty's standard object
29+
metadata. The two objects relevant to external integrations:
30+
31+
| Standard Object | `nameSingular` | Table in workspace schema |
32+
|-----------------|----------------|---------------------------|
33+
| Person | `person` | `workspace_<id>.person` |
34+
| Company | `company` | `workspace_<id>.company` |
35+
36+
There are **no** `people` or `companies` tables. Any external system using those
37+
names will fail.
38+
39+
### Key columns (person)
40+
41+
| Column | Type | Notes |
42+
|----------------------|------------|------------------------------------|
43+
| `id` | `uuid` | PK |
44+
| `nameFirstName` | `text` | Composite FULL_NAME field |
45+
| `nameLastName` | `text` | Composite FULL_NAME field |
46+
| `emailsPrimaryEmail` | `text` | Primary email (EMAILS composite) |
47+
| `jobTitle` | `text` | |
48+
| `companyId` | `uuid` | FK to `company.id` |
49+
| `createdAt` | `timestamptz` | |
50+
| `deletedAt` | `timestamptz` | Soft-delete |
51+
52+
### Key columns (company)
53+
54+
| Column | Type | Notes |
55+
|--------------------|------------|------------------------------------|
56+
| `id` | `uuid` | PK |
57+
| `name` | `text` | |
58+
| `domainNameUrl` | `text` | Composite LINKS field |
59+
| `employees` | `integer` | |
60+
| `createdAt` | `timestamptz` | |
61+
| `deletedAt` | `timestamptz` | Soft-delete |
62+
63+
## Integration contract
64+
65+
External systems MUST:
66+
67+
1. **Resolve the workspace ID** first (from `core.workspace` or via the admin
68+
API).
69+
2. **Compute the schema name** using `workspace_<base36(workspace_id)>`.
70+
3. **Query `person` and `company`** (singular) in that schema.
71+
4. **Respect soft-deletes** by filtering `WHERE "deletedAt" IS NULL`.
72+
73+
External systems MUST NOT:
74+
75+
- Assume a `crm` schema exists.
76+
- Use `people` or `companies` as table names.
77+
- Write directly to workspace tables (use the GraphQL API instead).
78+
79+
## Compatibility views
80+
81+
No SQL compatibility views are needed. The tables already exist under the correct
82+
names (`person`, `company`) inside workspace schemas. Gateway and ERP projections
83+
should be updated to use the correct names documented above.

packages/twenty-docker/docker-compose.yml

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ services:
1717
PG_DATABASE_URL: ${PG_DATABASE_URL:-postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:?Set PG_DATABASE_PASSWORD}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME:-default}}
1818
SERVER_URL: '${SERVER_URL:?SERVER_URL is required - set to your externally-reachable origin e.g. https://crm.example.com}'
1919
FRONTEND_URL: '${FRONTEND_URL:-${SERVER_URL}}'
20-
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:-exe-crm-redis-default}@redis:6379}
20+
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379}
2121
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS:-}
2222
DISABLE_CRON_JOBS_REGISTRATION: ${DISABLE_CRON_JOBS_REGISTRATION:-}
2323

@@ -95,7 +95,7 @@ services:
9595
PG_DATABASE_URL: ${PG_DATABASE_URL:-postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:?Set PG_DATABASE_PASSWORD}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME:-default}}
9696
SERVER_URL: '${SERVER_URL:?SERVER_URL is required - set to your externally-reachable origin e.g. https://crm.example.com}'
9797
FRONTEND_URL: '${FRONTEND_URL:-${SERVER_URL}}'
98-
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:-exe-crm-redis-default}@redis:6379}
98+
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379}
9999
DISABLE_DB_MIGRATIONS: 'true' # it already runs on the server
100100
DISABLE_CRON_JOBS_REGISTRATION: 'true' # it already runs on the server
101101

@@ -191,9 +191,9 @@ services:
191191
limits:
192192
memory: 512m
193193
cpus: '0.5'
194-
command: ['--maxmemory-policy', 'noeviction', '--requirepass', '${REDIS_PASSWORD:-exe-crm-redis-default}']
194+
command: ['--maxmemory-policy', 'noeviction', '--requirepass', '${REDIS_PASSWORD:?Set REDIS_PASSWORD}']
195195
healthcheck:
196-
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:-exe-crm-redis-default}', '--no-auth-warning', 'ping']
196+
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:?Set REDIS_PASSWORD}', '--no-auth-warning', 'ping']
197197
interval: 5s
198198
timeout: 5s
199199
retries: 10
@@ -204,23 +204,37 @@ services:
204204
command:
205205
- -c
206206
- |
207+
apk add --no-cache gnupg >/dev/null 2>&1
207208
echo "Starting backup cron (every 6 hours)..."
209+
if [ -z "$${EXE_BACKUP_KEY}" ]; then
210+
echo "WARNING: EXE_BACKUP_KEY not set — backups will NOT be encrypted!"
211+
fi
208212
while true; do
209213
TIMESTAMP=$$(date +%Y%m%d_%H%M%S)
210214
echo "[$${TIMESTAMP}] Running pg_dump..."
211215
PGPASSWORD=$${POSTGRES_PASSWORD} pg_dump \
212216
-h db -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-default} \
213217
--format=custom \
214218
--file=/backups/exe-crm_$${TIMESTAMP}.dump
215-
# Keep only the last 7 backups (42 hours of coverage)
216-
ls -t /backups/exe-crm_*.dump 2>/dev/null | tail -n +8 | xargs -r rm -f
219+
if [ -n "$${EXE_BACKUP_KEY}" ]; then
220+
echo "[$${TIMESTAMP}] Encrypting backup with GPG..."
221+
gpg --batch --yes --symmetric --cipher-algo AES256 \
222+
--passphrase "$${EXE_BACKUP_KEY}" \
223+
--output /backups/exe-crm_$${TIMESTAMP}.dump.gpg \
224+
/backups/exe-crm_$${TIMESTAMP}.dump
225+
rm -f /backups/exe-crm_$${TIMESTAMP}.dump
226+
ls -t /backups/exe-crm_*.dump.gpg 2>/dev/null | tail -n +8 | xargs -r rm -f
227+
else
228+
ls -t /backups/exe-crm_*.dump 2>/dev/null | tail -n +8 | xargs -r rm -f
229+
fi
217230
echo "[$${TIMESTAMP}] Backup complete. Next in 6h."
218231
sleep 21600
219232
done
220233
environment:
221234
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD:?Set PG_DATABASE_PASSWORD}
222235
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
223236
POSTGRES_DB: ${PG_DATABASE_NAME:-default}
237+
EXE_BACKUP_KEY: ${EXE_BACKUP_KEY:-}
224238
volumes:
225239
- db-backups:/backups
226240
healthcheck:

packages/twenty-server/src/engine/core-modules/auth/controllers/gotrue-auth.controller.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Body, Controller, Logger, Post, Res } from '@nestjs/common';
22
import { type Response } from 'express';
33
import { InjectRepository } from '@nestjs/typeorm';
4+
import { createHash, timingSafeEqual } from 'crypto';
45
import { DataSource, Repository } from 'typeorm';
56

67
import * as bcrypt from 'bcrypt';
@@ -30,7 +31,7 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
3031
export class GoTrueAuthController {
3132
private readonly logger = new Logger(GoTrueAuthController.name);
3233
private readonly gotrueUrl: string | undefined;
33-
private readonly adminToken: string | undefined;
34+
private readonly adminTokenHash: Buffer | undefined;
3435
private readonly serverBaseUrl: string | undefined;
3536

3637
constructor(
@@ -46,7 +47,11 @@ export class GoTrueAuthController {
4647
private readonly workspaceRepository: Repository<WorkspaceEntity>,
4748
) {
4849
this.gotrueUrl = process.env.GOTRUE_URL || process.env.EXE_GOTRUE_URL;
49-
this.adminToken = process.env.EXE_CRM_ADMIN_TOKEN;
50+
const rawToken = process.env.EXE_CRM_ADMIN_TOKEN;
51+
52+
this.adminTokenHash = rawToken
53+
? createHash('sha256').update(rawToken).digest()
54+
: undefined;
5055
this.serverBaseUrl = process.env.SERVER_URL || process.env.REACT_APP_SERVER_BASE_URL;
5156
}
5257

@@ -178,8 +183,13 @@ export class GoTrueAuthController {
178183
if (!gotrueRes.ok) {
179184
const errBody = await gotrueRes.json().catch(() => ({}));
180185

186+
this.logger.warn(
187+
`GoTrue auth failed for ${email}: status=${gotrueRes.status} ` +
188+
`error=${errBody?.error_description || errBody?.msg || 'unknown'}`,
189+
);
190+
181191
return res.status(401).json({
182-
error: errBody?.error_description || errBody?.msg || 'Invalid email or password',
192+
error: 'Authentication failed',
183193
});
184194
}
185195

@@ -300,12 +310,19 @@ export class GoTrueAuthController {
300310
return res.status(400).json({ error: 'Token is required' });
301311
}
302312

303-
if (!this.adminToken) {
313+
if (!this.adminTokenHash) {
304314
return res.status(500).json({ error: 'Admin token not configured' });
305315
}
306316

307-
if (token !== this.adminToken) {
308-
return res.status(401).json({ error: 'Invalid admin token' });
317+
const incomingHash = createHash('sha256').update(token).digest();
318+
319+
if (
320+
incomingHash.length !== this.adminTokenHash.length ||
321+
!timingSafeEqual(incomingHash, this.adminTokenHash)
322+
) {
323+
this.logger.warn('Admin token login rejected');
324+
325+
return res.status(401).json({ error: 'Authentication failed' });
309326
}
310327

311328
const workspace = await this.getWorkspace();

packages/twenty-server/src/engine/middlewares/admin-token.middleware.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,65 @@
1-
import { Injectable, type NestMiddleware } from '@nestjs/common';
1+
import { Injectable, Logger, type NestMiddleware } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
33

4+
import { createHash, timingSafeEqual } from 'crypto';
45
import { type NextFunction, type Request, type Response } from 'express';
56
import { Repository } from 'typeorm';
67

78
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
89

10+
/** SHA-256 hash a string and return a Buffer for timingSafeEqual. */
11+
const sha256 = (value: string): Buffer =>
12+
createHash('sha256').update(value).digest();
13+
14+
/** Simple in-memory sliding-window rate limiter (per IP). */
15+
class AdminTokenRateLimiter {
16+
private readonly attempts = new Map<string, number[]>();
17+
private readonly maxAttempts: number;
18+
private readonly windowMs: number;
19+
20+
constructor(maxAttempts = 10, windowMs = 60_000) {
21+
this.maxAttempts = maxAttempts;
22+
this.windowMs = windowMs;
23+
}
24+
25+
isRateLimited(ip: string): boolean {
26+
const now = Date.now();
27+
const timestamps = this.attempts.get(ip) ?? [];
28+
const recent = timestamps.filter((t) => now - t < this.windowMs);
29+
30+
this.attempts.set(ip, recent);
31+
32+
return recent.length >= this.maxAttempts;
33+
}
34+
35+
record(ip: string): void {
36+
const now = Date.now();
37+
const timestamps = this.attempts.get(ip) ?? [];
38+
39+
timestamps.push(now);
40+
this.attempts.set(ip, timestamps);
41+
}
42+
}
43+
944
@Injectable()
1045
export class AdminTokenMiddleware implements NestMiddleware {
11-
private readonly adminToken: string | undefined;
46+
private readonly logger = new Logger(AdminTokenMiddleware.name);
47+
private readonly adminTokenHash: Buffer | undefined;
48+
private readonly rateLimiter = new AdminTokenRateLimiter(10, 60_000);
1249

1350
constructor(
1451
@InjectRepository(WorkspaceEntity)
1552
private readonly workspaceRepository: Repository<WorkspaceEntity>,
1653
) {
17-
this.adminToken = process.env.EXE_CRM_ADMIN_TOKEN;
54+
const raw = process.env.EXE_CRM_ADMIN_TOKEN;
55+
56+
if (raw) {
57+
this.adminTokenHash = sha256(raw);
58+
}
1859
}
1960

2061
async use(req: Request, _res: Response, next: NextFunction) {
21-
if (!this.adminToken) {
62+
if (!this.adminTokenHash) {
2263
next();
2364

2465
return;
@@ -33,8 +74,35 @@ export class AdminTokenMiddleware implements NestMiddleware {
3374
}
3475

3576
const token = authHeader.slice(7);
77+
const clientIp =
78+
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ??
79+
req.socket.remoteAddress ??
80+
'unknown';
81+
82+
// Rate-limit check before any comparison
83+
if (this.rateLimiter.isRateLimited(clientIp)) {
84+
this.logger.warn(
85+
`Admin token rate limit exceeded for IP=${clientIp}`,
86+
);
87+
88+
next();
89+
90+
return;
91+
}
92+
93+
this.rateLimiter.record(clientIp);
94+
95+
// Timing-safe comparison using SHA-256 hashes
96+
const incomingHash = sha256(token);
97+
98+
if (
99+
incomingHash.length !== this.adminTokenHash.length ||
100+
!timingSafeEqual(incomingHash, this.adminTokenHash)
101+
) {
102+
this.logger.warn(
103+
`Admin token rejected — IP=${clientIp} path=${req.path}`,
104+
);
36105

37-
if (token !== this.adminToken) {
38106
next();
39107

40108
return;
@@ -55,6 +123,10 @@ export class AdminTokenMiddleware implements NestMiddleware {
55123
req.workspaceId = workspace.id;
56124
req.adminTokenAuthenticated = true;
57125

126+
this.logger.log(
127+
`Admin token accepted — IP=${clientIp} workspace=${workspace.id} path=${req.path}`,
128+
);
129+
58130
next();
59131
}
60132
}

0 commit comments

Comments
 (0)