564 lines
22 KiB
Python
564 lines
22 KiB
Python
import os
|
|
import warnings
|
|
from datetime import timedelta
|
|
from urllib.parse import urlparse
|
|
|
|
from corsheaders.defaults import default_headers
|
|
from dotenv import load_dotenv
|
|
|
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
load_dotenv()
|
|
|
|
# SECURITY WARNING: keep the secret key used in production secret!
|
|
SECRET_KEY = os.environ.get(
|
|
"SECRET_KEY", "django-insecure-dev-key-please-change-in-production"
|
|
)
|
|
|
|
IS_DEV_ENV = os.environ.get("ENV_TYPE", "dev") == "dev"
|
|
|
|
if not SECRET_KEY or SECRET_KEY.startswith("django-insecure"):
|
|
if not IS_DEV_ENV:
|
|
raise ValueError(
|
|
"SECRET_KEY must be set to a secure value in non-dev environments"
|
|
)
|
|
|
|
# This key also signs every JWT (see SIMPLE_JWT["SIGNING_KEY"] below). RFC 7518
|
|
# section 3.2 requires an HMAC key at least as long as the hash it produces, so
|
|
# HS256 needs 32 bytes. PyJWT only warns about a shorter one, on every call, and
|
|
# a warning nobody reads is not a control.
|
|
#
|
|
# Raising here means a deploy fails rather than quietly signing weak tokens.
|
|
# Lengthening the key invalidates every access and refresh token already issued,
|
|
# so everyone is signed out once when it changes.
|
|
JWT_MIN_SIGNING_KEY_BYTES = 32
|
|
|
|
if len(SECRET_KEY.encode()) < JWT_MIN_SIGNING_KEY_BYTES:
|
|
message = (
|
|
f"SECRET_KEY is {len(SECRET_KEY.encode())} bytes. It signs every JWT, "
|
|
f"and HS256 needs at least {JWT_MIN_SIGNING_KEY_BYTES}. Generate one with "
|
|
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.'
|
|
)
|
|
if not IS_DEV_ENV:
|
|
raise ValueError(message)
|
|
warnings.warn(message, RuntimeWarning, stacklevel=2)
|
|
|
|
# SECURITY WARNING: don't run with debug turned on in production!
|
|
DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
|
|
|
|
# Security: Restrict allowed hosts - set ALLOWED_HOSTS env var in production
|
|
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
|
|
|
INSTALLED_APPS = [
|
|
"django.contrib.auth",
|
|
"django.contrib.admin",
|
|
"django.contrib.contenttypes",
|
|
"django.contrib.messages",
|
|
"django.contrib.sessions",
|
|
"django.contrib.staticfiles",
|
|
"rest_framework",
|
|
"rest_framework_simplejwt",
|
|
# Required for refresh-token rotation: simplejwt only defines
|
|
# RefreshToken.blacklist()/check_blacklist() when this app is installed, so
|
|
# without it BLACKLIST_AFTER_ROTATION below is silently a no-op.
|
|
"rest_framework_simplejwt.token_blacklist",
|
|
"corsheaders",
|
|
"django_ses",
|
|
"drf_spectacular",
|
|
"common",
|
|
"accounts",
|
|
"cases",
|
|
"contacts",
|
|
"leads",
|
|
"opportunity",
|
|
"tasks",
|
|
"invoices",
|
|
"orders",
|
|
"business_hours",
|
|
"macros",
|
|
"webforms",
|
|
"webhooks",
|
|
# "teams", # Merged into common app
|
|
]
|
|
|
|
MIDDLEWARE = [
|
|
"django.middleware.security.SecurityMiddleware",
|
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
"django.middleware.common.CommonMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware", # CSRF protection
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
"corsheaders.middleware.CorsMiddleware",
|
|
"crum.CurrentRequestUserMiddleware",
|
|
"common.middleware.get_company.GetProfileAndOrg",
|
|
"common.middleware.rls_context.RequireOrgContext", # RLS: Enforce org context + set PostgreSQL session variable
|
|
# Last, so it queues the request's webhook events while crum still holds
|
|
# the request and the RLS context is still set. See webhooks/emit.py.
|
|
"webhooks.middleware.WebhookEventsMiddleware",
|
|
]
|
|
|
|
ROOT_URLCONF = "crm.urls"
|
|
|
|
TEMPLATES = [
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
# No project-level template directory. Every template lives in its
|
|
# owning app's templates/ dir and is found by APP_DIRS, so it ships
|
|
# inside the wheel as package data. A BASE_DIR entry here would resolve
|
|
# to site-packages/templates on a pip install, where nothing is
|
|
# installed, and the magic-link login emails would fail to render.
|
|
"DIRS": [],
|
|
"APP_DIRS": True,
|
|
"OPTIONS": {
|
|
"context_processors": [
|
|
"django.template.context_processors.debug",
|
|
"django.template.context_processors.request",
|
|
"django.contrib.auth.context_processors.auth",
|
|
"django.contrib.messages.context_processors.messages",
|
|
"common.context_processors.common.app_name",
|
|
# "django_settings_export.settings_export",
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
WSGI_APPLICATION = "crm.wsgi.application"
|
|
|
|
# Database
|
|
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
|
|
|
# Connection pooling (psycopg 3 + psycopg_pool, wired by Django's postgresql
|
|
# backend). Off by default so an existing deployment does not silently change
|
|
# its connection behaviour on upgrade; set DB_POOL_ENABLED=true to turn it on.
|
|
#
|
|
# Two things make this more than a performance knob here:
|
|
#
|
|
# 1. Every ASGI request gets its own thread-sensitive executor and therefore
|
|
# its own connection, with no ceiling. That is how two separate incidents
|
|
# exhausted PostgreSQL. `max_size` is the ceiling that was missing.
|
|
# 2. RLS context lives in a SESSION-scoped GUC, so a reused connection carries
|
|
# the previous tenant's org id unless something clears it. `reset` below is
|
|
# that something, and pooling MUST NOT be enabled without it. See
|
|
# common/rls/pool.py for the full argument.
|
|
#
|
|
# Sizing: the pool is per PROCESS, not per host. Real peak connections are
|
|
# roughly DB_POOL_MAX_SIZE x (uvicorn workers + celery prefork children), and
|
|
# docker-compose.yml runs `celery -A crm worker` with no --concurrency, so each
|
|
# worker forks one child per CPU. Multiply before comparing to PostgreSQL's
|
|
# max_connections (default 100).
|
|
DB_POOL_ENABLED = os.environ.get("DB_POOL_ENABLED", "False").lower() == "true"
|
|
DB_POOL_MIN_SIZE = int(os.environ.get("DB_POOL_MIN_SIZE", "2"))
|
|
DB_POOL_MAX_SIZE = int(os.environ.get("DB_POOL_MAX_SIZE", "10"))
|
|
|
|
DATABASES = {
|
|
"default": {
|
|
"ENGINE": "django.db.backends.postgresql",
|
|
"NAME": os.environ.get("DBNAME", "crm_db"),
|
|
"USER": os.environ.get("DBUSER", "postgres"),
|
|
"PASSWORD": os.environ.get("DBPASSWORD", "postgres"),
|
|
"HOST": os.environ.get("DBHOST", "localhost"),
|
|
"PORT": os.environ.get("DBPORT", "5432"),
|
|
# Django raises ImproperlyConfigured if this is non-zero alongside a
|
|
# pool ("Pooling doesn't support persistent connections"). Keep it 0.
|
|
"CONN_MAX_AGE": 0,
|
|
"CONN_HEALTH_CHECKS": DB_POOL_ENABLED,
|
|
}
|
|
}
|
|
|
|
if DB_POOL_ENABLED:
|
|
# Imported lazily and locally: common/rls/pool.py deliberately imports no
|
|
# Django, but keeping the import inside the branch means a non-pooled
|
|
# deployment never touches it at all.
|
|
from common.rls.pool import reset_rls_context
|
|
|
|
DATABASES["default"]["OPTIONS"] = {
|
|
"pool": {
|
|
"min_size": DB_POOL_MIN_SIZE,
|
|
"max_size": DB_POOL_MAX_SIZE,
|
|
# Load-bearing for tenant isolation, not a tidy-up. Removing this
|
|
# key turns pooling into a cross-tenant data leak.
|
|
"reset": reset_rls_context,
|
|
}
|
|
}
|
|
|
|
|
|
# Password validation
|
|
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
|
|
|
|
AUTH_PASSWORD_VALIDATORS = [
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
},
|
|
]
|
|
|
|
# Internationalization
|
|
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
|
|
|
|
|
# The fallback day, not the app's day. Each org carries its own `timezone` and
|
|
# `GetProfileAndOrg` activates it per request, so this is what gets used only
|
|
# where there is no org yet: the auth endpoints, and management commands.
|
|
#
|
|
# It was "Asia/Kolkata", which made one deployment's home timezone the day
|
|
# boundary for every tenant on it. Orgs that existed before `Org.timezone` keep
|
|
# that day, assigned to them by `common/migrations/0037_org_timezone.py`, so no
|
|
# live tenant's boundaries move.
|
|
TIME_ZONE = "UTC"
|
|
|
|
USE_I18N = True
|
|
|
|
USE_TZ = True
|
|
|
|
EMAIL_BACKEND = os.environ.get(
|
|
"EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
|
|
)
|
|
|
|
AUTH_USER_MODEL = "common.User"
|
|
|
|
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
|
STATIC_URL = "/static/"
|
|
# Only when it exists. A source checkout keeps backend/static/ as a place to
|
|
# drop files; an installed copy resolves BASE_DIR to site-packages, where there
|
|
# is no such directory, and an unconditional entry raises staticfiles.W004 on
|
|
# every startup check. A permanent warning is where the next real one hides.
|
|
STATICFILES_DIRS = [
|
|
path for path in [os.path.join(BASE_DIR, "static")] if os.path.isdir(path)
|
|
]
|
|
|
|
ENV_TYPE = os.environ.get("ENV_TYPE", "dev")
|
|
if ENV_TYPE == "dev":
|
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
|
MEDIA_URL = "/media/"
|
|
elif ENV_TYPE == "prod":
|
|
# A star import is the point here: server_settings.py is an operator-supplied
|
|
# override file, so its names are not knowable from this side. The old
|
|
# suppression said F401 (unused import), which is not the code a star import
|
|
# raises, so it suppressed nothing.
|
|
from .server_settings import * # noqa: F403
|
|
|
|
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@localhost")
|
|
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "admin@localhost")
|
|
|
|
# AWS SES settings (loaded when EMAIL_BACKEND is django_ses.SESBackend)
|
|
if "django_ses" in EMAIL_BACKEND:
|
|
AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME", "ap-south-1")
|
|
AWS_SES_REGION_ENDPOINT = os.environ.get(
|
|
"AWS_SES_REGION_ENDPOINT", f"email.{AWS_SES_REGION_NAME}.amazonaws.com"
|
|
)
|
|
# Uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from env if set;
|
|
# otherwise falls back to IAM role credentials.
|
|
|
|
|
|
# celery Tasks
|
|
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
|
CELERY_RESULT_BACKEND = os.environ.get(
|
|
"CELERY_RESULT_BACKEND", "redis://localhost:6379/0"
|
|
)
|
|
|
|
|
|
LOGGING = {
|
|
"version": 1,
|
|
"disable_existing_loggers": False,
|
|
"filters": {
|
|
"require_debug_false": {
|
|
"()": "django.utils.log.RequireDebugFalse",
|
|
},
|
|
"require_debug_true": {
|
|
"()": "django.utils.log.RequireDebugTrue",
|
|
},
|
|
},
|
|
"formatters": {
|
|
"django.server": {
|
|
"()": "django.utils.log.ServerFormatter",
|
|
"format": "[%(server_time)s] %(message)s",
|
|
},
|
|
"security": {
|
|
"format": "%(asctime)s | %(levelname)s | %(message)s",
|
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
},
|
|
},
|
|
"handlers": {
|
|
"console": {
|
|
"level": "INFO",
|
|
"filters": ["require_debug_true"],
|
|
"class": "logging.StreamHandler",
|
|
},
|
|
"console_debug_false": {
|
|
"level": "ERROR",
|
|
"filters": ["require_debug_false"],
|
|
"class": "logging.StreamHandler",
|
|
},
|
|
"django.server": {
|
|
"level": "INFO",
|
|
"class": "logging.StreamHandler",
|
|
"formatter": "django.server",
|
|
},
|
|
"mail_admins": {
|
|
"level": "ERROR",
|
|
"filters": ["require_debug_false"],
|
|
"class": "django.utils.log.AdminEmailHandler",
|
|
},
|
|
"logfile": {
|
|
"class": "logging.FileHandler",
|
|
"filename": "server.log",
|
|
},
|
|
"security_audit": {
|
|
"class": "logging.FileHandler",
|
|
"filename": "security_audit.log",
|
|
"formatter": "security",
|
|
},
|
|
},
|
|
"loggers": {
|
|
"django": {
|
|
"handlers": [
|
|
"console",
|
|
"console_debug_false",
|
|
"logfile",
|
|
],
|
|
"level": "INFO",
|
|
},
|
|
"django.server": {
|
|
"handlers": ["django.server"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
"security.audit": {
|
|
"handlers": ["security_audit", "console"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
APPLICATION_NAME = "bottlecrm"
|
|
|
|
SETTINGS_EXPORT = ["APPLICATION_NAME"]
|
|
|
|
REST_FRAMEWORK = {
|
|
"EXCEPTION_HANDLER": "rest_framework.views.exception_handler",
|
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
|
"common.pat_auth.PATAuthentication",
|
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
|
"common.external_auth.APIKeyAuthentication",
|
|
# "rest_framework.authentication.SessionAuthentication",
|
|
# "rest_framework.authentication.BasicAuthentication",
|
|
),
|
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
|
|
"PAGE_SIZE": 10,
|
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
|
# Public web form submission only (issue #634). There is deliberately no
|
|
# DEFAULT_THROTTLE_CLASSES entry: setting one would rate-limit every
|
|
# authenticated endpoint in the app, which nobody asked for and which would
|
|
# surface as intermittent 429s in the CRM UI. The two views that want these
|
|
# rates name their throttle classes explicitly.
|
|
"DEFAULT_THROTTLE_RATES": {
|
|
"webform_submit_ip": os.environ.get("WEBFORM_THROTTLE_IP", "10/hour"),
|
|
"webform_submit_global": os.environ.get("WEBFORM_THROTTLE_GLOBAL", "200/day"),
|
|
# Public help center pages, per visitor. Generous because a reader
|
|
# clicks through several articles and a crawler walks the sitemap.
|
|
"help_center_ip": os.environ.get("HELP_CENTER_THROTTLE_IP", "600/hour"),
|
|
# Public help center pages, per help center across all visitors. The
|
|
# per-visitor bucket trusts X-Forwarded-For; this one does not.
|
|
"help_center_global": os.environ.get(
|
|
"HELP_CENTER_THROTTLE_GLOBAL", "10000/hour"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
# Cache backend.
|
|
#
|
|
# This exists so DRF throttling is enforced across the deployment rather than
|
|
# per worker. Without a CACHES block Django falls back to a per-process
|
|
# LocMemCache, so with N gunicorn workers the effective rate limit is roughly N
|
|
# times the configured one and it resets on every restart. That makes the spam
|
|
# controls on the public web form endpoint decorative rather than real.
|
|
#
|
|
# Redis is already a dependency (it is the Celery broker) and Django ships the
|
|
# backend, so this adds no package. Local development falls back to LocMemCache,
|
|
# where per-process limits are fine because there is one process.
|
|
CACHE_URL = os.environ.get("CACHE_URL", "")
|
|
if CACHE_URL:
|
|
CACHES = {
|
|
"default": {
|
|
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
|
"LOCATION": CACHE_URL,
|
|
}
|
|
}
|
|
else:
|
|
CACHES = {
|
|
"default": {
|
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
"LOCATION": "crm-default",
|
|
}
|
|
}
|
|
|
|
|
|
SPECTACULAR_SETTINGS = {
|
|
"TITLE": "BottleCRM API",
|
|
"DESCRIPTION": "Open source CRM application",
|
|
"VERSION": "1.0.0",
|
|
"SERVE_INCLUDE_SCHEMA": False,
|
|
"COMPONENT_SPLIT_REQUEST": True,
|
|
"PREPROCESSING_HOOKS": ["common.custom_openapi.preprocessing_filter_spec"],
|
|
"ENUM_NAME_OVERRIDES": {
|
|
# Role enums
|
|
"ProfileRoleEnum": "common.utils.ROLES",
|
|
"BoardMemberRoleEnum": "tasks.models.BoardMember.ROLE_CHOICES",
|
|
# Priority enums
|
|
"TaskPriorityEnum": "tasks.models.Task.PRIORITY_CHOICES",
|
|
"CasePriorityEnum": "common.utils.PRIORITY_CHOICE",
|
|
"BoardTaskPriorityEnum": "tasks.models.BoardTask.PRIORITY_CHOICES",
|
|
# Status enums
|
|
"TaskStatusEnum": "tasks.models.Task.STATUS_CHOICES",
|
|
"CaseStatusEnum": "common.utils.STATUS_CHOICE",
|
|
"SolutionStatusEnum": "cases.models.Solution.STATUS_CHOICES",
|
|
"DocumentStatusEnum": "common.models.Document.DOCUMENT_STATUS_CHOICE",
|
|
"InvoiceStatusEnum": "invoices.models.Invoice.INVOICE_STATUS",
|
|
"ContactFormStatusEnum": "common.models.ContactFormSubmission.STATUS_CHOICES",
|
|
"LeadStatusEnum": "common.utils.LEAD_STATUS",
|
|
},
|
|
}
|
|
|
|
# JWT_SETTINGS = {
|
|
# 'bearerFormat': ('Bearer', 'jwt', 'Jwt')
|
|
# }
|
|
|
|
SWAGGER_SETTINGS = {
|
|
"DEFAULT_INFO": "crm.urls.info",
|
|
"SECURITY_DEFINITIONS": {
|
|
"Bearer": {
|
|
"type": "apiKey",
|
|
"name": "Authorization",
|
|
"in": "header",
|
|
"description": "Enter 'Bearer <token>'",
|
|
},
|
|
},
|
|
}
|
|
|
|
CORS_ALLOW_HEADERS = default_headers + ("org",)
|
|
# Security: CORS configuration via environment variables
|
|
CORS_ORIGIN_ALLOW_ALL = os.environ.get("CORS_ALLOW_ALL", "False").lower() == "true"
|
|
CORS_ALLOWED_ORIGINS = [
|
|
origin.strip()
|
|
for origin in os.environ.get(
|
|
"CORS_ALLOWED_ORIGINS", "http://localhost:5181,http://127.0.0.1:5181"
|
|
).split(",")
|
|
if origin.strip()
|
|
]
|
|
# Security: CSRF trusted origins via environment variable
|
|
_csrf_origins = os.environ.get("CSRF_TRUSTED_ORIGINS", "")
|
|
CSRF_TRUSTED_ORIGINS = [o.strip() for o in _csrf_origins.split(",") if o.strip()]
|
|
|
|
# Security: HSTS with 1 year duration (recommended minimum)
|
|
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
SECURE_HSTS_PRELOAD = True
|
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
|
|
|
# `SecurityMiddleware` emits Strict-Transport-Security only when
|
|
# `request.is_secure()` is true. Behind a TLS-terminating proxy (nginx, an ALB)
|
|
# the request reaches Django over plain HTTP, so `is_secure()` is false and the
|
|
# three HSTS settings above were inert in exactly the deployment they were
|
|
# written for. This is what lets Django read the proxy's verdict instead.
|
|
#
|
|
# It is opt-in rather than on by default because the header is
|
|
# attacker-controllable on any path that does not pass through the proxy: a
|
|
# request sent straight to gunicorn carrying `X-Forwarded-Proto: https` makes
|
|
# `request.is_secure()` lie. Set TRUST_PROXY_SSL_HEADER=True only when the proxy
|
|
# is the sole ingress AND it overwrites the header rather than appending to a
|
|
# client-supplied one. The http-to-https redirect belongs on that same proxy,
|
|
# which is why SECURE_SSL_REDIRECT is deliberately left unset here.
|
|
if os.environ.get("TRUST_PROXY_SSL_HEADER", "False").lower() == "true":
|
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
|
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
|
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
|
# STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
|
|
|
SIMPLE_JWT = {
|
|
# Security: Reduced token lifetimes
|
|
"ACCESS_TOKEN_LIFETIME": timedelta(hours=1),
|
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
|
|
# Security: Enable token rotation to invalidate old refresh tokens
|
|
"ROTATE_REFRESH_TOKENS": True,
|
|
"BLACKLIST_AFTER_ROTATION": True,
|
|
"UPDATE_LAST_LOGIN": False,
|
|
"ALGORITHM": "HS256",
|
|
"SIGNING_KEY": SECRET_KEY,
|
|
"VERIFYING_KEY": None,
|
|
"AUDIENCE": None,
|
|
"ISSUER": None,
|
|
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
|
|
"USER_ID_FIELD": "id",
|
|
"USER_ID_CLAIM": "user_id",
|
|
}
|
|
# it is needed in custome middlewere to get the user from the token
|
|
JWT_ALGO = "HS256"
|
|
|
|
|
|
DOMAIN_NAME = os.environ.get("DOMAIN_NAME", "http://localhost:8000")
|
|
|
|
# The organization API key (`Token: <org.api_key>` header) is one non-expiring
|
|
# key per tenant that resolves to an arbitrary active ADMIN. Even now that it is
|
|
# read-only and barred from credential endpoints (see common/scopes.py), it
|
|
# still reads every record in the org and cannot be revoked per-integration.
|
|
# Personal access tokens replace it and are scoped, per-user and revocable.
|
|
#
|
|
# Left on by default so an upgrade breaks nothing. Set DJANGO_ORG_API_KEY_AUTH
|
|
# to "false" once every integration has moved to a personal access token.
|
|
ORG_API_KEY_AUTH_ENABLED = os.environ.get(
|
|
"DJANGO_ORG_API_KEY_AUTH", "true"
|
|
).strip().lower() not in ("false", "0", "no", "off")
|
|
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:5181")
|
|
|
|
# Every link this system puts in an email is built from this one value, via
|
|
# `common.links.frontend_url`: the magic-link sign-in URL, the customer's
|
|
# invoice and estimate portal links, the CSAT survey, and the internal
|
|
# "assigned to you" notifications. Left at the dev default in production it
|
|
# mails customers a link to a port on their own machine, and mails a sign-in
|
|
# token to one, and neither failure shows up anywhere on the server.
|
|
#
|
|
# Checked here rather than at send time because there is no good answer at send
|
|
# time: the email has already been composed and the alternative is dropping it
|
|
# silently. A deploy that fails to start is the loud version of the same news.
|
|
if not IS_DEV_ENV:
|
|
_frontend = urlparse(FRONTEND_URL)
|
|
if _frontend.scheme not in ("http", "https") or not _frontend.netloc:
|
|
raise ValueError(
|
|
f"FRONTEND_URL is {FRONTEND_URL!r}, which is not an absolute URL. "
|
|
"Every emailed link is built from it, and a value without a scheme "
|
|
"and host produces a relative path, which is not a link at all "
|
|
"inside an email. Set it to the web app's public base URL, for "
|
|
"example https://app.example.com."
|
|
)
|
|
if _frontend.hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
|
|
raise ValueError(
|
|
f"FRONTEND_URL is {FRONTEND_URL!r}, which points at this machine. "
|
|
"It is emailed to customers (invoice and estimate portal links, "
|
|
"the CSAT survey) and carries the magic-link sign-in URL. Set it to "
|
|
"the web app's public base URL, for example https://app.example.com."
|
|
)
|
|
|
|
# Google OAuth Configuration
|
|
#
|
|
# There is no GOOGLE_REDIRECT_URI here. The redirect URI used in the token
|
|
# exchange is the one the frontend sends in the request body on each call
|
|
# (`common/views/auth_views.py`), so a setting of that name was read into
|
|
# Django and never referenced again. Same for SWAGGER_ROOT_URL, which named
|
|
# nothing: the schema is served from whatever host serves the app.
|
|
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
|
|
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
|