feat: harden data access and simplify user management
This commit is contained in:
1 parent
3dd5731751
commit
adb780bc82
51 files changed
+3239
-2121
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user