Skip to content

Security

Isolation model

Each tenant is provisioned as a dedicated Frappe site with its own database. This provides database-level isolation; no query can span tenants. The platform service does not access tenant databases directly. It uses the bench CLI for provisioning and the Frappe REST API for tenant data, each scoped to a single site.

The platform's own database holds only tenant metadata, such as slug, status, and billing records, and never ERP business data.

Controls in place

Sign-up and provisioning

  • Verified sign-up. Sign-up records a tenant and emails a single-use, hashed, time-limited confirmation link; no ERP site is built until the address is confirmed. This prevents an unverified request from triggering a full provisioning run and stops sign-up with another party's email address. Only the hash of the token is stored, so a database export cannot confirm an account.
  • Rate limiting. The public endpoints are rate limited per client address, which prevents sign-up and verification abuse. The limiter can be backed by a shared store so the limit holds across multiple workers.
  • Restricted cross-origin access. The public endpoints accept requests only from an explicit list of allowed origins.
  • Safe provisioning. Provisioning arguments are shell-escaped and tenant slugs are validated against a strict pattern before use. Provisioning is idempotent, so a failed tenant can be re-provisioned safely.

Marketing-site endpoints

The public site exposes two unauthenticated POST routes, so both treat the request as hostile. Neither costs us much in CPU; what an abusive caller spends is someone else's meter, the Formspree submission quota or the Apps Script execution budget, which is what the shared guards in lib/api-guard.ts exist to protect. Both routes cap the body at 16 KB while streaming it and throttle to five requests per IP per minute. The throttle is per serverless instance, since instances share no memory: a speed bump against a naive flood, not a guarantee. A durable store is the real fix if either route ever needs one.

/api/lead proxies the Formspree lead forms. It accepts only the form keys defined in formspree.json and listed in the route's own allowlist, keeps the Formspree project ID out of the client bundle, verifies the Cloudflare Turnstile token server-side whenever TURNSTILE_SECRET_KEY is set, and returns opaque errors with a machine-readable reason rather than upstream detail. A submission arriving with no token while the secret is configured is logged: that combination means the site key never reached the browser, which drops lead capture to zero without any other symptom. Every reason the route can return must exist in LeadFailure and have a message in the form's map, or the visitor sees an error with no text.

/api/assessment proxies the ERP-assessment lead to Google Apps Script, or to Formspree when no Apps Script endpoint is configured. It validates the exact payload shape the assessment page sends, and restricts the Apps Script redirect follow to Google's own hosts so the route cannot be used to reach arbitrary URLs. Apps Script serves its failures as an HTML page under HTTP 200, so delivery is confirmed by parsing the reply rather than trusting the status. Neither the upstream body nor the lead's contact details are written to the logs. The endpoint is configured as ASSESSMENT_ENDPOINT, deliberately without the NEXT_PUBLIC_ prefix it once carried: it is a write-capable webhook read only on the server, and the prefix published it in the client bundle to anyone reading the page source.

Marketing-site response headers

Every response carries HSTS, X-Content-Type-Options, X-Frame-Options, a referrer policy, a permissions policy, and a Content-Security-Policy.

The CSP allows 'unsafe-inline' on script-src, which is a deliberate trade rather than an oversight. Next's App Router emits inline bootstrap and flight-data scripts on every page, and the only way to allow those specifically is a per-request nonce from middleware; requesting a nonce opts every route out of static rendering. Trading the whole marketing site's static delivery for a stricter script-src is not the right price on a site with no authenticated session and no user-generated content to inject. Everything else is closed: object-src and base-uri are 'none', forms may only post to us, and the page cannot be framed.

Two allowances are load-bearing and easy to break. Cloudflare Turnstile needs script-src, connect-src and frame-src; and the signup, verify and provisioning pages call the platform API from the browser, so connect-src must name NEXT_PUBLIC_PLATFORM_API_URL's origin. Without it those calls fail as opaque network errors and the visitor is told we could not reach our servers, with nothing in any log to say why. Two of those three pages are live even with signups switched off. The config warns at build time when the variable is unset.

Secrets and administrative access

  • Secrets encrypted at rest. Tenant administrator passwords and API secrets are encrypted before storage, using a key derived from the platform secret key. A database export alone cannot recover a usable credential.
  • Authenticated administrative access. Administrative endpoints require an API key, compared in constant time, and fail closed when no key is configured.
  • Verified payment webhooks. Stripe callbacks are verified by signature and fail closed when a signing secret is configured; M-Pesa callbacks support an optional source-address allowlist.
  • Response hardening. Security headers are applied to every response, and transport security is enforced in production.

Inside the tenant ERP

  • Privileged operations guarded. Administrative application methods require the appropriate role.
  • Per-site scoping for site staff. A Foreman is restricted to their assigned project sites through Frappe User Permissions, synchronised from the user's site assignment. A Foreman cannot view or move another site's stock, request materials for another site, or record labour or daily site reports against a site they are not assigned to. Roles that are meant to see every project, such as the project manager and finance roles, are never scoped.
  • Client users see only their own records. The client-facing role is read-only and portal-only; when a client user is invited they are scoped with User Permissions to their own customer record, projects, and filed client documents (title deeds, approvals, identification).
  • Spend approval thresholds. Purchase orders above a configured amount require approval by the finance role before they can be submitted. The approval flow is a document workflow in the desk, and the threshold is also enforced server side on submission, so it cannot be bypassed through the API.
  • Two-factor authentication for sensitive roles. The finance and project-management roles are pre-configured to require two-factor authentication. A tenant enables it in system settings when its users are ready to enrol; enabling it then applies immediately to those roles. It is not enabled automatically, so a new site never walls a first login behind enrolment.
  • Bounded statement import. Uploaded M-Pesa statements are limited by file size, page count, and row count, so a malformed or oversized upload cannot exhaust the shared worker processes. Statement passwords are held in Frappe's encrypted password store.

Financial separation

Project managers cannot create purchase invoices or receipts; these are intentional restrictions. The M-Pesa import workflow creates draft journal entries, allowing a project manager to prepare them, while posting to the ledger requires the finance role at submission. Access to the M-Pesa import document should be limited to the roles intended to use it.

Data protection and recovery

Every tenant with a live site is backed up on a schedule, capturing the database and files. Each backup outcome is recorded, and one tenant's failure does not stop the others. Local backups are shipped off-box for retention, and a restore is a single operation with a documented runbook. Recovery objectives, the off-box shipping approach, and the step-by-step restore and whole-host rebuild procedures are described in the platform repository's disaster-recovery document.

Outstanding items

  • Set strong platform secret and API keys and a production origin list before onboarding live tenants.
  • Configure the transactional email sender so verification and welcome messages are delivered.
  • Back the rate limiter with the shared store and run provisioning on asynchronous workers in multi-worker production.
  • Implement the payment business logic behind the verified webhook signatures.