"""postgres_fdw USER MAPPING management for gendesign_remote server. Password lives in env GENDESIGN_FDW_PASSWORD — never in SQL migration or git. This helper: - validates password format (alphanumeric/hex, length >= 32) to prevent SQL injection via embedded quotes (the postgres CREATE USER MAPPING syntax does not accept bind parameters — password must be inlined into the DDL string), - applies idempotent CREATE or ALTER mapping on every backend startup so password rotation through .env.runtime is picked up after restart. """ from __future__ import annotations import logging import re from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings logger = logging.getLogger(__name__) # Strict whitelist — only hex/alphanumeric and a few separators. Refuses ANY # quote, backslash, unicode whitespace, control char. Min length 32 forces # rotation away from weak defaults. _PASSWORD_RE = re.compile(r"^[A-Za-z0-9_\-]{32,256}$") def ensure_fdw_user_mapping(db: Session) -> None: """Idempotent CREATE OR ALTER USER MAPPING for tradein → gendesign_remote. Skips silently if password not configured (dev / first deploy / forgotten env). Raises ValueError if password fails format whitelist — fail-fast on misconfig. """ password = settings.gendesign_fdw_password if not password or not str(password).strip(): logger.warning( "GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping " "(gendesign_cad_buildings queries will fail; cadastral lookups will " "fall back to Yandex/Nominatim)" ) return if not _PASSWORD_RE.fullmatch(password): # Do NOT echo the password into logs / exception even partially. raise ValueError( "GENDESIGN_FDW_PASSWORD failed format whitelist " "(allowed: 32-256 chars, [A-Za-z0-9_-]). Refusing to apply USER MAPPING " "— password rotation must use the same format." ) # NB: even after whitelist validation we still cannot use SQLAlchemy bind # parameters here — postgres CREATE/ALTER USER MAPPING is DDL and treats # OPTIONS values as literal text. Whitelist guarantees the value is # injection-safe; we still wrap in single quotes for the DDL syntax. exists = db.execute( text( "SELECT 1 FROM pg_user_mappings " "WHERE srvname = 'gendesign_remote' " "AND (usename = current_user OR usename IS NULL)" ) ).first() if exists is None: db.execute(text( f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " f"OPTIONS (user 'tradein_fdw_reader', password '{password}')" )) logger.info("created FDW user mapping for gendesign_remote") else: db.execute(text( f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " f"OPTIONS (SET password '{password}')" )) logger.info("refreshed FDW user mapping password for gendesign_remote") try: db.commit() except Exception: db.rollback() raise