It shipped 1.77 MB of vendored assets to every site - swagger-ui-bundle.min.js
alone is larger than Redoc - in order to provide an Execute button that only the
development config ever enabled. Production config had it off, testing had it
off hard, and when off the routes were never bound at all. The staticdocs
directory drops from 2.9 MB to 1.1 MB, and there is one fewer vendored
dependency to track for licences and advisories.
What replaces it: an agent has the MCP server's shopdb_call, and a human has
Redoc for reading plus the 324 harvested examples in the spec for a curl that is
mostly copy-paste. The read-only page was always the one that mattered.
The tests this leaves behind are the interesting part. That file was named for
try-it but half of it proved things about the page that stays, above all that
nothing it loads comes from the internet, which is what a CDN reference costs on
an air-gapped box. Those are kept and the file is renamed for what it covers.
Pointing the air-gap test at redoc.standalone.js immediately failed: the bundle
still carried a trailing sourceMappingURL for a .map that was never vendored,
so every devtools open was a guaranteed 404 with no internet to fall back on.
That was visible in a browser console and nobody had chased it, because the test
had only ever been parametrised over the two Swagger files. The pragma is gone
and the assertion now checks the TRAILING pragma specifically: Redoc's own code
contains that string three more times, in a data: URI it assembles and in a
worker blob, and a blunt "not in body" assertion fails on correct code.
A first attempt to strip it with a line-anchored pattern removed 507 KB, because
the bundle is minified onto long lines. Hence the anchored, byte-exact removal
and the assertion that can tell a directive from source.
(cherry picked from commit 581baafee0)
319 lines
14 KiB
Python
319 lines
14 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
|
|
|
|
# On in development: firing the request is the whole reason a developer
|
|
# opens the API docs. Still read from the environment, so a shared dev
|
|
# instance can turn it back off without editing this file.
|
|
|
|
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
|
|
# Off, and stated here rather than inherited, because a test suite that
|
|
# silently ran with it on would be testing a configuration no site ships.
|
|
# The session-wide app in tests/conftest.py is what
|
|
# tests/test_core/test_public_read_surface.py sweeps for ungated GET
|
|
# routes, and the try-it page plus its two vendored assets are
|
|
# deliberately unauthenticated. Keeping the default here means that sweep
|
|
# keeps describing what production actually exposes; the tests that ARE
|
|
# about the page build their own app with the flag on
|
|
# (tests/test_core/test_api_docs_tryit.py).
|
|
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,
|
|
}
|