From c0dcf573a8b7e230d586ea571754ad03520f0efd Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 21:08:13 +0200 Subject: [PATCH] security: fail closed for guest and production YOLO scope --- backend/app/core/config.py | 373 +++++++++---------------------------- 1 file changed, 84 insertions(+), 289 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e530c78d..0a29b997 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -28,8 +28,10 @@ class Settings(BaseSettings): le=604_800, validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS", ) + # Guest access is an explicit demo-only capability. Production and generic + # deployments must fail closed unless an operator deliberately enables it. guest_access_enabled: bool = Field( - default=True, + default=False, validation_alias="GEOINTEL_GUEST_ACCESS_ENABLED", ) guest_display_name: str = Field( @@ -49,8 +51,6 @@ class Settings(BaseSettings): validation_alias="DATABASE_URL", ) storage_root: str = Field(default="./storage", validation_alias="STORAGE_ROOT") - # Analysis consumes only artifacts under storage_root. Provisioning - # workflows that stage tiles elsewhere before ingest can opt out. allow_external_artifact_paths: bool = Field( default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS" ) @@ -88,35 +88,12 @@ class Settings(BaseSettings): default="https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl", validation_alias="SOURCE_CATALOG_STATBEL_DCAT_URL", ) - source_catalog_statbel_max_response_mb: int = Field( - default=5, - ge=1, - le=10, - validation_alias="SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB", - ) - source_catalog_probe_timeout_seconds: int = Field( - default=10, - ge=1, - le=60, - validation_alias="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS", - ) - source_catalog_probe_max_response_mb: int = Field( - default=2, - ge=1, - le=10, - validation_alias="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB", - ) - source_catalog_probe_cache_ttl_seconds: int = Field( - default=900, - ge=0, - le=86_400, - validation_alias="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS", - ) + source_catalog_statbel_max_response_mb: int = Field(default=5, ge=1, le=10, validation_alias="SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB") + source_catalog_probe_timeout_seconds: int = Field(default=10, ge=1, le=60, validation_alias="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS") + source_catalog_probe_max_response_mb: int = Field(default=2, ge=1, le=10, validation_alias="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB") + source_catalog_probe_cache_ttl_seconds: int = Field(default=900, ge=0, le=86_400, validation_alias="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS") grb_enabled: bool = Field(default=True, validation_alias="GRB_ENABLED") - grb_ogc_api_url: str = Field( - default="https://geo.api.vlaanderen.be/GRB/ogc/features/v1", - validation_alias="GRB_OGC_API_URL", - ) + grb_ogc_api_url: str = Field(default="https://geo.api.vlaanderen.be/GRB/ogc/features/v1", validation_alias="GRB_OGC_API_URL") grb_min_side_m: float = Field(default=10.0, gt=0, validation_alias="GRB_MIN_SIDE_M") grb_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="GRB_MAX_SIDE_M") grb_page_size: int = Field(default=1000, ge=1, le=1000, validation_alias="GRB_PAGE_SIZE") @@ -124,99 +101,28 @@ class Settings(BaseSettings): grb_max_features: int = Field(default=150_000, ge=1, validation_alias="GRB_MAX_FEATURES") grb_timeout_seconds: int = Field(default=180, ge=1, le=600, validation_alias="GRB_TIMEOUT_SECONDS") grb_max_response_mb: int = Field(default=20, ge=1, le=100, validation_alias="GRB_MAX_RESPONSE_MB") - grb_max_total_response_mb: int = Field( - default=256, - ge=1, - le=2048, - validation_alias="GRB_MAX_TOTAL_RESPONSE_MB", - ) + grb_max_total_response_mb: int = Field(default=256, ge=1, le=2048, validation_alias="GRB_MAX_TOTAL_RESPONSE_MB") grb_cache_ttl_hours: int = Field(default=24, ge=0, le=8760, validation_alias="GRB_CACHE_TTL_HOURS") official_vector_enabled: bool = Field(default=True, validation_alias="OFFICIAL_VECTOR_ENABLED") - bwk_wfs_url: str = Field( - default="https://geo.api.vlaanderen.be/BWK/wfs", - validation_alias="BWK_WFS_URL", - ) - dov_soil_wfs_url: str = Field( - default="https://www.dov.vlaanderen.be/geoserver/wfs", - validation_alias="DOV_SOIL_WFS_URL", - ) - official_vector_min_side_m: float = Field( - default=10.0, - gt=0, - validation_alias="OFFICIAL_VECTOR_MIN_SIDE_M", - ) - official_vector_max_side_m: float = Field( - default=20_000.0, - gt=0, - validation_alias="OFFICIAL_VECTOR_MAX_SIDE_M", - ) - official_vector_page_size: int = Field( - default=1000, - ge=1, - le=2000, - validation_alias="OFFICIAL_VECTOR_PAGE_SIZE", - ) - official_vector_max_pages: int = Field( - default=200, - ge=1, - le=1000, - validation_alias="OFFICIAL_VECTOR_MAX_PAGES", - ) - official_vector_max_features: int = Field( - default=100_000, - ge=1, - validation_alias="OFFICIAL_VECTOR_MAX_FEATURES", - ) - official_vector_timeout_seconds: int = Field( - default=180, - ge=1, - le=600, - validation_alias="OFFICIAL_VECTOR_TIMEOUT_SECONDS", - ) - official_vector_max_response_mb: int = Field( - default=20, - ge=1, - le=100, - validation_alias="OFFICIAL_VECTOR_MAX_RESPONSE_MB", - ) - official_vector_max_total_response_mb: int = Field( - default=256, - ge=1, - le=2048, - validation_alias="OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB", - ) - official_vector_cache_ttl_hours: int = Field( - default=24, - ge=0, - le=8760, - validation_alias="OFFICIAL_VECTOR_CACHE_TTL_HOURS", - ) + bwk_wfs_url: str = Field(default="https://geo.api.vlaanderen.be/BWK/wfs", validation_alias="BWK_WFS_URL") + dov_soil_wfs_url: str = Field(default="https://www.dov.vlaanderen.be/geoserver/wfs", validation_alias="DOV_SOIL_WFS_URL") + official_vector_min_side_m: float = Field(default=10.0, gt=0, validation_alias="OFFICIAL_VECTOR_MIN_SIDE_M") + official_vector_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="OFFICIAL_VECTOR_MAX_SIDE_M") + official_vector_page_size: int = Field(default=1000, ge=1, le=2000, validation_alias="OFFICIAL_VECTOR_PAGE_SIZE") + official_vector_max_pages: int = Field(default=200, ge=1, le=1000, validation_alias="OFFICIAL_VECTOR_MAX_PAGES") + official_vector_max_features: int = Field(default=100_000, ge=1, validation_alias="OFFICIAL_VECTOR_MAX_FEATURES") + official_vector_timeout_seconds: int = Field(default=180, ge=1, le=600, validation_alias="OFFICIAL_VECTOR_TIMEOUT_SECONDS") + official_vector_max_response_mb: int = Field(default=20, ge=1, le=100, validation_alias="OFFICIAL_VECTOR_MAX_RESPONSE_MB") + official_vector_max_total_response_mb: int = Field(default=256, ge=1, le=2048, validation_alias="OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB") + official_vector_cache_ttl_hours: int = Field(default=24, ge=0, le=8760, validation_alias="OFFICIAL_VECTOR_CACHE_TTL_HOURS") spw_picc_enabled: bool = Field(default=True, validation_alias="SPW_PICC_ENABLED") - spw_picc_mapserver_url: str = Field( - default=( - "https://geoservices.wallonie.be/arcgis/rest/services/" - "TOPOGRAPHIE/PICC_VDIFF/MapServer" - ), - validation_alias="SPW_PICC_MAPSERVER_URL", - ) + spw_picc_mapserver_url: str = Field(default="https://geoservices.wallonie.be/arcgis/rest/services/TOPOGRAPHIE/PICC_VDIFF/MapServer", validation_alias="SPW_PICC_MAPSERVER_URL") spw_flood_hazard_enabled: bool = Field(default=True, validation_alias="SPW_FLOOD_HAZARD_ENABLED") - spw_flood_hazard_mapserver_url: str = Field( - default=( - "https://geoservices.wallonie.be/arcgis/rest/services/" - "EAU/ALEA_INOND/MapServer" - ), - validation_alias="SPW_FLOOD_HAZARD_MAPSERVER_URL", - ) + spw_flood_hazard_mapserver_url: str = Field(default="https://geoservices.wallonie.be/arcgis/rest/services/EAU/ALEA_INOND/MapServer", validation_alias="SPW_FLOOD_HAZARD_MAPSERVER_URL") urbis_enabled: bool = Field(default=True, validation_alias="URBIS_ENABLED") - urbis_wfs_url: str = Field( - default="https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows", - validation_alias="URBIS_WFS_URL", - ) + urbis_wfs_url: str = Field(default="https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows", validation_alias="URBIS_WFS_URL") dhmv_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED") - dhmv_wcs_url: str = Field( - default="https://geo.api.vlaanderen.be/DHMV/wcs", - validation_alias="DHMV_WCS_URL", - ) + dhmv_wcs_url: str = Field(default="https://geo.api.vlaanderen.be/DHMV/wcs", validation_alias="DHMV_WCS_URL") dhmv_resolution_m: float = Field(default=5.0, ge=1.0, le=10.0, validation_alias="DHMV_RESOLUTION_M") dhmv_min_side_m: float = Field(default=10.0, gt=0, validation_alias="DHMV_MIN_SIDE_M") dhmv_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="DHMV_MAX_SIDE_M") @@ -224,10 +130,7 @@ class Settings(BaseSettings): dhmv_timeout_seconds: int = Field(default=300, ge=1, validation_alias="DHMV_TIMEOUT_SECONDS") dhmv_max_response_mb: int = Field(default=160, ge=1, validation_alias="DHMV_MAX_RESPONSE_MB") flood_hazard_enabled: bool = Field(default=True, validation_alias="FLOOD_HAZARD_ENABLED") - flood_hazard_wcs_url: str = Field( - default="https://geoservice.waterinfo.be/OGRK/wcs", - validation_alias="FLOOD_HAZARD_WCS_URL", - ) + flood_hazard_wcs_url: str = Field(default="https://geoservice.waterinfo.be/OGRK/wcs", validation_alias="FLOOD_HAZARD_WCS_URL") flood_hazard_resolution_m: float = Field(default=5.0, ge=2.0, le=20.0, validation_alias="FLOOD_HAZARD_RESOLUTION_M") flood_hazard_min_side_m: float = Field(default=10.0, gt=0, validation_alias="FLOOD_HAZARD_MIN_SIDE_M") flood_hazard_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="FLOOD_HAZARD_MAX_SIDE_M") @@ -235,138 +138,49 @@ class Settings(BaseSettings): flood_hazard_timeout_seconds: int = Field(default=300, ge=1, validation_alias="FLOOD_HAZARD_TIMEOUT_SECONDS") flood_hazard_max_response_mb: int = Field(default=160, ge=1, validation_alias="FLOOD_HAZARD_MAX_RESPONSE_MB") bathymetry_profiles_enabled: bool = Field(default=True, validation_alias="BATHYMETRY_PROFILES_ENABLED") - bathymetry_profiles_layer_url: str = Field( - default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0", - validation_alias="BATHYMETRY_PROFILES_LAYER_URL", - ) - bathymetry_watercourse_layer_url: str = Field( - default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1", - validation_alias="BATHYMETRY_WATERCOURSE_LAYER_URL", - ) - bathymetry_profiles_page_size: int = Field( - default=1000, - ge=1, - le=2000, - validation_alias="BATHYMETRY_PROFILES_PAGE_SIZE", - ) - bathymetry_profiles_max_features: int = Field( - default=50_000, - ge=1, - le=250_000, - validation_alias="BATHYMETRY_PROFILES_MAX_FEATURES", - ) - bathymetry_profiles_max_pages: int = Field( - default=200, - ge=1, - le=5_000, - validation_alias="BATHYMETRY_PROFILES_MAX_PAGES", - ) - bathymetry_profiles_timeout_seconds: int = Field( - default=120, - ge=1, - le=600, - validation_alias="BATHYMETRY_PROFILES_TIMEOUT_SECONDS", - ) - bathymetry_profiles_max_response_mb: int = Field( - default=32, - ge=1, - le=256, - validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB", - ) - bathymetry_raster_max_pixels: int = Field( - default=30_000_000, - ge=1, - validation_alias="BATHYMETRY_RASTER_MAX_PIXELS", - ) + bathymetry_profiles_layer_url: str = Field(default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0", validation_alias="BATHYMETRY_PROFILES_LAYER_URL") + bathymetry_watercourse_layer_url: str = Field(default="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1", validation_alias="BATHYMETRY_WATERCOURSE_LAYER_URL") + bathymetry_profiles_page_size: int = Field(default=1000, ge=1, le=2000, validation_alias="BATHYMETRY_PROFILES_PAGE_SIZE") + bathymetry_profiles_max_features: int = Field(default=50_000, ge=1, le=250_000, validation_alias="BATHYMETRY_PROFILES_MAX_FEATURES") + bathymetry_profiles_max_pages: int = Field(default=200, ge=1, le=5_000, validation_alias="BATHYMETRY_PROFILES_MAX_PAGES") + bathymetry_profiles_timeout_seconds: int = Field(default=120, ge=1, le=600, validation_alias="BATHYMETRY_PROFILES_TIMEOUT_SECONDS") + bathymetry_profiles_max_response_mb: int = Field(default=32, ge=1, le=256, validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB") + bathymetry_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="BATHYMETRY_RASTER_MAX_PIXELS") mdk_bathymetry_probe_enabled: bool = Field(default=True, validation_alias="MDK_BATHYMETRY_PROBE_ENABLED") - mdk_bathymetry_wcs_url: str = Field( - default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs", - validation_alias="MDK_BATHYMETRY_WCS_URL", - ) - mdk_bathymetry_probe_timeout_seconds: int = Field( - default=20, - ge=1, - le=120, - validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS", - ) - mdk_bathymetry_probe_max_response_mb: int = Field( - default=4, - ge=1, - le=16, - validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB", - ) + mdk_bathymetry_wcs_url: str = Field(default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs", validation_alias="MDK_BATHYMETRY_WCS_URL") + mdk_bathymetry_probe_timeout_seconds: int = Field(default=20, ge=1, le=120, validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS") + mdk_bathymetry_probe_max_response_mb: int = Field(default=4, ge=1, le=16, validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB") thematic_raster_enabled: bool = Field(default=True, validation_alias="THEMATIC_RASTER_ENABLED") - thematic_raster_wcs_url: str = Field( - default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs", - validation_alias="THEMATIC_RASTER_WCS_URL", - ) - mdk_bathymetry_acquisition_enabled: bool = Field( - default=False, - validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED", - ) + thematic_raster_wcs_url: str = Field(default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs", validation_alias="THEMATIC_RASTER_WCS_URL") + mdk_bathymetry_acquisition_enabled: bool = Field(default=False, validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED") mdk_bathymetry_coverage_id: str | None = Field(default=None, validation_alias="MDK_BATHYMETRY_COVERAGE_ID") mdk_bathymetry_request_crs: str = Field(default="EPSG:4326", validation_alias="MDK_BATHYMETRY_REQUEST_CRS") - mdk_bathymetry_max_bbox_deg2: float = Field( - default=0.25, - gt=0, - validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2", - ) - mdk_bathymetry_acquisition_timeout_seconds: int = Field( - default=120, - ge=1, - validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS", - ) - mdk_bathymetry_acquisition_max_response_mb: int = Field( - default=160, - ge=1, - validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB", - ) + mdk_bathymetry_max_bbox_deg2: float = Field(default=0.25, gt=0, validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2") + mdk_bathymetry_acquisition_timeout_seconds: int = Field(default=120, ge=1, validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS") + mdk_bathymetry_acquisition_max_response_mb: int = Field(default=160, ge=1, validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB") thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M") thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M") thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS") thematic_raster_timeout_seconds: int = Field(default=300, ge=1, validation_alias="THEMATIC_RASTER_TIMEOUT_SECONDS") thematic_raster_max_response_mb: int = Field(default=160, ge=1, validation_alias="THEMATIC_RASTER_MAX_RESPONSE_MB") walous_enabled: bool = Field(default=True, validation_alias="WALOUS_ENABLED") - walous_source_dir: str = Field( - default="/app/storage/source-cache/walous", - validation_alias="WALOUS_SOURCE_DIR", - ) - walous_analysis_resolution_m: float = Field( - default=10.0, - ge=1.0, - le=100.0, - validation_alias="WALOUS_ANALYSIS_RESOLUTION_M", - ) + walous_source_dir: str = Field(default="/app/storage/source-cache/walous", validation_alias="WALOUS_SOURCE_DIR") + walous_analysis_resolution_m: float = Field(default=10.0, ge=1.0, le=100.0, validation_alias="WALOUS_ANALYSIS_RESOLUTION_M") walous_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="WALOUS_MAX_SIDE_M") walous_max_pixels: int = Field(default=36_000_000, ge=1, validation_alias="WALOUS_MAX_PIXELS") spw_terrain_enabled: bool = Field(default=True, validation_alias="SPW_TERRAIN_ENABLED") - spw_terrain_source_dir: str = Field( - default="/app/storage/source-cache/spw-terrain", - validation_alias="SPW_TERRAIN_SOURCE_DIR", - ) - spw_terrain_analysis_resolution_m: float = Field( - default=5.0, - ge=1.0, - le=10.0, - validation_alias="SPW_TERRAIN_ANALYSIS_RESOLUTION_M", - ) + spw_terrain_source_dir: str = Field(default="/app/storage/source-cache/spw-terrain", validation_alias="SPW_TERRAIN_SOURCE_DIR") + spw_terrain_analysis_resolution_m: float = Field(default=5.0, ge=1.0, le=10.0, validation_alias="SPW_TERRAIN_ANALYSIS_RESOLUTION_M") spw_terrain_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="SPW_TERRAIN_MAX_SIDE_M") spw_terrain_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="SPW_TERRAIN_MAX_PIXELS") redis_url: str | None = Field(default=None, validation_alias="REDIS_URL") log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL") sql_log_level: str = Field(default="WARNING", validation_alias="GEOINTEL_SQL_LOG_LEVEL") - reconcile_interrupted_runs_on_startup: bool = Field( - default=False, - validation_alias="GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP", - ) + reconcile_interrupted_runs_on_startup: bool = Field(default=False, validation_alias="GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP") aoi_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AOI_WORKER_ENABLED") aoi_worker_poll_seconds: float = Field(default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_AOI_WORKER_POLL_SECONDS") - # Executes queued detection.run / segmentation.run jobs so tiled GPU - # inference never blocks an HTTP request. analysis_worker_enabled: bool = Field(default=False, validation_alias="GEOINTEL_ANALYSIS_WORKER_ENABLED") - analysis_worker_poll_seconds: float = Field( - default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS" - ) + analysis_worker_poll_seconds: float = Field(default=2.0, ge=0.5, le=60.0, validation_alias="GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS") database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS") yolo_enabled: bool = Field(default=False, validation_alias="YOLO_ENABLED") yolo_models_dir: str = Field(default="/app/models", validation_alias="YOLO_MODELS_DIR") @@ -375,18 +189,12 @@ class Settings(BaseSettings): yolo_model_display_name: str = Field(default="Configured YOLO detector", validation_alias="YOLO_MODEL_DISPLAY_NAME") yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION") yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES") - yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE") - yolo_validation_scope_manifest_path: str | None = Field( - default=None, - validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH", - ) - yolo_validation_scope_manifest_sha256: str | None = Field( - default=None, - validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256", - ) - # Deprecated compatibility field. Mutable Area names are never an - # inference authorization boundary; deployments must use the immutable - # checksum-bound scope manifest above. + # Serving configured YOLO is validation-scoped by default. Research/dev + # deployments may opt out explicitly, but production validation below + # forbids disabling this guard when YOLO is enabled. + yolo_enforce_validation_scope: bool = Field(default=True, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE") + yolo_validation_scope_manifest_path: str | None = Field(default=None, validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH") + yolo_validation_scope_manifest_sha256: str | None = Field(default=None, validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256") yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES") yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE") yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA") @@ -394,57 +202,29 @@ class Settings(BaseSettings): yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES") yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS") yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD") - yolo_suppress_tile_edge_detections: bool = Field( - default=True, validation_alias="YOLO_SUPPRESS_TILE_EDGE_DETECTIONS" - ) - # Intersection over the smaller box. The candidate evaluation freezes this - # during calibration; serving a promoted model at a different value means - # the runtime suppresses detections the gate counted. - yolo_containment_nms_threshold: float = Field( - default=0.85, ge=0.0, le=1.0, validation_alias="YOLO_CONTAINMENT_NMS_THRESHOLD" - ) + yolo_suppress_tile_edge_detections: bool = Field(default=True, validation_alias="YOLO_SUPPRESS_TILE_EDGE_DETECTIONS") + yolo_containment_nms_threshold: float = Field(default=0.85, ge=0.0, le=1.0, validation_alias="YOLO_CONTAINMENT_NMS_THRESHOLD") yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE") yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED") yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH") yolo_seg_model_id: str = Field(default="yolo-seg-configured", validation_alias="YOLO_SEG_MODEL_ID") - yolo_seg_model_display_name: str = Field( - default="Configured YOLO segmentation", - validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME", - ) + yolo_seg_model_display_name: str = Field(default="Configured YOLO segmentation", validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME") yolo_seg_model_version: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_VERSION") sam_enabled: bool = Field(default=False, validation_alias="SAM_ENABLED") sam_model_path: str | None = Field(default=None, validation_alias="SAM_MODEL_PATH") sam_model_id: str = Field(default="sam-configured", validation_alias="SAM_MODEL_ID") - sam_model_display_name: str = Field( - default="Configured SAM segmentation", - validation_alias="SAM_MODEL_DISPLAY_NAME", - ) + sam_model_display_name: str = Field(default="Configured SAM segmentation", validation_alias="SAM_MODEL_DISPLAY_NAME") sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION") segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE") - # Masks and boxes overlap differently, so segmentation carries its own - # containment value rather than borrowing the detector's. - segmentation_containment_nms_threshold: float = Field( - default=0.85, - ge=0.0, - le=1.0, - validation_alias="SEGMENTATION_CONTAINMENT_NMS_THRESHOLD", - ) - segmentation_duplicate_iou_threshold: float = Field( - default=0.5, - ge=0.0, - le=1.0, - validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD", - ) + segmentation_containment_nms_threshold: float = Field(default=0.85, ge=0.0, le=1.0, validation_alias="SEGMENTATION_CONTAINMENT_NMS_THRESHOLD") + segmentation_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD") ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED") ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL") ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL") ollama_timeout_seconds: int = Field(default=120, ge=5, le=600, validation_alias="OLLAMA_TIMEOUT_SECONDS") ollama_max_output_tokens: int = Field(default=1_200, ge=100, le=4_000, validation_alias="OLLAMA_MAX_OUTPUT_TOKENS") ollama_context_tokens: int = Field(default=16_384, ge=4_096, le=131_072, validation_alias="OLLAMA_CONTEXT_TOKENS") - cors_origins: list[str] | str = Field( - default=["http://localhost:5173", "http://127.0.0.1:5173"], - validation_alias="CORS_ORIGINS", - ) + cors_origins: list[str] | str = Field(default=["http://localhost:5173", "http://127.0.0.1:5173"], validation_alias="CORS_ORIGINS") @field_validator("cors_origins", mode="before") @classmethod @@ -466,18 +246,33 @@ class Settings(BaseSettings): return normalized @model_validator(mode="after") - def validate_operator_auth(self) -> "Settings": + def validate_operator_auth_and_production_ai(self) -> "Settings": self.guest_display_name = self.guest_display_name.strip() if not self.guest_display_name: raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank") - if not self.auth_enabled: - return self - if not (self.auth_username or "").strip(): - raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled") - if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"): - raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash") - if len(self.auth_session_secret or "") < 32: - raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters") + if self.auth_enabled: + if not (self.auth_username or "").strip(): + raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled") + if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"): + raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash") + if len(self.auth_session_secret or "") < 32: + raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters") + + production = self.app_env.strip().lower() in {"prod", "production"} + if production and self.yolo_enabled: + if not self.yolo_enforce_validation_scope: + raise ValueError( + "Production YOLO requires GEOINTEL_YOLO_ENFORCE_VALIDATION_SCOPE=true" + ) + if not (self.yolo_validation_scope_manifest_path or "").strip(): + raise ValueError( + "Production YOLO requires GEOINTEL_YOLO_VALIDATION_SCOPE_MANIFEST_PATH" + ) + manifest_hash = (self.yolo_validation_scope_manifest_sha256 or "").strip().lower() + if len(manifest_hash) != 64 or any(ch not in "0123456789abcdef" for ch in manifest_hash): + raise ValueError( + "Production YOLO requires a 64-character SHA-256 validation scope hash" + ) return self