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:
@@ -508,3 +508,66 @@ def test_no_credentials_leak_in_responses(super_client):
|
||||
assert r.status_code == 200
|
||||
for needle in ("password_hash", "salt", "scrypt", "demo-pass-1"):
|
||||
assert needle not in r.text, f"响应泄露凭据字段: {needle}"
|
||||
|
||||
|
||||
# ---- 站点/域名管理放开本租户管理员(Phase 3 Task 1) --------------------------------
|
||||
|
||||
|
||||
def test_tenant_admin_can_manage_own_sites(super_client):
|
||||
"""四端点(POST sites / PATCH sites / POST domains / DELETE domains)
|
||||
放宽到 _tenant_guard:本租户管理员可自助管理站点与域名;打别的租户
|
||||
仍 403(slug 收口)。"""
|
||||
_create(super_client, slug="demo", admin="demo-admin",
|
||||
password="demo-pass-1", domains=["demo.example.com"])
|
||||
_create(super_client, slug="other", profile="blank")
|
||||
token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1")
|
||||
h = _bearer(token)
|
||||
|
||||
# POST sites → 201
|
||||
r = super_client.post(f"{TENANTS}/demo/sites", headers=h,
|
||||
json={"slug": "es", "name": "ES 站",
|
||||
"domains": ["es.demo.example.com"]})
|
||||
assert r.status_code == 201, r.text
|
||||
# PATCH sites/{site} → 200
|
||||
r = super_client.patch(f"{TENANTS}/demo/sites/es", headers=h,
|
||||
json={"name": "ES 站 v2"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "ES 站 v2"
|
||||
# POST domains → 201
|
||||
r = super_client.post(f"{TENANTS}/demo/sites/es/domains", headers=h,
|
||||
json={"domain": "es2.demo.example.com"})
|
||||
assert r.status_code == 201
|
||||
# DELETE domains/{domain} → 200
|
||||
r = super_client.delete(
|
||||
f"{TENANTS}/demo/sites/es/domains/es2.demo.example.com", headers=h)
|
||||
assert r.status_code == 200
|
||||
|
||||
# 对照: 同 token 打别的租户四端点 → 403
|
||||
assert super_client.post(f"{TENANTS}/other/sites", headers=h,
|
||||
json={"slug": "x"}).status_code == 403
|
||||
assert super_client.patch(f"{TENANTS}/other/sites/main", headers=h,
|
||||
json={"name": "x"}).status_code == 403
|
||||
assert super_client.post(f"{TENANTS}/other/sites/main/domains",
|
||||
headers=h,
|
||||
json={"domain": "x.example.com"}).status_code == 403
|
||||
assert super_client.delete(
|
||||
f"{TENANTS}/other/sites/main/domains/x.example.com",
|
||||
headers=h).status_code == 403
|
||||
|
||||
|
||||
def test_tenant_lifecycle_still_super_only(super_client):
|
||||
"""不动清单:租户生命周期端点保持超管专属——创建 / 全平台列表 /
|
||||
PATCH /tenants/{slug}(停用/改名,租户不能自停自改)。"""
|
||||
_create(super_client, slug="demo", admin="demo-admin",
|
||||
password="demo-pass-1")
|
||||
token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1")
|
||||
h = _bearer(token)
|
||||
r = super_client.post(TENANTS, headers=h, json={
|
||||
"name": "越权租户", "slug": "evil", "profile": "blank",
|
||||
"admin_username": "evil-admin", "admin_password": "evil-pass-1"})
|
||||
assert r.status_code == 403
|
||||
assert super_client.get(TENANTS, headers=h).status_code == 403
|
||||
assert super_client.patch(f"{TENANTS}/demo", headers=h,
|
||||
json={"status": "disabled"}).status_code == 403
|
||||
assert super_client.patch(f"{TENANTS}/demo", headers=h,
|
||||
json={"name": "自改名"}).status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user