Packaging + provenance for the plugin marketplace. No runtime behavior change yet - verification is available on demand; enforcing it at plugin load/migrate and pulling from a shelf are Phase 2. - signing.py: ed25519 key pairs + provenance. Provenance is a sorted per-file SHA-256 map plus metadata; the detached signature covers the exact serialized provenance bytes, so verifying is re-hash files, re-serialize, check signature. verify() accepts any of several trusted keys (rotation). Uses cryptography (already a dependency). - packaging.py: pack() builds a signed <name>-<version>.shopdbplugin (zip + PROVENANCE.json + PROVENANCE.sig). verify_artifact()/verify_dir() re-hash and check the signature, and flag a tampered file, an unexpected file, a wrong/absent key - all fail closed. - CLI: `flask plugin keygen` (publisher key pair), `flask plugin pack <name> --key` (validates then signs), and `flask plugin validate` extended to a signed artifact by path (--pubkey, else PLUGIN_TRUSTED_KEYS). - config PLUGIN_TRUSTED_KEYS: os.pathsep-separated public-key PEM paths, delivered with the site config, never read from the shelf. .env.example documents it. - docs/PLUGIN-SIGNING.md: curator flow (keygen offline, review, pack, publish, pin keys, rotate). The signature proves an artifact is exactly what a curator signed, not that the code is safe - human review before signing is the control. 11 tests: sign/verify round trip, wrong key, provenance excludes noise, serialize determinism, pack + verify, tamper -> hash mismatch, extra file, no-key fail-closed, verify_dir. 1028 pass, naming green.
197 lines
7.2 KiB
Python
197 lines
7.2 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', '')
|
|
|
|
# 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()
|
|
]
|
|
|
|
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,
|
|
}
|