Security Architecture
This page summarises the security controls in the IntelliSales CRM backend, as hardened by the 2026-10 security review. It is aimed at IT and DevOps teams assessing or operating the platform, and at developers adding new modules.
Tenant Isolation
Every workspace (tenant) has its own PostgreSQL schema; master.tenants maps a tenant to its schema.
- Authenticated requests take the schema only from the verified token (
req.user.tenantSchema). No request parameter, header or body field can choose a schema. - Public endpoints (forms, booking pages, client portal, dashboard share links, proposal / contract pages, password reset, invitations) resolve the tenant from the URL's tenant slug or from the token itself — new public tokens are minted as
<tenant-slug>.<random>— and look the schema up inmaster.tenants. A schema name is never taken from user input and interpolated into SQL. - Webhooks identify the tenant cryptographically: the Xero webhook finds the tenant whose webhook signing key produces the request's
x-xero-signature(HMAC-SHA256 of the raw body), and applies only that Xero organisation's events. Google Calendar OAuthstateis HMAC-signed, expires after 15 minutes and is checked againstmaster.tenants. - Imports use per-tenant upload folders and 64-bit random file IDs matched exactly; the spreadsheet is deleted when the job finishes.
- Report Builder queries are built only from known data-source columns and the six supported aggregates;
LIMITis a capped integer; database error text is never returned to the client.
Authorization Layers
Every authenticated route passes three checks, all on the server.
1. Module permissions (RBAC)
PermissionGuard enforces @RequirePermission(module, action) against the user's role. @AdminOnly() passes only for real administrators — role === 'admin' or roleLevel >= 100; delegated admin.* / settings.* permissions no longer satisfy it. Administrators pass every permission check.
Role and user management add escalation checks for non-admins (RolesService.assertCanManageRole, UsersService.assertCanManageUser): no editing system roles, one's own role, or roles / users at or above one's own level; no granting permissions, record access or field access beyond one's own.
2. Record access
DataAccessService applies the role's per-module scope (own / team / department / reporting line / all) plus record-team membership:
| Helper | Use |
|---|---|
buildAccessFilter / buildEntityAccessFilter | SQL WHERE fragment for list, kanban, export, report and dashboard queries |
canAccessRecord / canAccessEntity | Boolean check for one record |
assertEntityAccess(user, entityType, id, 'view' | 'edit') | Throws 403 / 404 — used on detail routes, uploads, associations, history, sub-resources |
filterAccessibleIds | Narrows the ID list of bulk update / assign / delete to records in scope |
Scoping covers bulk operations, projects, Customer 360, invoices, proposals, contracts, uploads, reports, dashboards and widgets, the inbox, import jobs, workflow runs, the client portal, rosters, forecasts, SLA lists and duplicate checks.
3. Field permissions
common/utils/field-permissions.util.ts is the single source of truth for which fields of the 11 controlled modules can be restricted, and enforces them:
- Reads —
stripHiddenFields*remove hidden keys (and their aliases, e.g.ownerId→ownerName) from every response: lists, details, kanban, exports, search, reports, dashboards, Customer 360, history, activity, workflow data and duplicate matches. Keys are omitted, never nulled. - Queries — hidden columns are dropped from column search, sort and global-search matching, so values can't be probed.
- Writes —
assertWritableFieldsreturns 403 for any change to a hidden or read-only field on create, update, bulk update and imports (assertImportMappingWritable). Unchanged values pass, so clients that send whole objects keep working. - Derived data — amount aggregates are masked when amount is hidden; the kanban endpoint returns 403 when Stage is hidden; generated PDFs and emails omit hidden fields.
Field permissions are carried as { [module]: { [fieldKey]: 'hidden' | 'read_only' } } (custom fields as custom.<key>); unlisted fields are editable; administrators bypass them. The current request's user is available to deep helpers through an AsyncLocalStorage context (RequestUserContextInterceptor).
Sessions and Tokens
| Token | Lifetime | Stored | Notes |
|---|---|---|---|
Access token (JWT, typ: 'access') | 1 hour | Not stored | Rejected by /auth/refresh. User status, role and permissions are re-read from the database on each request (10-second cache), so deactivation and role changes apply within ~10 s. |
Refresh token (JWT, typ: 'refresh', jti) | 7 days | auth_refresh_sessions row per jti | Rotated on every refresh. Reuse of a rotated token (after a 30 s grace for parallel tabs) revokes all the user's sessions. /auth/logout revokes the session. |
| API key | 30 days – never | Not stored | A long-lived access token for a service user; regenerate / revoke sets users.tokens_valid_after. |
| Password reset, invitation | Short-lived, single use | sha256:<hex> hash | Raw token <tenant-slug>.<64 hex> is only ever emailed. |
| Client-portal link, dashboard share link | Until revoked / expiry | SHA-256 hash | Shown once at create / regenerate; regenerate invalidates the old link. |
| Client-portal session | 24 hours | SHA-256 hash | Minted after email OTP; sent as portal_<slug> httpOnly cookie or Authorization: Bearer. Blocked when the project's portal is disabled. |
users.tokens_valid_after makes every token issued before it invalid; it is set on password change and reset and on API key regenerate / revoke. Password changes also revoke the user's other refresh sessions.
Login is constant-time with respect to unknown tenants and users (a dummy bcrypt comparison) and returns the same Invalid credentials error. Passwords are hashed with bcrypt (cost 12).
Password policy
Enforced on every place a password is set (password-policy.util.ts, mirrored in the web app): 10–128 characters, at least 3 of lowercase / uppercase / digit / symbol, and not containing the email's local part (3+ characters).
Rate Limiting
@RateLimit(...) (common/guards/rate-limit.guard.ts) applies fixed-window counters in Redis (key prefix rl:, keys hashed so no emails or tokens are stored in clear), falling back to per-instance memory when Redis is unreachable. Responses over the limit are 429 with Retry-After.
| Endpoint | Limits (per 15 min unless noted) |
|---|---|
POST /auth/login | 10 failures per workspace + email (cleared on success); 30 failures per IP |
POST /auth/register | 5 per IP per hour |
POST /auth/forgot-password | 5 per IP + email; 20 per IP |
POST /auth/reset-password | 5 per IP |
POST /auth/invite/accept | 10 per IP |
| Portal OTP request / verify | 5 / 10 per token; 10 / 20 per IP |
| Dashboard share code request / verify | 5 / 10 per token; 10 / 20 per IP |
| Public form submit, public booking | 20 per IP + form / booking page per 10 min |
| Public proposal accept / decline, contract sign / decline | 10 per IP + token |
The client IP is req.ip, which honours X-Forwarded-For only when TRUST_PROXY is set.
Form auto-replies have an additional per-recipient cap (3 per recipient address per form per 24 hours) plus a honeypot and an HMAC-signed fill-time token — see Form Builder.
Secrets
JWT_SECRETis required in production (≥ 16 characters). Other keys are derived from it with HMAC-SHA256 and a purpose label when no dedicated value is configured: mailbox-password encryption (EMAIL_ENCRYPTION_KEY), email OAuthstate, Microsoft GraphclientState(MS_WEBHOOK_CLIENT_STATE) and form fill tokens.- Stored mailbox passwords use AES-256-GCM; rows encrypted with the old all-zero key are re-encrypted with the current key at startup.
- Integration configuration returned by
GET /admin/integrationsmasks every secret-like key (secret, password, token, api key, private key, webhook key, access key, signing key) as••••+ last 4 characters. Saves merge into the stored config and ignore masked values. - Secrets are compared in constant time.
Output Sanitising
- HTML — landing pages, page-designer content, email reply / forward quoting, booking and form emails are escaped or stripped of dangerous markup on the server (
html-escape.util.ts:escapeHtml,stripDangerousHtml,safeHref); the web app additionally sanitises with DOMPurify and only renders safe URLs in record links. - Spreadsheets — CSV / XLSX exports prefix text cells starting with
=,+,-,@, tab or carriage return with a single quote so they can't execute as formulas; numeric and phone-shaped values are left alone (spreadsheet-safe.util.ts). - Errors — database errors are mapped to generic 4xx messages; internal error text is not sent to clients.
Uploads
Uploaded files go to an S3-compatible bucket. The stored content type and extension come from a server allow-list — never from the client:
- Documents (25 MB): PNG, JPG, GIF, WebP, PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, CSV, TXT, ZIP. Pictures (5 MB): PNG, JPG, GIF, WebP.
- Files are checked by magic bytes where the format has a signature.
- HTML, SVG, XML and script types are refused.
- Images and PDFs are served inline; all other types with
Content-Disposition: attachment. - Attaching a file needs edit access to the record.
Outbound Requests
Workflow webhooks, form webhooks and usage pulls refuse private, loopback and link-local destinations and redirects to them, unless ALLOW_PRIVATE_WEBHOOK_TARGETS=true. The IMAP connection test only connects to mail ports and doesn't return raw socket errors.
Transport and Platform
helmetsecurity headers, CORS restricted toCORS_ORIGINS, JSON bodies limited toBODY_LIMIT(1 MB default).- Swagger is disabled in production unless
ENABLE_SWAGGER=true. - Outgoing SMTP verifies TLS certificates (
SMTP_ALLOW_INVALID_CERTSto opt out). - The API container runs as the unprivileged
nodeuser; CI actions are pinned to commit SHAs.
See Deployment, Environment Variables and RBAC Deep Dive.