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:
@@ -26,6 +26,12 @@
|
||||
鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户
|
||||
管理员可自助改配置);settings = require_super。409 门经 platform_user 连锁。
|
||||
|
||||
密钥掩码哨兵(Phase 3 Task 1):push.api_key / ai.api_key /
|
||||
email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的响应
|
||||
一律掩码为 "__SET__"(原值非空;空串/None 原样——前端能区分"配了"与
|
||||
"没配");PUT 收到 "__SET__" 还原为库值再写版本(前端"不改就原样传回"
|
||||
的往返契约)。库行/版本历史永远存原值——掩码只作用响应层。
|
||||
|
||||
版本写入一律走 db.write_config_version(与 Task 5 创建向导同一 MAX+1 路径)。
|
||||
"""
|
||||
import copy
|
||||
@@ -110,6 +116,111 @@ def _updated_by(user: UserContext) -> str:
|
||||
return f"{user.role}#{user.user_id}"
|
||||
|
||||
|
||||
# ---- 密钥掩码 / __SET__ 哨兵(Phase 3 Task 1) --------------------------------------
|
||||
|
||||
MASKED = "__SET__"
|
||||
|
||||
# 租户配置密钥字段清单(组 → 组内路径元组;多元素元组 = 嵌套键)。响应一律
|
||||
# 掩码;PUT 收到 MASKED 值还原为当前快照原值——前端"不改就原样传回"的往返
|
||||
# 契约。语义:原值非空 → MASKED;空串/None/缺键原样(前端能区分"配了"与
|
||||
# "没配")。空串传回仍 = 清空(既有语义不因掩码改变)。
|
||||
_SECRET_PATHS: dict[str, tuple[tuple[str, ...], ...]] = {
|
||||
"push": (("api_key",),),
|
||||
"ai": (("api_key",),),
|
||||
"email": (("smtp", "password"),),
|
||||
}
|
||||
|
||||
|
||||
def _dig(container, path: tuple[str, ...]):
|
||||
"""按路径取嵌套 dict 值;任一层不是 dict → None。"""
|
||||
node = container
|
||||
for key in path:
|
||||
node = node.get(key) if isinstance(node, dict) else None
|
||||
return node
|
||||
|
||||
|
||||
def _mask_group(group: str, value):
|
||||
"""单组掩码视图(深拷贝):密钥路径上非空字符串 → MASKED,空串/None/
|
||||
缺键原样。非 dict 组值原样深拷贝返回。"""
|
||||
out = copy.deepcopy(value)
|
||||
if not isinstance(out, dict):
|
||||
return out
|
||||
for path in _SECRET_PATHS.get(group, ()):
|
||||
leaf = _dig(out, path)
|
||||
if isinstance(leaf, str) and leaf:
|
||||
node = _dig(out, path[:-1])
|
||||
node[path[-1]] = MASKED
|
||||
return out
|
||||
|
||||
|
||||
def _mask_snapshot(snapshot: dict) -> dict:
|
||||
"""整份快照的掩码视图(逐组 _mask_group)。"""
|
||||
return {group: _mask_group(group, value)
|
||||
for group, value in snapshot.items()}
|
||||
|
||||
|
||||
def _restore_sentinels(group: str, body: dict, current: dict) -> dict:
|
||||
"""PUT 哨兵还原:body 密钥路径上值恰为 MASKED → 替换为当前快照同路径
|
||||
原值(无库存 → None,保持"未设置"——哨兵字面量绝不落库)。返回处理后
|
||||
的深拷贝,不改入参;非密钥路径与传新值/空串的路径一律原样。"""
|
||||
out = copy.deepcopy(body)
|
||||
for path in _SECRET_PATHS.get(group, ()):
|
||||
if _dig(out, path) == MASKED:
|
||||
node = _dig(out, path[:-1])
|
||||
node[path[-1]] = _dig(current.get(group), path)
|
||||
return out
|
||||
|
||||
|
||||
# 平台设置密钥判定:key 名(不区分大小写)含以下片段即视为密钥。适用设置
|
||||
# 项自身的 key(标量密钥,如 glm_api_key = "sk-...")与 value 的嵌套 dict
|
||||
# 递归两层。
|
||||
_SETTING_SECRET_MARKERS = ("key", "password", "secret")
|
||||
|
||||
|
||||
def _is_secret_setting_key(name: str) -> bool:
|
||||
lowered = name.lower() if isinstance(name, str) else ""
|
||||
return any(marker in lowered for marker in _SETTING_SECRET_MARKERS)
|
||||
|
||||
|
||||
def _mask_setting_value(value):
|
||||
"""递归掩码:密钥名下的非空字符串值 → MASKED;dict/list 递归,其余
|
||||
标量原样。"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: (MASKED if _is_secret_setting_key(k)
|
||||
and isinstance(v, str) and v else _mask_setting_value(v))
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_mask_setting_value(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _mask_setting_item(key: str, value):
|
||||
"""单个设置项的掩码视图:设置 key 本身命中密钥名且值为非空字符串 →
|
||||
掩码;否则递归掩码 value 内部。"""
|
||||
if _is_secret_setting_key(key) and isinstance(value, str) and value:
|
||||
return MASKED
|
||||
return _mask_setting_value(value)
|
||||
|
||||
|
||||
def _restore_setting_sentinels(value, stored):
|
||||
"""value 中密钥位置的 MASKED → stored 同路径值(递归结构与
|
||||
_mask_setting_value 对齐;stored 无该路径 → None)。不改入参。"""
|
||||
if isinstance(value, dict):
|
||||
src = stored if isinstance(stored, dict) else {}
|
||||
return {
|
||||
k: (copy.deepcopy(src.get(k))
|
||||
if _is_secret_setting_key(k) and v == MASKED
|
||||
else _restore_setting_sentinels(v, src.get(k)))
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
src = stored if isinstance(stored, list) else []
|
||||
return [_restore_setting_sentinels(
|
||||
v, src[i] if i < len(src) else None)
|
||||
for i, v in enumerate(value)]
|
||||
return value
|
||||
|
||||
|
||||
# ---- 配置快照 / 分组编辑 ----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -117,12 +228,14 @@ def _updated_by(user: UserContext) -> str:
|
||||
async def get_config(slug: str, request: Request,
|
||||
user=Depends(_tenant_guard)):
|
||||
"""当前生效快照(MAX 版本)+ version。原样快照,非合并视图——缺省键
|
||||
由消费侧(registry)深合并 DEFAULT_CONFIG。"""
|
||||
由消费侧(registry)深合并 DEFAULT_CONFIG。密钥字段掩码(Phase 3
|
||||
Task 1):非空 → MASKED,空串/None 原样。"""
|
||||
engine = request.app.state.platform_engine
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
tenant = await _get_tenant_or_404(session, slug)
|
||||
row, snapshot = await _load_current(session, tenant.id, slug)
|
||||
return {"slug": slug, "version": row.version, "config": snapshot}
|
||||
return {"slug": slug, "version": row.version,
|
||||
"config": _mask_snapshot(snapshot)}
|
||||
|
||||
|
||||
@router.put("/{slug}/config/{group}")
|
||||
@@ -131,7 +244,12 @@ async def put_config_group(
|
||||
user=Depends(_tenant_guard)):
|
||||
"""分组编辑:校验(config_validation 管线)→ 整组替换当前快照该组 →
|
||||
MAX+1 写新版本 → registry_reload 热生效。快照存用户配置**原样组**
|
||||
(组内缺省键消费侧深合并出厂默认补)。"""
|
||||
(组内缺省键消费侧深合并出厂默认补)。
|
||||
|
||||
密钥哨兵(Phase 3 Task 1):密钥字段传 MASKED = 保持库值(校验按
|
||||
字符串过,还原发生在校验后写入前);传新值 → 覆盖;空串传回 = 清空
|
||||
(既有语义不因掩码改变)。响应的密钥字段同样掩码——哨兵还原后的原值
|
||||
不经响应回显。"""
|
||||
body = await _json_body(request)
|
||||
engine = request.app.state.platform_engine
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
@@ -142,6 +260,7 @@ async def put_config_group(
|
||||
if errors:
|
||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||
_, current = await _load_current(session, tenant.id, slug)
|
||||
body = _restore_sentinels(group, body, current)
|
||||
new_snapshot = dict(current)
|
||||
new_snapshot[group] = copy.deepcopy(body)
|
||||
version = await write_config_version(
|
||||
@@ -151,7 +270,7 @@ async def put_config_group(
|
||||
|
||||
await request.app.state.registry.registry_reload(slug)
|
||||
return {"slug": slug, "group": group, "version": version,
|
||||
"config": new_snapshot[group]}
|
||||
"config": _mask_group(group, new_snapshot[group])}
|
||||
|
||||
|
||||
@router.post("/{slug}/config/{group}/reset")
|
||||
@@ -197,7 +316,8 @@ async def reset_config_group(
|
||||
await session.commit()
|
||||
|
||||
await request.app.state.registry.registry_reload(slug)
|
||||
return {"slug": slug, "group": group, "version": version, "config": value}
|
||||
return {"slug": slug, "group": group, "version": version,
|
||||
"config": _mask_group(group, value)}
|
||||
|
||||
|
||||
# ---- 版本历史 / 回滚 ---------------------------------------------------------------
|
||||
@@ -226,7 +346,8 @@ async def list_config_versions(
|
||||
@router.get("/{slug}/config/versions/{version}")
|
||||
async def get_config_version(
|
||||
slug: str, version: int, request: Request, user=Depends(_tenant_guard)):
|
||||
"""单版本详情(含整份 config)。不存在 → 404。"""
|
||||
"""单版本详情(含整份 config)。不存在 → 404。config 掩码视图
|
||||
(Phase 3 Task 1)——历史行本身永远存原值。"""
|
||||
engine = request.app.state.platform_engine
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
tenant = await _get_tenant_or_404(session, slug)
|
||||
@@ -242,7 +363,7 @@ async def get_config_version(
|
||||
"slug": slug, "version": row.version,
|
||||
"created_at": row.created_at.isoformat(),
|
||||
"updated_by": row.updated_by, "comment": row.comment,
|
||||
"config": json.loads(row.config_json),
|
||||
"config": _mask_snapshot(json.loads(row.config_json)),
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +371,7 @@ async def get_config_version(
|
||||
async def rollback_config(
|
||||
slug: str, request: Request, user=Depends(_tenant_guard)):
|
||||
"""回滚:目标版本整份复制为新版本(历史不可变——旧版本行永不改写),
|
||||
reload 生效。"""
|
||||
reload 生效。落库原值;响应为掩码视图(Phase 3 Task 1)。"""
|
||||
body = await _json_body(request)
|
||||
target = body.get("version")
|
||||
if not isinstance(target, int) or isinstance(target, bool) or target < 1:
|
||||
@@ -277,7 +398,7 @@ async def rollback_config(
|
||||
|
||||
await request.app.state.registry.registry_reload(slug)
|
||||
return {"slug": slug, "rollback_to": target, "version": new_version,
|
||||
"config": snapshot}
|
||||
"config": _mask_snapshot(snapshot)}
|
||||
|
||||
|
||||
# ---- 试金石(wall/test) -----------------------------------------------------------
|
||||
@@ -380,21 +501,28 @@ def _decode_setting(raw: str):
|
||||
|
||||
@settings_router.get("")
|
||||
async def list_settings(request: Request, user=Depends(require_super)):
|
||||
"""平台级默认(集团 AI 凭据/默认 SMTP 等,spec §5.2)——本阶段只落
|
||||
存储与接口;消费(resolver 平台默认回退链)归 Phase 3。"""
|
||||
"""平台级默认(集团 AI 凭据/默认 SMTP 等,spec §5.2)。值掩码视图
|
||||
(Phase 3 Task 1):key 名含 key/password/secret 的非空字符串值 →
|
||||
MASKED(设置 key 本身与嵌套 dict 递归都算);库行永远存原值。"""
|
||||
engine = request.app.state.platform_engine
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
rows = (await session.execute(
|
||||
select(PlatformSetting).order_by(PlatformSetting.key))
|
||||
).scalars().all()
|
||||
return {"items": [{"key": row.key, "value": _decode_setting(row.value)}
|
||||
return {"items": [{"key": row.key,
|
||||
"value": _mask_setting_item(
|
||||
row.key, _decode_setting(row.value))}
|
||||
for row in rows]}
|
||||
|
||||
|
||||
@settings_router.put("")
|
||||
async def put_setting(request: Request, user=Depends(require_super)):
|
||||
"""upsert 单个设置项:key 1-64 字符;value 任意 JSON(json.dumps 入
|
||||
Text,读回还原)。同 key 再写 = 整值替换。"""
|
||||
Text,读回还原)。同 key 再写 = 整值替换。
|
||||
|
||||
密钥哨兵(Phase 3 Task 1):key 命中密钥名且值恰为 MASKED,或嵌套
|
||||
dict 内密钥位置为 MASKED → 还原为现存同路径值(无库存 → None)。
|
||||
非密钥位置的 MASKED 字面量 = 普通字符串原样落库。响应掩码。"""
|
||||
body = await _json_body(request)
|
||||
key = body.get("key")
|
||||
if not isinstance(key, str) or not 1 <= len(key) <= 64:
|
||||
@@ -402,16 +530,21 @@ async def put_setting(request: Request, user=Depends(require_super)):
|
||||
status_code=400, detail="key: 须为 1-64 字符字符串")
|
||||
if "value" not in body:
|
||||
raise HTTPException(status_code=400, detail="value: 缺少(必填)")
|
||||
encoded = json.dumps(body["value"], ensure_ascii=False)
|
||||
|
||||
engine = request.app.state.platform_engine
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
row = (await session.execute(
|
||||
select(PlatformSetting).where(PlatformSetting.key == key)
|
||||
)).scalar_one_or_none()
|
||||
stored = _decode_setting(row.value) if row is not None else None
|
||||
if _is_secret_setting_key(key) and body["value"] == MASKED:
|
||||
value = copy.deepcopy(stored)
|
||||
else:
|
||||
value = _restore_setting_sentinels(body["value"], stored)
|
||||
encoded = json.dumps(value, ensure_ascii=False)
|
||||
if row is None:
|
||||
session.add(PlatformSetting(key=key, value=encoded))
|
||||
else:
|
||||
row.value = encoded
|
||||
await session.commit()
|
||||
return {"key": key, "value": body["value"]}
|
||||
return {"key": key, "value": _mask_setting_item(key, value)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -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