9b97eafa1c
- TenantHostMiddleware 增路径前缀分支:raw_path 直判 slug 段(未编码 [a-z0-9-],%61uto/大写/点号不匹配落 Host 解析);仅带尾斜杠形态剥离; path 定长切割(保留前缀尾斜杠为剩余路径领头斜杠),raw_path 按解码 path 重编码重算(非长度截断),orig_path 留痕;未知 404/ws 1008、 停用 503/ws 1013 与 Host 模式同语义 - _PREFIX_RE 字符类取自 registry.TENANT_SLUG_RE(掐 ^$ 锚)单一事实源; tenant_routes 的 SLUG_RE 换源同常量别名 import(删本地定义与 import re) - 评审折入:registry 空 sites 分支注释纠正为生产可达(DB 源全站点停用 合法状态),test_registry 用例 docstring 同步;conftest ADMIN_PASSWORD 注释补硬编码登录用例警示 - 新增 TestPathPrefixRouting 13 用例(全链/拒绝族/停用/site 覆盖/豁免 优先/direct-ASGI 改写钉位/WS 双例/路径优先) Co-Authored-By: Claude Code <noreply@anthropic.com>
204 lines
10 KiB
Python
204 lines
10 KiB
Python
"""TenantHostMiddleware——按 Host 头解析租户的纯 ASGI 中间件(Task 7)。
|
|
|
|
为什么必须是纯 ASGI 类而不是 ``@app.middleware("http")``(前序审查实证过
|
|
的坑): 那条路走 BaseHTTPMiddleware,只跑 http scope——WebSocket 请求会
|
|
整个绕过租户解析落进 routes/runtime.py 的兜底,租户 A 的 WS 流量静默写进
|
|
租户 B 的库(串库不崩溃,极难发现)。本类自己分发 http 与 websocket 两种
|
|
scope type,两条路都先过租户解析。
|
|
|
|
行为:
|
|
- scope 非 http/websocket(lifespan 等)原样放行;
|
|
- path 精确等于 ``/health`` 豁免(部署健康检查 ``curl 127.0.0.1:8018``
|
|
/health,Host 是 IP:端口必然未知;精确匹配,``/healthxxx`` 不豁免);
|
|
- path 前缀 ``/api/platform/`` 豁免(Phase 2 Task 3,平台后台不依赖租户
|
|
域名解析;安全模型详见 ``__call__`` 内注释——豁免≠放行);
|
|
- path 等于 ``/admin`` 或前缀 ``/admin/`` 豁免(Phase 4 Task 1,生产
|
|
admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免);
|
|
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
|
|
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013);
|
|
- 路径前缀模式(spec 2026-10-19 §3): ``/t/<slug>/...`` 前缀优先于 Host
|
|
解析(优先级:平台豁免 → 路径前缀 → Host)。slug 段在 raw_path 上
|
|
ASCII 直判(未编码 ``^[a-z0-9-]+$``——``%61uto``/大写/点号一律不匹配,
|
|
落 Host 解析);仅带尾斜杠形态剥离(``/t/x`` 无尾斜杠不剥);剥离后
|
|
path 去前缀、raw_path 按解码 path 重编码重算(非长度截断),原始路径
|
|
留痕 ``state["orig_path"]``;未知 slug → 404/ws 1008,停用 → 503/ws
|
|
1013(与 Host 模式同语义);站点默认取租户首站点(``?site=`` 覆盖链路
|
|
既有);``/t/x/api/platform/...`` 剥离后命中平台路由属 by design
|
|
(认证守护,spec §3.5);
|
|
- 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
|
|
与 scope["state"] 共享同一 dict(setdefault 保证),routes 的 get_runtime
|
|
即读到本中间件写入的 runtime。
|
|
|
|
Registry 延迟取: ``add_middleware`` 实例化时 lifespan 还没跑、
|
|
``app.state.registry`` 未挂,构造期拿不到——从 ``scope["app"].state``
|
|
逐请求取(Starlette 在中间件栈外层保证 scope 带 app)。
|
|
"""
|
|
import re
|
|
import urllib.parse
|
|
|
|
from starlette.responses import HTMLResponse, PlainTextResponse
|
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
# 归一复用 registry 的私有实现——同一包内单一事实源,中间件与域名索引
|
|
# 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害)。
|
|
# TENANT_SLUG_RE 同理:slug 形态与创建校验(tenant_routes)单一事实源。
|
|
from app.platform.registry import (
|
|
TENANT_SLUG_RE,
|
|
TenantDisabledError,
|
|
UnknownTenantError,
|
|
_normalize_host,
|
|
)
|
|
|
|
# 中性"服务暂停"页: 不暴露租户 slug/域名/停用原因(可能是欠费等敏感信息)
|
|
PAUSE_PAGE = (
|
|
"<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
|
|
"<title>服务暂停</title></head>"
|
|
"<body style=\"font-family:system-ui,sans-serif;text-align:center;"
|
|
"padding:4rem 1rem;color:#333;\">"
|
|
"<p style=\"font-size:1.1rem;\">服务暂停维护中,请稍后再试。</p>"
|
|
"</body></html>"
|
|
)
|
|
|
|
|
|
def _host_from_scope(scope: Scope) -> str:
|
|
"""ASGI headers(小写 bytes 键值对)里的 host → str 并归一。"""
|
|
for key, value in scope.get("headers") or []:
|
|
if key == b"host":
|
|
return _normalize_host(value.decode("latin-1"))
|
|
return ""
|
|
|
|
|
|
async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> None:
|
|
"""以 close 代 accept 拒绝 WS(ASGI 规范序列: 先收到 websocket.connect,
|
|
再回 websocket.close)。reason 中性,不带租户信息。
|
|
|
|
客户端在握手期间放弃时收到的首个事件是 websocket.disconnect 而非
|
|
connect——对端已撤,close 送达不了,直接 return。receive 本身抛异常
|
|
(传输层断)则向上传播,由外层 ServerErrorMiddleware 记日志:拒绝路径
|
|
无任何已建立状态可留,无需也不应再兜一层。"""
|
|
event = await receive()
|
|
if event["type"] == "websocket.disconnect":
|
|
return
|
|
await send({"type": "websocket.close", "code": code, "reason": reason})
|
|
|
|
|
|
class TenantHostMiddleware:
|
|
"""纯 ASGI 租户路由中间件(设计动机见模块 docstring)。"""
|
|
|
|
# 路径前缀形态(spec 2026-10-19 §3):raw_path 直判——slug 段必须
|
|
# 未编码(%61uto 解码后即 auto,解码形态判不出编码;raw bytes 上
|
|
# [a-z0-9-] 天然排除 % 与大写)。仅带尾斜杠形态剥离(/t/x 不剥)。
|
|
# slug 字符类取自 registry 的 TENANT_SLUG_RE(掐掉 ^$ 锚)——与创建
|
|
# 校验单一事实源,永不漂移。
|
|
# 安全注记:slug 只作 registry 的 dict lookup key,绝不插入任何
|
|
# 输出(404/503 均静态响应)——无注入面。
|
|
_PREFIX_RE = re.compile(
|
|
rb"^/t/(" + TENANT_SLUG_RE.pattern.strip("^$").encode("ascii")
|
|
+ rb")(/.*)?$", re.ASCII)
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
self.app = app
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] not in ("http", "websocket"):
|
|
return await self.app(scope, receive, send)
|
|
|
|
# 平台级豁免(先于租户解析;判断在 scope type 分发之后,http 与
|
|
# websocket 两类 scope 同构生效——当前 /api/platform 前缀没有 WS
|
|
# 端点,保持与 /health 同构以防将来有):
|
|
# - /health 精确匹配(见模块 docstring);
|
|
# - /api/platform/ 前缀(Phase 2 Task 3);
|
|
# - /admin 与 /admin/ 前缀(Phase 4 Task 1,生产 admin SPA 静态
|
|
# 面;hash 路由下直连的都是 /admin/ 一级,深链防御同构保留)。
|
|
#
|
|
# 安全模型——豁免≠放行:本豁免只跳过"租户解析"这一层,不打开
|
|
# 任何受保护端点:/api/platform/* 的认证由路由依赖守护
|
|
# (platform_user = get_current_user + enforce_password_changed,
|
|
# 未带有效 token 一律 401/409)。/admin 只是静态资源(公开的前端
|
|
# 产物),真正的数据面仍在 /api/platform/* 认证后。未知 Host 下
|
|
# admin 面可达是 **有意设计**:生产的 admin 域名与租户域名在反代
|
|
# 层区分(见 spec §4 边界规则与部署文档),应用层不按 Host 收紧
|
|
# admin 面。
|
|
# 前缀精确到斜杠:startswith("/api/platform/") 与 startswith(
|
|
# "/admin/"),/api/platform-evil、/admin-evil 不豁免(仍走租户
|
|
# 解析,测试 test_prefix_match_precise / test_admin_exempt_from_
|
|
# tenant_middleware 钉住)。
|
|
#
|
|
# 已知观察项(本任务仅注记不改实现):反代部署形态下登录限流键
|
|
# 取 request.client.host 不解析 XFF——所有请求同源(反代 IP),
|
|
# 键退化为按用户名(fail-safe:只更严不会更松,单账号爆破面
|
|
# 不变);可信代理头解析归部署批次统一处理。
|
|
path = scope.get("path") or ""
|
|
if (path == "/health"
|
|
or path.startswith("/api/platform/")
|
|
or path == "/admin"
|
|
or path.startswith("/admin/")):
|
|
return await self.app(scope, receive, send)
|
|
|
|
registry = getattr(scope["app"].state, "registry", None)
|
|
if registry is None:
|
|
raise RuntimeError(
|
|
"app.state.registry 未挂载——TenantHostMiddleware 需要 "
|
|
"lifespan(或测试注入缝)先加载 TenantRegistry")
|
|
|
|
# 路径前缀模式(spec 2026-10-19 §3):/t/<slug>/ 前缀优先于 Host
|
|
# 解析。不匹配(编码/大写/点号/无尾斜杠)一律不剥离,落 Host 解析
|
|
# (共享域名下即 404)——group(2) None = /t/x 无尾斜杠 → 不剥离。
|
|
raw = scope.get("raw_path") or b""
|
|
m = self._PREFIX_RE.match(raw)
|
|
if m and m.group(2) is not None:
|
|
slug = m.group(1).decode("ascii")
|
|
try:
|
|
runtime, site = await registry.resolve_by_slug(slug)
|
|
except UnknownTenantError:
|
|
if scope["type"] == "websocket":
|
|
await _close_ws(receive, send, 1008, "unknown tenant")
|
|
else:
|
|
await PlainTextResponse("Not Found", status_code=404)(
|
|
scope, receive, send)
|
|
return
|
|
except TenantDisabledError:
|
|
if scope["type"] == "websocket":
|
|
await _close_ws(receive, send, 1013, "service paused")
|
|
else:
|
|
await HTMLResponse(PAUSE_PAGE, status_code=503)(
|
|
scope, receive, send)
|
|
return
|
|
state = scope.setdefault("state", {})
|
|
state["orig_path"] = scope.get("path") or "" # 排障留痕(§3.3)
|
|
# 剥离量按解码 path 对应段计:slug 段在 raw 上未编码 → 解码/
|
|
# 原始同长,定长切割只切 "/t/"+slug 本身(len 3+slug),前缀的
|
|
# 尾斜杠保留为剩余路径的领头斜杠(/t/jiancai/ → 恰剩 "/")。
|
|
# 其后段的编码差异不影响这段定长切割
|
|
scope["path"] = scope["path"][len("/t/") + len(slug):] or "/"
|
|
# raw_path 不按长度截断,按剥离后的解码 path 重编码重算
|
|
# (spec §3.2——编码形态下解码/原始长度不一致,%2F 等会错位)
|
|
scope["raw_path"] = urllib.parse.quote(
|
|
scope["path"], safe="/:@!$&'()*+,;=-._~").encode("ascii")
|
|
state["tenant"] = runtime
|
|
state["site"] = site
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
try:
|
|
runtime, site = await registry.resolve_site(_host_from_scope(scope))
|
|
except UnknownTenantError:
|
|
if scope["type"] == "websocket":
|
|
await _close_ws(receive, send, 1008, "unknown host")
|
|
else:
|
|
await PlainTextResponse("Not Found", status_code=404)(
|
|
scope, receive, send)
|
|
return
|
|
except TenantDisabledError:
|
|
if scope["type"] == "websocket":
|
|
await _close_ws(receive, send, 1013, "service paused")
|
|
else:
|
|
await HTMLResponse(PAUSE_PAGE, status_code=503)(
|
|
scope, receive, send)
|
|
return
|
|
|
|
state = scope.setdefault("state", {})
|
|
state["tenant"] = runtime
|
|
state["site"] = site
|
|
await self.app(scope, receive, send)
|