simple-module-hosting 0.0.37__tar.gz → 0.0.38__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/.gitignore +1 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/PKG-INFO +7 -5
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/pyproject.toml +7 -5
- simple_module_hosting-0.0.38/simple_module_hosting/_api_version.py +37 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_app_boot.py +107 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_boot_checks.py +18 -1
- simple_module_hosting-0.0.38/simple_module_hosting/_cors.py +76 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_error_handlers.py +8 -23
- simple_module_hosting-0.0.38/simple_module_hosting/_forwarded_host.py +135 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_host_settings_support.py +108 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_hydrate_step.py +51 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_inertia_setup.py +10 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_inertia_shared.py +33 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_insecure_transport.py +66 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_lease_redis.py +149 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_lifespan.py +30 -16
- simple_module_hosting-0.0.37/simple_module_hosting/_phase_helpers.py → simple_module_hosting-0.0.38/simple_module_hosting/_middleware_pipeline.py +47 -100
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_module_routes.py +11 -8
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_openapi.py +51 -4
- simple_module_hosting-0.0.38/simple_module_hosting/_phase_helpers.py +110 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_preapp_config.py +89 -80
- simple_module_hosting-0.0.38/simple_module_hosting/_process_services.py +18 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_rate_limit.py +140 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_rate_limit_keys.py +67 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_rate_limit_store.py +126 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_registrations.py +42 -5
- simple_module_hosting-0.0.38/simple_module_hosting/_route_access.py +118 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_route_guard.py +19 -17
- simple_module_hosting-0.0.38/simple_module_hosting/_route_paths.py +72 -0
- simple_module_hosting-0.0.38/simple_module_hosting/_secret_key.py +211 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_settings_registration.py +5 -20
- simple_module_hosting-0.0.38/simple_module_hosting/_spa_mount.py +148 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_tenant.py +23 -5
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/app_builder.py +36 -82
- simple_module_hosting-0.0.38/simple_module_hosting/db_options.py +32 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/host_settings.py +92 -75
- simple_module_hosting-0.0.38/simple_module_hosting/leases.py +55 -0
- simple_module_hosting-0.0.38/simple_module_hosting/locales/de.json +28 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/locales/en.json +1 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/locales/es.json +2 -1
- simple_module_hosting-0.0.38/simple_module_hosting/log_sinks.py +238 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/logging.py +2 -3
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/maintenance.py +2 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/manifest.py +3 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/middleware.py +4 -0
- simple_module_hosting-0.0.38/simple_module_hosting/pagination.py +60 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/permissions.py +3 -11
- simple_module_hosting-0.0.38/simple_module_hosting/rate_limit_deps.py +46 -0
- simple_module_hosting-0.0.38/simple_module_hosting/resource_deps.py +106 -0
- simple_module_hosting-0.0.38/simple_module_hosting/sentry.py +169 -0
- simple_module_hosting-0.0.38/simple_module_hosting/sentry_scrub.py +153 -0
- simple_module_hosting-0.0.38/simple_module_hosting/sentry_scrub_text.py +110 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/session.py +8 -0
- simple_module_hosting-0.0.38/simple_module_hosting/settings.py +15 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +10 -16
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/components/StepForm.tsx +15 -22
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/token_guard.py +38 -0
- simple_module_hosting-0.0.38/simple_module_hosting/ui_locales/de.json +110 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/ui_locales/en.json +6 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/ui_locales/es.json +6 -0
- simple_module_hosting-0.0.38/tests/_hosting_settings_db.py +51 -0
- simple_module_hosting-0.0.38/tests/_resacc_models.py +79 -0
- simple_module_hosting-0.0.38/tests/_sentry_transport.py +34 -0
- simple_module_hosting-0.0.38/tests/conftest.py +15 -0
- simple_module_hosting-0.0.38/tests/test_boot_config_hardening.py +150 -0
- simple_module_hosting-0.0.38/tests/test_build_time_settings_marked.py +75 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_csp_module_sources.py +2 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_error_page_shared_props.py +50 -2
- simple_module_hosting-0.0.38/tests/test_forwarded_host.py +158 -0
- simple_module_hosting-0.0.38/tests/test_hosting_cors_api.py +61 -0
- simple_module_hosting-0.0.38/tests/test_hosting_cors_unset.py +19 -0
- simple_module_hosting-0.0.38/tests/test_hosting_csrf_shared_prop.py +85 -0
- simple_module_hosting-0.0.38/tests/test_hosting_lease_backends.py +169 -0
- simple_module_hosting-0.0.38/tests/test_hosting_lease_selection.py +48 -0
- simple_module_hosting-0.0.38/tests/test_hosting_log_sinks.py +209 -0
- simple_module_hosting-0.0.38/tests/test_hosting_openapi_security.py +114 -0
- simple_module_hosting-0.0.38/tests/test_hosting_pagination_deps.py +51 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_hosting_permissions.py +2 -2
- simple_module_hosting-0.0.38/tests/test_hosting_sentry.py +136 -0
- simple_module_hosting-0.0.38/tests/test_hosting_sentry_scrub.py +180 -0
- simple_module_hosting-0.0.38/tests/test_hosting_sentry_scrub_gaps.py +210 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_i18n_routes.py +1 -1
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_inertia_i18n_shared_props.py +8 -3
- simple_module_hosting-0.0.38/tests/test_insecure_transport_warning.py +86 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_invalidation_wiring.py +13 -13
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_lifespan_order.py +2 -2
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_logging.py +8 -8
- simple_module_hosting-0.0.38/tests/test_merge_host_settings_strict.py +52 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_middleware_order.py +4 -1
- simple_module_hosting-0.0.38/tests/test_native_link_prefixes_strip.py +13 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_preapp_config.py +6 -30
- simple_module_hosting-0.0.38/tests/test_preapp_invalid_values.py +64 -0
- simple_module_hosting-0.0.38/tests/test_preapp_read_failures.py +80 -0
- simple_module_hosting-0.0.38/tests/test_rate_limit_rules_redis.py +25 -0
- simple_module_hosting-0.0.38/tests/test_rate_limit_rules_registry.py +191 -0
- simple_module_hosting-0.0.38/tests/test_rate_limit_store_shared.py +149 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_request_guard_rate_limit.py +3 -2
- simple_module_hosting-0.0.38/tests/test_resource_deps.py +134 -0
- simple_module_hosting-0.0.38/tests/test_resource_policy_owned.py +113 -0
- simple_module_hosting-0.0.38/tests/test_resource_policy_parity.py +68 -0
- simple_module_hosting-0.0.38/tests/test_resource_types_wiring.py +31 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_route_guard.py +1 -1
- simple_module_hosting-0.0.38/tests/test_route_guard_fastapi_contract.py +128 -0
- simple_module_hosting-0.0.38/tests/test_route_paths.py +89 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_secret_key_bootstrap.py +3 -22
- simple_module_hosting-0.0.38/tests/test_server_rendered_head.py +63 -0
- simple_module_hosting-0.0.38/tests/test_session_cookie_secure.py +81 -0
- simple_module_hosting-0.0.38/tests/test_spa_mounts_541.py +271 -0
- simple_module_hosting-0.0.38/tests/test_tenant_resolver_types.py +70 -0
- simple_module_hosting-0.0.38/tests-js/hosting_SetupWizard.test.tsx +93 -0
- simple_module_hosting-0.0.37/simple_module_hosting/_hydrate_step.py +0 -39
- simple_module_hosting-0.0.37/simple_module_hosting/_rate_limit.py +0 -187
- simple_module_hosting-0.0.37/simple_module_hosting/_secret_key.py +0 -154
- simple_module_hosting-0.0.37/simple_module_hosting/settings.py +0 -10
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/LICENSE +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/README.md +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_anonymous.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_body_limit.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_csp_dynamic.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_dev_boot.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_favicon.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_i18n_build.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_i18n_target.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_project_root.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_request_guard_responses.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/i18n_routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/page_globs.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_gate.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/__init__.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/migrate.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/pages/Wizard.tsx +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/payloads.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/setup_wizard/routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/tenancy.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_check_migrations_expression_note.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_csp_providers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_diagnostics_state.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_error_status_coverage.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_favicon_default.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_grant_sources.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_home_url_default.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_host_settings_fields.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_hosting_error_json_and_session_cookie.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_hosting_openapi.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_i18n_build_overrides.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_i18n_manifest_published_target.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_i18n_ui_namespace.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_lifespan_deferred_startup.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_menu_providers_wiring.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_principal_permissions_claim.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_request_guard_auth_parity.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_request_guard_body_limit.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_request_guard_body_limit_middleware_read.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_request_guard_body_limit_started.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_session_cookie_window.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_session_vary_dedupe.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_setup_gate_single_flight.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_setup_migrate_error_redaction.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_setup_routes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_tenancy_api.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_tenant_resolver_diagnostic.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_tenant_scope_helpers.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_tenant_source.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_token_guard_effective_route.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_token_route_guard.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_translator_dep.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tests/test_view_prefixes.py +0 -0
- {simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.38
|
|
4
4
|
Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -22,18 +22,20 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: click>=8.1
|
|
25
|
-
Requires-Dist: fastapi
|
|
25
|
+
Requires-Dist: fastapi<0.157,>=0.140
|
|
26
26
|
Requires-Dist: httpx>=0.27
|
|
27
27
|
Requires-Dist: itsdangerous>=2.0
|
|
28
28
|
Requires-Dist: jinja2>=3.1
|
|
29
|
-
Requires-Dist: simple-module-core==0.0.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
31
|
-
Requires-Dist: simple-module-inertia==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.38
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.38
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.38
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
35
35
|
Provides-Extra: redis
|
|
36
36
|
Requires-Dist: redis>=5; extra == 'redis'
|
|
37
|
+
Provides-Extra: sentry
|
|
38
|
+
Requires-Dist: sentry-sdk[celery,fastapi]<3,>=2.20; extra == 'sentry'
|
|
37
39
|
Description-Content-Type: text/markdown
|
|
38
40
|
|
|
39
41
|
# simple_module_hosting
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.38"
|
|
4
4
|
description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -22,16 +22,16 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
24
|
"click>=8.1",
|
|
25
|
-
"fastapi>=0.
|
|
25
|
+
"fastapi>=0.140,<0.157",
|
|
26
26
|
"httpx>=0.27",
|
|
27
27
|
# SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
|
|
28
28
|
# module import time; starlette treats it as an optional extra, so declare
|
|
29
29
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
30
30
|
"itsdangerous>=2.0",
|
|
31
31
|
"jinja2>=3.1",
|
|
32
|
-
"simple_module_core==0.0.
|
|
33
|
-
"simple_module_db==0.0.
|
|
34
|
-
"simple_module_inertia==0.0.
|
|
32
|
+
"simple_module_core==0.0.38",
|
|
33
|
+
"simple_module_db==0.0.38",
|
|
34
|
+
"simple_module_inertia==0.0.38",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
@@ -40,6 +40,8 @@ dependencies = [
|
|
|
40
40
|
[project.optional-dependencies]
|
|
41
41
|
# Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker.
|
|
42
42
|
redis = ["redis>=5"]
|
|
43
|
+
# Error reporting (SM_SENTRY_DSN). Never imported unless the DSN is set.
|
|
44
|
+
sentry = ["sentry-sdk[fastapi,celery]>=2.20,<3"]
|
|
43
45
|
|
|
44
46
|
[project.scripts]
|
|
45
47
|
sm-host = "simple_module_hosting.host_cli:app"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Default ``HostSettings.api_version``: the host project's own version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def default_api_version() -> str:
|
|
7
|
+
"""The host project's version, else ``0.0.0``.
|
|
8
|
+
|
|
9
|
+
This repo's host is the installed ``simple-module-host`` distribution. A
|
|
10
|
+
scaffolded host is ``package = false`` (never installed), so there is no
|
|
11
|
+
distribution to ask: read ``[project].version`` from its ``pyproject.toml``
|
|
12
|
+
under ``SM_PROJECT_ROOT`` (or the cwd) instead.
|
|
13
|
+
"""
|
|
14
|
+
import tomllib
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
|
|
17
|
+
from simple_module_core.dotenv import find_env_file
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
return version("simple-module-host")
|
|
21
|
+
except PackageNotFoundError:
|
|
22
|
+
pass
|
|
23
|
+
# Same project-root convention as .env (SM_PROJECT_ROOT, else a bounded walk
|
|
24
|
+
# up from the cwd), so a CLI run from host/ finds the same project as the app.
|
|
25
|
+
root = find_env_file().parent
|
|
26
|
+
for candidate in (root / "host" / "pyproject.toml", root / "pyproject.toml"):
|
|
27
|
+
try:
|
|
28
|
+
project = tomllib.loads(candidate.read_text(encoding="utf-8")).get("project")
|
|
29
|
+
except (OSError, ValueError): # missing/unreadable, bad UTF-8, bad TOML
|
|
30
|
+
continue
|
|
31
|
+
if not isinstance(project, dict):
|
|
32
|
+
continue
|
|
33
|
+
# The first file declaring a [project] is the host's: a dynamic version
|
|
34
|
+
# must not fall through to an unrelated (workspace-root) project's.
|
|
35
|
+
found = project.get("version")
|
|
36
|
+
return found if isinstance(found, str) and found else "0.0.0"
|
|
37
|
+
return "0.0.0"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Boot-phase helpers for ``create_app``: app construction, database init, static mount."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI
|
|
9
|
+
from simple_module_core.design_packs import DesignPackRegistry
|
|
10
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
11
|
+
from simple_module_core.health import HealthRegistry
|
|
12
|
+
from simple_module_core.menu import MenuRegistry
|
|
13
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
14
|
+
from simple_module_core.public_routes import PublicRouteRegistry
|
|
15
|
+
from simple_module_db.listeners import register_listeners
|
|
16
|
+
from simple_module_db.session import init_db
|
|
17
|
+
|
|
18
|
+
from simple_module_hosting._db_health import register_database_check
|
|
19
|
+
from simple_module_hosting._openapi import (
|
|
20
|
+
API_OPENAPI_URL,
|
|
21
|
+
install_openapi_filter,
|
|
22
|
+
make_unique_id_function,
|
|
23
|
+
openapi_served,
|
|
24
|
+
)
|
|
25
|
+
from simple_module_hosting._process_services import configure_process_services
|
|
26
|
+
from simple_module_hosting.db_options import database_options
|
|
27
|
+
from simple_module_hosting.settings import Settings
|
|
28
|
+
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
29
|
+
from simple_module_hosting.token_guard import token_route_guard
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("simple_module_hosting.app_builder")
|
|
32
|
+
|
|
33
|
+
_DOCS_URL = "/api/docs"
|
|
34
|
+
_REDOC_URL = "/api/redoc"
|
|
35
|
+
_STATIC_MOUNT_PATH = "/static"
|
|
36
|
+
_STATIC_DIR_NAME = "static"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_fastapi_app(settings: Settings, lifespan) -> FastAPI:
|
|
40
|
+
serve_openapi = openapi_served(
|
|
41
|
+
is_development=settings.is_development, openapi_public=settings.openapi_public
|
|
42
|
+
)
|
|
43
|
+
app = FastAPI(
|
|
44
|
+
title=settings.api_title,
|
|
45
|
+
version=settings.api_version,
|
|
46
|
+
description=settings.api_description,
|
|
47
|
+
openapi_url=API_OPENAPI_URL if serve_openapi else None,
|
|
48
|
+
generate_unique_id_function=make_unique_id_function(),
|
|
49
|
+
docs_url=_DOCS_URL if settings.is_development else None,
|
|
50
|
+
redoc_url=_REDOC_URL if settings.is_development else None,
|
|
51
|
+
lifespan=lifespan,
|
|
52
|
+
# API tokens reach only permission-gated, non-page routes (or ones
|
|
53
|
+
# marked ``TokenAllowed``); see ``token_guard``.
|
|
54
|
+
dependencies=[Depends(token_route_guard)],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
install_openapi_filter(app) # AuthMiddleware reads openapi_url: anonymous iff served
|
|
58
|
+
app.state.openapi_url = API_OPENAPI_URL if serve_openapi else None
|
|
59
|
+
return app
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def log_registration_summary(
|
|
63
|
+
menu_registry: MenuRegistry,
|
|
64
|
+
perm_registry: PermissionRegistry,
|
|
65
|
+
ff_registry: FeatureFlagRegistry,
|
|
66
|
+
health_registry: HealthRegistry,
|
|
67
|
+
public_route_registry: PublicRouteRegistry,
|
|
68
|
+
design_pack_registry: DesignPackRegistry,
|
|
69
|
+
) -> None:
|
|
70
|
+
logger.info(
|
|
71
|
+
"Registered %d menu items, %d permissions, %d feature flags, "
|
|
72
|
+
"%d health checks, %d public routes, %d design packs",
|
|
73
|
+
len(menu_registry.all_items),
|
|
74
|
+
len(perm_registry.all_permissions),
|
|
75
|
+
len(ff_registry.all_flags),
|
|
76
|
+
len(health_registry.all_checks),
|
|
77
|
+
len(public_route_registry.routes),
|
|
78
|
+
len(design_pack_registry.all()),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def init_database(settings: Settings, health_registry: HealthRegistry):
|
|
83
|
+
db_state = init_db(
|
|
84
|
+
settings.database_url,
|
|
85
|
+
echo=settings.debug,
|
|
86
|
+
**database_options(settings),
|
|
87
|
+
)
|
|
88
|
+
# Multi-tenant installs fail closed: a tenant-scoped query with no tenant
|
|
89
|
+
# context raises instead of reading every tenant's rows.
|
|
90
|
+
db_state.tenant_strict = settings.multi_tenant
|
|
91
|
+
# Where unbound / all_tenants() writes land on a single-tenant install:
|
|
92
|
+
# its ``default_tenant`` when set, so they stay visible to its requests.
|
|
93
|
+
if not settings.multi_tenant and settings.default_tenant:
|
|
94
|
+
db_state.default_tenant_id = settings.default_tenant
|
|
95
|
+
register_listeners(db_state)
|
|
96
|
+
configure_process_services(settings.redis_url, engine=db_state.engine)
|
|
97
|
+
# The host's own readiness signal, and the only probe-safe check in a
|
|
98
|
+
# default install — module checks reach third parties and are on-demand.
|
|
99
|
+
register_database_check(health_registry, db_state)
|
|
100
|
+
return db_state
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mount_host_static(app: FastAPI, project_root: Path) -> None:
|
|
104
|
+
static_dir = project_root / "host" / _STATIC_DIR_NAME
|
|
105
|
+
if static_dir.is_dir():
|
|
106
|
+
static = PrecompressedStaticFiles(directory=static_dir)
|
|
107
|
+
app.mount(_STATIC_MOUNT_PATH, static, name=_STATIC_DIR_NAME)
|
{simple_module_hosting-0.0.37 → simple_module_hosting-0.0.38}/simple_module_hosting/_boot_checks.py
RENAMED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Diagnostics that need the built app, so cannot run from ``make doctor``.
|
|
2
2
|
|
|
3
|
+
SM025 runs in every environment; SM031 (an unguarded ``/api`` route) needs the
|
|
4
|
+
finished route table, so it runs at the end of a development boot only.
|
|
5
|
+
|
|
3
6
|
The module diagnostics in Phase 2 see only module classes and the source tree.
|
|
4
7
|
A check about what modules *registered* has to wait until after Phase 5, and
|
|
5
8
|
unlike the source-tree checks it is meaningful in production too.
|
|
@@ -12,6 +15,8 @@ from typing import TYPE_CHECKING, Any
|
|
|
12
15
|
|
|
13
16
|
from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
|
|
14
17
|
|
|
18
|
+
from simple_module_hosting._route_access import check_unguarded_api_routes
|
|
19
|
+
|
|
15
20
|
if TYPE_CHECKING:
|
|
16
21
|
from fastapi import FastAPI
|
|
17
22
|
from simple_module_core.services import DiagnosticsState
|
|
@@ -44,4 +49,16 @@ def report_tenant_resolution(
|
|
|
44
49
|
return found
|
|
45
50
|
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
def report_unguarded_routes(app: FastAPI, diagnostics_state: DiagnosticsState) -> list[Diagnostic]:
|
|
53
|
+
"""SM031 (dev boot only; needs the finished route table): print and fold into Doctor."""
|
|
54
|
+
found = check_unguarded_api_routes(app)
|
|
55
|
+
if found:
|
|
56
|
+
print_diagnostics(found)
|
|
57
|
+
runner = diagnostics_state.runner
|
|
58
|
+
if runner is not None:
|
|
59
|
+
diagnostics_state.runner = lambda: [*runner(), *check_unguarded_api_routes(app)]
|
|
60
|
+
diagnostics_state.results = [*diagnostics_state.results, *found]
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["report_tenant_resolution", "report_unguarded_routes"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CORS dispatch (#527).
|
|
2
|
+
|
|
3
|
+
One Starlette ``CORSMiddleware`` is built per distinct :class:`CorsPolicy`; each
|
|
4
|
+
request is routed to the one whose policy the registry matches for its method and
|
|
5
|
+
path (most specific wins). A request no policy covers passes through untouched,
|
|
6
|
+
so a path nobody opted in never gains CORS headers.
|
|
7
|
+
|
|
8
|
+
Installed between RequestLogging and BodyLimit, so a preflight is answered before
|
|
9
|
+
auth, rate limit, setup or maintenance can reject it, and a 413 from BodyLimit
|
|
10
|
+
still carries the CORS headers the browser needs to read it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from simple_module_core.cors import CorsPolicy, CorsPolicyRegistry
|
|
16
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
17
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
18
|
+
|
|
19
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def host_policy(settings: HostSettings) -> CorsPolicy | None:
|
|
23
|
+
"""The ``/api/`` policy from ``cors_*`` settings; ``None`` when no origin is set."""
|
|
24
|
+
# Every field is read even when unset: they are restart-required, and the
|
|
25
|
+
# build-time-settings guard learns that from the reads.
|
|
26
|
+
fields = (
|
|
27
|
+
settings.cors_allow_origins,
|
|
28
|
+
settings.cors_allow_credentials,
|
|
29
|
+
settings.cors_allow_methods,
|
|
30
|
+
settings.cors_allow_headers,
|
|
31
|
+
settings.cors_expose_headers,
|
|
32
|
+
settings.cors_max_age,
|
|
33
|
+
)
|
|
34
|
+
origins, credentials, methods, headers, expose, max_age = fields
|
|
35
|
+
if not origins:
|
|
36
|
+
return None
|
|
37
|
+
return CorsPolicy(
|
|
38
|
+
origins=tuple(origins),
|
|
39
|
+
allow_credentials=credentials,
|
|
40
|
+
methods=tuple(methods),
|
|
41
|
+
headers=tuple(headers),
|
|
42
|
+
expose_headers=tuple(expose),
|
|
43
|
+
max_age=max_age,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CorsMiddleware:
|
|
48
|
+
def __init__(self, app: ASGIApp, *, registry: CorsPolicyRegistry) -> None:
|
|
49
|
+
self.app = app
|
|
50
|
+
self.registry = registry
|
|
51
|
+
self._handlers: dict[CorsPolicy, ASGIApp] = {}
|
|
52
|
+
|
|
53
|
+
def _handler(self, policy: CorsPolicy) -> ASGIApp:
|
|
54
|
+
handler = self._handlers.get(policy)
|
|
55
|
+
if handler is None:
|
|
56
|
+
handler = CORSMiddleware(
|
|
57
|
+
self.app,
|
|
58
|
+
allow_origins=list(policy.origins),
|
|
59
|
+
allow_credentials=policy.allow_credentials,
|
|
60
|
+
allow_methods=list(policy.methods),
|
|
61
|
+
allow_headers=list(policy.headers),
|
|
62
|
+
expose_headers=list(policy.expose_headers),
|
|
63
|
+
max_age=policy.max_age,
|
|
64
|
+
)
|
|
65
|
+
self._handlers[policy] = handler
|
|
66
|
+
return handler
|
|
67
|
+
|
|
68
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
69
|
+
if scope["type"] != "http" or len(self.registry) == 0:
|
|
70
|
+
await self.app(scope, receive, send)
|
|
71
|
+
return
|
|
72
|
+
policy = self.registry.match(scope["method"], scope["path"])
|
|
73
|
+
if policy is None:
|
|
74
|
+
await self.app(scope, receive, send)
|
|
75
|
+
return
|
|
76
|
+
await self._handler(policy)(scope, receive, send)
|
|
@@ -10,6 +10,7 @@ from fastapi.encoders import jsonable_encoder
|
|
|
10
10
|
from fastapi.exceptions import RequestValidationError
|
|
11
11
|
from fastapi.responses import JSONResponse
|
|
12
12
|
from simple_module_core.exceptions import NotFoundError
|
|
13
|
+
from simple_module_core.permission_denied import PermissionDenied
|
|
13
14
|
from simple_module_inertia import (
|
|
14
15
|
Inertia,
|
|
15
16
|
InertiaConfig,
|
|
@@ -22,7 +23,6 @@ from starlette.responses import Response
|
|
|
22
23
|
|
|
23
24
|
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
24
25
|
from simple_module_hosting._observability import CorrelationIdMiddleware
|
|
25
|
-
from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
|
|
26
26
|
|
|
27
27
|
logger = logging.getLogger(__name__)
|
|
28
28
|
|
|
@@ -55,26 +55,6 @@ def _specific_detail(status_code: int, message: str) -> str:
|
|
|
55
55
|
return "" if message.strip().casefold() == phrase.casefold() else message
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
def _required_permission(message: str) -> str | None:
|
|
59
|
-
"""The permission named by a ``RequiresPermission`` denial, if this is one.
|
|
60
|
-
|
|
61
|
-
The 403 page says "Your role doesn't include ``<perm>``. Ask an admin to
|
|
62
|
-
grant it." — it needs the bare permission name, not the sentence the guard
|
|
63
|
-
wrote for logs and API clients. Reading it back off the detail (using the
|
|
64
|
-
guard's own exported prefix) keeps ``RequiresPermission`` as the single
|
|
65
|
-
place that knows the name; the alternative, stashing it on
|
|
66
|
-
``request.state``, only works when the exception travels the one code path
|
|
67
|
-
that set it, and 403s are also raised by hand.
|
|
68
|
-
|
|
69
|
-
Anything else — a role-gated ``/admin`` 403, a hand-raised
|
|
70
|
-
``HTTPException(403, "Administrator access required")`` — yields ``None``,
|
|
71
|
-
and the page falls back to copy that names no permission.
|
|
72
|
-
"""
|
|
73
|
-
if not message.startswith(PERMISSION_DENIED_PREFIX):
|
|
74
|
-
return None
|
|
75
|
-
return message[len(PERMISSION_DENIED_PREFIX) :].strip() or None
|
|
76
|
-
|
|
77
|
-
|
|
78
58
|
def _login_url(request: Request) -> str | None:
|
|
79
59
|
"""Best-effort login URL for the sign-in statuses.
|
|
80
60
|
|
|
@@ -159,9 +139,13 @@ async def render_error_page(
|
|
|
159
139
|
status_code: int,
|
|
160
140
|
message: str,
|
|
161
141
|
headers: Mapping[str, str] | None = None,
|
|
142
|
+
required_permission: str | None = None,
|
|
162
143
|
) -> Response:
|
|
163
144
|
"""Render the Inertia error page for *status_code*.
|
|
164
145
|
|
|
146
|
+
``required_permission`` names the permission a 403 lacked (from
|
|
147
|
+
:class:`PermissionDenied`); the page words its own sentence around it.
|
|
148
|
+
|
|
165
149
|
``headers`` carries an ``HTTPException``'s own headers through to the
|
|
166
150
|
response. A rendered page is still the same status as the JSON body it
|
|
167
151
|
replaces, so ``WWW-Authenticate`` on a 401 and ``Retry-After`` on a
|
|
@@ -208,7 +192,7 @@ async def render_error_page(
|
|
|
208
192
|
# Always present, null when no permission is involved: the page
|
|
209
193
|
# branches on it, and an absent prop is indistinguishable there
|
|
210
194
|
# from one that was never wired.
|
|
211
|
-
"required_permission":
|
|
195
|
+
"required_permission": required_permission,
|
|
212
196
|
"login_url": (_login_url(request) if status_code in _SIGN_IN_STATUSES else None),
|
|
213
197
|
# Set by MaintenanceMiddleware. A planned outage reads very
|
|
214
198
|
# differently from the same status code arriving unbidden.
|
|
@@ -249,7 +233,8 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> Respon
|
|
|
249
233
|
headers = getattr(exc, "headers", None)
|
|
250
234
|
if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
|
|
251
235
|
detail = str(exc.detail) if exc.detail else ""
|
|
252
|
-
|
|
236
|
+
required = exc.permission if isinstance(exc, PermissionDenied) else None
|
|
237
|
+
return await render_error_page(request, exc.status_code, detail, headers, required)
|
|
253
238
|
return JSONResponse(
|
|
254
239
|
status_code=exc.status_code,
|
|
255
240
|
content={"detail": exc.detail},
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Honour ``X-Forwarded-Host`` from a trusted proxy, for allowlisted hosts only (#528).
|
|
2
|
+
|
|
3
|
+
uvicorn's ``ProxyHeadersMiddleware`` corrects scheme and client address but not
|
|
4
|
+
the host, so behind a proxy that rewrites ``Host`` to the upstream name,
|
|
5
|
+
``request.url_for(...)`` and the tenants subdomain resolver see the internal
|
|
6
|
+
name. This middleware swaps the ``host`` header (and ``scope["server"]``) for the
|
|
7
|
+
forwarded one, but only when the peer is a trusted proxy *and* the value is on
|
|
8
|
+
the operator's allowlist: an unlisted host is ignored, so a forged header cannot
|
|
9
|
+
poison absolute URLs or pick a tenant.
|
|
10
|
+
|
|
11
|
+
It runs before ProxyHeaders (which replaces ``scope["client"]`` with the
|
|
12
|
+
forwarded client) so the peer it checks is still the proxy's TCP address.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ipaddress
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
|
|
22
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_WILDCARD = "*"
|
|
27
|
+
_SUBDOMAIN_PREFIX = "*."
|
|
28
|
+
_HOST_HEADER = b"host"
|
|
29
|
+
_FORWARDED_HOST_HEADER = b"x-forwarded-host"
|
|
30
|
+
_FORWARDED_PROTO_HEADER = b"x-forwarded-proto"
|
|
31
|
+
_DEFAULT_PORTS = {"https": 443, "wss": 443}
|
|
32
|
+
_FALLBACK_PORT = 80
|
|
33
|
+
_HOST_PATTERN = re.compile(r"^(?P<host>[a-z0-9]([a-z0-9._-]*[a-z0-9])?)(:(?P<port>\d{1,5}))?$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_host(value: str) -> tuple[str, str | None] | None:
|
|
37
|
+
match = _HOST_PATTERN.match(value.strip().lower())
|
|
38
|
+
return (match["host"], match["port"]) if match else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _forwarded_proto(scope: Scope) -> str | None:
|
|
42
|
+
"""Rightmost ``X-Forwarded-Proto``: ProxyHeaders has not rewritten the scheme yet."""
|
|
43
|
+
raw = ",".join(v.decode("latin-1") for k, v in scope["headers"] if k == _FORWARDED_PROTO_HEADER)
|
|
44
|
+
return raw.split(",")[-1].strip().lower() or None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TrustedPeers:
|
|
48
|
+
"""Mirror of uvicorn's trusted-hosts rule: ``*``, IPs and CIDRs, comma-separated."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, spec: str) -> None:
|
|
51
|
+
self._always = spec.strip() == _WILDCARD
|
|
52
|
+
self._literals: set[str] = set()
|
|
53
|
+
self._networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
|
54
|
+
for item in spec.split(","):
|
|
55
|
+
item = item.strip()
|
|
56
|
+
if not item or item == _WILDCARD:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
self._networks.append(ipaddress.ip_network(item, strict=False))
|
|
60
|
+
except ValueError:
|
|
61
|
+
self._literals.add(item) # e.g. a unix-socket peer name
|
|
62
|
+
|
|
63
|
+
def __contains__(self, peer: str | None) -> bool:
|
|
64
|
+
if self._always:
|
|
65
|
+
return True
|
|
66
|
+
if not peer:
|
|
67
|
+
return False
|
|
68
|
+
if peer in self._literals:
|
|
69
|
+
return True
|
|
70
|
+
try:
|
|
71
|
+
address = ipaddress.ip_address(peer)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return False
|
|
74
|
+
return any(address in network for network in self._networks)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HostAllowlist:
|
|
78
|
+
"""Exact hosts or ``*.example.com`` (any depth, never the apex), optional port."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, entries: Iterable[str]) -> None:
|
|
81
|
+
self._exact: list[tuple[str, str | None]] = []
|
|
82
|
+
self._suffixes: list[tuple[str, str | None]] = []
|
|
83
|
+
for entry in entries:
|
|
84
|
+
wildcard = entry.strip().lower().startswith(_SUBDOMAIN_PREFIX)
|
|
85
|
+
parsed = _split_host(entry.strip()[len(_SUBDOMAIN_PREFIX) :] if wildcard else entry)
|
|
86
|
+
if parsed is None:
|
|
87
|
+
logger.warning("Ignoring invalid forwarded_host_allowlist entry %r", entry)
|
|
88
|
+
continue
|
|
89
|
+
host, port = parsed
|
|
90
|
+
(self._suffixes if wildcard else self._exact).append((host, port))
|
|
91
|
+
|
|
92
|
+
def allows(self, host: str, port: str | None) -> bool:
|
|
93
|
+
def port_ok(allowed: str | None) -> bool:
|
|
94
|
+
return allowed is None or allowed == port
|
|
95
|
+
|
|
96
|
+
if any(host == name and port_ok(p) for name, p in self._exact):
|
|
97
|
+
return True
|
|
98
|
+
return any(host.endswith("." + name) and port_ok(p) for name, p in self._suffixes)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ForwardedHostMiddleware:
|
|
102
|
+
def __init__(self, app: ASGIApp, *, trusted_hosts: str, allowlist: Iterable[str]) -> None:
|
|
103
|
+
self.app = app
|
|
104
|
+
self._peers = TrustedPeers(trusted_hosts)
|
|
105
|
+
self._allowed = HostAllowlist(allowlist)
|
|
106
|
+
|
|
107
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
108
|
+
if scope["type"] in ("http", "websocket"):
|
|
109
|
+
client = scope.get("client")
|
|
110
|
+
if client and client[0] in self._peers:
|
|
111
|
+
self._rewrite(scope)
|
|
112
|
+
await self.app(scope, receive, send)
|
|
113
|
+
|
|
114
|
+
def _rewrite(self, scope: Scope) -> None:
|
|
115
|
+
raw = ",".join(
|
|
116
|
+
v.decode("latin-1") for k, v in scope["headers"] if k == _FORWARDED_HOST_HEADER
|
|
117
|
+
)
|
|
118
|
+
if not raw:
|
|
119
|
+
return
|
|
120
|
+
# Rightmost value: the one our trusted proxy added. Earlier values came
|
|
121
|
+
# from upstream hops or the client and cannot be trusted (an appending
|
|
122
|
+
# proxy keeps whatever the client sent at the front).
|
|
123
|
+
parsed = _split_host(raw.split(",")[-1])
|
|
124
|
+
if parsed is None or not self._allowed.allows(*parsed):
|
|
125
|
+
logger.debug("Ignoring X-Forwarded-Host %r: not on the allowlist", raw)
|
|
126
|
+
return
|
|
127
|
+
host, port = parsed
|
|
128
|
+
value = host if port is None else f"{host}:{port}"
|
|
129
|
+
scope["headers"] = [
|
|
130
|
+
*((k, v) for k, v in scope["headers"] if k != _HOST_HEADER),
|
|
131
|
+
(_HOST_HEADER, value.encode("latin-1")),
|
|
132
|
+
]
|
|
133
|
+
proto = _forwarded_proto(scope) or scope.get("scheme", "")
|
|
134
|
+
default = _DEFAULT_PORTS.get(proto, _FALLBACK_PORT)
|
|
135
|
+
scope["server"] = (host, int(port) if port else default)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Restart-required markers and validator bodies for :class:`HostSettings`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic_core import PydanticCustomError
|
|
8
|
+
from simple_module_core.cors import WILDCARD as CORS_WILDCARD
|
|
9
|
+
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
|
|
10
|
+
from simple_module_core.stored_settings import RELATED_FIELDS_CTX
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
14
|
+
|
|
15
|
+
# Marks a field the running process cannot pick up without a restart, so the
|
|
16
|
+
# admin UI can say so rather than letting the edit look like it took effect.
|
|
17
|
+
_RESTART_DB = {"requires_restart": True, "group": "Database"}
|
|
18
|
+
_RESTART_GUARD = {"requires_restart": True, "group": "Request guards"}
|
|
19
|
+
_RESTART_CORS = {"requires_restart": True, "group": "CORS"}
|
|
20
|
+
_RESTART_API = {"requires_restart": True, "group": "API"}
|
|
21
|
+
|
|
22
|
+
# Consumed while create_app builds the tenant middleware, the strict tenant
|
|
23
|
+
# filter, the i18n registry and LocaleMiddleware, the auth provider and the
|
|
24
|
+
# public-route registry, the proxy middleware and logging — none of which the
|
|
25
|
+
# lifespan hydration can rebuild (#499). test_build_time_settings_marked keeps
|
|
26
|
+
# this in step with what create_app actually reads.
|
|
27
|
+
_RESTART_TENANCY = {"requires_restart": True, "group": "Tenancy"}
|
|
28
|
+
_RESTART_I18N = {"requires_restart": True, "group": "Localization"}
|
|
29
|
+
_RESTART_AUTH = {"requires_restart": True, "group": "Authentication"}
|
|
30
|
+
_RESTART_LOGGING = {"requires_restart": True, "group": "Logging"}
|
|
31
|
+
_RESTART_SECURITY = {"requires_restart": True, "group": "Security"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_public_base_url(value: str) -> str:
|
|
35
|
+
from urllib.parse import urlsplit
|
|
36
|
+
|
|
37
|
+
value = value.strip().rstrip("/")
|
|
38
|
+
parts = urlsplit(value)
|
|
39
|
+
if value and not (parts.scheme in ("http", "https") and parts.netloc):
|
|
40
|
+
raise ValueError("public_base_url must be an http(s) origin such as https://example.org")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_rate(value: str) -> str:
|
|
45
|
+
from simple_module_core.rate_limit import parse_rate
|
|
46
|
+
|
|
47
|
+
parse_rate(value) # raises RateLimitError (a ValueError) on a typo
|
|
48
|
+
return value.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_native_prefixes(value: list[str]) -> list[str]:
|
|
52
|
+
cleaned = [prefix.strip() for prefix in value]
|
|
53
|
+
for prefix in cleaned:
|
|
54
|
+
if not prefix.startswith("/"):
|
|
55
|
+
raise ValueError(f"native_link_prefixes entry {prefix!r} must start with '/'")
|
|
56
|
+
return cleaned
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_body_bytes(value: int) -> int:
|
|
60
|
+
if value < 0:
|
|
61
|
+
raise ValueError("max_request_body_bytes must be >= 0 (0 disables the guard)")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_auth_provider(value: str) -> str:
|
|
66
|
+
return value.strip() or DEFAULT_AUTH_PROVIDER
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_trusted_proxy(value: str | None) -> str | None:
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
return value.strip() or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_default_tenant(value: str) -> str:
|
|
76
|
+
from simple_module_db import PLATFORM_TENANT_ID, is_valid_tenant_id
|
|
77
|
+
|
|
78
|
+
value = value.strip()
|
|
79
|
+
if value == PLATFORM_TENANT_ID:
|
|
80
|
+
raise ValueError(f"default_tenant {value!r} is reserved for platform-owned rows")
|
|
81
|
+
if value and not is_valid_tenant_id(value):
|
|
82
|
+
raise ValueError(f"default_tenant {value!r} is not a valid tenant id")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_cors_wildcard(settings: HostSettings) -> None:
|
|
87
|
+
if settings.cors_allow_credentials and CORS_WILDCARD in settings.cors_allow_origins:
|
|
88
|
+
raise PydanticCustomError(
|
|
89
|
+
"cors_wildcard_credentials",
|
|
90
|
+
"cors_allow_origins '*' cannot be combined with cors_allow_credentials",
|
|
91
|
+
{RELATED_FIELDS_CTX: ["cors_allow_origins", "cors_allow_credentials"]},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_default_locale_supported(settings: HostSettings) -> None:
|
|
96
|
+
if settings.i18n_default_locale not in settings.i18n_supported_locales:
|
|
97
|
+
# Model-level, so the error has no field location: the context
|
|
98
|
+
# names both halves, letting lenient boot hydration drop whichever
|
|
99
|
+
# one came from the DB instead of refusing to boot (#497).
|
|
100
|
+
raise PydanticCustomError(
|
|
101
|
+
"default_locale_unsupported",
|
|
102
|
+
"i18n_default_locale '{default}' is not in i18n_supported_locales {supported}",
|
|
103
|
+
{
|
|
104
|
+
"default": settings.i18n_default_locale,
|
|
105
|
+
"supported": settings.i18n_supported_locales,
|
|
106
|
+
RELATED_FIELDS_CTX: ["i18n_default_locale", "i18n_supported_locales"],
|
|
107
|
+
},
|
|
108
|
+
)
|