"""Application configuration.""" import logging import os from os.path import abspath, dirname, join, pardir from abacus_common_logic.constants.constants import DEFAULT_OWS_CLIENT_TIMEOUT from abacus_common_logic.utils.ssh import get_private_key from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from dotenv import load_dotenv from owsclient import OwsClient from owsrequest.ows_client import correlation_id_getter, request_context_getter from python_pdp_sdk.backends import authorization_backend from python_pdp_sdk.connectors.ows_pdp import ows_pdp from secrets_manager.flask_ext import FlaskSecretsManager # Load environment variables from a .env file if present dotenv_path = abspath(join(dirname(__file__), pardir, '.env')) load_dotenv(dotenv_path) _MIN_BODY_LIMIT_MB = 1 _MAX_BODY_LIMIT_MB = 10 OWS_REFERENCE_CACHE_TTL_MAX_SECONDS = 86400 # 24 h def _max_content_length_bytes(mb_env: str) -> int: """Validate the configured body-size ceiling (in MB) and return it in bytes. Fail fast on a value outside the supported range so a typo cannot silently shrink the abuse backstop to nothing or balloon per-thread memory under 15 threads. """ mb = int(mb_env) if not _MIN_BODY_LIMIT_MB <= mb <= _MAX_BODY_LIMIT_MB: raise ValueError( f'MAX_CONTENT_LENGTH_MB must be between {_MIN_BODY_LIMIT_MB} and ' f'{_MAX_BODY_LIMIT_MB} MB, got {mb}' ) return mb * 1024 * 1024 def _int_env_in_range(name, default, minimum, maximum): """Parse an int env var, raising if it is non-integer or out of range. Fail loudly at startup rather than silently clamping, so a misconfigured value surfaces immediately instead of behaving in a surprising way. """ raw = os.environ.get(name, default) try: value = int(raw) except (TypeError, ValueError): raise ValueError(f'{name} ({raw!r}) must be an integer.') from None if value < minimum or value > maximum: raise ValueError( f'{name} ({value}) must be an integer between {minimum} and {maximum}.' ) return value class Config: """Base application configuration. Subclass and override for specific environments. """ # AWS # Required for M2M AWS_REGION = os.environ.get('AWS_REGION', 'us-east-1') # Service information SERVICE_NAME = 'ows-royalties' SERVICE_VERSION = '1.0.0' # Production environment PROD_ENVIRONMENT = 'prod' DEV_ENVIRONMENT = 'dev' QA_ENVIRONMENT = 'qa' UAT_ENVIRONMENT = 'uat' ENVIRONMENT = os.environ.get('Environment', DEV_ENVIRONMENT) # Errors and loggers SENTRY = os.environ.get('SENTRY_DSN') LOGGER_LEVEL = logging.INFO LOGGER_NAME = 'ows1' ONLY_LOG_ACCESS_ERRORS = False # Generic handlers HEALTH_CHECK = '/hello/' READY_CHECK = '/ready/' # Polled frequently by the load balancer; excluded from access/request logging so probe # traffic doesn't drown out real requests in the logs. HEALTH_CHECK_PATHS = [HEALTH_CHECK, READY_CHECK] # Swagger documentation path SWAGGER_FILE_PATH = 'spec/ows_royalties-1.0.0.yaml' # Kafka KAFKA_BOOTSTRAP_SERVERS = os.environ.get('KAFKA_BOOTSTRAP_SERVERS') KAFKA_PRODUCERS_BY_ACTION_NAME = {} # Database vendor DB_VENDOR = 'mysql' # MySQL — royalty_accounting (primary) MYSQL_DB_HOST = os.environ.get('MYSQL_DB_HOST') MYSQL_DB_PORT = os.environ.get('MYSQL_DB_PORT', '3306') MYSQL_DB_NAME = os.environ.get('MYSQL_DB_NAME', 'royalty_accounting') MYSQL_TEST_DB_NAME = os.environ.get('MYSQL_TEST_DB_NAME', 'royalty_accounting_test') MYSQL_DB_USER = os.environ.get('MYSQL_DB_USER') MYSQL_DB_PASS = os.environ.get('MYSQL_DB_PASS') OWS_BATCH_LIMIT = int(os.environ.get('OWS_BATCH_LIMIT', '200')) # Reference-data HTTP caching (set on origin responses so all consumers benefit). # 0 disables Cache-Control headers; max 86400 (24 h). Out-of-range values # fail loudly at startup. OWS_REFERENCE_CACHE_TTL_SECONDS = _int_env_in_range( 'OWS_REFERENCE_CACHE_TTL_SECONDS', '300', 0, OWS_REFERENCE_CACHE_TTL_MAX_SECONDS, ) # S3 S3_SALES_BUCKET_NAME = os.environ.get('S3_SALES_BUCKET_NAME') S3_ABACUS_ADJUSTMENTS_BUCKET = os.environ.get('S3_ABACUS_ADJUSTMENTS_BUCKET') S3_ABACUS_QUARANTINE_BUCKET = os.environ.get('S3_ABACUS_QUARANTINE_BUCKET') # Schema SCHEMA_REGISTRY_URL = os.environ.get( 'SCHEMA_REGISTRY_URL', 'https://dev-schema-registry.dev.theorchard.io' ) SNOWFLAKE_CONFIG = { 'account': os.environ.get('SNOWFLAKE_ACCOUNT'), 'role': os.environ.get('SNOWFLAKE_ROLE', ''), 'warehouse': os.environ.get('SNOWFLAKE_WAREHOUSE', ''), 'db': os.environ.get('SNOWFLAKE_DATABASE', 'ORCHARD_APP_REPORTING_V2'), 'schema': os.environ.get('SNOWFLAKE_SCHEMA', ENVIRONMENT), 'user': os.environ.get('SNOWFLAKE_USER', ''), 'private_key': None, } # Snowflake SSH-Keypair access SNOWFLAKE_KEY_PASSPHRASE = os.environ.get('SNOWFLAKE_KEY_PASSPHRASE') SNOWFLAKE_PRIVATE_KEY_PATH = os.environ.get('SNOWFLAKE_PRIVATE_KEY_PATH') SNOWFLAKE_PRIVATE_KEY = os.environ.get('SNOWFLAKE_PRIVATE_KEY', None) # --- API hardening (only genuinely-environmental values are configurable) --- # Dark-launch default is 'shadow' (count+log would-be-429s, never block) so activating the # limiter changes no client outcome; flipped to 'enforce' once the shared store lands and the # shadow data confirms the caps. There is no 'off': a bad value fails fast at setup. RATELIMIT_MODE = os.environ.get('RATELIMIT_MODE', 'shadow') # 'shadow' | 'enforce' RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://') # Default per-key rate limit, calibrated from prod Datadog (busiest legit user ~1000 read req/min). RATELIMIT_DEFAULT = os.environ.get('RATELIMIT_DEFAULT', '1000/minute') # JSON-only API (file uploads/downloads are presigned-direct-to-S3, never through Flask) with # bulk arrays bounded by OWS_BATCH_LIMIT. 90 days of the body_bytes distribution show a prod max # of ~60 KB (the bulk contract-term-condition endpoints; everything else is < 1 KB), so this # abuse backstop steps down again from 2 MB to 1 MB -- still ~17x over the observed max, with # ample room for the bulk endpoints to grow under OWS_BATCH_LIMIT. Env-overridable per env. MAX_CONTENT_LENGTH = _max_content_length_bytes( os.environ.get('MAX_CONTENT_LENGTH_MB', '1') ) PROXYFIX_X_FOR = int( os.environ.get('PROXYFIX_X_FOR', '1') ) # proxy hop count -- genuinely per-env # Temporary edge header-trust probe (ACC-10614). When set, a request carrying an # X-Edge-Probe header equal to this token gets its edge-forwarded identity/IP headers # logged. Unset (default) disables it entirely. Remove after edge sign-off. EDGE_PROBE_TOKEN = os.environ.get('EDGE_PROBE_TOKEN', '') secrets_manager_client = FlaskSecretsManager( application_context=False, environment=Config.ENVIRONMENT, service_name=Config.SERVICE_NAME, force_remote=bool(Config.ENVIRONMENT == Config.DEV_ENVIRONMENT), ) def get_cred_value(cred_name: str): """Return secrets value of given cred_name.""" return secrets_manager_client.get_cred(cred_name) # Pull UAT, QA & Prod credentials from secrets manager if Config.ENVIRONMENT in ( Config.QA_ENVIRONMENT, Config.PROD_ENVIRONMENT, Config.UAT_ENVIRONMENT, ): # NOTE: The SENTRY_DSN is now coming as an env variable in QA. # We should probably do the same for PROD and UAT at some point. if Config.ENVIRONMENT in (Config.PROD_ENVIRONMENT, Config.UAT_ENVIRONMENT): Config.SENTRY = get_cred_value('SENTRY_DSN') Config.MYSQL_DB_PASS = get_cred_value('MYSQL_DB_PASS') Config.SNOWFLAKE_KEY_PASSPHRASE = get_cred_value('SNOWFLAKE_KEY_PASSPHRASE') Config.SNOWFLAKE_PRIVATE_KEY = get_cred_value('SNOWFLAKE_PRIVATE_KEY') sf_private_key = serialization.load_pem_private_key( bytes(Config.SNOWFLAKE_PRIVATE_KEY, 'utf8'), password=bytes(Config.SNOWFLAKE_KEY_PASSPHRASE, 'utf8'), backend=default_backend(), ) Config.SNOWFLAKE_CONFIG['private_key'] = sf_private_key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) elif Config.SNOWFLAKE_PRIVATE_KEY_PATH and Config.SNOWFLAKE_KEY_PASSPHRASE: Config.SNOWFLAKE_CONFIG['private_key'] = get_private_key( Config.SNOWFLAKE_PRIVATE_KEY_PATH.replace('~', os.environ['HOME']), Config.SNOWFLAKE_KEY_PASSPHRASE, ) # PDP authorization backend def setup_ows_client(environment) -> OwsClient: """Set up ows-client.""" return OwsClient( correlation_id_getter=correlation_id_getter, environment=environment, request_context_getter=request_context_getter, service_name='ows-royalties', timeout=DEFAULT_OWS_CLIENT_TIMEOUT, ) def setup_authorization_backend( ows_client: OwsClient, ) -> authorization_backend.AuthorizationBackend: """Set up Authorization Backend.""" ows_pdp_client = ows_pdp.OwsPdpClient(ows_client=ows_client) return authorization_backend.PdpAuthorizationBackend(ows_pdp_client) ows_client = setup_ows_client(Config.ENVIRONMENT) pdp_authorization_backend = setup_authorization_backend(ows_client)