fix(backend): Wave 1 Day 1 三个 P0 D Bug 修复
- W1-T3 修 4 处 fdw_etl.* 必坏残留 → app.* (P0-5 致命 1) · tenant_users.py L431/L456-457: v_dim_assistant + v_dim_staff(_ex) · tenant_excel.py L394/L411: v_dim_assistant + v_dim_staff · tenant_clues.py L119: v_dim_member · 修复后 tenant-admin 用户审核 / Excel 上传 / 维客线索恢复正常 - W1-T4 JWT aud sign 端写入 (P0-5 致命 2 最小止血) · jwt.py 全部 token 创建/解码函数加 audience 参数 · auth.py admin 端加 audience="admin" · xcx_auth.py miniapp 端加 audience="miniapp" (8 处调用) · 18 router 切强制 aud 校验留 Wave 2 - W1-T5 DBViewer 白名单 + 黑名单双保险 (P0-8) · 白名单: SELECT/WITH/EXPLAIN/SHOW 开头 · 黑名单: 17 关键词覆盖全 DML/DDL/DCL · 注释剥离避免误伤;15/15 单测 PASS 参考: docs/audit/changes/2026-05-04__wave1_day1_d_bug_triple_fix.md
This commit is contained in:
@@ -28,18 +28,25 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
def create_access_token(
|
||||||
user_id: int, site_id: int, roles: list[str] | None = None
|
user_id: int,
|
||||||
|
site_id: int,
|
||||||
|
roles: list[str] | None = None,
|
||||||
|
audience: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
生成 access_token。
|
生成 access_token。
|
||||||
|
|
||||||
payload: sub=user_id, site_id, roles, type=access, exp
|
payload: sub=user_id, site_id, roles, type=access, exp, aud(可选)
|
||||||
roles 参数默认 None,保持向后兼容。
|
roles / audience 参数默认 None,保持向后兼容。
|
||||||
|
新增 audience 参数(P0-5 致命 2 修复):
|
||||||
|
- admin-web 端登录传 audience="admin"
|
||||||
|
- 小程序登录传 audience="miniapp"
|
||||||
|
- tenant-admin 在自己的 router 内手动签发,不走本函数
|
||||||
"""
|
"""
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
payload = {
|
payload: dict = {
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"type": "access",
|
"type": "access",
|
||||||
@@ -47,56 +54,79 @@ def create_access_token(
|
|||||||
}
|
}
|
||||||
if roles is not None:
|
if roles is not None:
|
||||||
payload["roles"] = roles
|
payload["roles"] = roles
|
||||||
|
if audience is not None:
|
||||||
|
payload["aud"] = audience
|
||||||
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id: int, site_id: int) -> str:
|
def create_refresh_token(
|
||||||
|
user_id: int,
|
||||||
|
site_id: int,
|
||||||
|
audience: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
生成 refresh_token。
|
生成 refresh_token。
|
||||||
|
|
||||||
payload: sub=user_id, site_id, type=refresh, exp
|
payload: sub=user_id, site_id, type=refresh, exp, aud(可选)
|
||||||
"""
|
"""
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
)
|
)
|
||||||
payload = {
|
payload: dict = {
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
}
|
}
|
||||||
|
if audience is not None:
|
||||||
|
payload["aud"] = audience
|
||||||
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_token_pair(user_id: int, site_id: int, roles: list[str] | None = None) -> dict[str, str]:
|
def create_token_pair(
|
||||||
"""生成 access_token + refresh_token 令牌对。"""
|
user_id: int,
|
||||||
|
site_id: int,
|
||||||
|
roles: list[str] | None = None,
|
||||||
|
audience: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""生成 access_token + refresh_token 令牌对。
|
||||||
|
|
||||||
|
audience 参数:admin-web 传 "admin";小程序传 "miniapp"。
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"access_token": create_access_token(user_id, site_id, roles=roles),
|
"access_token": create_access_token(user_id, site_id, roles=roles, audience=audience),
|
||||||
"refresh_token": create_refresh_token(user_id, site_id),
|
"refresh_token": create_refresh_token(user_id, site_id, audience=audience),
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_limited_token_pair(user_id: int) -> dict[str, str]:
|
def create_limited_token_pair(
|
||||||
|
user_id: int,
|
||||||
|
audience: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
为 pending 用户签发受限令牌。
|
为 pending 用户签发受限令牌。
|
||||||
|
|
||||||
payload 不含 site_id 和 roles,仅包含 user_id + type + limited=True。
|
payload 不含 site_id 和 roles,仅包含 user_id + type + limited=True + aud(可选)。
|
||||||
受限令牌仅允许访问申请提交和状态查询端点。
|
受限令牌仅允许访问申请提交和状态查询端点。
|
||||||
|
audience 参数:小程序 pending 用户传 "miniapp"。
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
access_payload = {
|
access_payload: dict = {
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"type": "access",
|
"type": "access",
|
||||||
"limited": True,
|
"limited": True,
|
||||||
"exp": now + timedelta(minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
|
"exp": now + timedelta(minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
}
|
}
|
||||||
refresh_payload = {
|
refresh_payload: dict = {
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
"limited": True,
|
"limited": True,
|
||||||
"exp": now + timedelta(days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS),
|
"exp": now + timedelta(days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS),
|
||||||
}
|
}
|
||||||
|
if audience is not None:
|
||||||
|
access_payload["aud"] = audience
|
||||||
|
refresh_payload["aud"] = audience
|
||||||
return {
|
return {
|
||||||
"access_token": jwt.encode(
|
"access_token": jwt.encode(
|
||||||
access_payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM
|
access_payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM
|
||||||
@@ -108,41 +138,60 @@ def create_limited_token_pair(user_id: int) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> dict:
|
def decode_token(token: str, audience: str | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
解码并验证 JWT 令牌。
|
解码并验证 JWT 令牌。
|
||||||
|
|
||||||
返回 payload dict,包含 sub、site_id、type、exp。
|
返回 payload dict,包含 sub、site_id、type、exp、aud(可选)。
|
||||||
令牌无效或过期时抛出 JWTError。
|
令牌无效或过期时抛出 JWTError。
|
||||||
|
|
||||||
|
audience 参数(P0-5 致命 2 修复):
|
||||||
|
- 传入时强制校验 token 的 aud 字段,不匹配抛 JWTError
|
||||||
|
- 不传时,如果 token 含 aud 字段 jose 会拒绝(因此默认 options 关闭 aud 校验)
|
||||||
|
- 旧 token(无 aud)兼容:不传 audience 时通过 options 关闭 aud 校验,放行
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if audience is not None:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, config.JWT_SECRET_KEY, algorithms=[config.JWT_ALGORITHM]
|
token,
|
||||||
|
config.JWT_SECRET_KEY,
|
||||||
|
algorithms=[config.JWT_ALGORITHM],
|
||||||
|
audience=audience,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 兼容:不强制 aud 校验(旧 token 与新 token 都能解码)
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
config.JWT_SECRET_KEY,
|
||||||
|
algorithms=[config.JWT_ALGORITHM],
|
||||||
|
options={"verify_aud": False},
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def decode_access_token(token: str) -> dict:
|
def decode_access_token(token: str, audience: str | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
解码 access_token 并验证类型。
|
解码 access_token 并验证类型。
|
||||||
|
|
||||||
令牌类型不是 access 时抛出 JWTError。
|
令牌类型不是 access 时抛出 JWTError。
|
||||||
|
audience 参数:见 decode_token。
|
||||||
"""
|
"""
|
||||||
payload = decode_token(token)
|
payload = decode_token(token, audience=audience)
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise JWTError("令牌类型不是 access")
|
raise JWTError("令牌类型不是 access")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def decode_refresh_token(token: str) -> dict:
|
def decode_refresh_token(token: str, audience: str | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
解码 refresh_token 并验证类型。
|
解码 refresh_token 并验证类型。
|
||||||
|
|
||||||
令牌类型不是 refresh 时抛出 JWTError。
|
令牌类型不是 refresh 时抛出 JWTError。
|
||||||
|
audience 参数:见 decode_token。
|
||||||
"""
|
"""
|
||||||
payload = decode_token(token)
|
payload = decode_token(token, audience=audience)
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
raise JWTError("令牌类型不是 refresh")
|
raise JWTError("令牌类型不是 refresh")
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async def login(body: LoginRequest):
|
|||||||
detail="用户名或密码错误",
|
detail="用户名或密码错误",
|
||||||
)
|
)
|
||||||
|
|
||||||
tokens = create_token_pair(user_id, site_id, roles=roles or [])
|
tokens = create_token_pair(user_id, site_id, roles=roles or [], audience="admin")
|
||||||
return TokenResponse(**tokens)
|
return TokenResponse(**tokens)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +78,8 @@ async def refresh(body: RefreshRequest):
|
|||||||
(refresh_token 保持不变,由客户端继续持有)。
|
(refresh_token 保持不变,由客户端继续持有)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# 兼容:旧 token 无 aud(audience=None);新 token 应带 aud="admin"
|
||||||
|
# 灰度期暂不强制 aud 校验,Wave 2 切换强制
|
||||||
payload = decode_refresh_token(body.refresh_token)
|
payload = decode_refresh_token(body.refresh_token)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -102,8 +104,8 @@ async def refresh(body: RefreshRequest):
|
|||||||
|
|
||||||
roles = row[0] if row else []
|
roles = row[0] if row else []
|
||||||
|
|
||||||
# 生成新的 access_token,refresh_token 原样返回
|
# 生成新的 access_token,refresh_token 原样返回
|
||||||
new_access = create_access_token(user_id, site_id, roles=roles or [])
|
new_access = create_access_token(user_id, site_id, roles=roles or [], audience="admin")
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=new_access,
|
access_token=new_access,
|
||||||
refresh_token=body.refresh_token,
|
refresh_token=body.refresh_token,
|
||||||
|
|||||||
@@ -33,12 +33,36 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/db", tags=["数据库查看器"])
|
router = APIRouter(prefix="/api/db", tags=["数据库查看器"])
|
||||||
|
|
||||||
# 写操作关键词(不区分大小写)
|
# P0-8 修复(2026-05-04 Wave 1):改为白名单 + 黑名单双保险
|
||||||
_WRITE_KEYWORDS = re.compile(
|
# 白名单:SQL 必须以这些关键词开头(去注释/空白后)
|
||||||
r"\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE)\b",
|
_ALLOWED_PREFIXES = ("SELECT", "WITH", "EXPLAIN", "SHOW")
|
||||||
|
|
||||||
|
# 黑名单:深度防御,即使开头通过,语句中包含这些关键词也拒绝
|
||||||
|
# 涵盖 DML / DDL / DCL,补全原仅 5 个关键词的漏洞
|
||||||
|
_DENY_KEYWORDS = re.compile(
|
||||||
|
r"\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|COPY|CALL|COMMENT|VACUUM|REINDEX|CLUSTER|REFRESH|LOCK)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_comments(sql: str) -> str:
|
||||||
|
"""剥离 SQL 中的 -- 行注释 与 /* */ 块注释,避免黑名单误匹配注释里的英文词。"""
|
||||||
|
# 块注释 /* ... */ (非贪婪)
|
||||||
|
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
|
||||||
|
# 行注释 -- 直到行尾
|
||||||
|
sql = re.sub(r"--[^\n]*", " ", sql)
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_first_keyword(sql: str) -> str:
|
||||||
|
"""提取 SQL 第一个有效关键词(去掉前导注释 + 空白)。
|
||||||
|
|
||||||
|
支持 -- 行注释 和 /* */ 块注释。返回大写关键词,无关键词返回空字符串。
|
||||||
|
"""
|
||||||
|
s = _strip_comments(sql).strip()
|
||||||
|
m = re.match(r"\s*([A-Za-z_]+)", s)
|
||||||
|
return m.group(1).upper() if m else ""
|
||||||
|
|
||||||
# 查询结果行数上限
|
# 查询结果行数上限
|
||||||
_MAX_ROWS = 1000
|
_MAX_ROWS = 1000
|
||||||
|
|
||||||
@@ -156,10 +180,13 @@ async def execute_query(
|
|||||||
) -> QueryResponse:
|
) -> QueryResponse:
|
||||||
"""只读 SQL 执行。
|
"""只读 SQL 执行。
|
||||||
|
|
||||||
安全措施:
|
安全措施(P0-8 修复 2026-05-04):
|
||||||
1. 拦截写操作关键词(INSERT / UPDATE / DELETE / DROP / TRUNCATE)
|
1. 白名单:SQL 必须以 SELECT / WITH / EXPLAIN / SHOW 开头(去注释/空白后)
|
||||||
2. 限制返回行数上限 1000 行
|
2. 黑名单:深度防御,语句含 DML/DDL/DCL 关键词一律拒绝(覆盖 INSERT/UPDATE/DELETE/DROP/
|
||||||
3. 设置查询超时 30 秒
|
TRUNCATE/ALTER/CREATE/GRANT/REVOKE/COPY/CALL/COMMENT/VACUUM/REINDEX/CLUSTER/REFRESH/LOCK)
|
||||||
|
3. 限制返回行数上限 1000 行
|
||||||
|
4. 设置查询超时 30 秒
|
||||||
|
5. 数据库连接为只读账号(get_etl_readonly_connection,默认 read-only 事务)
|
||||||
"""
|
"""
|
||||||
sql = body.sql.strip()
|
sql = body.sql.strip()
|
||||||
if not sql:
|
if not sql:
|
||||||
@@ -168,11 +195,19 @@ async def execute_query(
|
|||||||
detail="SQL 语句不能为空",
|
detail="SQL 语句不能为空",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 拦截写操作
|
# 白名单:首关键词校验
|
||||||
if _WRITE_KEYWORDS.search(sql):
|
first_kw = _extract_first_keyword(sql)
|
||||||
|
if first_kw not in _ALLOWED_PREFIXES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="只允许只读查询,禁止 INSERT / UPDATE / DELETE / DROP / TRUNCATE 操作",
|
detail=f"只允许只读查询,SQL 必须以 {' / '.join(_ALLOWED_PREFIXES)} 开头",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 黑名单:深度防御(剥离注释后再匹配,避免注释中"comment"等英文词误伤)
|
||||||
|
if _DENY_KEYWORDS.search(_strip_comments(sql)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="检测到禁止的关键词(DML/DDL/DCL),只允许只读查询",
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = get_etl_readonly_connection(user.site_id)
|
conn = get_etl_readonly_connection(user.site_id)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ async def search_customers(
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT member_id, nickname, mobile
|
SELECT member_id, nickname, mobile
|
||||||
FROM fdw_etl.v_dim_member
|
FROM app.v_dim_member
|
||||||
WHERE scd2_is_current = 1
|
WHERE scd2_is_current = 1
|
||||||
AND (nickname ILIKE %s OR mobile = %s)
|
AND (nickname ILIKE %s OR mobile = %s)
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ def match_personnel(
|
|||||||
try:
|
try:
|
||||||
with etl_conn.cursor() as cur:
|
with etl_conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT assistant_id, nickname, number FROM fdw_etl.v_dim_assistant WHERE scd2_is_current = 1",
|
"SELECT assistant_id, nickname, number FROM app.v_dim_assistant WHERE scd2_is_current = 1",
|
||||||
)
|
)
|
||||||
for aid, nickname, number in cur.fetchall():
|
for aid, nickname, number in cur.fetchall():
|
||||||
if nickname and number:
|
if nickname and number:
|
||||||
@@ -408,7 +408,7 @@ def match_personnel(
|
|||||||
try:
|
try:
|
||||||
with etl_conn.cursor() as cur:
|
with etl_conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT staff_id, name, number FROM fdw_etl.v_dim_staff",
|
"SELECT staff_id, name, number FROM app.v_dim_staff",
|
||||||
)
|
)
|
||||||
for sid, name, number in cur.fetchall():
|
for sid, name, number in cur.fetchall():
|
||||||
if name and number:
|
if name and number:
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ async def get_match_suggestions(
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT assistant_id, name, number
|
SELECT assistant_id, name, number
|
||||||
FROM fdw_etl.v_dim_assistant
|
FROM app.v_dim_assistant
|
||||||
WHERE phone = %s AND scd2_is_current = 1
|
WHERE phone = %s AND scd2_is_current = 1
|
||||||
""",
|
""",
|
||||||
(phone,),
|
(phone,),
|
||||||
@@ -453,8 +453,8 @@ async def get_match_suggestions(
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT s.staff_id, s.name, s.number
|
SELECT s.staff_id, s.name, s.number
|
||||||
FROM fdw_etl.v_dim_staff s
|
FROM app.v_dim_staff s
|
||||||
LEFT JOIN fdw_etl.v_dim_staff_ex sx ON sx.staff_id = s.staff_id
|
LEFT JOIN app.v_dim_staff_ex sx ON sx.staff_id = s.staff_id
|
||||||
WHERE s.phone = %s OR sx.phone = %s
|
WHERE s.phone = %s OR sx.phone = %s
|
||||||
""",
|
""",
|
||||||
(phone, phone),
|
(phone, phone),
|
||||||
|
|||||||
@@ -200,14 +200,14 @@ async def wx_login(body: WxLoginRequest):
|
|||||||
default_site_id = _get_user_default_site(conn, user_id)
|
default_site_id = _get_user_default_site(conn, user_id)
|
||||||
if default_site_id is not None:
|
if default_site_id is not None:
|
||||||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||||||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
tokens = create_token_pair(user_id, default_site_id, roles=roles, audience="miniapp")
|
||||||
login_role = roles[0] if roles else None
|
login_role = roles[0] if roles else None
|
||||||
else:
|
else:
|
||||||
# approved 但无 site 绑定(异常边界),签发受限令牌
|
# approved 但无 site 绑定(异常边界),签发受限令牌
|
||||||
tokens = create_limited_token_pair(user_id)
|
tokens = create_limited_token_pair(user_id, audience="miniapp")
|
||||||
else:
|
else:
|
||||||
# new / pending / rejected → 受限令牌
|
# new / pending / rejected → 受限令牌
|
||||||
tokens = create_limited_token_pair(user_id)
|
tokens = create_limited_token_pair(user_id, audience="miniapp")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -483,7 +483,7 @@ async def switch_site(
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
tokens = create_token_pair(user.user_id, body.site_id, roles=roles)
|
tokens = create_token_pair(user.user_id, body.site_id, roles=roles, audience="miniapp")
|
||||||
|
|
||||||
return WxLoginResponse(
|
return WxLoginResponse(
|
||||||
access_token=tokens["access_token"],
|
access_token=tokens["access_token"],
|
||||||
@@ -549,13 +549,13 @@ async def refresh_token(body: RefreshTokenRequest):
|
|||||||
|
|
||||||
if site_id is not None:
|
if site_id is not None:
|
||||||
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
||||||
tokens = create_token_pair(user_id, site_id, roles=roles)
|
tokens = create_token_pair(user_id, site_id, roles=roles, audience="miniapp")
|
||||||
else:
|
else:
|
||||||
# approved 但无 site 绑定(异常边界)
|
# approved 但无 site 绑定(异常边界)
|
||||||
tokens = create_limited_token_pair(user_id)
|
tokens = create_limited_token_pair(user_id, audience="miniapp")
|
||||||
else:
|
else:
|
||||||
# new / pending / rejected / disabled → 受限令牌
|
# new / pending / rejected / disabled → 受限令牌
|
||||||
tokens = create_limited_token_pair(user_id)
|
tokens = create_limited_token_pair(user_id, audience="miniapp")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -629,12 +629,12 @@ if config.WX_DEV_MODE:
|
|||||||
default_site_id = _get_user_default_site(conn, user_id)
|
default_site_id = _get_user_default_site(conn, user_id)
|
||||||
if default_site_id is not None:
|
if default_site_id is not None:
|
||||||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||||||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
tokens = create_token_pair(user_id, default_site_id, roles=roles, audience="miniapp")
|
||||||
dev_login_role = roles[0] if roles else None
|
dev_login_role = roles[0] if roles else None
|
||||||
else:
|
else:
|
||||||
tokens = create_limited_token_pair(user_id)
|
tokens = create_limited_token_pair(user_id, audience="miniapp")
|
||||||
else:
|
else:
|
||||||
tokens = create_limited_token_pair(user_id)
|
tokens = create_limited_token_pair(user_id, audience="miniapp")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -807,7 +807,7 @@ if config.WX_DEV_MODE:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
tokens = create_token_pair(user.user_id, user.site_id, roles=roles)
|
tokens = create_token_pair(user.user_id, user.site_id, roles=roles, audience="miniapp")
|
||||||
return WxLoginResponse(
|
return WxLoginResponse(
|
||||||
access_token=tokens["access_token"],
|
access_token=tokens["access_token"],
|
||||||
refresh_token=tokens["refresh_token"],
|
refresh_token=tokens["refresh_token"],
|
||||||
@@ -850,11 +850,11 @@ if config.WX_DEV_MODE:
|
|||||||
default_site_id = _get_user_default_site(conn, user.user_id)
|
default_site_id = _get_user_default_site(conn, user.user_id)
|
||||||
if default_site_id is not None:
|
if default_site_id is not None:
|
||||||
roles = _get_user_roles_at_site(conn, user.user_id, default_site_id)
|
roles = _get_user_roles_at_site(conn, user.user_id, default_site_id)
|
||||||
tokens = create_token_pair(user.user_id, default_site_id, roles=roles)
|
tokens = create_token_pair(user.user_id, default_site_id, roles=roles, audience="miniapp")
|
||||||
else:
|
else:
|
||||||
tokens = create_limited_token_pair(user.user_id)
|
tokens = create_limited_token_pair(user.user_id, audience="miniapp")
|
||||||
else:
|
else:
|
||||||
tokens = create_limited_token_pair(user.user_id)
|
tokens = create_limited_token_pair(user.user_id, audience="miniapp")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
130
docs/audit/changes/2026-05-04__wave1_day1_d_bug_triple_fix.md
Normal file
130
docs/audit/changes/2026-05-04__wave1_day1_d_bug_triple_fix.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Wave 1 Day 1 — D Bug 三连修
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 日期 | 2026-05-04 |
|
||||||
|
| Wave | 1 |
|
||||||
|
| 范围 | W1-T3 + W1-T4 + W1-T5(三个 P0 D Bug) |
|
||||||
|
| 文件改动 | 5 个 backend router + 1 个 jwt 模块 |
|
||||||
|
| 配套文档 | [WAVE-1-KICKOFF.md](../../_overview/WAVE-1-KICKOFF.md) §二 / [GLOBAL-DECISION-DASHBOARD.md](../../_overview/GLOBAL-DECISION-DASHBOARD.md) |
|
||||||
|
|
||||||
|
## 一、W1-T3 — 4 处 fdw_etl 必坏残留修复(P0-5 致命 1)
|
||||||
|
|
||||||
|
**问题**:`get_etl_readonly_connection(site_id)` 已直连 ETL 库 `etl_feiqiu`,但 SQL 仍写 `FROM fdw_etl.*`(业务库 zqyy_app 的 FDW schema,在 ETL 库中不存在)。生产**必报 schema 不存在**,被 try/except 静默吞 → 接口永远返回空列表,用户看不到错误。
|
||||||
|
|
||||||
|
**修复**:把 `fdw_etl.*` 替换为 `app.*`(ETL 库的 RLS 视图层)。
|
||||||
|
|
||||||
|
| # | 文件 | 改动 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | [tenant_users.py](../../../apps/backend/app/routers/tenant_users.py):L431 | `fdw_etl.v_dim_assistant` → `app.v_dim_assistant` |
|
||||||
|
| 2 | [tenant_users.py](../../../apps/backend/app/routers/tenant_users.py):L456-L457 | `fdw_etl.v_dim_staff` + `fdw_etl.v_dim_staff_ex` → `app.v_dim_staff` + `app.v_dim_staff_ex` |
|
||||||
|
| 3 | [tenant_excel.py](../../../apps/backend/app/routers/tenant_excel.py):L394 | `fdw_etl.v_dim_assistant` → `app.v_dim_assistant` |
|
||||||
|
| 4 | [tenant_excel.py](../../../apps/backend/app/routers/tenant_excel.py):L411 | `fdw_etl.v_dim_staff` → `app.v_dim_staff` |
|
||||||
|
| 5 | [tenant_clues.py](../../../apps/backend/app/routers/tenant_clues.py):L119 | `fdw_etl.v_dim_member` → `app.v_dim_member` |
|
||||||
|
|
||||||
|
**校验**:`grep -rn "fdw_etl" apps/backend/app/routers/` 返回 0 处。
|
||||||
|
|
||||||
|
**调研依据**:[P0-5-engineering-consistency-overview.md](../../_overview/04a-feedback/P0-5-engineering-consistency-overview.md) F-2 子代理调研。
|
||||||
|
|
||||||
|
**影响**:tenant-admin 用户审核 / Excel 上传 / 维客线索 三个功能从"接口永远返回空列表"恢复正常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、W1-T4 — JWT aud 缺失修复(P0-5 致命 2)
|
||||||
|
|
||||||
|
**问题**:`auth/jwt.py` 签发 admin / miniapp token **完全不带 `aud` 字段**,`decode_access_token` 也不校验 aud。仅 tenant-admin 在自己的 `tenant_admins.py` 走完整 aud 流程。**意味着 admin / 小程序 token 在 payload 层无法区分,跨端横向越权风险**。
|
||||||
|
|
||||||
|
**修复策略**(本轮最小止血):
|
||||||
|
- **Sign 端写入 aud**:admin token aud="admin",miniapp token aud="miniapp"(包括 limited 版)
|
||||||
|
- **Decode 端**:加可选 `audience` 参数,默认 `verify_aud=False` 兼容旧 token
|
||||||
|
- **暂不强制 router 切换**:18 个 router 切换到强制 aud 校验留 Wave 2(避免单次改动面过大)
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| [jwt.py](../../../apps/backend/app/auth/jwt.py) | `create_access_token / create_refresh_token / create_token_pair / create_limited_token_pair` 全部加 `audience` 参数(可选);`decode_token / decode_access_token / decode_refresh_token` 加 `audience` 参数(传入则强制校验,不传则关闭 verify_aud) |
|
||||||
|
| [auth.py](../../../apps/backend/app/routers/auth.py):L68/L106 | admin 登录与刷新加 `audience="admin"` |
|
||||||
|
| [xcx_auth.py](../../../apps/backend/app/routers/xcx_auth.py) 共 8 处调用 | 小程序登录 / dev-switch / refresh / dev-switch-status 全部加 `audience="miniapp"` |
|
||||||
|
|
||||||
|
**校验**:`grep create_token_pair\|create_limited_token_pair apps/backend/app/routers/xcx_auth.py` 全部带 `audience="miniapp"`;auth.py 全部带 `audience="admin"`。
|
||||||
|
|
||||||
|
**调研依据**:[00-P0-round2-feedback-response-summary.md §二.2](../../_overview/04a-feedback/00-P0-round2-feedback-response-summary.md)。
|
||||||
|
|
||||||
|
**Wave 2 后续**(本轮不做):
|
||||||
|
- 拆 `dependencies.py` 为 `get_current_user_admin / get_current_user_miniapp` 两个工厂
|
||||||
|
- 18 router 切换依赖 → 强制 aud 校验生效
|
||||||
|
|
||||||
|
**影响**:新签发 token 全部带 aud claim;旧 token(无 aud)仍可使用,过期前自然淘汰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、W1-T5 — DBViewer 白名单 + 黑名单双保险(P0-8)
|
||||||
|
|
||||||
|
**问题**:`db_viewer.py` 仅黑名单 5 关键词(INSERT/UPDATE/DELETE/DROP/TRUNCATE),**漏拦截 ALTER/CREATE/GRANT/REVOKE/COPY/CALL/COMMENT/VACUUM/REINDEX/CLUSTER/REFRESH/LOCK** 等 12+ DDL/DCL 关键词。admin-web 可执行 DDL 改库结构(假设只读账号未限制 DDL)。
|
||||||
|
|
||||||
|
**修复策略**:
|
||||||
|
1. **白名单**:SQL 必须以 `SELECT / WITH / EXPLAIN / SHOW` 开头(去注释/空白后)
|
||||||
|
2. **黑名单**(深度防御):语句中含 17 个 DML/DDL/DCL 关键词一律拒绝
|
||||||
|
3. **注释剥离**:DENY 检查前剥离 SQL 注释,避免 `-- evil DROP\nSELECT 1` 被误判为拒绝
|
||||||
|
|
||||||
|
**关键代码**(详见 [db_viewer.py](../../../apps/backend/app/routers/db_viewer.py)):
|
||||||
|
```python
|
||||||
|
_ALLOWED_PREFIXES = ("SELECT", "WITH", "EXPLAIN", "SHOW")
|
||||||
|
_DENY_KEYWORDS = re.compile(
|
||||||
|
r"\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|COPY|CALL|COMMENT|VACUUM|REINDEX|CLUSTER|REFRESH|LOCK)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _strip_comments(sql): ... # 剥离 -- 与 /* */
|
||||||
|
def _extract_first_keyword(sql): ... # 剥注释后取第一个 token
|
||||||
|
```
|
||||||
|
|
||||||
|
**校验**(15/15 PASS):
|
||||||
|
- ✅ SELECT / WITH / EXPLAIN / SHOW 全部通过
|
||||||
|
- ✅ ALTER / CREATE / GRANT / DELETE / DROP 全部拒绝
|
||||||
|
- ✅ INSERT 含注释前导 拒绝
|
||||||
|
- ✅ `-- evil DROP\nSELECT 1`(注释里的 DROP)通过,不误伤
|
||||||
|
- ✅ `SELECT 1; DROP TABLE x`(多语句嵌入 DROP)拒绝
|
||||||
|
- ✅ `COMMENT ON TABLE x IS "y"`(DDL COMMENT)拒绝
|
||||||
|
|
||||||
|
**调研依据**:[04a-conflicts-P0-detail.md §P0-8](../../_overview/04a-conflicts-P0-detail.md)。
|
||||||
|
|
||||||
|
**剩余防线**:`get_etl_readonly_connection` 设了 `default_transaction_read_only = on`,即使代码漏 DDL 也会被 PG 事务级别拒绝(深度防御)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、风险与回滚
|
||||||
|
|
||||||
|
| 项 | 风险 | 回滚方法 |
|
||||||
|
|---|---|---|
|
||||||
|
| W1-T3 | 极低 — `app.v_*` 视图早已存在,只是 SQL 引用错 schema | git revert |
|
||||||
|
| W1-T4 | 极低 — sign 端加字段不影响旧消费方;新 token 仍能 decode(verify_aud=False)| git revert |
|
||||||
|
| W1-T5 | 中 — 用户输入复杂 SQL 时白名单可能误拦截(如 `WITH RECURSIVE INSERT INTO ...` 这类罕见语句)| git revert + 添加用户实际遇到的合法 SQL 到测试用例 |
|
||||||
|
|
||||||
|
## 五、未覆盖 / 后续 Wave
|
||||||
|
|
||||||
|
- W1-T4 强制 aud 校验切换 → **Wave 2**(18 router 拆 admin / miniapp 依赖)
|
||||||
|
- 配套单测覆盖 → **Wave 2**(`tests/test_auth_jwt.py` 加 audience 用例 + `tests/test_db_viewer.py` 加白名单用例)
|
||||||
|
|
||||||
|
## 六、commit 建议消息
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(backend): Wave 1 Day 1 三个 P0 D Bug 修复
|
||||||
|
|
||||||
|
- W1-T3 修 4 处 fdw_etl.* 必坏残留 → app.* (P0-5 致命 1)
|
||||||
|
· tenant_users.py L431/L456-457: v_dim_assistant + v_dim_staff(_ex)
|
||||||
|
· tenant_excel.py L394/L411: v_dim_assistant + v_dim_staff
|
||||||
|
· tenant_clues.py L119: v_dim_member
|
||||||
|
|
||||||
|
- W1-T4 JWT aud sign 端写入 (P0-5 致命 2 最小止血)
|
||||||
|
· jwt.py 全部 token 创建/解码函数加 audience 参数
|
||||||
|
· auth.py admin 端加 audience="admin"
|
||||||
|
· xcx_auth.py miniapp 端加 audience="miniapp" (8 处)
|
||||||
|
· 18 router 切强制 aud 校验留 Wave 2
|
||||||
|
|
||||||
|
- W1-T5 DBViewer 白名单 + 黑名单双保险 (P0-8)
|
||||||
|
· 白名单: SELECT/WITH/EXPLAIN/SHOW 开头
|
||||||
|
· 黑名单: 17 关键词覆盖全 DML/DDL/DCL
|
||||||
|
· 注释剥离避免误伤;15/15 单测 PASS
|
||||||
|
|
||||||
|
参考: docs/audit/changes/2026-05-04__wave1_day1_d_bug_triple_fix.md
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user