feat: harden data access and simplify user management

This commit is contained in:
xuelong committed 2026-08-25 21:02:30 -07:00
1 parent 3dd5731751
commit adb780bc82
51 files changed
+3239 -2121

No files matched your search

+428 -1
View File
@@ -1,11 +1,17 @@
"""API client, credential and least-privilege policy management."""
from __future__ import annotations
import base64
import binascii
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
from typing import Any
from urllib.parse import urlsplit
import uuid
@@ -13,7 +19,11 @@ import uuid
from fastapi import HTTPException
from app.config import settings
from app.data_platform.mysql_db import data_pool_available, get_data_conn
from app.data_platform.mysql_db import (
data_pool_available,
get_data_conn,
get_provisioner_conn,
)
from app.data_platform.mysql_service import (
ensure_platform_registry,
list_project_table_entries,
@@ -21,6 +31,30 @@ from app.data_platform.mysql_service import (
VALID_ACTIONS = {"metadata", "read", "create", "update", "delete"}
_SSH_PUBLIC_KEY_RE = re.compile(
r"^(ssh-rsa|ssh-ed25519)\s+([A-Za-z0-9+/]+={0,2})(?:\s+[^\r\n]{1,120})?$"
)
_MYSQL_ACCOUNT_RE = re.compile(r"^dba_[a-f0-9]{12}$")
def _ssh_field(blob: bytes, offset: int) -> tuple[bytes, int]:
if offset + 4 > len(blob):
raise ValueError("SSH 公钥内容不完整")
length = int.from_bytes(blob[offset : offset + 4], "big")
start = offset + 4
end = start + length
if length > 4096 or end > len(blob):
raise ValueError("SSH 公钥字段长度不正确")
return blob[start:end], end
def _mysql_account_sql(username: str, *, parameterized: bool = False) -> str:
if not _MYSQL_ACCOUNT_RE.fullmatch(username):
raise ValueError("DBeaver管理账号格式异常")
# PyMySQL/aiomysql use percent-style parameter interpolation. A literal
# host wildcard must therefore be doubled only in statements with %s.
host = "%%" if parameterized else "%"
return f"'{username}'@'{host}'"
async def _ready() -> None:
@@ -115,6 +149,8 @@ async def interface_summary() -> dict[str, Any]:
(SELECT COUNT(*) FROM api_credentials
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP(6))) AS active_credentials,
(SELECT COUNT(*) FROM api_policies WHERE status='active') AS active_policies,
(SELECT COUNT(*) FROM dbeaver_access_grants
WHERE status='active') AS active_dbeaver_grants,
(SELECT COUNT(*) FROM api_call_logs
WHERE created_at >= CURRENT_TIMESTAMP(6) - INTERVAL 24 HOUR) AS calls_24h,
(SELECT COUNT(*) FROM api_call_logs
@@ -151,7 +187,17 @@ async def interface_summary() -> dict[str, Any]:
"ssh_tunnel_required": settings.data_mysql_ssh_tunnel_required,
"ssh_host": ssh_host,
"ssh_port": settings.data_mysql_ssh_port,
"ssh_username": settings.data_mysql_ssh_username,
"ssh_auth_method": settings.data_mysql_ssh_auth_method,
"managed_access_enabled": settings.data_mysql_managed_access_enabled,
"ssh_key_auto_install": bool(
settings.data_mysql_ssh_authorized_keys_file.strip()
),
"account_provisioning_ready": bool(
settings.data_mysql_provisioner_user.strip()
and settings.data_mysql_provisioner_password
),
"ssh_host_key_fingerprint": _ssh_host_key_fingerprint(),
"database_host": settings.data_mysql_admin_host.strip() or "127.0.0.1",
"database_port": database_port,
"database_account_policy": settings.data_mysql_admin_account_policy,
@@ -166,6 +212,387 @@ async def interface_summary() -> dict[str, Any]:
}
def _normalize_ssh_public_key(value: Any) -> tuple[str, str, str]:
candidate = " ".join(str(value or "").strip().split())
match = _SSH_PUBLIC_KEY_RE.fullmatch(candidate)
if not match:
raise ValueError("SSH 公钥格式不正确,仅支持 RSA 或 ED25519 公钥")
key_type, encoded = match.group(1), match.group(2)
try:
decoded = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("SSH 公钥内容不是有效的 Base64") from exc
if len(decoded) < 48 or len(decoded) > 4096:
raise ValueError("SSH 公钥长度不正确")
embedded_type, offset = _ssh_field(decoded, 0)
if embedded_type.decode("ascii", errors="ignore") != key_type:
raise ValueError("SSH 公钥类型与内容不一致")
if key_type == "ssh-ed25519":
public_bytes, offset = _ssh_field(decoded, offset)
if len(public_bytes) != 32:
raise ValueError("ED25519 公钥长度不正确")
else:
exponent, offset = _ssh_field(decoded, offset)
modulus, offset = _ssh_field(decoded, offset)
exponent_value = int.from_bytes(exponent, "big")
modulus_bits = int.from_bytes(modulus, "big").bit_length()
if exponent_value < 3 or exponent_value % 2 == 0:
raise ValueError("RSA 公钥指数不正确")
if not 2048 <= modulus_bits <= 8192:
raise ValueError("RSA 公钥必须为 2048–8192 位")
if offset != len(decoded):
raise ValueError("SSH 公钥包含多余数据")
fingerprint = base64.b64encode(hashlib.sha256(decoded).digest()).decode().rstrip("=")
return key_type, f"{key_type} {encoded}", f"SHA256:{fingerprint}"
def _ssh_host_key_fingerprint() -> str:
raw_path = settings.data_mysql_ssh_host_public_key_file.strip()
if not raw_path:
return ""
path = Path(raw_path)
try:
if not path.is_absolute() or path.is_symlink() or path.stat().st_size > 8192:
return ""
_key_type, _public_key, fingerprint = _normalize_ssh_public_key(
path.read_text(encoding="utf-8")
)
return fingerprint
except (OSError, UnicodeError, ValueError):
return ""
def _authorized_keys_path() -> Path:
if not settings.data_mysql_managed_access_enabled:
raise HTTPException(503, "DBeaver 管理接入尚未启用")
raw_path = settings.data_mysql_ssh_authorized_keys_file.strip()
if not raw_path:
raise HTTPException(503, "服务器尚未配置 DBeaver SSH 公钥文件")
path = Path(raw_path)
if not path.is_absolute():
raise HTTPException(503, "DBeaver SSH 公钥文件必须使用绝对路径")
return path
def _write_authorized_keys(rows: list[dict[str, Any]]) -> None:
target = _authorized_keys_path()
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
raise RuntimeError("拒绝写入符号链接形式的 SSH 公钥文件")
lines = [
"# Managed by the Interface Center. Manual edits will be replaced."
]
for item in rows:
username = str(item["mysql_username"])
if not _MYSQL_ACCOUNT_RE.fullmatch(username):
continue
_key_type, public_key, _fingerprint = _normalize_ssh_public_key(
item["ssh_public_key"]
)
lines.append(
'restrict,port-forwarding,permitopen="127.0.0.1:3307" '
f"{public_key} nianxx:{item['id']}:{username}"
)
content = "\n".join(lines) + "\n"
file_descriptor, temporary_name = tempfile.mkstemp(
prefix=".authorized_keys.", dir=str(target.parent)
)
try:
with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_name, 0o600)
os.replace(temporary_name, target)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
async def _refresh_dbeaver_authorized_keys() -> None:
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT id, mysql_username, ssh_public_key
FROM dbeaver_access_grants
WHERE status='active'
ORDER BY created_at
"""
)
rows = [dict(item) for item in await cur.fetchall()]
_write_authorized_keys(rows)
async def list_dbeaver_access_grants() -> list[dict[str, Any]]:
await _ready()
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT id, display_name, mysql_username, database_id,
database_name, permission_level, ssh_key_type,
ssh_key_fingerprint, created_by, status,
revoked_at, created_at
FROM dbeaver_access_grants
ORDER BY created_at DESC
"""
)
return [_row(dict(item)) or {} for item in await cur.fetchall()]
async def _write_dbeaver_audit(
cur: Any,
*,
actor: str,
action: str,
grant_id: str,
outcome: str,
details: dict[str, Any],
) -> None:
await cur.execute(
"""
INSERT INTO admin_action_logs (
actor, action_name, resource_type, resource_id,
outcome, details_json
) VALUES (%s, %s, 'dbeaver_access', %s, %s, %s)
""",
(actor, action, grant_id, outcome, json.dumps(details, ensure_ascii=False)),
)
def _mysql_access_details() -> dict[str, Any]:
mysql_url = urlsplit(settings.data_mysql_url)
return {
"ssh_host": settings.data_mysql_ssh_host.strip(),
"ssh_port": settings.data_mysql_ssh_port,
"ssh_username": settings.data_mysql_ssh_username,
"database_host": settings.data_mysql_admin_host.strip() or "127.0.0.1",
"database_port": (
settings.data_mysql_admin_port
or settings.data_mysql_public_port
or mysql_url.port
or 3306
),
}
async def issue_dbeaver_access(
body: dict[str, Any], actor: str
) -> dict[str, Any]:
await _ready()
_authorized_keys_path()
display_name = str(body.get("display_name") or "").strip()
database_id = str(body.get("database_id") or "").strip()
permission = str(body.get("permission") or "read").strip().lower()
if not display_name:
raise ValueError("请输入数据管理员姓名")
if len(display_name) > 100:
raise ValueError("数据管理员姓名不能超过 100 个字符")
if permission not in {"read", "write"}:
raise ValueError("DBeaver 权限只能选择只读或读写")
key_type, public_key, fingerprint = _normalize_ssh_public_key(
body.get("public_key")
)
grant_id = str(uuid.uuid4())
mysql_username = f"dba_{uuid.uuid4().hex[:12]}"
mysql_password = secrets.token_urlsafe(24)
account = _mysql_account_sql(mysql_username)
parameterized_account = _mysql_account_sql(mysql_username, parameterized=True)
database_name = ""
created_account = False
try:
# Verify the shared key file before creating a database account. This
# avoids issuing a credential that cannot be used by the SSH gateway.
await _refresh_dbeaver_authorized_keys()
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT database_name FROM project_databases
WHERE project_id=%s AND status='ready'
""",
(database_id,),
)
database = await cur.fetchone()
if not database:
raise ValueError("目标数据库不存在或尚未就绪")
database_name = str(database["database_name"])
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", database_name):
raise ValueError("目标数据库编码不合法")
await cur.execute(
"""
SELECT 1 FROM dbeaver_access_grants
WHERE ssh_key_fingerprint=%s AND status='active'
""",
(fingerprint,),
)
if await cur.fetchone():
raise ValueError("该 SSH 公钥已用于一个有效的管理账号")
# CREATE USER and GRANT are intentionally isolated from the normal
# Data Center connection. Only this short-lived broker account has
# account-provisioning privileges.
async with get_provisioner_conn() as provisioner_conn:
async with provisioner_conn.cursor() as cur:
await cur.execute(
f"CREATE USER {parameterized_account} IDENTIFIED BY %s "
"PASSWORD EXPIRE INTERVAL 90 DAY "
"FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1",
(mysql_password,),
)
created_account = True
privileges = "SELECT, SHOW VIEW"
if permission == "write":
privileges = "SELECT, INSERT, UPDATE, DELETE, SHOW VIEW"
await cur.execute(
f"GRANT {privileges} ON `{database_name}`.* TO {account}"
)
await provisioner_conn.commit()
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
INSERT INTO dbeaver_access_grants (
id, display_name, mysql_username, database_id,
database_name, permission_level, ssh_key_type,
ssh_public_key, ssh_key_fingerprint, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
grant_id,
display_name,
mysql_username,
database_id,
database_name,
permission,
key_type,
public_key,
fingerprint,
actor,
),
)
await _write_dbeaver_audit(
cur,
actor=actor,
action="issue_dbeaver_access",
grant_id=grant_id,
outcome="success",
details={
"database_id": database_id,
"database_name": database_name,
"mysql_username": mysql_username,
"permission": permission,
"ssh_key_fingerprint": fingerprint,
},
)
await conn.commit()
await _refresh_dbeaver_authorized_keys()
except Exception as exc:
if created_account:
try:
async with get_provisioner_conn() as cleanup_conn:
async with cleanup_conn.cursor() as cleanup_cur:
await cleanup_cur.execute(f"DROP USER IF EXISTS {account}")
await cleanup_conn.commit()
async with get_data_conn() as metadata_conn:
async with metadata_conn.cursor() as cleanup_cur:
await cleanup_cur.execute(
"DELETE FROM dbeaver_access_grants WHERE id=%s",
(grant_id,),
)
await metadata_conn.commit()
await _refresh_dbeaver_authorized_keys()
except Exception:
pass
if isinstance(exc, ValueError):
raise
if isinstance(exc, HTTPException):
raise
raise HTTPException(
503,
"DBeaver账号签发失败,请确认MySQL账号签发权限和SSH网关已完成部署",
) from exc
return {
"id": grant_id,
"display_name": display_name,
"mysql_username": mysql_username,
"mysql_password": mysql_password,
"password_shown_once": True,
"database_id": database_id,
"database_name": database_name,
"permission_level": permission,
"ssh_key_type": key_type,
"ssh_key_fingerprint": fingerprint,
"status": "active",
**_mysql_access_details(),
}
async def revoke_dbeaver_access(grant_id: str, actor: str) -> dict[str, bool]:
await _ready()
_authorized_keys_path()
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT mysql_username, database_id, database_name, status
FROM dbeaver_access_grants
WHERE id=%s
""",
(grant_id,),
)
grant = await cur.fetchone()
if not grant:
raise ValueError("DBeaver管理账号不存在")
username = str(grant["mysql_username"])
if not _MYSQL_ACCOUNT_RE.fullmatch(username):
raise ValueError("DBeaver管理账号格式异常,已拒绝操作")
if str(grant["status"]) == "active":
try:
async with get_provisioner_conn() as provisioner_conn:
async with provisioner_conn.cursor() as cur:
await cur.execute(
f"DROP USER IF EXISTS {_mysql_account_sql(username)}"
)
await provisioner_conn.commit()
except Exception as exc:
raise HTTPException(503, "MySQL管理账号撤销失败,请检查账号签发服务") from exc
async with get_data_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
UPDATE dbeaver_access_grants
SET status='revoked', revoked_at=CURRENT_TIMESTAMP(6)
WHERE id=%s
""",
(grant_id,),
)
await _write_dbeaver_audit(
cur,
actor=actor,
action="revoke_dbeaver_access",
grant_id=grant_id,
outcome="success",
details={
"database_id": grant["database_id"],
"database_name": grant["database_name"],
"mysql_username": username,
},
)
await conn.commit()
try:
await _refresh_dbeaver_authorized_keys()
except Exception as exc:
raise HTTPException(503, "账号已撤销,但SSH公钥同步失败,请重试撤销操作") from exc
return {"ok": True}
async def interface_catalog() -> list[dict[str, Any]]:
await _ready()
async with get_data_conn() as conn: