Some checks failed
Deploy Trade-In / changes (push) Successful in 5s
Deploy / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Successful in 26s
Deploy Trade-In / build-backend (push) Successful in 47s
Deploy / build-frontend (push) Successful in 29s
Deploy / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Successful in 40s
Deploy / build-worker (push) Successful in 2m57s
Deploy / deploy (push) Failing after 37s
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.
81 lines
3.1 KiB
Python
81 lines
3.1 KiB
Python
"""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
|