"""Flask application configuration.""" import os from datetime import timedelta from sqlalchemy.pool import StaticPool class ConfigError(Exception): """Raised when required configuration is missing or unsafe.""" # Connection pool per worker process. Env-driven so a site can size it against # its own MySQL max_connections without editing code; see the arithmetic in # docker-compose.yml next to the worker count. # # Deliberately NOT named SQLALCHEMY_POOL_SIZE and friends: those were # Flask-SQLAlchemy 2.x config keys, removed in 3.x, and reusing the names would # suggest the extension still reads them. It reads SQLALCHEMY_ENGINE_OPTIONS. DB_POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', 10)) DB_MAX_OVERFLOW = int(os.environ.get('DB_MAX_OVERFLOW', 10)) DB_POOL_TIMEOUT = int(os.environ.get('DB_POOL_TIMEOUT', 5)) def _required_env(varname): """Read an env var; raise ConfigError if missing or empty.""" value = os.environ.get(varname) if not value: raise ConfigError( f'{varname} is required in production. Set it in the environment ' f'before starting the app. Insecure defaults are not permitted in ' f'ProductionConfig.' ) return value class Config: """Base configuration.""" SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') SQLALCHEMY_DATABASE_URI = os.environ.get( 'DATABASE_URL', 'mysql+pymysql://root:password@localhost:3306/shopdb_flask', ) SQLALCHEMY_TRACK_MODIFICATIONS = False # Connection pool, sized on purpose rather than left to SQLAlchemy. # # The defaults (pool_size 5, max_overflow 10, pool_timeout 30) were never # chosen: they simply applied, and nothing in the repo related them to the # gunicorn worker count or to the MySQL max_connections ceiling. The # arithmetic that ties the three together is written next to the worker # count in docker-compose.yml. # # Under gevent a pooled connection is held for the duration of whatever the # greenlet is doing, so a request that sits inside a slow external HTTP call # WITH THE SESSION OPEN pins a connection for that whole call. Hundreds of # greenlets can enter such a path at once and drain the pool, at which point # every other request blocks on checkout. Hence the fail-fast timeout: 5 # seconds, not 30. A pool that is empty for five seconds is not going to be # fine in thirty, and a fast 500 is diagnosable where a 30-second hang looks # like a dead database. # # See PLUGIN-DEVELOPMENT / the plugin docs: a plugin must not hold a DB # session across external I/O. SQLALCHEMY_ENGINE_OPTIONS = { 'pool_pre_ping': True, 'pool_recycle': 300, 'pool_size': DB_POOL_SIZE, 'max_overflow': DB_MAX_OVERFLOW, 'pool_timeout': DB_POOL_TIMEOUT, # Pin the client connection to utf8mb4 so reads/writes match the # utf8mb4 schema regardless of the server default charset. TestingConfig # (SQLite) replaces this whole dict, so the charset arg never reaches it. # # STRICT_TRANS_TABLES is the other half, and it is a correctness # setting rather than a preference. Without it MySQL accepts a write it # cannot store and adjusts it: a 257-character username becomes the # first 100 characters, a malformed date becomes '0000-00-00', an # over-long asset number is cut at 50 and then collides with a # different one. Every one of those was found in the product answering # 200 or 201, because nothing in the stack knew the value had changed. # # Set on the CONNECTION rather than left to the server, so a site whose # my.cnf says otherwise still gets it - the sql_mode seen on the 5.6 # dev server was NO_ENGINE_SUBSTITUTION alone. NO_ENGINE_SUBSTITUTION is # kept alongside it, since dropping it would change how a CREATE TABLE # naming an unavailable engine behaves. 'connect_args': { 'charset': 'utf8mb4', 'init_command': "SET SESSION sql_mode='STRICT_TRANS_TABLES," "NO_ENGINE_SUBSTITUTION'", }, } JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') JWT_ACCESS_TOKEN_EXPIRES = timedelta( seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) ) JWT_REFRESH_TOKEN_EXPIRES = timedelta( seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000)) ) # Number of reverse proxies in front of this instance. 0 (the default) # means none, and the app reads the connection it actually receives. # Set to 1 when IIS or nginx fronts it, so X-Forwarded-Proto/Host/Prefix are # honoured and a generated absolute URL matches what the browser asked for. # Do NOT set it without a proxy: it makes the app believe forwarded headers # a client can send itself, and the login rate limit and account lockout are # keyed on the client address. TRUSTED_PROXY_COUNT = int(os.environ.get('TRUSTED_PROXY_COUNT', 0)) CORS_ORIGINS = [ origin.strip() for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',') if origin.strip() ] LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') # API key for the unattended PowerShell collector scripts COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '') # Trusted plugin publisher public keys (ADR-013). os.pathsep-separated PEM # file paths, delivered out-of-band with the deployed config - NEVER read # from the plugin shelf. Used to verify signed plugin artifacts. Empty on a # site that does not adopt marketplace plugins. PLUGIN_TRUSTED_KEYS = [ path.strip() for path in os.environ.get('PLUGIN_TRUSTED_KEYS', '').split(os.pathsep) if path.strip() ] # When true, a plugin only loads/migrates if its tree matches a trusted # signature (verify-at-load, verify-at-migrate). Default false = existing # behavior. Turn on only after stamping plugins + pinning keys. PLUGIN_REQUIRE_SIGNED = os.environ.get( 'PLUGIN_REQUIRE_SIGNED', 'false').lower() == 'true' # Directories whose unsigned plugins are trusted - honored ONLY under # DEBUG/TESTING (the external-repo/symlink dev workflow). Production ignores. PLUGIN_DEV_TRUST_DIRS = [ path.strip() for path in os.environ.get('PLUGIN_DEV_TRUST_DIRS', '').split(os.pathsep) if path.strip() ] # Read-only folder the app pulls plugin artifacts from (a SharePoint-synced # or copied shelf). Empty = no shelf configured. The app never speaks any # network protocol; it reads this folder. PLUGIN_SHELF_DIR = os.environ.get('PLUGIN_SHELF_DIR', '') ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true' ZABBIX_URL = os.environ.get('ZABBIX_URL', '') ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') # Read-only HR/employee directory database (separate from the app DB). # Credentials come from the environment; never hardcode them in source. # No safe default for the password: unset means empty, the connection # fails loud rather than silently trying a guessed credential. EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost') EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '') EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '') EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees') # Read-write CMMC USB check-in/out database (separate from the app DB). # Credentials come from the environment; never hardcode them in source. # No safe default for the password: unset means empty, the connection # fails loud rather than silently trying a guessed credential. CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost') CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '') CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '') CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb') CACHE_TYPE = 'SimpleCache' CACHE_DEFAULT_TIMEOUT = 600 # IP-based login rate limit (fixed window). Defense in depth atop the # per-account lockout. Backed by the existing cache extension. AUTH_RATELIMIT_ENABLED = os.environ.get( 'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true' AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30)) AUTH_RATELIMIT_WINDOW_SECONDS = int( os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)) DEFAULT_PAGE_SIZE = 20 MAX_PAGE_SIZE = 100 # Hard ceiling on a request body, enforced by Werkzeug before a view runs. # # Upload routes cap their own payloads, but every one of those checks runs # AFTER the body has arrived: the first touch of request.form or # request.files makes Werkzeug parse the whole multipart and spool the file # part to a temp file, so a route can reject the content and still have paid # for the transfer and the disk. Three separate reviews found that shape in # three different routes, which makes it a property of the app rather than # of any handler. # # 256 MB clears the largest thing anyone legitimately sends - the agent # binary upload caps itself at 128 MB - with room for the multipart # envelope. This must stay STRICTLY ABOVE the agent cap: it bounds the # whole request (file + boundaries + form fields), so setting the two # equal would reject a file sitting exactly at the agent limit. # Over this, Werkzeug answers 413 without reading the body. MAX_CONTENT_LENGTH = int( os.environ.get('MAX_CONTENT_LENGTH', 256 * 1024 * 1024)) class DevelopmentConfig(Config): """Development configuration.""" DEBUG = True SQLALCHEMY_ECHO = True SQLALCHEMY_DATABASE_URI = os.environ.get( 'DATABASE_URL', 'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask', ) class TestingConfig(Config): """Testing configuration.""" TESTING = True # Off by default so login-heavy fixtures do not trip the limiter; tests # that exercise rate limiting flip it on via app.config override. AUTH_RATELIMIT_ENABLED = False # Same reason: the enrollment suites post to /enroll dozens of times, and # the limiter counts in a process-wide cache that outlives a test, so with # it on by default a budget spent in one module surfaces as an unrelated # failure in another - and which modules share a worker depends on the # xdist distribution, so the failure moves around. The tests that are about # the limiter turn it on themselves. ENDPOINTS_ENROLL_RATELIMIT_ENABLED = False SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' SQLALCHEMY_ENGINE_OPTIONS = { 'connect_args': {'check_same_thread': False}, 'poolclass': StaticPool, } JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5) class ProductionConfig(Config): """Production configuration. Validation is deferred to validate() so that importing this class in a non-production environment (tests, dev, tooling) does not raise. create_app() invokes validate() when config_name == 'production' so a misconfigured production deploy still fails loud at boot. """ DEBUG = False SQLALCHEMY_ECHO = False JWT_COOKIE_SECURE = True JWT_COOKIE_CSRF_PROTECT = True @classmethod def validate(cls): """Verify production config is safe. Called from create_app.""" secret_key = os.environ.get('SECRET_KEY', '') jwt_secret = os.environ.get('JWT_SECRET_KEY', '') database_url = os.environ.get('DATABASE_URL', '') cors_raw = os.environ.get('CORS_ORIGINS', '').strip() insecure_defaults = { 'dev-secret-key-change-in-production', 'jwt-secret-key-change-in-production', } if not secret_key or secret_key in insecure_defaults: raise ConfigError( 'SECRET_KEY is required in production and must not be the ' 'development default. Set a strong random value in the ' 'environment before starting the app.' ) if not jwt_secret or jwt_secret in insecure_defaults: raise ConfigError( 'JWT_SECRET_KEY is required in production and must not be ' 'the development default. Set a strong random value in the ' 'environment before starting the app.' ) if not database_url: raise ConfigError( 'DATABASE_URL is required in production. No fallback to a ' 'development localhost URL is permitted.' ) if not cors_raw or cors_raw == '*': raise ConfigError( 'CORS_ORIGINS must be a comma-separated allowlist of ' 'explicit origins in production. Wildcard "*" is not ' 'permitted. Example: ' 'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com' ) config = { 'development': DevelopmentConfig, 'testing': TestingConfig, 'production': ProductionConfig, 'default': DevelopmentConfig, }