✨ feat: 阶段 0 收尾——认证边界、请求上下文与垂直切片(WP-01 第 5-7 步)

- 认证:Web Session(__Host- Cookie、HttpOnly/Secure/SameSite=Lax、CSPRNG opaque token 数据库只存哈希)、CSRF 双提交、Origin 校验、dev 登录/登出端点;未认证 401、缺 CSRF 403
- 中间件:Hunjing-Request-ID 回显/生成(request_ UUIDv4)+ W3C traceparent 校验透传;JSON 结构化日志与敏感头脱敏
- 迁移 2:conversation/branch/message/run/idempotency_record 五表(含检查约束与复合索引);Session 补 token/csrf 哈希列
- 垂直切片:POST /v1/conversations/{id}/messages 按 Idempotency-Key 幂等(同键同体 200 重放、异体 409),同事务原子写 Message+Run+Audit+Outbox+幂等记录;ContentBlock text/markdown 判别联合(未知类型 422)
- SSE:GET /v1/runs/{id}/events 返回快照与终止状态事件 envelope
- 测试 26 项全绿:IDOR 跨域 404 fail-closed、失败注入回滚无孤儿(Run 崩溃后 message/run/audit/outbox/幂等全零)、SSE 事件序、auth 边界;ruff/mypy strict 干净
- 修复真实问题:asyncpg 跨事件循环(anyio/pytest-asyncio 双 loop、模块级 engine 改为 app 实例持有)、httpx 拒存 http 下 Secure Cookie
This commit is contained in:
chenxin committed 2026-09-10 10:13:45 +08:00
1 parent 28127949db
commit ba502297ac
16 files changed
+1165

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
"""请求上下文中间件与结构化日志。
- `Hunjing-Request-ID`:合法则回显,缺失由服务端生成(UUIDv4,request_ 前缀)。
- `traceparent`:透传 W3C Trace Context;缺失生成新 trace-id(不与业务请求 ID 混用)。
- JSON 结构化日志:Authorization/Cookie/Set-Cookie 等敏感头脱敏。
"""
import json
import logging
import re
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from app.core.ids import new_id
REDACTED_HEADERS = {"authorization", "cookie", "set-cookie", "hunjing-csrf-token", "x-api-key"}
_TRACEPARENT_RE = re.compile(r"^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$")
def _sanitize_headers(headers: Any) -> dict[str, str]:
return {
k: ("[REDACTED]" if k.lower() in REDACTED_HEADERS else v) for k, v in headers.items()
}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key in ("request_id", "trace_id", "method", "path", "status"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def configure_logging(level: str = "info") -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level.upper())
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Any:
request_id = request.headers.get("Hunjing-Request-ID") or new_id("request_", version=4)
traceparent = request.headers.get("traceparent")
if traceparent is None or not _TRACEPARENT_RE.match(traceparent):
traceparent = f"00-{uuid.uuid4().hex}-{uuid.uuid4().hex[:16]}-01"
trace_id = traceparent.split("-")[1]
request.state.request_id = request_id
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["Hunjing-Request-ID"] = request_id
response.headers["traceparent"] = traceparent
logging.getLogger("hunjing.access").info(
"request",
extra={
"request_id": request_id,
"trace_id": trace_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code,
},
)
return response
__all__ = ["RequestContextMiddleware", "configure_logging", "_sanitize_headers"]