Files
shopdb-flask/shopdb/config.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00

187 lines
6.8 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."""
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
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True,
'pool_recycle': 300,
# 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.
'connect_args': {'charset': 'utf8mb4'},
}
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))
)
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', '')
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
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
SQLALCHEMY_ECHO = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://root:rootpassword@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
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,
}