Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
305 lines
13 KiB
Python
305 lines
13 KiB
Python
"""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,
|
|
}
|