From 0c25287c190bcfd9c48c0806092013b268231836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 13:39:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=86=E9=92=A5=E6=8E=A9=E7=A0=81?= =?UTF-8?q?=E5=93=A8=E5=85=B5(=5F=5FSET=5F=5F)=E5=9B=9B=E5=A4=84=E5=93=8D?= =?UTF-8?q?=E5=BA=94=20+=20=E7=AB=99=E7=82=B9=E7=AE=A1=E7=90=86=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=94=BE=E5=BC=80=E7=A7=9F=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/platform/routes/config_routes.py | 163 +++++++++++++++++++++++--- app/platform/routes/tenant_routes.py | 24 ++-- tests/test_config_api.py | 167 +++++++++++++++++++++++++++ tests/test_tenant_admin_api.py | 63 ++++++++++ 4 files changed, 392 insertions(+), 25 deletions(-) diff --git a/app/platform/routes/config_routes.py b/app/platform/routes/config_routes.py index 8f27eb0..cac7ccd 100644 --- a/app/platform/routes/config_routes.py +++ b/app/platform/routes/config_routes.py @@ -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)} diff --git a/app/platform/routes/tenant_routes.py b/app/platform/routes/tenant_routes.py index 5a9bef3..d6e0e07 100644 --- a/app/platform/routes/tenant_routes.py +++ b/app/platform/routes/tenant_routes.py @@ -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: diff --git a/tests/test_config_api.py b/tests/test_config_api.py index 43dc30c..09a9e89 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -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__" diff --git a/tests/test_tenant_admin_api.py b/tests/test_tenant_admin_api.py index f084ac8..032312e 100644 --- a/tests/test_tenant_admin_api.py +++ b/tests/test_tenant_admin_api.py @@ -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