feat(middleware): /t/<slug>/ 路径前缀解析(与 Host 模式共存)

- 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>
This commit is contained in:
浅念
2026-10-19 17:27:01 +08:00
parent 6fc43c1f1c
commit 9b97eafa1c
6 changed files with 248 additions and 10 deletions
+65 -1
View File
@@ -16,6 +16,15 @@ scope type,两条路都先过租户解析。
admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免); admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免);
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008); - Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013); 租户停用 → 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 - 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
与 scope["state"] 共享同一 dict(setdefault 保证),routes 的 get_runtime 与 scope["state"] 共享同一 dict(setdefault 保证),routes 的 get_runtime
即读到本中间件写入的 runtime。 即读到本中间件写入的 runtime。
@@ -24,12 +33,17 @@ Registry 延迟取: ``add_middleware`` 实例化时 lifespan 还没跑、
``app.state.registry`` 未挂,构造期拿不到——从 ``scope["app"].state`` ``app.state.registry`` 未挂,构造期拿不到——从 ``scope["app"].state``
逐请求取(Starlette 在中间件栈外层保证 scope 带 app)。 逐请求取(Starlette 在中间件栈外层保证 scope 带 app)。
""" """
import re
import urllib.parse
from starlette.responses import HTMLResponse, PlainTextResponse from starlette.responses import HTMLResponse, PlainTextResponse
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
# 归一复用 registry 的私有实现——同一包内单一事实源,中间件与域名索引 # 归一复用 registry 的私有实现——同一包内单一事实源,中间件与域名索引
# 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害) # 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害)。
# TENANT_SLUG_RE 同理:slug 形态与创建校验(tenant_routes)单一事实源。
from app.platform.registry import ( from app.platform.registry import (
TENANT_SLUG_RE,
TenantDisabledError, TenantDisabledError,
UnknownTenantError, UnknownTenantError,
_normalize_host, _normalize_host,
@@ -71,6 +85,17 @@ async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> Non
class TenantHostMiddleware: class TenantHostMiddleware:
"""纯 ASGI 租户路由中间件(设计动机见模块 docstring)。""" """纯 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: def __init__(self, app: ASGIApp) -> None:
self.app = app self.app = app
@@ -116,6 +141,45 @@ class TenantHostMiddleware:
"app.state.registry 未挂载——TenantHostMiddleware 需要 " "app.state.registry 未挂载——TenantHostMiddleware 需要 "
"lifespan(或测试注入缝)先加载 TenantRegistry") "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: try:
runtime, site = await registry.resolve_site(_host_from_scope(scope)) runtime, site = await registry.resolve_site(_host_from_scope(scope))
except UnknownTenantError: except UnknownTenantError:
+3 -1
View File
@@ -242,7 +242,9 @@ class TenantRegistry:
raise TenantDisabledError(f"租户已停用: {slug!r}") raise TenantDisabledError(f"租户已停用: {slug!r}")
rt = await self._ensure_ready(rt) rt = await self._ensure_ready(rt)
if not rt.config.sites: if not rt.config.sites:
# 创建向导恒建 main 站点,此分支为防御(spec §5) # 全站点停用的租户(DB 源合法状态:tenant_source 过滤 active
# 站点后可发出 sites=[],见其 226-235 行注记)——路径模式无从
# 选站点 → 按未知处理(404)。非防御分支,生产可达
raise UnknownTenantError( raise UnknownTenantError(
f"租户 {slug!r} 无站点——路径模式无从选站点") f"租户 {slug!r} 无站点——路径模式无从选站点")
return rt, rt.config.sites[0] return rt, rt.config.sites[0]
+4 -4
View File
@@ -31,8 +31,6 @@ tenant_source.py(配置 API 的重置出厂同样消费);版本 1 写入改走
db.write_config_version(与配置 API 的 PUT/rollback/reset 同一版本写入 db.write_config_version(与配置 API 的 PUT/rollback/reset 同一版本写入
路径,单一事实源);Tenant 落 profile 列(重置出厂按它取对应档案组)。 路径,单一事实源);Tenant 落 profile 列(重置出厂按它取对应档案组)。
""" """
import re
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -55,14 +53,16 @@ from app.platform.db import (
other_push_prefixes, other_push_prefixes,
write_config_version, write_config_version,
) )
from app.platform.registry import _normalize_host # slug 形态校验的单一事实源在 registry(TENANT_SLUG_RE——创建校验与
# 路径前缀解析 /t/<slug>/ 共用,永不漂移,spec 2026-10-19 §3.1);别名
# import 让本文件既有 SLUG_RE 引用零改动
from app.platform.registry import TENANT_SLUG_RE as SLUG_RE, _normalize_host
from app.platform.tenant_source import _profile_snapshot from app.platform.tenant_source import _profile_snapshot
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"]) router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"])
PROFILES = ("auto", "jiancai", "blank") PROFILES = ("auto", "jiancai", "blank")
STATUSES = ("active", "disabled") STATUSES = ("active", "disabled")
SLUG_RE = re.compile(r"^[a-z0-9-]+$")
# ---- 校验助手 ------------------------------------------------------------------- # ---- 校验助手 -------------------------------------------------------------------
+2 -1
View File
@@ -17,7 +17,8 @@ from app.engine.models import Base
# 测试环境封闭(2026-10-19 修复):Settings(BaseSettings) 会读仓库 .env—— # 测试环境封闭(2026-10-19 修复):Settings(BaseSettings) 会读仓库 .env——
# 开发机 .env 的 ADMIN_PASSWORD(如 Docker 测试环境的强密码)会盖过 lifespan # 开发机 .env 的 ADMIN_PASSWORD(如 Docker 测试环境的强密码)会盖过 lifespan
# 引导的出厂值 admin123,测试登录全员 401。真实环境变量优先级本就高于 # 引导的出厂值 admin123,测试登录全员 401。真实环境变量优先级本就高于
# .env,这里把测试期望值钉进 os.environ(已显式设置则不覆盖,CI 可另行注入)。 # .env,这里把测试期望值钉进 os.environ(已显式设置则不覆盖,CI 可另行
# 注入;注入非 admin123 值会破坏 ~20 个硬编码登录用例)。
os.environ.setdefault("ADMIN_PASSWORD", "admin123") os.environ.setdefault("ADMIN_PASSWORD", "admin123")
# Task 5b 装配切换: app 不再有全局 engine/async_session——旧 conftest 里 # Task 5b 装配切换: app 不再有全局 engine/async_session——旧 conftest 里
+170
View File
@@ -7,12 +7,14 @@
import json import json
import shutil import shutil
from types import SimpleNamespace
import pytest import pytest
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.main import app from app.main import app
from app.platform.host_middleware import TenantHostMiddleware from app.platform.host_middleware import TenantHostMiddleware
from app.platform.registry import TenantRegistry
from tests.conftest import ( from tests.conftest import (
AUTO_HOST, AUTO_HOST,
JIANCAI_HOST, JIANCAI_HOST,
@@ -150,3 +152,171 @@ def test_ws_routes_to_correct_tenant_db(app_client, tmp_path):
assert r_jc.status_code == 200 assert r_jc.status_code == 200
assert r_jc.json()["is_new"] is True assert r_jc.json()["is_new"] is True
assert r_jc.json()["session_id"] != sid assert r_jc.json()["session_id"] != sid
# ---- 路径前缀模式(/t/<slug>/,spec 2026-10-19 §3) ----------------------------
class TestPathPrefixRouting:
"""路径前缀与 Host 模式共存:优先级 平台豁免 → 路径前缀 → Host 解析。
Host 用未知值(NOPE_HOST)——路径命中即证明解析先于 Host;反向用已知
AUTO_HOST(Host 说是 auto 租户)+ /t/jiancai/ 断言路径优先(spec §2)。"""
def test_聊天页_t_slug_根路径(self, app_client):
r = app_client.get("/t/jiancai/", headers={"host": NOPE_HOST})
assert r.status_code == 200
assert "chat" in r.text.lower()
def test_静态与公开API全链(self, app_client):
r = app_client.get("/t/jiancai/static/widget.js",
headers={"host": NOPE_HOST})
assert r.status_code == 200
r = app_client.get("/t/jiancai/api/public/brand",
headers={"host": NOPE_HOST})
assert r.status_code == 200
assert r.json()["slug"] == "jiancai"
def test_无尾斜杠不剥离_走Host解析404(self, app_client):
# 只断言未知 Host 形态:/t/x 在 Host 模式下本就无路由(/ 才是聊天页),
# 若带已知 Host 断言 200 会诱导实现去剥离无斜杠形态——违反 spec §3.2
r = app_client.get("/t/jiancai", headers={"host": NOPE_HOST})
assert r.status_code == 404
# 已知 Host 的正常根路径(Host 模式对照,不受路径模式影响)
assert app_client.get("/", headers={"host": JIANCAI_HOST}).status_code == 200
def test_编码slug拒绝(self, app_client):
# %61uto 解码后即合法 slug auto——解码形态判不出编码,raw_path 直判
# 为准(spec §3.1);不匹配 → 落 Host 解析(未知 Host)→ 404
r = app_client.get("/t/%61uto/", headers={"host": NOPE_HOST})
assert r.status_code == 404
def test_大写与点号拒绝(self, app_client):
# 不能用 /t/../:httpx 客户端侧归一点段到不了中间件——用字面点号
assert app_client.get(
"/t/Jiancai/", headers={"host": NOPE_HOST}).status_code == 404
assert app_client.get(
"/t/jian.cai/", headers={"host": NOPE_HOST}).status_code == 404
def test_未知slug_404(self, app_client):
r = app_client.get("/t/no-such/x", headers={"host": NOPE_HOST})
assert r.status_code == 404
def test_停用租户_503(self, disabled_client):
# 路径模式同样吃 TenantDisabledError → 503 中性暂停页(与 Host 模式
# 同语义;此处 Host 头无关紧要,解析根本不走到 Host)
r = disabled_client.get("/t/jiancai/api/chat/session",
headers={"host": NOPE_HOST})
assert r.status_code == 503
assert "暂停" in r.text
def test_site参数覆盖在路径模式生效(self, app_client):
# jiancai 的 es 站点(空域名站点):默认取首站点 main,?site= 覆盖
# 链路既有(spec §3.4)——brand title 取站点覆盖值即证覆盖真生效
r = app_client.get("/t/jiancai/api/public/brand?site=es",
headers={"host": NOPE_HOST})
assert r.status_code == 200
assert r.json()["title"] == "HESUPIPE Soporte"
def test_平台豁免优先级不受影响(self, app_client):
# /health 豁免不受路径模式影响;/t/x/api/platform/... 剥离后命中
# 平台路由属 by design(spec §3.5)——认证守护兜底,未带凭据
# 401/422/429(POST 空 body → 422)
assert app_client.get("/health", headers={"host": NOPE_HOST}).status_code == 200
r = app_client.post("/t/jiancai/api/platform/auth/login",
headers={"host": NOPE_HOST})
assert r.status_code in (401, 422, 429)
async def test_orig_path与路径改写直接钉位(self, tmp_path):
"""直接构造 ASGI scope 断言改写(不经 httpx 归一化):capture app
记录中间件改写后的 path/raw_path/state["orig_path"]——spec §3.2/
§3.3 的直证。registry 手工装配,用完 dispose_all(Windows 文件锁)。"""
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
captured = {}
async def capture_app(scope, receive, send):
state = scope.get("state") or {}
captured.update(
path=scope.get("path"),
raw_path=scope.get("raw_path"),
orig_path=state.get("orig_path"),
tenant=(state.get("tenant").config.slug
if state.get("tenant") else None),
site=state.get("site").slug if state.get("site") else None,
)
async def noop_receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def noop_send(message):
pass
def make_scope(rtype, path, raw_path):
return {
"type": rtype, "asgi": {"version": "3.0"},
"http_version": "1.1", "method": "GET", "scheme": "http",
"path": path, "raw_path": raw_path, "query_string": b"",
"headers": [(b"host", b"unknown.example")],
"client": ("127.0.0.1", 12345),
"app": SimpleNamespace(state=SimpleNamespace(registry=reg)),
}
mw = TenantHostMiddleware(capture_app)
try:
# http:基本剥离 + orig_path 留痕 + state 写入
await mw(make_scope("http", "/t/jiancai/static/widget.js",
b"/t/jiancai/static/widget.js"),
noop_receive, noop_send)
assert captured["path"] == "/static/widget.js"
assert captured["raw_path"] == b"/static/widget.js"
assert captured["orig_path"] == "/t/jiancai/static/widget.js"
assert captured["tenant"] == "jiancai"
assert captured["site"] == "main"
# raw_path 重编码而非长度截断(spec §3.2):%2F 解码成 "/" 后,
# 截断会得 b"/a%2Fb",重算得 b"/a/b"
await mw(make_scope("http", "/t/jiancai/a/b",
b"/t/jiancai/a%2Fb"),
noop_receive, noop_send)
assert captured["path"] == "/a/b"
assert captured["raw_path"] == b"/a/b"
# 根路径形态:/t/jiancai/ → "/"(剥空兜底)
await mw(make_scope("http", "/t/jiancai/", b"/t/jiancai/"),
noop_receive, noop_send)
assert captured["path"] == "/"
assert captured["raw_path"] == b"/"
# websocket scope 同构改写(不必真握手,capture 收到即证)
await mw(make_scope("websocket", "/t/jiancai/ws/chat",
b"/t/jiancai/ws/chat"),
noop_receive, noop_send)
assert captured["path"] == "/ws/chat"
assert captured["orig_path"] == "/t/jiancai/ws/chat"
finally:
await reg.dispose_all()
def test_WS路径模式握手(self, app_client):
# WS 握手后服务端先等客户端 init 消息(chat.py:48)——先发
# standalone 再收 init;session_id 即证路径模式 WS 全链打通
with app_client.websocket_connect(
"/t/jiancai/ws/chat", headers={"host": NOPE_HOST}) as ws:
ws.send_text(json.dumps({"source": "standalone"}))
data = ws.receive_json()
assert data["type"] == "init"
assert "session_id" in data
def test_WS未知slug拒绝(self, app_client):
with pytest.raises(WebSocketDisconnect) as ei:
with app_client.websocket_connect(
"/t/no-such/ws/chat", headers={"host": NOPE_HOST}):
pass # pragma: no cover — 未 accept 即 close,进不来
assert ei.value.code == 1008
def test_Host模式回归_路径优先(self, app_client):
# 已知 Host(AUTO_HOST 属 auto 租户)+ /t/jiancai/ → 出 jiancai
# 而非 auto,证明路径前缀解析赢过 Host 解析(spec §2 优先级)
r = app_client.get("/t/jiancai/api/public/brand",
headers={"host": AUTO_HOST})
assert r.status_code == 200
assert r.json()["slug"] == "jiancai"
+4 -3
View File
@@ -329,9 +329,10 @@ class TestResolveBySlug:
await reg.dispose_all() await reg.dispose_all()
async def test_空站点表按未知处理(self, tmp_path): async def test_空站点表按未知处理(self, tmp_path):
"""防御分支(spec §5):两源加载期均拒零站点,直装 sites=[] 的 """空站点表 → 按未知处理(404)。该分支生产可达而非防御:DB 源
LoadedTenant 绕过源校验覆盖该分支——正常运行不可达,防将来源 全站点停用是合法运营状态(tenant_source 过滤 active 站点后可发出
语义漂移。""" sites=[])。文件源加载期拒零站点——直装 sites=[] 的 LoadedTenant
绕过源校验直接覆盖此分支。"""
seed = _write_seed(tmp_path, {"jiancai": _tenant("jiancai")}) seed = _write_seed(tmp_path, {"jiancai": _tenant("jiancai")})
source = FileTenantSource(seed) source = FileTenantSource(seed)
lt = dataclasses.replace(source.read_tenant("jiancai"), sites=[]) lt = dataclasses.replace(source.read_tenant("jiancai"), sites=[])