feat: 密钥掩码哨兵(__SET__)四处响应 + 站点管理权限放开租户管理员

- config_routes: push.api_key/ai.api_key/email.smtp.password 掩码助手
  (MASKED="__SET__";非空才掩,空串/None 原样)。作用于 GET config、
  GET versions/{n}、POST rollback 响应、GET settings(计划四处),并盖
  PUT/reset/PUT settings 的写回显——哨兵还原后的库值不经任何响应外泄。
- PUT 哨兵还原: 密钥字段收到 __SET__ → 当前快照原值替换后再写版本
  (前端"不改就原样传回");传新值照常覆盖;空串传回仍=清空(既有语义
  不变)。settings 同款(密钥名含 key/password/secret,递归嵌套 dict;
  设置 key 本身命中也算)。库行/版本历史永远存原值。
- tenant_routes: 站点/域名四端点(POST sites/PATCH sites/POST domains/
  DELETE domains)require_super → _tenant_guard;租户生命周期三端点
  (POST /tenants/GET 列表/PATCH /tenants/{slug})保持超管专属。
- 测试: 掩码四处响应+落库分离断言(_current_snapshot 直查库)、哨兵
  往返/新值/清空/嵌套路径/未设还原 None、settings 标量+嵌套+非密钥
  字面量;租户管理员四端点自助(201/200)+他租户 403+生命周期仍 403。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 13:39:23 +08:00
parent 0741069f49
commit 0c25287c19
4 changed files with 392 additions and 25 deletions
+14 -10
View File
@@ -13,8 +13,10 @@ DEFAULT_CONFIG 满足不了。
也直接插入,创建即可用);停用走 reload 路径顺带 dispose 旧 engine
(引擎释放由 registry 负责,本层不碰)。
鉴权:写操作与列表 require_super;GET /{slug} 详情放宽 require_tenant
(slug)(租户管理员看自己概要,403 边界有测试钉住)。
鉴权:租户生命周期(POST 创建 / GET 列表 / PATCH 停用改名)require_super;
GET /{slug} 详情与站点·域名四端点(POST sites / PATCH sites /
POST domains / DELETE domains)放宽 require_tenant(slug)(Phase 3 Task 1:
本租户管理员自助管理站点与域名,403 边界有测试钉住)。
安全纪律:
- 密码哈希/salt 绝不进响应——管理员序列化显式挑 {username, status}
@@ -386,9 +388,10 @@ async def patch_tenant(
@router.post("/{slug}/sites", status_code=201)
async def create_site(
slug: str, body: CreateSiteRequest, request: Request,
user=Depends(require_super)):
user=Depends(_tenant_guard)):
"""建站:slug/name/brand_overlay/domains 可选。brand_overlay 是 ORM
JSON 列——整字典赋值(M-2a)。"""
JSON 列——整字典赋值(M-2a)。鉴权 _tenant_guard(Phase 3 Task 1
放宽):本租户管理员可自助建站。"""
_validate_slug(body.slug)
domains = list(dict.fromkeys(
_normalize_domain(d) for d in body.domains))
@@ -427,9 +430,9 @@ async def create_site(
@router.patch("/{slug}/sites/{site_slug}")
async def patch_site(
slug: str, site_slug: str, body: dict, request: Request,
user=Depends(require_super)):
user=Depends(_tenant_guard)):
"""改站点:停用/改名/改 overlay(整字典赋值,旧键不残留)。站点 slug
同样不可变(400)。"""
同样不可变(400)。鉴权 _tenant_guard(Phase 3 Task 1 放宽)。"""
fields = _patch_fields(body, allow=("name", "status", "brand_overlay"))
if "name" in fields:
_check_name(fields["name"])
@@ -462,9 +465,9 @@ async def patch_site(
@router.post("/{slug}/sites/{site_slug}/domains", status_code=201)
async def add_domain(
slug: str, site_slug: str, body: AddDomainRequest, request: Request,
user=Depends(require_super)):
user=Depends(_tenant_guard)):
"""域名绑定:全局唯一(撞车 400 带现有归属);绑定后 registry reload
生效。"""
生效。鉴权 _tenant_guard(Phase 3 Task 1 放宽)。"""
domain = _normalize_domain(body.domain)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
@@ -486,8 +489,9 @@ async def add_domain(
@router.delete("/{slug}/sites/{site_slug}/domains/{domain}")
async def delete_domain(
slug: str, site_slug: str, domain: str, request: Request,
user=Depends(require_super)):
"""域名解绑:只解本站点的该域名(不存在 404);解绑后 reload 生效。"""
user=Depends(_tenant_guard)):
"""域名解绑:只解本站点的该域名(不存在 404);解绑后 reload 生效。
鉴权 _tenant_guard(Phase 3 Task 1 放宽)。"""
domain = _normalize_domain(domain)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session: