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`` 不豁免);
- 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。
@@ -24,12 +33,17 @@ 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 内部也会再归一次,双保险无害)
# 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害)。
# TENANT_SLUG_RE 同理:slug 形态与创建校验(tenant_routes)单一事实源。
from app.platform.registry import (
TENANT_SLUG_RE,
TenantDisabledError,
UnknownTenantError,
_normalize_host,
@@ -71,6 +85,17 @@ async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> Non
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
@@ -116,6 +141,45 @@ class TenantHostMiddleware:
"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:
+3 -1
View File
@@ -242,7 +242,9 @@ class TenantRegistry:
raise TenantDisabledError(f"租户已停用: {slug!r}")
rt = await self._ensure_ready(rt)
if not rt.config.sites:
# 创建向导恒建 main 站点,此分支为防御(spec §5)
# 全站点停用的租户(DB 源合法状态:tenant_source 过滤 active
# 站点后可发出 sites=[],见其 226-235 行注记)——路径模式无从
# 选站点 → 按未知处理(404)。非防御分支,生产可达
raise UnknownTenantError(
f"租户 {slug!r} 无站点——路径模式无从选站点")
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 同一版本写入
路径,单一事实源);Tenant 落 profile 列(重置出厂按它取对应档案组)。
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import func, select
@@ -55,14 +53,16 @@ from app.platform.db import (
other_push_prefixes,
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
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"])
PROFILES = ("auto", "jiancai", "blank")
STATUSES = ("active", "disabled")
SLUG_RE = re.compile(r"^[a-z0-9-]+$")
# ---- 校验助手 -------------------------------------------------------------------