Skip to main content

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 in master.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 OAuth state is HMAC-signed, expires after 15 minutes and is checked against master.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; LIMIT is 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:

HelperUse
buildAccessFilter / buildEntityAccessFilterSQL WHERE fragment for list, kanban, export, report and dashboard queries
canAccessRecord / canAccessEntityBoolean check for one record
assertEntityAccess(user, entityType, id, 'view' | 'edit')Throws 403 / 404 — used on detail routes, uploads, associations, history, sub-resources
filterAccessibleIdsNarrows 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 — assertWritableFields returns 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​

TokenLifetimeStoredNotes
Access token (JWT, typ: 'access')1 hourNot storedRejected 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 daysauth_refresh_sessions row per jtiRotated 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 key30 days – neverNot storedA long-lived access token for a service user; regenerate / revoke sets users.tokens_valid_after.
Password reset, invitationShort-lived, single usesha256:<hex> hashRaw token <tenant-slug>.<64 hex> is only ever emailed.
Client-portal link, dashboard share linkUntil revoked / expirySHA-256 hashShown once at create / regenerate; regenerate invalidates the old link.
Client-portal session24 hoursSHA-256 hashMinted 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.

EndpointLimits (per 15 min unless noted)
POST /auth/login10 failures per workspace + email (cleared on success); 30 failures per IP
POST /auth/register5 per IP per hour
POST /auth/forgot-password5 per IP + email; 20 per IP
POST /auth/reset-password5 per IP
POST /auth/invite/accept10 per IP
Portal OTP request / verify5 / 10 per token; 10 / 20 per IP
Dashboard share code request / verify5 / 10 per token; 10 / 20 per IP
Public form submit, public booking20 per IP + form / booking page per 10 min
Public proposal accept / decline, contract sign / decline10 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_SECRET is 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 OAuth state, Microsoft Graph clientState (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/integrations masks 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​

  • helmet security headers, CORS restricted to CORS_ORIGINS, JSON bodies limited to BODY_LIMIT (1 MB default).
  • Swagger is disabled in production unless ENABLE_SWAGGER=true.
  • Outgoing SMTP verifies TLS certificates (SMTP_ALLOW_INVALID_CERTS to opt out).
  • The API container runs as the unprivileged node user; CI actions are pinned to commit SHAs.

See Deployment, Environment Variables and RBAC Deep Dive.