feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
Some checks failed
CI / pytest (push) Waiting to run
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Fix IPv6 host parsing with _strip_port() utility
- Remove dangerous StorePlatform→Store.subdomain silent fallback
- Close storefront gate bypass when frontend_type is None
- Add custom subdomain management UI and API for stores
- Add domain health diagnostic tool
- Convert db.add() in loops to db.add_all() (24 PERF-006 fixes)
- Add tests for all new functionality (18 subdomain service tests)
- Add .github templates for validator compliance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:13:01 +01:00
parent 07fab01f6a
commit 540205402f
38 changed files with 1827 additions and 134 deletions

View File

@@ -82,7 +82,7 @@ class FrontendTypeMiddleware(BaseHTTPMiddleware):
return response
def get_frontend_type(request: Request) -> FrontendType:
def get_frontend_type(request: Request) -> FrontendType | None:
"""
Helper function to get current frontend type from request.
@@ -90,6 +90,7 @@ def get_frontend_type(request: Request) -> FrontendType:
request: FastAPI request object
Returns:
FrontendType enum value (defaults to PLATFORM if not set)
FrontendType enum value, or None if the middleware hasn't run yet.
Callers should handle None explicitly where context is clear.
"""
return getattr(request.state, "frontend_type", FrontendType.PLATFORM)
return getattr(request.state, "frontend_type", None)

View File

@@ -36,6 +36,31 @@ MAIN_PLATFORM_CODE = "main"
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "testserver"}
def _strip_port(host: str) -> str:
"""
Remove port from host, handling IPv6 bracket notation.
Examples:
"localhost:8000""localhost"
"[::1]:8000""::1"
"[::1]""::1"
"example.com""example.com"
"192.168.1.1:80""192.168.1.1"
"""
if not host:
return host
# IPv6 with brackets: [::1]:8000 or [::1]
if host.startswith("["):
bracket_end = host.find("]")
if bracket_end != -1:
return host[1:bracket_end]
return host # malformed, return as-is
# Regular host:port
if ":" in host:
return host.split(":")[0]
return host
class PlatformContextManager:
"""Manages platform context detection for multi-platform routing."""
@@ -62,7 +87,7 @@ class PlatformContextManager:
path = request.url.path
# Remove port from host if present (e.g., localhost:9999 -> localhost)
host_without_port = host.split(":")[0] if ":" in host else host
host_without_port = _strip_port(host)
# Skip platform detection for admin routes - admin is global
if FrontendDetector.is_admin(host, path):
@@ -359,7 +384,7 @@ class PlatformContextMiddleware:
# For storefront API requests on localhost, the path doesn't contain
# /platforms/{code}/, so extract platform from the Referer header instead.
# e.g., Referer: http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/...
host_without_port = host.split(":")[0] if ":" in host else host
host_without_port = _strip_port(host)
if (
host_without_port in _LOCAL_HOSTS
and path.startswith("/api/v1/storefront/")
@@ -450,7 +475,7 @@ class PlatformContextMiddleware:
- /platforms/oms/pricing → OMS platform pricing
- /platforms/loyalty/ → Loyalty platform homepage
"""
host_without_port = host.split(":")[0] if ":" in host else host
host_without_port = _strip_port(host)
# Method 1: Domain-based (production)
if host_without_port and host_without_port not in _LOCAL_HOSTS:

View File

@@ -245,7 +245,7 @@ class StoreContextManager:
)
return store
# 2b. Fallback to Store.subdomain (global default)
# 2b. Fallback to Store.subdomain with platform membership check
store = (
db.query(Store)
.filter(func.lower(Store.subdomain) == subdomain.lower())
@@ -254,6 +254,33 @@ class StoreContextManager:
)
if store:
# When a platform context exists and detection is "subdomain",
# verify the store actually has an active membership on this
# platform. Without this check, a subdomain like
# "other-tenant.omsflow.lu" could resolve a store that only
# belongs to the loyalty platform — a cross-tenant leak.
if platform and context.get("detection_method") == "subdomain":
from app.modules.tenancy.models.store_platform import (
StorePlatform as SP,
)
has_membership = (
db.query(SP)
.filter(
SP.store_id == store.id,
SP.platform_id == platform.id,
SP.is_active.is_(True),
)
.first()
)
if not has_membership:
logger.warning(
f"[FAIL-CLOSED] Store '{subdomain}' exists but has no "
f"active membership on platform {platform.code}"
f"blocking cross-tenant resolution"
)
return None
method = context.get("detection_method", "unknown")
logger.info(
f"[OK] Store found via {method}: {subdomain}{store.name}"

View File

@@ -83,6 +83,11 @@ def _is_static_request(path: str) -> bool:
return "favicon.ico" in lower
def _looks_like_storefront(path: str) -> bool:
"""Return True if path belongs to the storefront surface."""
return path.startswith(("/storefront/", "/api/v1/storefront/"))
class StorefrontAccessMiddleware(BaseHTTPMiddleware):
"""
Gate storefront requests behind an active subscription.
@@ -94,6 +99,25 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
frontend_type = getattr(request.state, "frontend_type", None)
# Safety net: if frontend_type is None (upstream middleware failed) but
# the path looks like a storefront path, block instead of letting it
# through — a None frontend_type must never bypass the gate.
if frontend_type is None and _looks_like_storefront(request.url.path):
logger.error(
"[STOREFRONT_ACCESS] frontend_type is None on storefront path "
f"'{request.url.path}' — blocking (fail-closed). "
"Check middleware chain ordering."
)
if request.url.path.startswith("/api/"):
return JSONResponse(
status_code=403,
content={
"error": "storefront_not_available",
"reason": "middleware_misconfigured",
},
)
return self._render_unavailable(request, "not_found")
# Only gate storefront requests
if frontend_type != FrontendType.STOREFRONT:
return await call_next(request)