Files
shopdb-flask/shopdb/config.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

328 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')
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))
# Swagger UI "try it" page at /api/docs/try, beside the read-only Redoc
# page at /api/docs. One spec, two renderers.
#
# Off unless a site turns it on, and that is the point rather than caution:
# Swagger UI's Execute button fires a REAL request at THIS server from the
# operator's browser, carrying whatever session that browser already holds.
# On a production box that is a one-click way to POST against live fleet
# data from a page anyone can open. Redoc, which cannot send anything, stays
# the default and is always on.
#
# When off the routes are never bound at all, so /api/docs/try is Flask's
# own 404 and the Redoc page does not mention it. See
# shopdb/core/api/docs.py.
API_DOCS_TRYIT_ENABLED = os.environ.get(
'API_DOCS_TRYIT_ENABLED', 'false').lower() == 'true'
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.
API_DOCS_TRYIT_ENABLED = os.environ.get(
'API_DOCS_TRYIT_ENABLED', 'true').lower() == '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
# 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).
API_DOCS_TRYIT_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,
}