Security hardening¶
BottleCRM is multi-tenant: every deployment holds more than one organization's data behind the same database and the same application process. This page collects the settings and surfaces that matter most once you're running something other people depend on, most of which are covered in more depth elsewhere in Self-hosting. This page is the checklist, with pointers to the detail.
Production checklist¶
DEBUG=False.crm/settings.pyreads it asos.environ.get("DEBUG", "False").lower() == "true", so it's alreadyFalseby default. The failure mode to avoid is.env.docker(which shipsDEBUG=Truefor local development) carrying into a production environment unchanged.- A real
SECRET_KEY, not the shippeddjango-insecure-...placeholder. See Secrets below. ALLOWED_HOSTSset to your real hostname(s), andCSRF_TRUSTED_ORIGINSset with a scheme (https://crm.example.com, not a bare hostname), both detailed in Production deployment.CORS_ALLOW_ALL=False(the default) withCORS_ALLOWED_ORIGINSscoped to your real frontend origin(s);.env.dockersetsCORS_ALLOW_ALL=Truefor local development, which should not carry into production either.SECURE_PROXY_SSL_HEADERadded yourself if you terminate TLS at a reverse proxy, nothing incrm/settings.pysets it, so without it Django never considers a proxied request secure and the HSTS headers this project does set unconditionally (SECURE_HSTS_SECONDS,SECURE_HSTS_INCLUDE_SUBDOMAINS,SECURE_HSTS_PRELOAD) silently never get sent. See Production deployment.- A non-superuser database role: see Database role below.
- Don't set
ENV_TYPE=prodcasually. It's not just a flag: it makesAWS_BUCKET_NAME,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SES_REGION_NAME,AWS_SES_REGION_ENDPOINTandSENTRY_DSNmandatory (aKeyErrorat startup if any is missing), switches email to Amazon SES, and hardcodesSESSION_COOKIE_DOMAIN = ".bottlecrm.io", a domain specific to this project's own SaaS hosting. See Production deployment before setting it.
Database role¶
The single highest-leverage setting in this list: DBUSER must not be a PostgreSQL superuser, or
every Row-Level Security policy in the schema is bypassed silently, with no error and no log line.
The default (DBUSER unset, falling back to postgres) is a superuser on most installations. Create
a dedicated role and confirm it isn't one:
Full detail: how to create the role, how RLS policies are applied, and how to prove isolation holds for real data rather than trusting that it does, is in PostgreSQL and RLS.
Secrets¶
SECRET_KEY does more than Django's usual signing duties here: SIMPLE_JWT["SIGNING_KEY"] in
crm/settings.py is set to SECRET_KEY directly, so it is also the key every access and refresh
token is signed and verified with. There is no separate JWT signing-key setting anywhere in this
codebase, rotating SECRET_KEY invalidates every outstanding access and refresh token immediately,
which is worth knowing before you rotate it as a routine maintenance step rather than in response to
an actual compromise.
crm/settings.py itself guards against the worst case of forgetting to set it: if SECRET_KEY is
empty or still starts with "django-insecure" and ENV_TYPE is anything other than dev, the
module raises ValueError("SECRET_KEY must be set to a secure value in non-dev environments") at
import time, a hard startup failure rather than a silent insecure default. That guard only fires
when ENV_TYPE is explicitly non-dev, so set a real, unique SECRET_KEY regardless of whether you
ever set ENV_TYPE=prod.
Token surfaces¶
Three different credential types exist, with different blast radii if one leaks:
- Personal access tokens (
bcrm_pat_..., managed atprofile/tokens/) are scoped to the one profile that created them.resolve_valid_pat()inbackend/common/pat_auth.pycheckspat.profile.is_active(andpat.org.is_active) on every use, so deactivating a profile cuts off its personal access tokens immediately. There's no separate token-revocation step required when offboarding someone. - Admin oversight of tokens is separate from the self-service surface above:
org/tokens/(OrgAccessTokenListView/OrgAccessTokenDetailView) is admin-only and org-wide. An admin can see and revoke any token in their org, including one belonging to a colleague who has since been deactivated. It's deliberately a distinct endpoint fromprofile/tokens/rather than a widened version of it, so the self-service guard never has to be loosened to support oversight. - The org API key (
GET/POST /api/org/api-key/,OrgApiKeyView, admin-only) is the bluntest of the three:common/middleware/get_company.py's_process_api_key_authresolves a request bearing this key to the org's first activeADMINprofile. One key per tenant, never expiring, so it cannot be revoked per integration. It is read-only and barred from the credential endpoints (common/scopes.py), which stops a leaked key deleting records, escalating a role, or minting a personal access token owned by the admin whose identity it borrowed. It still reads every record in the org. It's kept out of every nested API representation and served only by this endpoint. SetDJANGO_ORG_API_KEY_AUTH=falseonce every integration uses a personal access token, which refuses the key as an authentication method entirely. If it's ever exposed: committed to a repo, logged, shared over an insecure channel, rotate it withPOST /api/org/api-key/from a signed-in session; the response is explicit that "the previous key is no longer valid" immediately on rotation. - No token can manage a credential.
/api/profile/tokens/,/api/org/tokens/and/api/org/api-key/are refused for any personal access token and for the org API key, whatever their scopes. Both chains would otherwise defeat revocation: a token that mints tokens leaves children behind when you revoke it, and a token that reads the org API key upgrades itself into a credential that outlives its own revocation. Credential management requires an interactive sign-in.
What to monitor¶
SecurityAuditLog (backend/common/audit_log.py, table security_audit_log) is an RLS-protected,
per-org table of security-relevant events, with a matching security.audit Python logger that also
writes to security_audit.log (configured in crm/settings.py's LOGGING dict). It's worth
watching, with one caveat: of the thirteen event types the model defines
(LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT, ORG_SWITCH, TOKEN_REFRESH, TOKEN_REVOKED,
PERMISSION_DENIED, CROSS_ORG_ATTEMPT, API_KEY_USED, API_KEY_INVALID, MEMBERSHIP_REVOKED,
SUSPICIOUS_ACTIVITY, SAMPLE_DATA_CLEARED), only LOGIN_SUCCESS, ORG_SWITCH, TOKEN_REFRESH,
TOKEN_REVOKED, PERMISSION_DENIED (from one call site) and SAMPLE_DATA_CLEARED are actually
logged anywhere in the application code as of this writing. LOGIN_FAILURE, LOGOUT,
CROSS_ORG_ATTEMPT, API_KEY_USED, API_KEY_INVALID, MEMBERSHIP_REVOKED and
SUSPICIOUS_ACTIVITY are defined on the model and have corresponding methods on the AuditLogger
helper, but nothing in the codebase currently calls them. Don't rely on this table to surface a
brute-forced login or a bad API key attempt today; it doesn't yet.