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
+167
View File
@@ -5,13 +5,24 @@
鉴权矩阵(spec §3):config GET/PUT/reset/versions/rollback/wall-test =
require_tenant(slug)(租户全开);settings = require_super。
Phase 3 Task 1:密钥掩码哨兵——push.api_key / ai.api_key /
email.smtp.password 及平台设置的密钥名值,响应一律 "__SET__"(原值非空),
PUT 收到 "__SET__" 还原为库值(前端"不改就原样传回"的往返契约)。
"""
import json
from sqlalchemy import select
from app.platform.db import PlatformSetting
from app.platform.tenant_source import FileTenantSource
from tests.conftest import SEED_DIR
from tests.test_tenant_admin_api import (
_auth,
_bearer,
_create,
_current_snapshot,
_db_fetch,
_tenant_admin_token,
)
@@ -458,3 +469,159 @@ def test_wall_group_round_trip_and_body_hygiene(super_client):
).json()["token"]
r = super_client.get(_cfg("t2"), headers=_bearer(token))
assert r.status_code == 409
# ---- 密钥掩码 / __SET__ 哨兵(Phase 3 Task 1) ---------------------------------------
def test_get_config_masks_secrets(super_client):
"""GET config:密钥字段非空 → "__SET__";空串/None 原样(区分"配了"与
"没配");原值绝不出现在响应。"""
_create(super_client, slug="demo")
_put_group(super_client, "demo", "push",
{"prefix": "demo-p", "api_key": "push-s3cret"})
_put_group(super_client, "demo", "ai", {"api_key": "ai-s3cret"})
_put_group(super_client, "demo", "email",
{"smtp": {"host": "smtp.example.com", "port": 465,
"password": "smtp-s3cret"}})
r = super_client.get(_cfg("demo"), headers=_auth(super_client))
assert r.status_code == 200
cfg = r.json()["config"]
assert cfg["push"]["api_key"] == "__SET__"
assert cfg["ai"]["api_key"] == "__SET__"
assert cfg["email"]["smtp"]["password"] == "__SET__"
for secret in ("push-s3cret", "ai-s3cret", "smtp-s3cret"):
assert secret not in r.text, f"响应泄露密钥: {secret}"
# 未设语义不受掩码遮蔽: 空串 → "";None → null
_put_group(super_client, "demo", "push",
{"prefix": "demo-p", "api_key": ""})
_put_group(super_client, "demo", "ai", {"api_key": None})
_put_group(super_client, "demo", "email", {"smtp": None})
cfg = _get_config(super_client, "demo")["config"]
assert cfg["push"]["api_key"] == ""
assert cfg["ai"]["api_key"] is None
assert cfg["email"]["smtp"] is None
def test_put_set_sentinel_keeps_stored_value(super_client, tmp_path):
"""PUT 哨兵还原:密钥字段传 "__SET__" = 保持库值(版本+1 照常);传新值
→ 覆盖;空串传回仍 = 清空(既有语义不因掩码改变)。"""
_create(super_client, slug="demo")
_put_group(super_client, "demo", "push",
{"prefix": "demo-p", "api_key": "orig-s3cret"}) # v2
r = _put_group(super_client, "demo", "push",
{"prefix": "demo-p", "api_key": "__SET__"}) # v3
assert r.status_code == 200, r.text
assert r.json()["version"] == 3
assert r.json()["config"]["api_key"] == "__SET__" # PUT 响应同样掩码
snapshot = _current_snapshot(tmp_path, "demo")
assert snapshot["push"]["api_key"] == "orig-s3cret" # 库值原样
assert snapshot["push"]["prefix"] == "demo-p" # 非密钥字段照常写
# 传新值 → 覆盖;空串 → 清空
_put_group(super_client, "demo", "push",
{"prefix": "demo-p", "api_key": "new-s3cret"}) # v4
assert _current_snapshot(tmp_path, "demo")["push"]["api_key"] == "new-s3cret"
_put_group(super_client, "demo", "push",
{"prefix": "demo-p", "api_key": ""}) # v5
assert _current_snapshot(tmp_path, "demo")["push"]["api_key"] == ""
# 嵌套路径(email.smtp.password)哨兵同款
_put_group(super_client, "demo", "email",
{"smtp": {"host": "smtp.example.com", "port": 465,
"password": "smtp-orig"}}) # v6
r = _put_group(super_client, "demo", "email",
{"smtp": {"host": "smtp2.example.com", "port": 587,
"password": "__SET__"}}) # v7
assert r.status_code == 200
assert r.json()["config"]["smtp"]["password"] == "__SET__"
assert _current_snapshot(tmp_path, "demo")["email"]["smtp"] == {
"host": "smtp2.example.com", "port": 587, "password": "smtp-orig"}
# 库值未设时哨兵还原为 None(保持"未设置"),而非落哨兵字面量
r = _put_group(super_client, "demo", "ai", {"api_key": "__SET__"})
assert r.status_code == 200
assert _current_snapshot(tmp_path, "demo")["ai"]["api_key"] is None
def test_version_detail_and_rollback_response_masked(super_client, tmp_path):
"""GET versions/{n} 与 rollback 响应的密钥字段也是 "__SET__";写入不受
影响——rollback 落库仍是原值(掩码只作用响应,不改历史与回滚写入)。"""
_create(super_client, slug="demo")
_put_group(super_client, "demo", "ai", {"api_key": "v2-s3cret"}) # v2
r = super_client.get(f"{_cfg('demo')}/versions/2",
headers=_auth(super_client))
assert r.status_code == 200
assert r.json()["config"]["ai"]["api_key"] == "__SET__"
assert "v2-s3cret" not in r.text
_put_group(super_client, "demo", "ai", {"api_key": "v3-s3cret"}) # v3
r = super_client.post(f"{_cfg('demo')}/rollback", headers=_auth(super_client),
json={"version": 2})
assert r.status_code == 200
assert r.json()["version"] == 4
assert r.json()["config"]["ai"]["api_key"] == "__SET__" # 响应掩码
assert "v2-s3cret" not in r.text
# 落库仍是原值(分离断言:响应掩码 ≠ 写入掩码)
assert _current_snapshot(tmp_path, "demo")["ai"]["api_key"] == "v2-s3cret"
# 当前生效视图(GET config)也掩码
assert _get_config(super_client, "demo")["config"]["ai"]["api_key"] == "__SET__"
def test_platform_settings_masked(super_client, tmp_path):
"""平台设置同款:key 名含 key/password/secret 的字符串值掩码(设置 key
本身与嵌套 dict 递归都算);PUT 哨兵还原;非密钥值原样。"""
assert super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "glm_api_key",
"value": "sk-plat-1"}).status_code == 200
assert super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "default_smtp", "value": {
"host": "smtp.example.com", "port": 465,
"password": "smtp-pw-1", "user": "bot"}}
).status_code == 200
assert super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "hmac_secret",
"value": "sec-plat-1"}).status_code == 200
assert super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "glm_timeout",
"value": 30}).status_code == 200
r = super_client.get(SETTINGS, headers=_auth(super_client))
assert r.status_code == 200
items = {i["key"]: i["value"] for i in r.json()["items"]}
assert items["glm_api_key"] == "__SET__" # 设置 key 本身命中
assert items["default_smtp"] == { # 嵌套 dict 递归
"host": "smtp.example.com", "port": 465,
"password": "__SET__", "user": "bot"}
assert items["hmac_secret"] == "__SET__"
assert items["glm_timeout"] == 30 # 非密钥原样
for secret in ("sk-plat-1", "smtp-pw-1", "sec-plat-1"):
assert secret not in r.text, f"响应泄露密钥: {secret}"
# PUT 哨兵还原: 嵌套密钥位置传 "__SET__" → 库值保持,其余字段更新
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "default_smtp", "value": {
"host": "smtp2.example.com", "port": 587,
"password": "__SET__", "user": "bot2"}})
assert r.status_code == 200
assert r.json()["value"]["password"] == "__SET__" # 响应掩码
rows = _db_fetch(tmp_path, select(PlatformSetting)
.where(PlatformSetting.key == "default_smtp"))
assert json.loads(rows[0][0].value) == {
"host": "smtp2.example.com", "port": 587,
"password": "smtp-pw-1", "user": "bot2"}
# 标量密钥设置哨兵还原
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "glm_api_key", "value": "__SET__"})
assert r.status_code == 200
rows = _db_fetch(tmp_path, select(PlatformSetting)
.where(PlatformSetting.key == "glm_api_key"))
assert json.loads(rows[0][0].value) == "sk-plat-1"
# 非密钥 key 的 "__SET__" 字面量 = 普通字符串(不还原也不掩码)
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "plain_note", "value": "__SET__"})
assert r.status_code == 200
assert r.json()["value"] == "__SET__"
items = {i["key"]: i["value"] for i in super_client.get(
SETTINGS, headers=_auth(super_client)).json()["items"]}
assert items["plain_note"] == "__SET__"
+63
View File
@@ -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