From adb780bc82a465fd63e6832f7d92ac940764c1ab Mon Sep 17 00:00:00 2001 From: 3452078359-xuexue <3452078359@qq.com> Date: Tue, 25 Aug 2026 21:02:30 -0700 Subject: [PATCH] feat: harden data access and simplify user management --- .env.example | 24 +- Dockerfile | 4 +- README.md | 4 +- admin-web/src/App.tsx | 37 +- admin-web/src/api.ts | 55 +- .../panels/data-platform/DataCenterPanel.tsx | 207 +-- .../src/panels/system/AgentTasksPanel.tsx | 151 -- .../panels/system/InterfaceCenterPanel.tsx | 1404 +++++------------ admin-web/src/panels/system/LogsPanel.tsx | 48 - .../src/panels/system/PermissionsAudit.tsx | 209 --- .../src/panels/system/UserManagement.tsx | 332 ++-- admin-web/src/sshKey.ts | 97 ++ admin-web/src/styles.css | 206 ++- .../tests/interfaceCenterSurface.test.ts | 50 +- admin-web/tests/navigationSurface.test.ts | 17 +- admin-web/tests/sshKeyGeneration.test.ts | 15 + admin-web/tests/userManagementSurface.test.ts | 43 + app/api/__init__.py | 9 +- app/api/agent_call_logs.py | 23 - app/api/data_platform.py | 22 +- app/api/interface_center.py | 27 + app/api/rbac.py | 152 +- app/auth.py | 10 +- app/config.py | 16 +- app/contracts.py | 37 +- app/data_platform/interface_service.py | 429 ++++- app/data_platform/mysql_db.py | 49 +- app/data_platform/mysql_service.py | 20 + app/db.py | 217 +-- app/security_baseline.py | 191 ++- deploy/dbeaver-gateway/Dockerfile | 16 + deploy/dbeaver-gateway/entrypoint.sh | 42 + deploy/dbeaver-gateway/sshd_config | 35 + deploy/dbeaver-gateway/tunnel-only.sh | 7 + docker-compose.server.yml | 68 + docker-compose.yml | 38 +- docker/mysql-init/01-data-center-grants.sh | 39 + docker/mysql-storage-guard.sh | 39 + docs/API_REFERENCE.md | 4 +- docs/ARCHITECTURE.md | 2 +- docs/DEPLOYMENT.md | 182 ++- docs/data-center-mysql-interface-center.md | 81 +- scripts/backup_mysql_encrypted.sh | 137 +- scripts/enable_managed_dbeaver_access.sh | 10 + scripts/install_mysql_backup_systemd.sh | 108 ++ scripts/prepare_mysql_production_storage.sh | 109 ++ scripts/verify_mysql_backup.sh | 29 +- tests/test_management_surface.py | 15 +- tests/test_mysql_data_center.py | 78 +- tests/test_security_baseline.py | 91 +- tests/test_user_management.py | 125 ++ 51 files changed, 3239 insertions(+), 2121 deletions(-) delete mode 100644 admin-web/src/panels/system/AgentTasksPanel.tsx delete mode 100644 admin-web/src/panels/system/LogsPanel.tsx delete mode 100644 admin-web/src/panels/system/PermissionsAudit.tsx create mode 100644 admin-web/src/sshKey.ts create mode 100644 admin-web/tests/sshKeyGeneration.test.ts create mode 100644 admin-web/tests/userManagementSurface.test.ts delete mode 100644 app/api/agent_call_logs.py create mode 100644 deploy/dbeaver-gateway/Dockerfile create mode 100755 deploy/dbeaver-gateway/entrypoint.sh create mode 100644 deploy/dbeaver-gateway/sshd_config create mode 100755 deploy/dbeaver-gateway/tunnel-only.sh create mode 100755 docker/mysql-storage-guard.sh create mode 100755 scripts/enable_managed_dbeaver_access.sh create mode 100755 scripts/install_mysql_backup_systemd.sh create mode 100755 scripts/prepare_mysql_production_storage.sh create mode 100644 tests/test_user_management.py diff --git a/.env.example b/.env.example index 902a066..5848765 100644 --- a/.env.example +++ b/.env.example @@ -17,28 +17,48 @@ MYSQL_ROOT_PASSWORD=change-this-root-password MYSQL_DATABASE=platform_control MYSQL_USER=data_center MYSQL_PASSWORD=change-this-data-password +MYSQL_ACCESS_BROKER_USER=access_broker +MYSQL_ACCESS_BROKER_PASSWORD=change-this-access-broker-credential DATA_MYSQL_URL=mysql://data_center:change-this-data-password@mysql:3306/platform_control DATA_MYSQL_REQUIRED=true DATA_MYSQL_POOL_MIN_SIZE=2 DATA_MYSQL_POOL_MAX_SIZE=20 DATA_SQL_CONSOLE_WRITE_ENABLED=false INTERFACE_API_SECRET=change-me-interface-api-secret -DATA_MYSQL_DIRECT_ACCESS_ENABLED=false +DATA_MYSQL_DIRECT_ACCESS_ENABLED=true # Legacy fallback used by older deployments. Prefer DATA_MYSQL_SSH_HOST. DATA_MYSQL_PUBLIC_HOST= DATA_MYSQL_PUBLIC_PORT=0 DATA_MYSQL_DIRECT_TRANSPORT=SSH 隧道(强制) DATA_MYSQL_SSH_TUNNEL_REQUIRED=true DATA_MYSQL_SSH_HOST= -DATA_MYSQL_SSH_PORT=22 +DATA_MYSQL_SSH_PORT=2222 +DATA_MYSQL_SSH_BIND=127.0.0.1 +DATA_MYSQL_SSH_USERNAME=dbeaver DATA_MYSQL_SSH_AUTH_METHOD=SSH 私钥 / SSH Agent +DATA_MYSQL_MANAGED_ACCESS_ENABLED=true +DATA_MYSQL_SSH_AUTHORIZED_KEYS_FILE=/var/lib/travel-kg/dbeaver-access/authorized_keys +DATA_MYSQL_SSH_HOST_PUBLIC_KEY_FILE=/var/lib/travel-kg/dbeaver-access/ssh_host_ed25519_key.pub +DATA_MYSQL_PROVISIONER_USER=access_broker +DATA_MYSQL_PROVISIONER_PASSWORD=change-this-access-broker-credential DATA_MYSQL_ADMIN_HOST=127.0.0.1 DATA_MYSQL_ADMIN_PORT=3307 DATA_MYSQL_ADMIN_ACCOUNT_POLICY=一人一号 · 单库授权 · 禁止 root DATA_MYSQL_AUDIT_ENABLED=false +# 开发环境继续使用 Docker named volume。生产服务器覆盖文件强制改为独立块存储: +DATA_MYSQL_STORAGE_BACKEND=docker-volume +# 生产示例:/mnt/sdr、prod-mysql-storage-01、/mnt/sdr/nianxx/mysql +MYSQL_STORAGE_MOUNT= +MYSQL_STORAGE_ID= +MYSQL_DATA_DIR= +# mysql:8.4 官方镜像默认用户;更换镜像时先核对 UID/GID。 +MYSQL_CONTAINER_UID=999 +MYSQL_CONTAINER_GID=999 DATA_BACKUP_ENABLED=false DATA_BACKUP_ENCRYPTION_REQUIRED=true DATA_BACKUP_RETENTION_DAYS=30 +# 生产必须是代码仓库和 MySQL 运行目录之外的现有目录。 +DATA_BACKUP_ROOT= # 必须位于代码仓库之外,文件权限设为 600,内容至少 32 个随机字符。 DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase diff --git a/Dockerfile b/Dockerfile index d3811a4..0196206 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,8 +39,8 @@ COPY app ./app COPY schema搭建 ./schema搭建 COPY --from=admin-web-build /build/app/static/admin ./app/static/admin -RUN addgroup --system appgroup \ - && adduser --system --ingroup appgroup --no-create-home appuser \ +RUN addgroup --system --gid 10001 appgroup \ + && adduser --system --uid 10001 --ingroup appgroup --no-create-home appuser \ && chown -R appuser:appgroup /app USER appuser diff --git a/README.md b/README.md index 1a47c30..fe188b1 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ ## 项目亮点 -- 精简后台:图谱中心、数据中心、知识广场、本体 Schema 查看,以及用户、权限和调用日志。 -- 双数据库架构:PostgreSQL 保存项目与结构化业务数据,FalkorDB 保存可查询图谱。 +- 精简后台:图谱中心、数据中心、知识广场、本体 Schema,以及用户管理和接口中心。 +- 分层存储:MySQL 保存数据中心关系数据,PostgreSQL 保存系统元数据,FalkorDB 保存可查询图谱。 - 可复现数据:仓库内置 PostgreSQL 与 FalkorDB 快照,下载后可恢复图谱数据。 - Docker 一键启动:`docker compose up -d --build` 同时启动 API、管理后台、PostgreSQL 和 FalkorDB。 - 旅行客服场景:内置固定线路、周边资源、酒店报价、车辆、行程推荐和图谱问答相关接口。 diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index dfd5ee1..4359086 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -23,9 +23,6 @@ import ProjectUserExperiencePanel from "./panels/plaza/ProjectUserExperiencePane import OntologySchemaPanel from "./panels/modeling/OntologySchemaPanel"; // System panels -import AgentTasksPanel from "./panels/system/AgentTasksPanel"; -import LogsPanel from "./panels/system/LogsPanel"; -import PermissionsAudit from "./panels/system/PermissionsAudit"; import UserManagement from "./panels/system/UserManagement"; import InterfaceCenterPanel from "./panels/system/InterfaceCenterPanel"; @@ -51,10 +48,7 @@ const MENU_ITEMS = [ ]}, { key: "/system", icon: , label: "系统", children: [ { key: "/system/interfaces", icon: , label: "接口中心" }, - { key: "/system/agents", label: "Agent 任务" }, - { key: "/system/logs", label: "调用日志" }, { key: "/system/users", label: "用户管理" }, - { key: "/system/permissions", label: "角色权限" }, ]}, ]; @@ -62,6 +56,7 @@ export default function App() { const [authed, setAuthed] = useState(false); const [checking, setChecking] = useState(true); const [collapsed, setCollapsed] = useState(false); + const [roles, setRoles] = useState([]); const [projectContext, setProjectContextState] = useState(getProjectContext()); const navigate = useNavigate(); const location = useLocation(); @@ -70,10 +65,12 @@ export default function App() { const token = localStorage.getItem("token"); if (!token) { setChecking(false); return; } try { - await whoami(); + const { data } = await whoami(); + setRoles(Array.isArray(data?.roles) ? data.roles : []); setAuthed(true); } catch { localStorage.removeItem("token"); + setRoles([]); } setChecking(false); }, []); @@ -88,6 +85,7 @@ export default function App() { const handleLogout = () => { localStorage.removeItem("token"); + setRoles([]); setAuthed(false); navigate("/admin/login"); }; @@ -97,8 +95,8 @@ export default function App() { if (!authed) { return ( - { setAuthed(true); navigate("/admin/projects"); }} />} /> - { setAuthed(true); navigate("/admin/projects"); }} />} /> + { setAuthed(true); void checkAuth(); navigate("/admin/projects"); }} />} /> + { setAuthed(true); void checkAuth(); navigate("/admin/projects"); }} />} /> } /> ); @@ -116,6 +114,10 @@ export default function App() { const openKeys = [selectedKey.split("/").slice(0, 2).join("/")]; const showProjectContextBar = location.pathname === "/admin/projects"; const showDataCenterWorkbench = location.pathname === "/admin/data"; + const isDataManager = roles.includes("admin"); + const visibleMenuItems = isDataManager + ? MENU_ITEMS + : MENU_ITEMS.filter((item) => item.key !== "/system"); return ( @@ -147,7 +149,7 @@ export default function App() { mode="inline" selectedKeys={[selectedKey]} defaultOpenKeys={collapsed ? [] : openKeys} - items={MENU_ITEMS} + items={visibleMenuItems} onClick={onMenuClick} style={{ borderRight: 0 }} /> @@ -177,7 +179,7 @@ export default function App() { {showProjectContextBar && } } /> - } /> + } /> {/* Plaza */} } /> } /> @@ -187,11 +189,14 @@ export default function App() { } /> {/* System */} - } /> - } /> - } /> - } /> - } /> + : } + /> + : } + /> navigate("/admin/projects")} />} /> } /> diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts index d50cbd6..4e91ffa 100644 --- a/admin-web/src/api.ts +++ b/admin-web/src/api.ts @@ -372,6 +372,7 @@ export type InterfaceSummary = { active_policies: number; calls_24h: number; errors_24h: number; + active_dbeaver_grants?: number; data_engine: string; public_base_path: string; direct_database_access: string; @@ -382,7 +383,12 @@ export type InterfaceSummary = { ssh_tunnel_required?: boolean; ssh_host?: string; ssh_port?: number; + ssh_username?: string; ssh_auth_method?: string; + managed_access_enabled?: boolean; + ssh_key_auto_install?: boolean; + account_provisioning_ready?: boolean; + ssh_host_key_fingerprint?: string; database_host?: string; database_port?: number; database_account_policy?: string; @@ -396,6 +402,28 @@ export type InterfaceSummary = { backup_retention_days?: number; }; +export type DbeaverAccessGrant = { + id: string; + display_name: string; + mysql_username: string; + mysql_password?: string; + password_shown_once?: boolean; + database_id: string; + database_name: string; + permission_level: "read" | "write"; + ssh_key_type: string; + ssh_key_fingerprint: string; + ssh_host?: string; + ssh_port?: number; + ssh_username?: string; + database_host?: string; + database_port?: number; + created_by?: string; + status: "active" | "revoked"; + revoked_at?: string | null; + created_at?: string; +}; + export type InterfaceClient = { id: string; name: string; @@ -477,6 +505,12 @@ export const getInterfaceCenterSummary = () => api.get("/interface-center/summary"); export const getInterfaceCenterCatalog = () => api.get("/interface-center/catalog"); +export const listDbeaverAccessGrants = () => + api.get("/interface-center/dbeaver-access"); +export const issueDbeaverAccess = (data: unknown) => + api.post("/interface-center/dbeaver-access", data); +export const revokeDbeaverAccess = (grantId: string) => + api.post(`/interface-center/dbeaver-access/${encodeURIComponent(grantId)}/revoke`, {}); export const listInterfaceClients = () => api.get("/interface-center/clients"); export const createInterfaceClient = (data: unknown) => @@ -541,30 +575,11 @@ export const graphOverview = () => api.get("/graph/overview"); export const graphQuery = (cypher: string, limit = 200) => api.post("/graph/query", { cypher, limit }); -// ── RBAC & Accounts (P1) ── -export const listRoles = () => api.get("/roles"); -export const createRole = (data: unknown) => api.post("/roles", data); -export const updateRole = (roleKey: string, data: unknown) => - api.patch(`/roles/${roleKey}`, data); -export const deleteRole = (roleKey: string) => api.delete(`/roles/${roleKey}`); - -export const listCapabilities = () => api.get("/capabilities"); -export const createCapability = (data: unknown) => api.post("/capabilities", data); -export const deleteCapability = (capKey: string) => - api.delete(`/capabilities/${capKey}`); - -export const getPermissionMatrix = () => api.get("/permission-matrix"); -export const setPermissionCell = ( - roleKey: string, - capKey: string, - value: string -) => api.put("/permission-matrix", { role_key: roleKey, cap_key: capKey, value }); - +// ── User management ── export const listUsers = () => api.get("/users"); export const createUser = (data: unknown) => api.post("/users", data); export const updateUser = (id: number, data: unknown) => api.patch(`/users/${id}`, data); -export const deleteUser = (id: number) => api.delete(`/users/${id}`); // ── Super Agent (autonomous KG curator) ── export const superAgentRun = () => api.post("/super-agent/run", {}); diff --git a/admin-web/src/panels/data-platform/DataCenterPanel.tsx b/admin-web/src/panels/data-platform/DataCenterPanel.tsx index 41ce9a5..b018856 100644 --- a/admin-web/src/panels/data-platform/DataCenterPanel.tsx +++ b/admin-web/src/panels/data-platform/DataCenterPanel.tsx @@ -299,6 +299,7 @@ function TableStructureView({ tableCode, inspection, loading, + canManage, onRefresh, onChanged, }: { @@ -306,6 +307,7 @@ function TableStructureView({ tableCode: string; inspection: TableInspection | null; loading: boolean; + canManage: boolean; onRefresh: () => void; onChanged: () => Promise; }) { @@ -482,7 +484,7 @@ function TableStructureView({ key: "actions", width: 112, fixed: "right", - render: (_value, record) => record.structure_editable ? ( + render: (_value, record) => canManage && record.structure_editable ? ( - + {canManage && ( + + )} {inspection ? ( @@ -918,7 +922,7 @@ function SqlConsoleView({ ); } -export default function DataCenterPanel() { +export default function DataCenterPanel({ canManage = true }: { canManage?: boolean }) { const [databaseForm] = Form.useForm(); const [databaseRenameForm] = Form.useForm(); const [tableForm] = Form.useForm(); @@ -1778,7 +1782,7 @@ export default function DataCenterPanel() { : value; const isEditing = editingCell?.recordId === record.id && editingCell.fieldCode === field.code; - const canEdit = field.editable && ( + const canEdit = canManage && field.editable && ( draftRecord ? selectedTable?.allow_create : selectedTable?.allow_update ); if (isEditing && canEdit) { @@ -1815,7 +1819,7 @@ export default function DataCenterPanel() { }, }))]; return dataColumns; - }, [editingCell, pendingChanges, pendingDeletes, selectedRecordId, selectedTable, visibleFields]); + }, [canManage, editingCell, pendingChanges, pendingDeletes, selectedRecordId, selectedTable, visibleFields]); return (
@@ -1827,7 +1831,7 @@ export default function DataCenterPanel() {
{!explorerCollapsed && 数据中心}
- {!explorerCollapsed && ( + {!explorerCollapsed && canManage && ( + {canManage && ( + + )}
{selectedDatabase && ( @@ -2119,14 +2127,16 @@ export default function DataCenterPanel() { style={{ width: 250 }} /> - + {canManage && ( + + )}
@@ -2177,56 +2187,60 @@ export default function DataCenterPanel() {
- - - - + {canManage && ( + <> + + + + + + )} - + {canManage && ( + + )} - {pendingCount + {!canManage + ? "只读权限" + : pendingCount ? `${pendingSummary}待保存` : "双击单元格编辑"} @@ -2268,6 +2286,7 @@ export default function DataCenterPanel() { tableCode={selectedTableCode} inspection={tableInspection} loading={inspectionLoading} + canManage={canManage} onRefresh={loadTableInspection} onChanged={reloadAfterStructureChange} /> diff --git a/admin-web/src/panels/system/AgentTasksPanel.tsx b/admin-web/src/panels/system/AgentTasksPanel.tsx deleted file mode 100644 index f7d215c..0000000 --- a/admin-web/src/panels/system/AgentTasksPanel.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Tag, Space, Typography } from "antd"; -import { - RobotOutlined, SwapOutlined, ScanOutlined, - ThunderboltOutlined, ToolOutlined, -} from "@ant-design/icons"; -import api from "../../api"; - -const { Text } = Typography; - -const AGENT_META: Record = { - aligner: { label: "实体归一", icon: }, - field_mapping:{ label: "字段映射", icon: }, - schema_lint: { label: "Schema 审计", icon: }, - extraction: { label: "值归一", icon: }, -}; - -const STATUS_LABELS: Record = { - success: "成功", error: "失败", timeout: "超时", -}; -const STATUS_COLORS: Record = { - success: "green", error: "red", timeout: "orange", -}; - -function fmtBeijing(iso: string): string { - if (!iso) return "—"; - const d = new Date(iso); - if (isNaN(d.getTime())) return iso; - // Render the absolute instant in Beijing time regardless of the ISO - // suffix (Z / +00:00 / offset) or the viewer's local timezone. - const parts = new Intl.DateTimeFormat("zh-CN", { - timeZone: "Asia/Shanghai", - year: "numeric", month: "2-digit", day: "2-digit", - hour: "2-digit", minute: "2-digit", second: "2-digit", - hour12: false, - }).formatToParts(d); - const p: Record = {}; - for (const x of parts) p[x.type] = x.value; - return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`; -} - -export default function AgentTasksPanel() { - const [logs, setLogs] = useState>>([]); - const [loading, setLoading] = useState(false); - - const fetch = async () => { - setLoading(true); - try { - const { data } = await api.get("/agent-call-logs"); - setLogs(data.filter( - (log: Record) => !["publisher", "auditor"].includes(String(log.agent_name)), - )); - } catch { - // fallback sample - setLogs([]); - } - setLoading(false); - }; - - useEffect(() => { fetch(); }, []); - - return ( -
-

Agent 任务

- - 0 ? logs : []} - loading={loading} - rowKey="id" - pagination={{ pageSize: 20, hideOnSinglePage: true, showSizeChanger: true }} - locale={{ emptyText: "暂无调用记录,Agent 运行后将在这里显示日志" }} - columns={[ - { - title: "Agent", - dataIndex: "agent_name", - width: 220, - render: (v: string) => { - const meta = AGENT_META[v]; - return ( - - - {meta?.icon || } - - {meta?.label || v} - - ); - }, - }, - { - title: "调用对象", - dataIndex: "request_id", - width: 120, - ellipsis: true, - render: (v: string) => v ? {v} : —, - }, - { - title: "模型", - dataIndex: "model", - width: 140, - ellipsis: true, - render: (v: string) => v || —, - }, - { - title: "提示词长度", - dataIndex: "prompt_chars", - width: 100, - align: "right", - render: (v: number) => v != null ? `${v.toLocaleString()} 字` : "—", - }, - { - title: "返回值长度", - dataIndex: "response_chars", - width: 110, - align: "right", - render: (v: number) => v != null ? `${v.toLocaleString()} 字` : "—", - }, - { - title: "延迟", - dataIndex: "latency_ms", - width: 120, - align: "right", - sorter: (a: any, b: any) => (a.latency_ms || 0) - (b.latency_ms || 0), - render: (v: number) => v != null ? `${v} ms` : "—", - }, - { - title: "状态", - dataIndex: "status", - width: 80, - align: "center", - render: (v: string) => ( - {STATUS_LABELS[v] || v} - ), - }, - { - title: "调用时间(北京)", - dataIndex: "created_at", - width: 180, - sorter: (a: any, b: any) => - new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime(), - defaultSortOrder: "descend", - render: (v: string) => ( - - {fmtBeijing(v)} - - ), - }, - ]} - /> - - ); -} diff --git a/admin-web/src/panels/system/InterfaceCenterPanel.tsx b/admin-web/src/panels/system/InterfaceCenterPanel.tsx index 2c5a398..31a4ad9 100644 --- a/admin-web/src/panels/system/InterfaceCenterPanel.tsx +++ b/admin-web/src/panels/system/InterfaceCenterPanel.tsx @@ -12,136 +12,78 @@ import { Select, Space, Spin, - Switch, Table, Tag, Typography, message, } from "antd"; import { - ApiOutlined, CheckCircleOutlined, CopyOutlined, DatabaseOutlined, - EditOutlined, + DownloadOutlined, ExclamationCircleOutlined, FileProtectOutlined, - GlobalOutlined, - HistoryOutlined, KeyOutlined, - LinkOutlined, LockOutlined, - PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, - SettingOutlined, UserOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import { - createInterfaceClient, - createInterfaceCredential, - createInterfacePolicy, - deleteInterfaceClient, - deleteInterfacePolicy, getInterfaceCenterCatalog, getInterfaceCenterSummary, - listInterfaceCallLogs, - listInterfaceClients, - listInterfaceCredentials, - listInterfacePolicies, - revokeInterfaceCredential, - updateInterfaceClient, - updateInterfacePolicy, - type InterfaceCallLog, + issueDbeaverAccess, + listDbeaverAccessGrants, + revokeDbeaverAccess, + type DbeaverAccessGrant, type InterfaceCatalogDatabase, - type InterfaceClient, - type InterfaceCredential, - type InterfacePolicy, type InterfaceSummary, } from "../../api"; +import { + generateDbeaverSshKeyPair, + type BrowserSshKeyPair, +} from "../../sshKey"; -const { Text, Title, Paragraph } = Typography; - -type AccessMode = "mysql" | "api"; -type WorkspaceView = "connection" | "security"; - -const ACTION_LABELS: Record = { - metadata: "查看结构", - read: "查询", - create: "新增", - update: "修改", - delete: "删除", -}; - -const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ - value, - label, -})); - -const formatTime = (value?: string | null) => - value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "-"; +const { Paragraph, Text, Title } = Typography; const errorDetail = (error: any, fallback: string) => - error?.response?.data?.detail || fallback; + error?.response?.data?.detail || error?.message || fallback; -const credentialIsActive = (credential: InterfaceCredential) => - !credential.revoked_at - && (!credential.expires_at || dayjs(credential.expires_at).isAfter(dayjs())); +const formatTime = (value?: string | null) => + value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"; export default function InterfaceCenterPanel() { const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); const [loadError, setLoadError] = useState(""); const [summary, setSummary] = useState(null); - const [clients, setClients] = useState([]); - const [credentials, setCredentials] = useState([]); - const [policies, setPolicies] = useState([]); const [catalog, setCatalog] = useState([]); - const [logs, setLogs] = useState([]); - - const [accessMode, setAccessMode] = useState("mysql"); - const [workspaceView, setWorkspaceView] = useState("connection"); - const [selectedClientId, setSelectedClientId] = useState(""); - const [selectedAccessDatabaseId, setSelectedAccessDatabaseId] = useState(""); + const [grants, setGrants] = useState([]); + const [selectedDatabaseId, setSelectedDatabaseId] = useState(""); const [docsOpen, setDocsOpen] = useState(false); - const [logsOpen, setLogsOpen] = useState(false); - - const [clientOpen, setClientOpen] = useState(false); - const [editingClient, setEditingClient] = useState(null); - const [credentialOpen, setCredentialOpen] = useState(false); - const [credentialClient, setCredentialClient] = useState(null); - const [issuedKey, setIssuedKey] = useState(null); - const [policyOpen, setPolicyOpen] = useState(false); - const [editingPolicy, setEditingPolicy] = useState(null); - const [saving, setSaving] = useState(false); - - const [clientForm] = Form.useForm(); - const [credentialForm] = Form.useForm(); - const [policyForm] = Form.useForm(); - const policyDatabaseId = Form.useWatch("database_id", policyForm); - const policyTableCode = Form.useWatch("table_code", policyForm); + const [accessOpen, setAccessOpen] = useState(false); + const [issuedAccess, setIssuedAccess] = useState<{ + grant: DbeaverAccessGrant; + keyPair: BrowserSshKeyPair; + } | null>(null); + const [accessForm] = Form.useForm(); const load = useCallback(async () => { setLoading(true); setLoadError(""); try { - const [summaryResult, clientResult, credentialResult, policyResult, catalogResult, logResult] = - await Promise.all([ - getInterfaceCenterSummary(), - listInterfaceClients(), - listInterfaceCredentials(), - listInterfacePolicies(), - getInterfaceCenterCatalog(), - listInterfaceCallLogs(), - ]); + const [summaryResult, catalogResult, grantsResult] = await Promise.all([ + getInterfaceCenterSummary(), + getInterfaceCenterCatalog(), + listDbeaverAccessGrants(), + ]); setSummary(summaryResult.data); - setClients(clientResult.data); - setCredentials(credentialResult.data); - setPolicies(policyResult.data); setCatalog(catalogResult.data); - setLogs(logResult.data); + setGrants(grantsResult.data); } catch (error: any) { - setLoadError(errorDetail(error, "接口中心加载失败")); + setLoadError(errorDetail(error, "数据库管理接入加载失败")); } finally { setLoading(false); } @@ -151,100 +93,39 @@ export default function InterfaceCenterPanel() { void load(); }, [load]); - useEffect(() => { - if (!clients.length) { - setSelectedClientId(""); - return; - } - setSelectedClientId((current) => { - if (current && clients.some((item) => item.id === current)) return current; - return clients.find((item) => item.status === "active")?.id || clients[0].id; - }); - }, [clients]); - useEffect(() => { if (!catalog.length) { - setSelectedAccessDatabaseId(""); + setSelectedDatabaseId(""); return; } - setSelectedAccessDatabaseId((current) => + setSelectedDatabaseId((current) => current && catalog.some((item) => item.project_id === current) ? current : catalog[0].project_id, ); }, [catalog]); - const selectedClient = useMemo( - () => clients.find((item) => item.id === selectedClientId) || null, - [clients, selectedClientId], - ); - const selectedAccessDatabase = useMemo( - () => catalog.find((item) => item.project_id === selectedAccessDatabaseId) || null, - [catalog, selectedAccessDatabaseId], - ); - const selectedClientCredentials = useMemo( - () => credentials.filter((item) => item.client_id === selectedClientId), - [credentials, selectedClientId], - ); - const selectedCredential = useMemo( - () => selectedClientCredentials.find(credentialIsActive) || null, - [selectedClientCredentials], - ); - const selectedClientPolicies = useMemo( - () => policies.filter((item) => item.client_id === selectedClientId), - [policies, selectedClientId], - ); - const selectedDatabasePolicies = useMemo( - () => selectedClientPolicies.filter( - (item) => item.database_id === selectedAccessDatabaseId && item.status === "active", - ), - [selectedAccessDatabaseId, selectedClientPolicies], - ); - const selectedActions = useMemo( - () => new Set(selectedDatabasePolicies.flatMap((item) => item.actions)), - [selectedDatabasePolicies], - ); - const policyDatabase = useMemo( - () => catalog.find((item) => item.project_id === policyDatabaseId), - [catalog, policyDatabaseId], - ); - const policyTable = useMemo( - () => policyDatabase?.tables.find((item) => item.code === policyTableCode), - [policyDatabase, policyTableCode], + const selectedDatabase = useMemo( + () => catalog.find((item) => item.project_id === selectedDatabaseId) || null, + [catalog, selectedDatabaseId], ); const browserHost = window.location.hostname || "服务器域名"; const sshHost = summary?.ssh_host || summary?.direct_access_host || browserHost; - const sshPort = summary?.ssh_port || 22; - const sshAuthMethod = summary?.ssh_auth_method || "SSH 私钥 / SSH Agent"; + const sshPort = summary?.ssh_port || 2222; + const sshUsername = summary?.ssh_username || "dbeaver"; + const sshFingerprint = summary?.ssh_host_key_fingerprint || "SSH 网关启动后自动显示"; const mysqlHost = summary?.database_host || "127.0.0.1"; const mysqlPort = summary?.database_port || summary?.direct_access_port || 3307; - const mysqlTransport = summary?.direct_access_transport || "SSH 隧道(强制)"; - const mysqlAccountPolicy = summary?.database_account_policy || "一人一号 · 单库授权 · 禁止 root"; + const databaseName = selectedDatabase?.database_name || "请选择数据库"; + const mysqlBindingKnown = typeof summary?.mysql_publicly_bound === "boolean"; const mysqlPubliclyBound = summary?.mysql_publicly_bound === true; - const mysqlAuditEnabled = summary?.database_audit_enabled === true; - const backupEnabled = summary?.backup_enabled === true; - const backupRetentionDays = summary?.backup_retention_days || 30; - const mysqlSecurityReady = !mysqlPubliclyBound && mysqlAuditEnabled && backupEnabled; - const databaseName = selectedAccessDatabase?.database_name || "请选择数据库"; - const apiBaseUrl = `${window.location.origin}${summary?.public_base_path || "/v1/openapi/data"}`; - const apiCatalogUrl = `${apiBaseUrl}/catalog`; - const dbeaverParameters = [ - "DBeaver Main", - `Server Host: ${mysqlHost}`, - `Port: ${mysqlPort}`, - `Database: ${databaseName}`, - "Show all databases: 关闭", - "Username: 由运维签发的个人 MySQL 账号", - "", - "DBeaver SSH", - `Host/IP: ${sshHost}`, - `Port: ${sshPort}`, - `Authentication: ${sshAuthMethod}`, - "", - `安全策略: ${mysqlAccountPolicy}`, - `网络通道: ${mysqlTransport}`, - ].join("\n"); + const managedAccessReady = summary?.direct_access_enabled === true + && summary?.managed_access_enabled === true + && summary?.ssh_key_auto_install === true + && summary?.account_provisioning_ready === true + && !!summary?.ssh_host_key_fingerprint; + const activeGrantCount = grants.filter((item) => item.status === "active").length; const copyText = async (value: string, success = "已复制") => { try { @@ -255,681 +136,152 @@ export default function InterfaceCenterPanel() { } }; - const openCreateClient = () => { - setEditingClient(null); - clientForm.resetFields(); - setClientOpen(true); + const downloadTextFile = (filename: string, content: string) => { + const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); }; - const openEditClient = (client: InterfaceClient) => { - setEditingClient(client); - clientForm.setFieldsValue({ - name: client.name, - description: client.description, - status: client.status, + const openAccess = () => { + accessForm.resetFields(); + accessForm.setFieldsValue({ + database_id: selectedDatabaseId || undefined, + permission: "read", }); - setClientOpen(true); + setAccessOpen(true); }; - const submitClient = async () => { - const values = await clientForm.validateFields(); + const submitAccess = async () => { + const values = await accessForm.validateFields(); setSaving(true); try { - if (editingClient) { - await updateInterfaceClient(editingClient.id, values); - } else { - const result = await createInterfaceClient(values); - setSelectedClientId(result.data.id); - } - message.success(editingClient ? "接入方已更新" : "接入方已创建"); - setClientOpen(false); + // The private key is generated and retained only in this browser tab. + // The server receives the public key in the request below. + const keyPair = await generateDbeaverSshKeyPair(values.display_name); + const result = await issueDbeaverAccess({ + display_name: values.display_name, + database_id: values.database_id, + permission: values.permission, + public_key: keyPair.publicKey, + }); + setSelectedDatabaseId(values.database_id); + setAccessOpen(false); + setIssuedAccess({ grant: result.data, keyPair }); await load(); } catch (error: any) { - message.error(errorDetail(error, "保存失败")); + message.error(errorDetail(error, "DBeaver 管理接入生成失败")); } finally { setSaving(false); } }; - const openCredential = (client: InterfaceClient) => { - setCredentialClient(client); - credentialForm.resetFields(); - credentialForm.setFieldsValue({ name: "服务器访问密钥" }); - setCredentialOpen(true); - }; + const issuedParameters = issuedAccess ? [ + "DBeaver · Main", + `Server Host: ${issuedAccess.grant.database_host || mysqlHost}`, + `Port: ${issuedAccess.grant.database_port || mysqlPort}`, + `Database: ${issuedAccess.grant.database_name}`, + `Username: ${issuedAccess.grant.mysql_username}`, + `Password: ${issuedAccess.grant.mysql_password || ""}`, + "Show all databases: 关闭", + "", + "DBeaver · SSH", + `Host/IP: ${issuedAccess.grant.ssh_host || sshHost}`, + `Port: ${issuedAccess.grant.ssh_port || sshPort}`, + `User Name: ${issuedAccess.grant.ssh_username || sshUsername}`, + "Authentication Method: Public Key", + `Private Key: ${issuedAccess.keyPair.privateKeyFilename}`, + `Host Key Fingerprint: ${sshFingerprint}`, + "Bypass host verification: 关闭", + ].join("\n") : ""; - const submitCredential = async () => { - if (!credentialClient) return; - const values = await credentialForm.validateFields(); - setSaving(true); - try { - const result = await createInterfaceCredential(credentialClient.id, values); - setCredentialOpen(false); - setIssuedKey(result.data); - await load(); - } catch (error: any) { - message.error(errorDetail(error, "签发密钥失败")); - } finally { - setSaving(false); - } - }; - - const openCreatePolicy = () => { - setEditingPolicy(null); - policyForm.resetFields(); - policyForm.setFieldsValue({ - client_id: selectedClientId || undefined, - database_id: selectedAccessDatabaseId || undefined, - table_code: "*", - actions: ["metadata", "read"], - readable_fields: ["*"], - writable_fields: ["*"], - row_filter: "{}", - status: "active", - }); - setPolicyOpen(true); - }; - - const openEditPolicy = (policy: InterfacePolicy) => { - setEditingPolicy(policy); - policyForm.setFieldsValue({ - client_id: policy.client_id, - database_id: policy.database_id, - table_code: policy.table_code, - actions: policy.actions, - readable_fields: policy.readable_fields, - writable_fields: policy.writable_fields, - row_filter: JSON.stringify(policy.row_filter || {}, null, 2), - status: policy.status, - }); - setPolicyOpen(true); - }; - - const submitPolicy = async () => { - const values = await policyForm.validateFields(); - let rowFilter: Record; - try { - rowFilter = JSON.parse(values.row_filter || "{}"); - if (!rowFilter || Array.isArray(rowFilter) || typeof rowFilter !== "object") { - throw new Error(); - } - } catch { - message.error("行级数据范围必须是合法 JSON 对象"); - return; - } - setSaving(true); - try { - const payload = { ...values, row_filter: rowFilter }; - if (editingPolicy) { - await updateInterfacePolicy(editingPolicy.id, payload); - } else { - await createInterfacePolicy(payload); - } - message.success(editingPolicy ? "权限策略已更新" : "权限策略已创建"); - setPolicyOpen(false); - await load(); - } catch (error: any) { - message.error(errorDetail(error, "保存权限策略失败")); - } finally { - setSaving(false); - } - }; - - const credentialColumns = [ + const grantColumns = [ { - title: "密钥", - dataIndex: "name", - render: (value: string, row: InterfaceCredential) => ( + title: "数据管理员", + dataIndex: "display_name", + render: (value: string, row: DbeaverAccessGrant) => ( {value} - {row.key_prefix}•••••••• + {row.mysql_username} ), }, - { title: "最近使用", dataIndex: "last_used_at", render: formatTime, width: 168 }, - { title: "到期时间", dataIndex: "expires_at", render: formatTime, width: 168 }, + { + title: "数据库", + render: (_: unknown, row: DbeaverAccessGrant) => ( + + {catalog.find((item) => item.project_id === row.database_id)?.display_name || row.database_name} + {row.database_name} + + ), + }, + { + title: "权限", + dataIndex: "permission_level", + width: 86, + render: (value: DbeaverAccessGrant["permission_level"]) => ( + {value === "write" ? "读写" : "只读"} + ), + }, + { + title: "SSH 公钥", + dataIndex: "ssh_key_fingerprint", + width: 190, + render: (value: string) => {`${value.slice(0, 22)}…`}, + }, + { + title: "生成时间", + dataIndex: "created_at", + width: 142, + render: formatTime, + }, { title: "状态", - width: 84, - render: (_: unknown, row: InterfaceCredential) => credentialIsActive(row) + dataIndex: "status", + width: 82, + render: (value: DbeaverAccessGrant["status"]) => value === "active" ? 有效 - : {row.revoked_at ? "已撤销" : "已过期"}, + : 已撤销, }, { title: "操作", width: 76, - render: (_: unknown, row: InterfaceCredential) => credentialIsActive(row) ? ( + render: (_: unknown, row: DbeaverAccessGrant) => row.status === "active" ? ( { try { - await revokeInterfaceCredential(row.id); - message.success("密钥已撤销"); + await revokeDbeaverAccess(row.id); + message.success("管理接入已撤销"); await load(); } catch (error: any) { message.error(errorDetail(error, "撤销失败")); } }} > - + ) : null, }, ]; - const policyColumns = [ - { - title: "数据范围", - render: (_: unknown, row: InterfacePolicy) => ( - - {row.database_name || row.database_id} - {row.table_code === "*" ? "全部数据表" : row.table_code} - - ), - }, - { - title: "允许动作", - dataIndex: "actions", - render: (values: string[]) => ( - - {values.map((value) => ( - - {ACTION_LABELS[value] || value} - - ))} - - ), - }, - { - title: "状态", - dataIndex: "status", - width: 84, - render: (value: string) => value === "active" - ? 启用 - : 停用, - }, - { - title: "操作", - width: 128, - render: (_: unknown, row: InterfacePolicy) => ( - - - { - try { - await deleteInterfacePolicy(row.id); - message.success("权限策略已删除"); - await load(); - } catch (error: any) { - message.error(errorDetail(error, "删除失败")); - } - }} - > - - - - ), - }, - ]; - - const logColumns = [ - { title: "时间", dataIndex: "created_at", render: formatTime, width: 168 }, - { title: "接入方", dataIndex: "client_name", render: (value: string) => value || "未认证", width: 150 }, - { - title: "请求", - render: (_: unknown, row: InterfaceCallLog) => ( - - {row.method}{row.action_name || "-"} - {row.path} - - ), - }, - { - title: "结果", - width: 112, - render: (_: unknown, row: InterfaceCallLog) => ( - - {row.status_code} - {Number(row.duration_ms).toFixed(2)} ms - - ), - }, - { title: "来源 IP", dataIndex: "source_ip", render: (value: string) => value || "-", width: 136 }, - ]; - - const mysqlConnection = ( - <> -
- -
- 安全连接模式:SSH 隧道 + 独立 MySQL 账号 - MySQL 只监听服务器本机;DBeaver 先通过 SSH 加密通道,再访问数据库。 -
- - {mysqlPubliclyBound ? "检测到公网绑定" : "公网端口已关闭"} - -
- -
-
- 01 - DBeaver · SSH - 在 SSH 标签页填写服务器信息 -
-
- - - -
-
- -
-
- 02 - DBeaver · Main - 数据库主机必须填写隧道远端的本机地址 -
-
- - - -
- -
- -
-
- 03 - 账号与权限 - 连接参数不包含密码,权限由服务器运维签发 -
-
-
独立凭证密码不在接口中心保存
-
单库授权{databaseName}
-
默认只读写权限临时开通
-
-
- -
- - -
- - ); - - const apiConnection = ( - <> -
- -
- 本服务器向外提供受控 HTTPS 数据接口 - 每个电脑、设备或业务系统使用独立身份、密钥和最小权限。 -
- 服务端接入 -
- -
-
- 服务地址 - 外部设备使用该地址访问本服务器数据中心 -
-
- - -
-
- -
-
- 身份认证 - 完整密钥只在签发时显示一次 -
-
- - -
-
- -
-
- 目录接口预览 - 连接成功后先查询当前账号可访问的数据目录 -
- void copyText(apiCatalogUrl)} />} /> -
- -
- - -
- - ); - - const connectionSummary = ( - - ); - - const mysqlSecurity = ( -
-
-
-
DBeaver 管理接入安全边界生产服务器只接受经过 SSH 隧道的管理员连接
- - {mysqlPubliclyBound ? "需要修复公网绑定" : "MySQL 仅本机监听"} - -
-
-
01
网络层拒绝公网 MySQL

MySQL 绑定 127.0.0.1,安全组不开放数据库端口;公网只允许受控 SSH 与 HTTPS。

-
02
SSH 使用私钥或 Agent

DBeaver 的 SSH 标签页填写服务器地址;禁用共享 SSH 密码,并限制管理员来源 IP。

-
03
Main 页只连接 127.0.0.1

数据库端口通过 SSH 隧道抵达服务器本机,关闭 Show all databases,避免暴露无关库名。

-
04
一人一号并按数据库授权

禁止 root 和 data_center;默认只读,写权限按需临时授予,DROP 单独审批。

-
05
数据库操作独立审计

API 日志无法覆盖 DBeaver SQL,生产环境必须启用登录、DDL/DML 审计和 binlog 恢复。

-
06
加密备份与恢复演练

每日备份使用独立密钥加密并同步异地存储;每月至少完成一次隔离恢复演练。

-
- -
- -
- ); - - const apiSecurity = ( -
-
-
-
- 接入方 -
}} - /> - - -
-
-
权限策略限定数据库、数据表、字段、动作和行级范围
- -
-
}} - /> - - - )} - - - - - ); - return (
接口中心 - }>服务运行中 + }>DBeaver 管理接入 - 管理外部电脑、设备和业务系统访问本服务器数据中心。 + 为电脑上的 DBeaver 生成连接服务器数据中心所需的密钥、账号和最小权限。
- - +
@@ -938,7 +290,7 @@ export default function InterfaceCenterPanel() { void load()}>重试} className="interface-center-load-error" @@ -946,237 +298,311 @@ export default function InterfaceCenterPanel() { )} -
- - -
+
+
+
+ +
+ 电脑 DBeaver → SSH 加密隧道 → 服务器 MySQL + MySQL 不开放公网端口;SSH 账号只能转发数据库连接,不能进入服务器终端。 +
+ + {!mysqlBindingKnown ? "等待安全状态" : mysqlPubliclyBound ? "检测到风险绑定" : "MySQL 公网端口关闭"} + +
-
- - -
+
+
+ 01 + 选择服务器数据库 + 每个账号只允许访问一个数据库 +
+ + + + + + + +
+ + +
+
+
+ 03 + 生成密钥与数据库账号 + 私钥在当前浏览器生成,服务器只保存公钥 +
+ +
+ {!managedAccessReady && ( + + )} +
+
私钥不上传遗失后只能撤销重建
+
一人一号、单库授权不共享 root 或业务账号
+
默认只读写权限按需选择并可随时撤销
+
+
8 ? { pageSize: 8, size: "small" } : false} + locale={{ emptyText: }} + /> + + + + + - setDocsOpen(false)} width={640}> + setDocsOpen(false)} width={620}> - - {sshHost} - {sshPort} - {sshAuthMethod} - 仅允许管理员 IP 或 VPN;MySQL 端口不开放公网 - - + {mysqlHost} {mysqlPort} - {databaseName} + {databaseName} + 使用本页面生成的个人 MySQL 凭证 关闭 - 运维签发的个人 MySQL 账号 - {mysqlAccountPolicy} + + + {sshHost} + {sshPort} + {sshUsername} + Public Key,选择下载的 .pem 私钥 + {sshFingerprint} + 关闭 - - Authorization: Bearer YOUR_API_KEY - GET {summary?.public_base_path || "/v1/openapi/data"}/catalog - GET .../databases/{`{database_id}`}/tables/{`{table_code}`}/records - POST / PATCH / DELETE(必须单独授权) - -
-
查询示例
-
{`curl -H "Authorization: Bearer YOUR_API_KEY" \\\n  "${apiCatalogUrl}"`}
-
-
- setLogsOpen(false)} - width={960} - extra={} - > - {logs.length ? ( -
- ) : ( - - )} - - setClientOpen(false)} - onOk={() => void submitClient()} + title="生成 DBeaver 管理接入" + open={accessOpen} + onCancel={() => setAccessOpen(false)} + onOk={() => void submitAccess()} + okText="生成密钥与账号" confirmLoading={saving} destroyOnClose > -
- - + + + + - - - - {editingClient && ( - - - - - - - -
- - setIssuedKey(null)} - footer={} - closable={false} - maskClosable={false} - > - - - - - - setPolicyOpen(false)} - onOk={() => void submitPolicy()} - confirmLoading={saving} - width={760} - destroyOnClose - > -
-
- - ({ value: item.project_id, label: `${item.display_name} · ${item.database_name}` }))} - onChange={() => policyForm.setFieldsValue({ table_code: "*", readable_fields: ["*"], writable_fields: ["*"], row_filter: "{}" })} - /> - -
- + - -
- - ({ value: field.code, label: `${field.label} · ${field.code}` }))]} - /> - -
- - - - -
+ + setIssuedAccess(null)}> + 我已下载并安全保存 + + )} + > + + + {issuedAccess?.grant.display_name} + {issuedAccess?.grant.database_name} + {issuedAccess?.grant.permission_level === "write" ? "读写" : "只读"} + + + {issuedAccess?.grant.mysql_username} + + + + + + 在 DBeaver 的 SSH 页选择 Public Key,并加载下载的 .pem 私钥文件。 + + ); } diff --git a/admin-web/src/panels/system/LogsPanel.tsx b/admin-web/src/panels/system/LogsPanel.tsx deleted file mode 100644 index 24625cc..0000000 --- a/admin-web/src/panels/system/LogsPanel.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Tag, Input, Space } from "antd"; -import api from "../../api"; - -export default function LogsPanel() { - const [logs, setLogs] = useState>>([]); - const [search, setSearch] = useState(""); - - useEffect(() => { - api.get("/agent-call-logs").then(({ data }) => { - setLogs(data.filter( - (log: Record) => !["publisher", "auditor"].includes(String(log.agent_name)), - )); - }).catch(() => {}); - }, []); - - return ( -
-

LLM 调用日志

- - setSearch(e.target.value)} - style={{ width: 300 }} - /> - -
( - !search - || String(log.agent_name || "").includes(search) - || String(log.request_id || "").includes(search) - || String(log.model || "").includes(search) - ))} - rowKey="id" - columns={[ - { title: "Agent", dataIndex: "agent_name", width: 150 }, - { title: "请求", dataIndex: "request_id", ellipsis: true }, - { title: "模型", dataIndex: "model", width: 160, ellipsis: true }, - { title: "状态", dataIndex: "status", width: 90, render: (v: string) => {v} }, - { title: "耗时", dataIndex: "latency_ms", width: 100, render: (v: number) => v == null ? "—" : `${v} ms` }, - { title: "错误", dataIndex: "error_message", ellipsis: true }, - { title: "时间", dataIndex: "created_at", width: 180 }, - ]} - /> - - ); -} diff --git a/admin-web/src/panels/system/PermissionsAudit.tsx b/admin-web/src/panels/system/PermissionsAudit.tsx deleted file mode 100644 index f28928f..0000000 --- a/admin-web/src/panels/system/PermissionsAudit.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import { - Table, Tag, Card, Button, Space, Modal, Form, Input, InputNumber, - Select, Popconfirm, message, Spin, -} from "antd"; -import { PlusOutlined, DeleteOutlined, SafetyCertificateOutlined } from "@ant-design/icons"; -import { - listRoles, createRole, deleteRole, - listCapabilities, createCapability, deleteCapability, - getPermissionMatrix, setPermissionCell, -} from "../../api"; - -type Role = { role_key: string; label: string; description?: string; is_system: boolean; sort_order: number }; -type Cap = { cap_key: string; label: string; sort_order: number }; -type Matrix = Record>; - -const CELL_PRESETS = ["✓", "—", "部分", "查看", "领任务", "仅会签"]; - -function cellTag(v: string) { - if (v === "✓") return ✓; - if (v === "—" || !v) return —; - return {v}; -} - -export default function PermissionsAudit() { - const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState([]); - const [caps, setCaps] = useState([]); - const [matrix, setMatrix] = useState({}); - const [roleModal, setRoleModal] = useState(false); - const [capModal, setCapModal] = useState(false); - const [roleForm] = Form.useForm(); - const [capForm] = Form.useForm(); - - const load = useCallback(async () => { - setLoading(true); - try { - const { data } = await getPermissionMatrix(); - setRoles(data.roles); - setCaps(data.capabilities); - setMatrix(data.matrix || {}); - } catch { - message.error("加载权限矩阵失败"); - } - setLoading(false); - }, []); - - useEffect(() => { load(); }, [load]); - - const changeCell = async (capKey: string, roleKey: string, value: string) => { - const prev = matrix[capKey]?.[roleKey]; - setMatrix((m) => ({ ...m, [capKey]: { ...(m[capKey] || {}), [roleKey]: value } })); - try { - await setPermissionCell(roleKey, capKey, value); - } catch { - message.error("保存失败,已回滚"); - setMatrix((m) => ({ ...m, [capKey]: { ...(m[capKey] || {}), [roleKey]: prev || "—" } })); - } - }; - - const addRole = async () => { - const v = await roleForm.validateFields(); - try { - await createRole(v); - message.success("角色已新增"); - setRoleModal(false); - roleForm.resetFields(); - load(); - } catch (e: any) { - message.error(e?.response?.data?.detail || "新增失败"); - } - }; - - const removeRole = async (roleKey: string) => { - try { - await deleteRole(roleKey); - message.success("角色已删除"); - load(); - } catch (e: any) { - message.error(e?.response?.data?.detail || "删除失败(系统内置角色不可删)"); - } - }; - - const addCap = async () => { - const v = await capForm.validateFields(); - try { - await createCapability(v); - message.success("能力项已新增"); - setCapModal(false); - capForm.resetFields(); - load(); - } catch (e: any) { - message.error(e?.response?.data?.detail || "新增失败"); - } - }; - - const removeCap = async (capKey: string) => { - try { - await deleteCapability(capKey); - message.success("能力项已删除"); - load(); - } catch { - message.error("删除失败"); - } - }; - - const columns = [ - { - title: "能力 / 角色", - dataIndex: "label", - fixed: "left" as const, - width: 200, - render: (label: string, row: Cap) => ( - - {label} - removeCap(row.cap_key)}> - - - )} - - ), - dataIndex: r.role_key, - key: r.role_key, - align: "center" as const, - width: 160, - render: (_: any, row: Cap) => { - const val = matrix[row.cap_key]?.[r.role_key] ?? "—"; - return ( -
- - - setRoleModal(false)} okText="创建"> -
- - - - - - - - - -
- - setCapModal(false)} okText="创建"> -
- - - - - - - - -
- - ); -} diff --git a/admin-web/src/panels/system/UserManagement.tsx b/admin-web/src/panels/system/UserManagement.tsx index fbdf465..240daa6 100644 --- a/admin-web/src/panels/system/UserManagement.tsx +++ b/admin-web/src/panels/system/UserManagement.tsx @@ -1,168 +1,312 @@ -import { useEffect, useState, useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { - Table, Button, Space, Modal, Form, Input, Select, Tag, - Popconfirm, message, Spin, + Button, + Form, + Input, + message, + Modal, + Popconfirm, + Radio, + Select, + Space, + Table, + Tag, + Typography, } from "antd"; -import { PlusOutlined, UserOutlined, TeamOutlined } from "@ant-design/icons"; import { - listUsers, createUser, updateUser, deleteUser, listRoles, -} from "../../api"; + CheckCircleOutlined, + DatabaseOutlined, + EditOutlined, + EyeOutlined, + PlusOutlined, + StopOutlined, + TeamOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { createUser, listUsers, updateUser, whoami } from "../../api"; + +const { Text } = Typography; + +type DataPermission = "data_manage" | "data_view"; -type Role = { role_key: string; label: string }; type User = { - id: number; username: string; full_name?: string; phone?: string; - status: string; roles: string[]; + id: number; + username: string; + full_name?: string; + status: "active" | "disabled"; + permission: DataPermission; + created_at?: string; +}; + +const PERMISSIONS: Record = { + data_manage: { + label: "数据管理", + description: "可查看、导入、新增、修改数据,并管理用户与接口", + color: "blue", + }, + data_view: { + label: "数据查看", + description: "仅可查看和导出数据,不能进行任何写入", + color: "cyan", + }, }; export default function UserManagement() { const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); const [users, setUsers] = useState([]); - const [roles, setRoles] = useState([]); + const [currentUsername, setCurrentUsername] = useState(""); const [open, setOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); - const roleLabel = useCallback( - (k: string) => roles.find((r) => r.role_key === k)?.label || k, - [roles] - ); - const load = useCallback(async () => { setLoading(true); try { - const [u, r] = await Promise.all([listUsers(), listRoles()]); - setUsers(u.data); - setRoles(r.data); + const [userResponse, meResponse] = await Promise.all([listUsers(), whoami()]); + setUsers(userResponse.data); + setCurrentUsername(String(meResponse.data?.username || "")); } catch { - message.error("加载用户失败"); + message.error("用户列表加载失败"); + } finally { + setLoading(false); } - setLoading(false); }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + void load(); + }, [load]); const openCreate = () => { setEditing(null); form.resetFields(); - form.setFieldsValue({ status: "active", roles: [] }); + form.setFieldsValue({ + status: "active", + permission: "data_view", + }); setOpen(true); }; - const openEdit = (u: User) => { - setEditing(u); + const openEdit = (user: User) => { + setEditing(user); + form.resetFields(); form.setFieldsValue({ - username: u.username, full_name: u.full_name, phone: u.phone, - status: u.status, roles: u.roles, password: "", + username: user.username, + full_name: user.full_name, + status: user.status, + permission: user.permission, + password: "", }); setOpen(true); }; const submit = async () => { - const v = await form.validateFields(); + const values = await form.validateFields(); + setSaving(true); try { if (editing) { - const patch: any = { - full_name: v.full_name, phone: v.phone, status: v.status, roles: v.roles, + const patch: Record = { + full_name: values.full_name?.trim() || null, + status: values.status, + permission: values.permission, }; - if (v.password) patch.password = v.password; + if (values.password) patch.password = values.password; await updateUser(editing.id, patch); } else { - await createUser(v); + await createUser({ + username: values.username.trim(), + password: values.password, + full_name: values.full_name?.trim() || null, + status: values.status, + permission: values.permission, + }); } - message.success(editing ? "用户已更新" : "用户已创建"); + message.success(editing ? "用户信息已更新" : "用户已创建"); setOpen(false); - load(); - } catch (e: any) { - message.error(e?.response?.data?.detail || "保存失败"); + await load(); + } catch (error: any) { + message.error(error?.response?.data?.detail || "保存失败"); + } finally { + setSaving(false); } }; - const remove = async (u: User) => { + const changeStatus = async (user: User) => { + const nextStatus = user.status === "active" ? "disabled" : "active"; try { - await deleteUser(u.id); - message.success("用户已删除"); - load(); - } catch { - message.error("删除失败"); + await updateUser(user.id, { status: nextStatus }); + message.success(nextStatus === "active" ? "用户已启用" : "用户已停用"); + await load(); + } catch (error: any) { + message.error(error?.response?.data?.detail || "状态修改失败"); } }; + const editingCurrentUser = editing?.username === currentUsername; + const columns = [ - { title: "账号", dataIndex: "username", render: (v: string) => {v} }, - { title: "姓名", dataIndex: "full_name", render: (v: string) => v || "-" }, - { title: "电话", dataIndex: "phone", render: (v: string) => v || "-" }, { - title: "角色", dataIndex: "roles", width: 160, - render: (rs: string[]) => - rs?.length ? rs.map((r) => {roleLabel(r)}) : 无, - }, - { - title: "状态", dataIndex: "status", width: 80, - render: (s: string) => - s === "active" ? 启用 : 停用, - }, - { - title: "操作", key: "ops", width: 150, - render: (_: any, u: User) => ( - - - remove(u)}> - - - + title: "用户", + key: "user", + render: (_: unknown, user: User) => ( +
+ +
+ {user.full_name || user.username} + {user.full_name && {user.username}} +
+
), }, + { + title: "数据权限", + dataIndex: "permission", + width: 210, + render: (permission: DataPermission) => { + const meta = PERMISSIONS[permission] || PERMISSIONS.data_view; + return ( +
+ {meta.label} + {permission === "data_manage" ? "可读写" : "只读"} +
+ ); + }, + }, + { + title: "状态", + dataIndex: "status", + width: 110, + render: (status: User["status"]) => status === "active" + ? 正常 + : 已停用, + }, + { + title: "操作", + key: "actions", + width: 190, + render: (_: unknown, user: User) => { + const isCurrentUser = user.username === currentUsername; + return ( + + + changeStatus(user)} + > + + + + ); + }, + }, ]; return ( -
-

用户管理

-

- 管理登录账号、用户状态与角色分配。 -

- - - +
+
+
+

用户管理

+

管理系统登录账号及其数据访问范围。

+
+ +
- -
- +
+
+ +

数据管理{PERMISSIONS.data_manage.description}

+
+
+ +

数据查看{PERMISSIONS.data_view.description}

+
+
+ + + className="user-management-table" + dataSource={users} + rowKey="id" + columns={columns} + loading={loading} + pagination={users.length > 20 ? { pageSize: 20, showSizeChanger: false } : false} + locale={{ emptyText: "暂无用户" }} + /> setOpen(false)} okText="保存" - width={620} - destroyOnClose + cancelText="取消" + confirmLoading={saving} + width={560} + destroyOnHidden > -
- - + + + + + + - + - - - - - + + + + 数据管理 + 查看并维护数据,同时可以管理用户和接口 + + + 数据查看 + 只允许查看和导出,不允许写入 + + + + -
diff --git a/admin-web/src/sshKey.ts b/admin-web/src/sshKey.ts new file mode 100644 index 0000000..11eb445 --- /dev/null +++ b/admin-web/src/sshKey.ts @@ -0,0 +1,97 @@ +export type BrowserSshKeyPair = { + privateKeyPem: string; + publicKey: string; + privateKeyFilename: string; + publicKeyFilename: string; +}; + +const concatBytes = (...parts: Uint8Array[]) => { + const result = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + parts.forEach((part) => { + result.set(part, offset); + offset += part.length; + }); + return result; +}; + +const uint32 = (value: number) => { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, false); + return bytes; +}; + +const sshField = (value: Uint8Array) => concatBytes(uint32(value.length), value); + +const base64UrlBytes = (value: string) => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +}; + +const bytesToBase64 = (value: Uint8Array) => { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < value.length; offset += chunkSize) { + binary += String.fromCharCode(...value.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +}; + +const sshMpint = (input: Uint8Array) => { + let first = 0; + while (first < input.length - 1 && input[first] === 0) first += 1; + let value = input.subarray(first); + if (value[0] & 0x80) value = concatBytes(new Uint8Array([0]), value); + return sshField(value); +}; + +const pem = (label: string, bytes: Uint8Array) => { + const encoded = bytesToBase64(bytes); + const lines = encoded.match(/.{1,64}/g) || []; + return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`; +}; + +const safeComment = (displayName: string) => { + const value = displayName.trim().replace(/[^A-Za-z0-9._@-]+/g, "_").slice(0, 48); + return `nianxx-${value || "data-admin"}`; +}; + +export async function generateDbeaverSshKeyPair( + displayName: string, +): Promise { + if (!globalThis.crypto?.subtle) { + throw new Error("当前浏览器不支持安全密钥生成,请升级Chrome、Edge或Safari"); + } + const keyPair = await globalThis.crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 3072, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) as CryptoKeyPair; + const [privateKey, publicJwk] = await Promise.all([ + globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey), + globalThis.crypto.subtle.exportKey("jwk", keyPair.publicKey), + ]); + if (!publicJwk.e || !publicJwk.n) throw new Error("SSH公钥生成失败"); + + const keyType = new TextEncoder().encode("ssh-rsa"); + const publicBlob = concatBytes( + sshField(keyType), + sshMpint(base64UrlBytes(publicJwk.e)), + sshMpint(base64UrlBytes(publicJwk.n)), + ); + const suffix = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); + const filename = `nianxx_dbeaver_${suffix}`; + return { + privateKeyPem: pem("PRIVATE KEY", new Uint8Array(privateKey)), + publicKey: `ssh-rsa ${bytesToBase64(publicBlob)} ${safeComment(displayName)}`, + privateKeyFilename: `${filename}.pem`, + publicKeyFilename: `${filename}.pub`, + }; +} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 0065c53..27e88c6 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -3151,11 +3151,28 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro padding-top: 22px; } +.interface-center-dbeaver-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + max-width: 940px; +} + +.interface-center-dbeaver-toolbar .interface-center-section-heading { + min-width: 0; +} + +.interface-center-inline-notice { + max-width: 940px; + margin-bottom: 16px; +} + .interface-center-security-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; - max-width: 760px; + max-width: 940px; } .interface-center-security-strip > div { @@ -3201,6 +3218,12 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro padding-top: 20px; } +.interface-center-dbeaver-table { + max-width: 940px; + margin-top: 16px; + overflow-x: auto; +} + .interface-center-rail-title { display: flex; align-items: center; @@ -3476,6 +3499,174 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro margin-top: 12px; } +.interface-center-issued-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +.interface-center-issued-tip.ant-typography { + display: block; + margin-top: 12px; +} + +/* ── User management ── */ +.user-management-page { + max-width: 1180px; + margin: 0 auto; +} + +.user-management-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 18px; +} + +.user-management-header h2 { + margin: 0 0 5px; + color: #1f2937; + font-size: 22px; +} + +.user-management-header p { + margin: 0; + color: #7a8494; +} + +.user-management-permission-guide { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.user-management-permission-guide > div { + display: flex; + align-items: center; + gap: 12px; + min-height: 66px; + padding: 12px 15px; + border: 1px solid #e5eaf1; + border-radius: 8px; + background: #fafbfd; +} + +.user-management-permission-guide > div > span { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 8px; + font-size: 17px; +} + +.user-management-permission-guide .manage { + color: #1677ff; + background: #eaf3ff; +} + +.user-management-permission-guide .view { + color: #08979c; + background: #e6fffb; +} + +.user-management-permission-guide p { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; +} + +.user-management-permission-guide small { + color: #7a8494; + line-height: 1.45; +} + +.user-management-table { + overflow: hidden; + border: 1px solid #e5eaf1; + border-radius: 8px; +} + +.user-management-table .ant-table-thead > tr > th { + color: #5d6878; + background: #f7f9fc; +} + +.user-management-identity { + display: flex; + align-items: center; + gap: 11px; +} + +.user-management-avatar { + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + color: #55708f; + background: #edf2f7; + border-radius: 50%; + place-items: center; +} + +.user-management-identity > div, +.user-management-permission-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.user-management-identity .ant-typography { + font-size: 12px; +} + +.user-management-permission-cell .ant-tag { + margin: 0; +} + +.user-management-permission-cell .ant-typography { + font-size: 12px; +} + +.user-management-permission-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + width: 100%; +} + +.user-management-permission-options .ant-radio-wrapper { + align-items: flex-start; + min-height: 78px; + margin: 0; + padding: 12px; + border: 1px solid #dfe5ed; + border-radius: 8px; +} + +.user-management-permission-options .ant-radio-wrapper-checked { + border-color: #1677ff; + background: #f2f7ff; +} + +.user-management-permission-options .ant-radio + span { + display: flex; + flex-direction: column; + gap: 3px; + padding-inline-start: 8px; +} + +.user-management-permission-options .ant-radio + span > span { + color: #7a8494; + font-size: 12px; + line-height: 1.45; +} + @media (max-width: 1120px) { .interface-center-connection-layout, .interface-center-security-layout { @@ -3492,9 +3683,20 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro } @media (max-width: 860px) { + .user-management-header { + align-items: stretch; + flex-direction: column; + } + + .user-management-permission-guide, + .user-management-permission-options { + grid-template-columns: 1fr; + } + .interface-center-header, .interface-center-client-toolbar, - .interface-center-panel-heading { + .interface-center-panel-heading, + .interface-center-dbeaver-toolbar { align-items: stretch; flex-direction: column; } diff --git a/admin-web/tests/interfaceCenterSurface.test.ts b/admin-web/tests/interfaceCenterSurface.test.ts index dad8020..b27eefc 100644 --- a/admin-web/tests/interfaceCenterSurface.test.ts +++ b/admin-web/tests/interfaceCenterSurface.test.ts @@ -14,6 +14,14 @@ const serverComposeSource = readFileSync( "utf8", ); const dockerfileSource = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8"); +const backupScriptSource = readFileSync( + new URL("../../scripts/backup_mysql_encrypted.sh", import.meta.url), + "utf8", +); +const storageGuardSource = readFileSync( + new URL("../../docker/mysql-storage-guard.sh", import.meta.url), + "utf8", +); const dataCenterSource = readFileSync( new URL("../src/panels/data-platform/DataCenterPanel.tsx", import.meta.url), "utf8", @@ -30,11 +38,11 @@ test("Interface Center is available under System", () => { }); test("Interface Center is designed for inbound server access", () => { - for (const label of ["DBeaver 管理接入", "HTTPS API", "接入信息", "安全设置"]) { + for (const label of ["DBeaver 管理接入", "选择服务器数据库", "连接参数", "生成密钥与数据库账号"]) { assert.match(panelSource, new RegExp(label)); } - assert.match(panelSource, /SSH 隧道 \+ 独立 MySQL 账号/); - assert.match(panelSource, /不要在 Main 页填写服务器公网 IP/); + assert.match(panelSource, /SSH 加密隧道/); + assert.match(panelSource, /Main 页不要填写服务器公网 IP/); assert.match(panelSource, /Show all databases/); assert.doesNotMatch(panelSource, /JDBC URL/); assert.doesNotMatch(panelSource, /测试连接/); @@ -62,28 +70,42 @@ test("production deployment fails closed and preserves recoverability", () => { assert.match(serverComposeSource, /SECURITY_STRICT_MODE: "true"/); assert.match(serverComposeSource, /API_BIND_HOST:-127\.0\.0\.1/); assert.match(serverComposeSource, /必须设置至少 32 位 AUTH_SECRET/); + assert.match(serverComposeSource, /mysql-storage-guard/); + assert.match(serverComposeSource, /MYSQL_STORAGE_MOUNT:\?必须设置 MYSQL_STORAGE_MOUNT/); + assert.match(serverComposeSource, /MYSQL_DATA_DIR:\?必须设置 MYSQL_DATA_DIR/); + assert.match(serverComposeSource, /create_host_path: false/); + assert.match(serverComposeSource, /127\.0\.0\.1:\$\{MYSQL_PORT:-3307\}:3306/); + assert.match(storageGuardSource, /storage identity mismatch/); + assert.match(backupScriptSource, /拒绝将生产备份写入代码仓库/); + assert.match(backupScriptSource, /verify_mysql_backup\.sh/); + assert.match(backupScriptSource, /DATA_BACKUP_RETENTION_DAYS/); assert.match(dockerfileSource, /USER appuser/); assert.match(dataCenterSource, /安全只读模式/); }); -test("Interface Center keeps clients, credentials, policies, docs and logs", () => { - for (const label of ["客户端与密钥", "权限策略", "接口说明", "调用日志"]) { +test("Interface Center keeps the DBeaver flow focused", () => { + for (const label of ["私钥不会上传", "一人一号、单库授权", "一键撤销", "复制全部连接参数"]) { assert.match(panelSource, new RegExp(label)); } - assert.match(panelSource, /完整密钥只在签发时显示一次/); - assert.match(panelSource, /留空默认 30 天,最长 90 天/); - assert.match(panelSource, /加密备份待部署/); - assert.match(panelSource, /接口不接受任意 SQL/); + for (const removedLabel of ["HTTPS API", "客户端与密钥", "权限策略", "调用日志"]) { + assert.doesNotMatch(panelSource, new RegExp(removedLabel)); + } }); -test("frontend uses the administrative Interface Center API", () => { +test("frontend uses only the managed DBeaver endpoints on this page", () => { for (const path of [ "/interface-center/summary", - "/interface-center/clients", - "/interface-center/credentials", - "/interface-center/policies", - "/interface-center/logs", + "/interface-center/catalog", + "/interface-center/dbeaver-access", ]) { assert.match(apiSource, new RegExp(path)); } + for (const unusedCall of [ + "listInterfaceClients", + "listInterfaceCredentials", + "listInterfacePolicies", + "listInterfaceCallLogs", + ]) { + assert.doesNotMatch(panelSource, new RegExp(unusedCall)); + } }); diff --git a/admin-web/tests/navigationSurface.test.ts b/admin-web/tests/navigationSurface.test.ts index acd701b..f1a8470 100644 --- a/admin-web/tests/navigationSurface.test.ts +++ b/admin-web/tests/navigationSurface.test.ts @@ -37,20 +37,31 @@ test("retired plaza pages are absent while the core plaza remains", () => { }); test("retired system pages and the notification bell are absent", () => { - for (const page of ["areas", "notifications", "settings"]) { + for (const page of ["areas", "notifications", "settings", "agents", "logs", "permissions"]) { assert.doesNotMatch(appSource, new RegExp(`/admin/system/${page}`)); assert.doesNotMatch(appSource, new RegExp(`key: "/system/${page}`)); } - for (const component of ["AreaManagement", "Notifications", "AgentSettings", "NotificationBell"]) { + for (const component of [ + "AreaManagement", + "Notifications", + "AgentSettings", + "AgentTasksPanel", + "LogsPanel", + "PermissionsAudit", + "NotificationBell", + ]) { assert.doesNotMatch(appSource, new RegExp(component)); } + + assert.match(appSource, /key: "\/system\/users"[\s\S]*label: "用户管理"/); + assert.match(appSource, /path="\/admin\/system\/users"/); }); test("the data center uses its own full-width workbench surface", () => { assert.match(appSource, /const showDataCenterWorkbench = location\.pathname === "\/admin\/data"/); assert.match(appSource, /\{!showDataCenterWorkbench && \(\s*\}/); + assert.match(appSource, /path="\/admin\/data" element=\{\}/); }); test("the data center entry opens the standalone URL in a new browser tab", () => { diff --git a/admin-web/tests/sshKeyGeneration.test.ts b/admin-web/tests/sshKeyGeneration.test.ts new file mode 100644 index 0000000..2e6bb5b --- /dev/null +++ b/admin-web/tests/sshKeyGeneration.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generateDbeaverSshKeyPair } from "../src/sshKey.ts"; + +test("DBeaver SSH private key is generated locally in a compatible PEM format", async () => { + const result = await generateDbeaverSshKeyPair("Alice Admin"); + + assert.match(result.privateKeyPem, /^-----BEGIN PRIVATE KEY-----/); + assert.match(result.privateKeyPem, /-----END PRIVATE KEY-----\n$/); + assert.match(result.publicKey, /^ssh-rsa [A-Za-z0-9+/]+=* nianxx-Alice_Admin$/); + assert.match(result.privateKeyFilename, /\.pem$/); + assert.match(result.publicKeyFilename, /\.pub$/); + assert.equal(result.publicKey.includes("PRIVATE KEY"), false); +}); diff --git a/admin-web/tests/userManagementSurface.test.ts b/admin-web/tests/userManagementSurface.test.ts new file mode 100644 index 0000000..fb50d25 --- /dev/null +++ b/admin-web/tests/userManagementSurface.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const appSource = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8"); +const panelSource = readFileSync( + new URL("../src/panels/system/UserManagement.tsx", import.meta.url), + "utf8", +); +const apiSource = readFileSync(new URL("../src/api.ts", import.meta.url), "utf8"); +const dataCenterSource = readFileSync( + new URL("../src/panels/data-platform/DataCenterPanel.tsx", import.meta.url), + "utf8", +); + +test("System keeps one focused user-management page", () => { + assert.match(appSource, /key: "\/system\/users"[\s\S]*label: "用户管理"/); + assert.match(appSource, /path="\/admin\/system\/users"/); + for (const path of ["agents", "logs", "permissions"]) { + assert.doesNotMatch(appSource, new RegExp(`/admin/system/${path}`)); + } +}); + +test("User management exposes exactly two understandable data permissions", () => { + for (const label of ["数据管理", "数据查看", "仅可查看和导出数据", "不能进行任何写入"]) { + assert.match(panelSource, new RegExp(label)); + } + assert.match(panelSource, /type DataPermission = "data_manage" \| "data_view"/); + assert.doesNotMatch(panelSource, /listRoles|角色|权限矩阵|新增能力项|deleteUser/); +}); + +test("User removal is replaced by reversible account disabling", () => { + assert.match(panelSource, /确认停用该用户/); + assert.match(panelSource, /立即失去登录权限/); + assert.doesNotMatch(apiSource, /api\.delete\(`\/users/); +}); + +test("Data-view accounts receive a read-only data-center surface", () => { + assert.match(appSource, / tuple[bytes, str]: async def preview_records_import( project_id: str, table_code: str, - user: DataOperatorUser, + user: AdminUser, file: UploadFile = File(...), ): content, file_name = await _read_csv_upload(file) @@ -304,7 +304,7 @@ async def preview_records_import( async def import_records( project_id: str, table_code: str, - user: DataOperatorUser, + user: AdminUser, file: UploadFile = File(...), ): content, file_name = await _read_csv_upload(file) @@ -321,7 +321,7 @@ async def import_records( async def export_records( project_id: str, table_code: str, - _user: DataOperatorUser, + _user: DataViewerUser, search: str | None = None, ): content, filename, total = await export_csv_records( @@ -344,7 +344,7 @@ async def add_record( project_id: str, table_code: str, body: dict[str, Any], - user: DataOperatorUser, + user: AdminUser, ): return await create_record(project_id, table_code, body, user["username"]) @@ -355,7 +355,7 @@ async def edit_record( table_code: str, record_id: str, body: dict[str, Any], - user: DataOperatorUser, + user: AdminUser, ): return await update_record(project_id, table_code, record_id, body, user["username"]) @@ -365,6 +365,6 @@ async def remove_record( project_id: str, table_code: str, record_id: str, - user: DataOperatorUser, + user: AdminUser, ): return await delete_record(project_id, table_code, record_id, user["username"]) diff --git a/app/api/interface_center.py b/app/api/interface_center.py index c68f2dd..7acadff 100644 --- a/app/api/interface_center.py +++ b/app/api/interface_center.py @@ -19,12 +19,15 @@ from app.data_platform.interface_service import ( interface_catalog, interface_summary, issue_api_credential, + issue_dbeaver_access, list_api_call_logs, list_api_clients, list_api_credentials, list_api_policies, + list_dbeaver_access_grants, resolve_api_policy, revoke_api_credential, + revoke_dbeaver_access, update_api_client, update_api_policy, write_api_call_log, @@ -75,6 +78,30 @@ async def catalog(user: CurrentUser): return await interface_catalog() +@router.get("/dbeaver-access") +async def dbeaver_access_grants(user: CurrentUser): + _require_admin(user) + return await list_dbeaver_access_grants() + + +@router.post("/dbeaver-access") +async def add_dbeaver_access(body: dict[str, Any], user: CurrentUser): + _require_admin(user) + try: + return await issue_dbeaver_access(body, user["username"]) + except ValueError as exc: + raise _bad_request(exc) from exc + + +@router.post("/dbeaver-access/{grant_id}/revoke") +async def revoke_dbeaver_access_grant(grant_id: str, user: CurrentUser): + _require_admin(user) + try: + return await revoke_dbeaver_access(grant_id, user["username"]) + except ValueError as exc: + raise _bad_request(exc) from exc + + @router.get("/clients") async def clients(user: CurrentUser): _require_admin(user) diff --git a/app/api/rbac.py b/app/api/rbac.py index 1ba99d6..cc72925 100644 --- a/app/api/rbac.py +++ b/app/api/rbac.py @@ -1,135 +1,71 @@ -"""RBAC & Accounts (P1) — roles, capability matrix, users CRUD.""" +"""Simple user administration with two data-access levels.""" +from __future__ import annotations + +from typing import Any + from fastapi import APIRouter, HTTPException from app.auth import AdminUser -from app.contracts import ( - CapabilityCreate, - RoleCapCell, - RoleCreate, - RoleUpdate, - UserCreate, - UserUpdate, -) -from app.db import ( - create_capability, - create_role, - create_user, - delete_capability, - delete_role, - delete_user, - get_permission_matrix, - list_capabilities, - list_roles, - list_users, - set_role_cap, - update_role, - update_user, -) +from app.contracts import DataPermission, UserCreate, UserUpdate +from app.db import create_user, get_user_by_id, list_users, update_user from app.security import hash_password router = APIRouter() - -# ── Roles ──────────────────────────────────────────────────────────────────── - -@router.get("/roles") -async def _list_roles(_user: AdminUser): - return await list_roles() +_PERMISSION_ROLES: dict[DataPermission, str] = { + "data_manage": "admin", + "data_view": "operator", +} -@router.post("/roles") -async def _create_role(body: RoleCreate, _user: AdminUser): - try: - return await create_role(body.model_dump()) - except Exception as e: # unique violation etc. - raise HTTPException(400, f"创建角色失败:{str(e)[:200]}") +def _permission_from_roles(roles: list[str] | None) -> DataPermission: + return "data_manage" if "admin" in set(roles or []) else "data_view" -@router.patch("/roles/{role_key}") -async def _update_role(role_key: str, body: RoleUpdate, _user: AdminUser): - row = await update_role(role_key, body.model_dump(exclude_none=True)) - if not row: - raise HTTPException(404, "角色不存在") - return row +def _public_user(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + result["permission"] = _permission_from_roles(list(result.pop("roles", []) or [])) + return result -@router.delete("/roles/{role_key}") -async def _delete_role(role_key: str, _user: AdminUser): - try: - ok = await delete_role(role_key) - except ValueError as e: - raise HTTPException(400, str(e)) - if not ok: - raise HTTPException(404, "角色不存在") - return {"deleted": role_key} - - -# ── Capabilities ───────────────────────────────────────────────────────────── - -@router.get("/capabilities") -async def _list_caps(_user: AdminUser): - return await list_capabilities() - - -@router.post("/capabilities") -async def _create_cap(body: CapabilityCreate, _user: AdminUser): - try: - return await create_capability(body.model_dump()) - except Exception as e: - raise HTTPException(400, f"创建能力项失败:{str(e)[:200]}") - - -@router.delete("/capabilities/{cap_key}") -async def _delete_cap(cap_key: str, _user: AdminUser): - await delete_capability(cap_key) - return {"deleted": cap_key} - - -# ── Permission matrix ──────────────────────────────────────────────────────── - -@router.get("/permission-matrix") -async def _matrix(_user: AdminUser): - return await get_permission_matrix() - - -@router.put("/permission-matrix") -async def _set_cell(body: RoleCapCell, _user: AdminUser): - await set_role_cap(body.role_key, body.cap_key, body.value) - return {"ok": True, "role_key": body.role_key, "cap_key": body.cap_key, "value": body.value} - - -# ── Users ──────────────────────────────────────────────────────────────────── - @router.get("/users") async def _list_users(_user: AdminUser): - return await list_users() + return [_public_user(row) for row in await list_users()] @router.post("/users") async def _create_user(body: UserCreate, _user: AdminUser): data = body.model_dump() - roles = data.pop("roles", []) - pw = data.pop("password") - data["hashed_password"] = hash_password(pw) + permission = data.pop("permission") + password = data.pop("password") + data["username"] = data["username"].strip() + data["hashed_password"] = hash_password(password) try: - return await create_user(data, roles) - except Exception as e: - raise HTTPException(400, f"创建用户失败:{str(e)[:200]}") + row = await create_user(data, [_PERMISSION_ROLES[permission]]) + except Exception as exc: + raise HTTPException(400, f"创建用户失败:{str(exc)[:200]}") from exc + return _public_user(row) @router.patch("/users/{user_id}") -async def _update_user(user_id: int, body: UserUpdate, _user: AdminUser): - data = body.model_dump(exclude_none=True) - roles = data.pop("roles", None) - if "password" in data: - data["hashed_password"] = hash_password(data.pop("password")) +async def _update_user(user_id: int, body: UserUpdate, current_user: AdminUser): + target = await get_user_by_id(user_id) + if not target: + raise HTTPException(404, "用户不存在") + + data = body.model_dump(exclude_unset=True) + permission = data.pop("permission", None) + is_current_user = target["username"] == current_user["username"] + if is_current_user and data.get("status") == "disabled": + raise HTTPException(400, "不能停用当前登录账号") + if is_current_user and permission == "data_view": + raise HTTPException(400, "不能移除当前账号的数据管理权限") + + roles = [_PERMISSION_ROLES[permission]] if permission else None + password = data.pop("password", None) + if password: + data["hashed_password"] = hash_password(password) row = await update_user(user_id, data, roles) if not row: raise HTTPException(404, "用户不存在") - return row - - -@router.delete("/users/{user_id}") -async def _delete_user(user_id: int, _user: AdminUser): - await delete_user(user_id) - return {"deleted": user_id} + return _public_user(row) diff --git a/app/auth.py b/app/auth.py index 9e770d0..b1305ae 100644 --- a/app/auth.py +++ b/app/auth.py @@ -85,15 +85,17 @@ def require_admin(user: "CurrentUser") -> dict: return user -def require_data_operator(user: "CurrentUser") -> dict: - if not ({"admin", "operator"} & set(user.get("roles") or [])): +def require_data_viewer(user: "CurrentUser") -> dict: + # collector is a retired role key kept as read-only compatibility for + # existing accounts. New assignments use admin or operator exclusively. + if not ({"admin", "operator", "collector"} & set(user.get("roles") or [])): raise HTTPException( status_code=403, - detail="当前账号没有数据中心管理权限", + detail="当前账号没有数据查看权限", ) return user CurrentUser = Annotated[dict, Depends(get_current_user)] AdminUser = Annotated[dict, Depends(require_admin)] -DataOperatorUser = Annotated[dict, Depends(require_data_operator)] +DataViewerUser = Annotated[dict, Depends(require_data_viewer)] diff --git a/app/config.py b/app/config.py index da82768..134ae93 100644 --- a/app/config.py +++ b/app/config.py @@ -42,8 +42,14 @@ class Settings(BaseSettings): data_mysql_direct_transport: str = "SSH 隧道(强制)" data_mysql_ssh_tunnel_required: bool = True data_mysql_ssh_host: str = "" - data_mysql_ssh_port: int = 22 + data_mysql_ssh_port: int = 2222 + data_mysql_ssh_username: str = "dbeaver" data_mysql_ssh_auth_method: str = "SSH 私钥 / SSH Agent" + data_mysql_managed_access_enabled: bool = False + data_mysql_ssh_authorized_keys_file: str = "" + data_mysql_ssh_host_public_key_file: str = "" + data_mysql_provisioner_user: str = "" + data_mysql_provisioner_password: str = "" data_mysql_admin_host: str = "127.0.0.1" data_mysql_admin_port: int = 0 data_mysql_admin_account_policy: str = "一人一号 · 单库授权 · 禁止 root" @@ -51,9 +57,17 @@ class Settings(BaseSettings): # Mirrors the host-side Docker port binding so the Interface Center can # report an unsafe deployment instead of merely showing reassuring copy. mysql_host_bind: str = "127.0.0.1" + # Production MySQL must use a durable block/filesystem mount outside the + # application checkout. The host path and storage identity are passed in + # for fail-closed readiness checks; the API never reads the data directory. + data_mysql_storage_backend: str = "docker-volume" + data_mysql_storage_id: str = "" + data_mysql_storage_mount: str = "" + data_mysql_data_dir: str = "" data_backup_enabled: bool = False data_backup_encryption_required: bool = True data_backup_retention_days: int = 30 + data_backup_root: str = "" # FalkorDB falkordb_host: str = "localhost" diff --git a/app/contracts.py b/app/contracts.py index d175cf4..28f04fa 100644 --- a/app/contracts.py +++ b/app/contracts.py @@ -1,6 +1,8 @@ """Pydantic request/response models.""" from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, Field @@ -16,31 +18,10 @@ class TokenResponse(BaseModel): token_type: str = "bearer" -# ── RBAC & Accounts (P1) ───────────────────────────────────────────────────── +# ── User management ────────────────────────────────────────────────────────── -class RoleCreate(BaseModel): - role_key: str = Field(pattern=r"^[a-z][a-z0-9_]{1,30}$") - label: str - description: str | None = None - sort_order: int | None = None - - -class RoleUpdate(BaseModel): - label: str | None = None - description: str | None = None - sort_order: int | None = None - - -class CapabilityCreate(BaseModel): - cap_key: str = Field(pattern=r"^[a-z][a-z0-9_]{1,40}$") - label: str - sort_order: int | None = None - - -class RoleCapCell(BaseModel): - role_key: str - cap_key: str - value: str +DataPermission = Literal["data_manage", "data_view"] +UserStatus = Literal["active", "disabled"] class UserCreate(BaseModel): @@ -48,13 +29,13 @@ class UserCreate(BaseModel): password: str = Field(min_length=12, max_length=72) full_name: str | None = None phone: str | None = None - status: str | None = None - roles: list[str] = [] + status: UserStatus = "active" + permission: DataPermission = "data_view" class UserUpdate(BaseModel): full_name: str | None = None phone: str | None = None - status: str | None = None + status: UserStatus | None = None password: str | None = Field(default=None, min_length=12, max_length=72) - roles: list[str] | None = None + permission: DataPermission | None = None diff --git a/app/data_platform/interface_service.py b/app/data_platform/interface_service.py index 8664ab6..d51e564 100644 --- a/app/data_platform/interface_service.py +++ b/app/data_platform/interface_service.py @@ -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: diff --git a/app/data_platform/mysql_db.py b/app/data_platform/mysql_db.py index 500362a..4e3f96a 100644 --- a/app/data_platform/mysql_db.py +++ b/app/data_platform/mysql_db.py @@ -33,12 +33,12 @@ def _connection_options(*, include_database: bool) -> dict[str, object]: parsed = urlparse(settings.data_mysql_url) if parsed.scheme not in {"mysql", "mysql+aiomysql"}: raise ValueError("DATA_MYSQL_URL 必须使用 mysql:// 协议") - database = unquote(parsed.path.lstrip("/")) or "platform_control" - if not _DATABASE_PATTERN.fullmatch(database): + control_database = unquote(parsed.path.lstrip("/")) or "platform_control" + if not _DATABASE_PATTERN.fullmatch(control_database): raise ValueError("DATA_MYSQL_URL 中的控制数据库名称不合法") global _control_database - _control_database = database + _control_database = control_database options: dict[str, object] = { "host": parsed.hostname or "localhost", "port": parsed.port or 3306, @@ -49,7 +49,7 @@ def _connection_options(*, include_database: bool) -> dict[str, object]: "cursorclass": aiomysql.DictCursor, } if include_database: - options["db"] = database + options["db"] = control_database return options @@ -153,3 +153,44 @@ async def get_data_conn(database: str | None = None) -> AsyncIterator[aiomysql.C await conn.select_db(_control_database) except Exception: logger.warning("Failed to reset a Data Center MySQL connection", exc_info=True) + + +@asynccontextmanager +async def get_provisioner_conn() -> AsyncIterator[aiomysql.Connection]: + """Open the isolated account-provisioning connection. + + The normal Data Center account must not hold ``CREATE USER`` or ``GRANT + OPTION``. This short-lived connection is used only by the admin-only + DBeaver issuance and revocation endpoints. + """ + + username = settings.data_mysql_provisioner_user.strip() + password = settings.data_mysql_provisioner_password + if not username or not password: + raise DataCenterUnavailable("DBeaver账号签发服务尚未配置") + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,31}", username): + raise ValueError("MySQL账号签发用户名称不合法") + + parsed = urlparse(settings.data_mysql_url) + database = unquote(parsed.path.lstrip("/")) or "platform_control" + if not _DATABASE_PATTERN.fullmatch(database): + raise ValueError("DATA_MYSQL_URL 中的控制数据库名称不合法") + conn = await aiomysql.connect( + host=parsed.hostname or "localhost", + port=parsed.port or 3306, + user=username, + password=password, + charset="utf8mb4", + autocommit=False, + cursorclass=aiomysql.DictCursor, + ) + try: + yield conn + except Exception: + await conn.rollback() + raise + finally: + try: + await conn.rollback() + finally: + conn.close() diff --git a/app/data_platform/mysql_service.py b/app/data_platform/mysql_service.py index f0cca49..68f8cdd 100644 --- a/app/data_platform/mysql_service.py +++ b/app/data_platform/mysql_service.py @@ -276,6 +276,26 @@ async def ensure_platform_registry() -> bool: ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci """, """ + CREATE TABLE IF NOT EXISTS dbeaver_access_grants ( + id CHAR(36) PRIMARY KEY, + display_name VARCHAR(100) NOT NULL, + mysql_username VARCHAR(32) NOT NULL UNIQUE, + database_id VARCHAR(63) NOT NULL, + database_name VARCHAR(64) NOT NULL, + permission_level VARCHAR(16) NOT NULL, + ssh_key_type VARCHAR(32) NOT NULL, + ssh_public_key TEXT NOT NULL, + ssh_key_fingerprint VARCHAR(100) NOT NULL, + created_by VARCHAR(191) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + revoked_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_dbeaver_access_status (status, created_at), + KEY idx_dbeaver_access_database (database_id, status), + KEY idx_dbeaver_access_fingerprint (ssh_key_fingerprint, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """, + """ CREATE TABLE IF NOT EXISTS api_clients ( id CHAR(36) PRIMARY KEY, name VARCHAR(100) NOT NULL, diff --git a/app/db.py b/app/db.py index ee39640..a0afcdb 100644 --- a/app/db.py +++ b/app/db.py @@ -326,59 +326,30 @@ async def ensure_project_lifecycle_support() -> None: await conn.commit() -# 3 roles kept by design: 系统管理员 / 运营人员 / 采集人员 +# Internal role keys stay stable for existing tokens and foreign keys. The UI +# exposes only two data permissions: admin=data management, operator=data view. _SEED_ROLES = [ - ("admin", "系统管理员", "全部权限(含用户与权限管理)", True, 10), - ("operator", "运营人员", "知识广场与日常运营", True, 20), - ("collector", "采集人员", "领补藏任务、CSV 导入、Python 采集", True, 30), + ("admin", "数据管理", "查看和维护数据,并管理用户与接口", True, 10), + ("operator", "数据查看", "只读查看和导出数据", True, 20), ] -_RETIRED_CAPABILITY_KEYS = { - "modify_schema", - "review_candidate", - "review_field", - "publish_falkor", - "governance", -} - -_SEED_CAPS = [ - ("view_plaza", "查看知识广场", 10), - ("import_data", "导入数据", 30), - ("system_settings", "系统设置", 80), -] - -# matrix[cap_key] = {role_key: value} -_SEED_MATRIX = { - "view_plaza": {"admin": "✓", "operator": "✓", "collector": "部分"}, - "import_data": {"admin": "✓", "operator": "✓", "collector": "✓"}, - "system_settings": {"admin": "✓", "operator": "—", "collector": "—"}, -} - async def _seed_rbac() -> None: - """Idempotent seed: 3 roles, capability matrix, default admin user.""" + """Idempotent seed: two data permissions and the default manager.""" s = settings.db_schema async with get_conn() as conn: async with conn.cursor() as cur: for role_key, label, desc, is_sys, order in _SEED_ROLES: await cur.execute( f"""INSERT INTO {s}.rbac_roles (role_key, label, description, is_system, sort_order) - VALUES (%s, %s, %s, %s, %s) ON CONFLICT (role_key) DO NOTHING""", + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (role_key) DO UPDATE SET + label=EXCLUDED.label, + description=EXCLUDED.description, + is_system=TRUE, + sort_order=EXCLUDED.sort_order""", (role_key, label, desc, is_sys, order), ) - for cap_key, label, order in _SEED_CAPS: - await cur.execute( - f"""INSERT INTO {s}.rbac_capabilities (cap_key, label, sort_order) - VALUES (%s, %s, %s) ON CONFLICT (cap_key) DO NOTHING""", - (cap_key, label, order), - ) - for cap_key, role_vals in _SEED_MATRIX.items(): - for role_key, value in role_vals.items(): - await cur.execute( - f"""INSERT INTO {s}.rbac_role_caps (role_key, cap_key, value) - VALUES (%s, %s, %s) ON CONFLICT (role_key, cap_key) DO NOTHING""", - (role_key, cap_key, value), - ) # default admin account so existing credentials keep working await cur.execute( @@ -1923,145 +1894,6 @@ async def log_agent_call(data: dict) -> None: await conn.commit() -# ── RBAC: roles ────────────────────────────────────────────────────────────── - -async def list_roles() -> list[dict]: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"SELECT * FROM {s}.rbac_roles ORDER BY sort_order, role_key" - ) - return await cur.fetchall() - - -async def create_role(data: dict) -> dict: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"""INSERT INTO {s}.rbac_roles (role_key, label, description, is_system, sort_order) - VALUES (%(role_key)s, %(label)s, %(description)s, FALSE, - COALESCE(%(sort_order)s, 100)) RETURNING *""", - {"sort_order": None, "description": None, **data}, - ) - row = await cur.fetchone() - await conn.commit() - return row - - -async def update_role(role_key: str, data: dict) -> dict | None: - s = settings.db_schema - if not data: - return None - sets = ", ".join(f"{k}=%({k})s" for k in data) - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"UPDATE {s}.rbac_roles SET {sets} WHERE role_key=%(role_key)s RETURNING *", - {**data, "role_key": role_key}, - ) - row = await cur.fetchone() - await conn.commit() - return row - - -async def delete_role(role_key: str) -> bool: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"SELECT is_system FROM {s}.rbac_roles WHERE role_key=%s", (role_key,) - ) - r = await cur.fetchone() - if not r: - return False - if r["is_system"]: - raise ValueError("系统内置角色不可删除") - await cur.execute(f"DELETE FROM {s}.rbac_roles WHERE role_key=%s", (role_key,)) - await conn.commit() - return True - - -# ── RBAC: capabilities & matrix ────────────────────────────────────────────── - -async def list_capabilities() -> list[dict]: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"SELECT * FROM {s}.rbac_capabilities ORDER BY sort_order, cap_key" - ) - rows = await cur.fetchall() - return [row for row in rows if row["cap_key"] not in _RETIRED_CAPABILITY_KEYS] - - -async def create_capability(data: dict) -> dict: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"""INSERT INTO {s}.rbac_capabilities (cap_key, label, sort_order) - VALUES (%(cap_key)s, %(label)s, COALESCE(%(sort_order)s, 100)) - RETURNING *""", - {"sort_order": None, **data}, - ) - row = await cur.fetchone() - await conn.commit() - return row - - -async def delete_capability(cap_key: str) -> bool: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"DELETE FROM {s}.rbac_capabilities WHERE cap_key=%s", (cap_key,) - ) - await conn.commit() - return True - - -async def get_permission_matrix() -> dict: - """Returns roles, capabilities and the cell values for the editable grid.""" - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"SELECT * FROM {s}.rbac_roles ORDER BY sort_order, role_key" - ) - roles = await cur.fetchall() - await cur.execute( - f"SELECT * FROM {s}.rbac_capabilities ORDER BY sort_order, cap_key" - ) - caps = [ - row - for row in await cur.fetchall() - if row["cap_key"] not in _RETIRED_CAPABILITY_KEYS - ] - await cur.execute(f"SELECT role_key, cap_key, value FROM {s}.rbac_role_caps") - cells = await cur.fetchall() - matrix: dict[str, dict[str, str]] = {} - for c in cells: - if c["cap_key"] in _RETIRED_CAPABILITY_KEYS: - continue - matrix.setdefault(c["cap_key"], {})[c["role_key"]] = c["value"] - return {"roles": roles, "capabilities": caps, "matrix": matrix} - - -async def set_role_cap(role_key: str, cap_key: str, value: str) -> None: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"""INSERT INTO {s}.rbac_role_caps (role_key, cap_key, value) - VALUES (%s, %s, %s) - ON CONFLICT (role_key, cap_key) DO UPDATE SET value=EXCLUDED.value""", - (role_key, cap_key, value), - ) - await conn.commit() - - # ── Accounts: users ────────────────────────────────────────────────────────── async def list_users() -> list[dict]: @@ -2080,6 +1912,24 @@ async def list_users() -> list[dict]: return await cur.fetchall() +async def get_user_by_id(user_id: int) -> dict | None: + """Return one user with internal roles for safe account updates.""" + s = settings.db_schema + async with get_conn() as conn: + async with conn.cursor() as cur: + await cur.execute( + f"""SELECT u.id, u.username, u.full_name, u.phone, u.status, + COALESCE(array_agg(ur.role_key) + FILTER (WHERE ur.role_key IS NOT NULL), '{{}}') AS roles + FROM {s}.users u + LEFT JOIN {s}.user_roles ur ON ur.user_id = u.id + WHERE u.id=%s + GROUP BY u.id, u.username, u.full_name, u.phone, u.status""", + (user_id,), + ) + return await cur.fetchone() + + async def get_user_auth(username: str) -> dict | None: """For authentication — includes hashed_password and role keys.""" s = settings.db_schema @@ -2154,15 +2004,6 @@ async def update_user(user_id: int, data: dict, role_keys: list[str] | None) -> return row -async def delete_user(user_id: int) -> bool: - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute(f"DELETE FROM {s}.users WHERE id=%s", (user_id,)) - await conn.commit() - return True - - # ── Super Agent escalation records ─────────────────────────────────────────── async def create_notification( diff --git a/app/security_baseline.py b/app/security_baseline.py index 2e34907..2281588 100644 --- a/app/security_baseline.py +++ b/app/security_baseline.py @@ -1,6 +1,8 @@ """Fail-closed production security checks without exposing secret values.""" from __future__ import annotations +from pathlib import PurePosixPath +import re from typing import Any from urllib.parse import unquote, urlsplit @@ -15,6 +17,13 @@ _PLACEHOLDER_MARKERS = ( "example", ) _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} +_STORAGE_ID_RE = re.compile(r"^[A-Za-z0-9._-]{16,128}$") +_UNSAFE_STORAGE_ROOTS = ( + PurePosixPath("/app"), + PurePosixPath("/tmp"), + PurePosixPath("/var/tmp"), + PurePosixPath("/var/lib/docker"), +) def _finding(code: str, severity: str, message: str) -> dict[str, str]: @@ -37,10 +46,31 @@ def _url_password(value: str) -> str: return "" +def _url_username(value: str) -> str: + try: + parsed = urlsplit(value) + return unquote(parsed.username or "") + except ValueError: + return "" + + def _csv_values(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] +def _safe_external_storage_path(value: str) -> bool: + candidate = value.strip() + if not candidate.startswith("/") or candidate == "/": + return False + try: + path = PurePosixPath(candidate) + except ValueError: + return False + if ".." in path.parts: + return False + return not any(path == root or root in path.parents for root in _UNSAFE_STORAGE_ROOTS) + + def security_readiness(config: Settings = settings) -> dict[str, Any]: """Return a sanitized security report suitable for an admin endpoint.""" @@ -96,6 +126,57 @@ def security_readiness(config: Settings = settings) -> dict[str, Any]: findings.append( _finding("MYSQL_PUBLIC_BIND", "critical", "MySQL 不能绑定公网地址") ) + + storage_backend = config.data_mysql_storage_backend.strip().lower() + if production and storage_backend not in {"dedicated-block", "managed-mysql"}: + findings.append( + _finding( + "MYSQL_STORAGE_BACKEND", + "critical", + "生产MySQL必须使用独立持久化块存储或托管MySQL", + ) + ) + if storage_backend == "dedicated-block": + if not _STORAGE_ID_RE.fullmatch(config.data_mysql_storage_id.strip()): + findings.append( + _finding( + "MYSQL_STORAGE_ID", + "critical" if production else "warning", + "独立MySQL数据盘必须配置不可混淆的存储标识", + ) + ) + if not _safe_external_storage_path(config.data_mysql_storage_mount): + findings.append( + _finding( + "MYSQL_STORAGE_MOUNT", + "critical" if production else "warning", + "独立MySQL数据盘必须配置项目外的绝对挂载点", + ) + ) + if not _safe_external_storage_path(config.data_mysql_data_dir): + findings.append( + _finding( + "MYSQL_DATA_DIR", + "critical" if production else "warning", + "MySQL数据目录必须是项目外的绝对持久化路径", + ) + ) + storage_mount = PurePosixPath( + config.data_mysql_storage_mount.strip() or "/" + ) + data_dir = PurePosixPath(config.data_mysql_data_dir.strip() or "/") + if ( + storage_mount != PurePosixPath("/") + and data_dir != PurePosixPath("/") + and storage_mount not in data_dir.parents + ): + findings.append( + _finding( + "MYSQL_DATA_OUTSIDE_MOUNT", + "critical" if production else "warning", + "MySQL数据目录必须位于已声明的独立数据盘挂载点内", + ) + ) if config.data_mysql_direct_access_enabled and not config.data_mysql_ssh_tunnel_required: findings.append( _finding( @@ -104,6 +185,67 @@ def security_readiness(config: Settings = settings) -> dict[str, Any]: "启用 DBeaver 管理接入时必须强制 SSH 隧道", ) ) + if ( + production + and config.data_mysql_direct_access_enabled + and not config.data_mysql_ssh_host.strip() + ): + findings.append( + _finding( + "DBEAVER_SSH_HOST", + "critical", + "启用DBeaver管理接入时必须配置服务器SSH域名或IP", + ) + ) + if config.data_mysql_managed_access_enabled: + if not config.data_mysql_direct_access_enabled: + findings.append( + _finding( + "MANAGED_ACCESS_WITHOUT_DB_ACCESS", + "critical" if production else "warning", + "启用账号签发前必须启用DBeaver管理接入", + ) + ) + if not config.data_mysql_ssh_authorized_keys_file.strip(): + findings.append( + _finding( + "DBEAVER_AUTHORIZED_KEYS", + "critical" if production else "warning", + "账号签发必须配置专用SSH公钥文件", + ) + ) + if not config.data_mysql_ssh_host_public_key_file.strip(): + findings.append( + _finding( + "DBEAVER_HOST_KEY", + "critical" if production else "warning", + "账号签发必须配置可核验的SSH主机公钥文件", + ) + ) + provisioner_user = config.data_mysql_provisioner_user.strip() + if ( + not provisioner_user + or provisioner_user.lower() == "root" + or provisioner_user == _url_username(config.data_mysql_url) + ): + findings.append( + _finding( + "DBEAVER_PROVISIONER_USER", + "critical" if production else "warning", + "账号签发必须使用独立的非root MySQL签发用户", + ) + ) + if _looks_insecure_secret( + config.data_mysql_provisioner_password, + minimum=24, + ): + findings.append( + _finding( + "DBEAVER_PROVISIONER_PASSWORD", + "critical" if production else "warning", + "MySQL账号签发密码未替换或强度不足", + ) + ) origins = _csv_values(config.cors_allowed_origins) if not origins or "*" in origins: @@ -163,8 +305,55 @@ def security_readiness(config: Settings = settings) -> dict[str, Any]: if not config.data_backup_enabled: findings.append( - _finding("BACKUP_DISABLED", "warning", "尚未声明已启用自动加密备份") + _finding( + "BACKUP_DISABLED", + "critical" if production else "warning", + "生产环境必须启用自动加密备份", + ) ) + else: + if not config.data_backup_encryption_required: + findings.append( + _finding( + "BACKUP_ENCRYPTION_DISABLED", + "critical" if production else "warning", + "MySQL备份必须加密保存", + ) + ) + if not 7 <= config.data_backup_retention_days <= 3650: + findings.append( + _finding( + "BACKUP_RETENTION", + "critical" if production else "warning", + "MySQL备份保留天数必须在7–3650天之间", + ) + ) + if not _safe_external_storage_path(config.data_backup_root): + findings.append( + _finding( + "BACKUP_ROOT", + "critical" if production else "warning", + "MySQL备份目录必须是项目外的绝对路径", + ) + ) + data_dir = PurePosixPath(config.data_mysql_data_dir.strip() or "/") + backup_root = PurePosixPath(config.data_backup_root.strip() or "/") + if ( + data_dir != PurePosixPath("/") + and backup_root != PurePosixPath("/") + and ( + data_dir == backup_root + or data_dir in backup_root.parents + or backup_root in data_dir.parents + ) + ): + findings.append( + _finding( + "BACKUP_DATA_OVERLAP", + "critical" if production else "warning", + "MySQL运行数据与备份目录不能相互包含", + ) + ) if not config.data_mysql_audit_enabled: findings.append( _finding("DB_AUDIT_DISABLED", "warning", "DBeaver 数据库审计尚未启用") diff --git a/deploy/dbeaver-gateway/Dockerfile b/deploy/dbeaver-gateway/Dockerfile new file mode 100644 index 0000000..61bb821 --- /dev/null +++ b/deploy/dbeaver-gateway/Dockerfile @@ -0,0 +1,16 @@ +FROM alpine:3.22 + +RUN apk add --no-cache openssh-server socat \ + && addgroup -g 10001 dbeaver \ + && adduser -D -u 10001 -G dbeaver -s /bin/sh dbeaver \ + && sed -i 's/^dbeaver:[^:]*/dbeaver:NP/' /etc/shadow + +COPY sshd_config /etc/ssh/sshd_config +COPY entrypoint.sh /usr/local/bin/dbeaver-gateway-entrypoint +COPY tunnel-only.sh /usr/local/bin/tunnel-only + +RUN chmod 0755 /usr/local/bin/dbeaver-gateway-entrypoint /usr/local/bin/tunnel-only + +EXPOSE 22 + +ENTRYPOINT ["/usr/local/bin/dbeaver-gateway-entrypoint"] diff --git a/deploy/dbeaver-gateway/entrypoint.sh b/deploy/dbeaver-gateway/entrypoint.sh new file mode 100755 index 0000000..1480547 --- /dev/null +++ b/deploy/dbeaver-gateway/entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +access_dir=/var/lib/travel-kg/dbeaver-access +host_key_dir=/etc/ssh/host-keys + +mkdir -p "${access_dir}" "${host_key_dir}" /run/sshd +touch "${access_dir}/authorized_keys" +chown -R 10001:10001 "${access_dir}" +chmod 0700 "${access_dir}" +chmod 0600 "${access_dir}/authorized_keys" + +if [ ! -s "${host_key_dir}/ssh_host_ed25519_key" ]; then + ssh-keygen -q -t ed25519 -N '' -f "${host_key_dir}/ssh_host_ed25519_key" +fi +if [ ! -s "${host_key_dir}/ssh_host_rsa_key" ]; then + ssh-keygen -q -t rsa -b 3072 -N '' -f "${host_key_dir}/ssh_host_rsa_key" +fi +chmod 0600 "${host_key_dir}"/ssh_host_*_key +ssh-keygen -y -f "${host_key_dir}/ssh_host_ed25519_key" > "${access_dir}/ssh_host_ed25519_key.pub" +chown 10001:10001 "${access_dir}/ssh_host_ed25519_key.pub" +chmod 0644 "${access_dir}/ssh_host_ed25519_key.pub" + +socat TCP-LISTEN:3307,bind=127.0.0.1,reuseaddr,fork TCP:mysql:3306 & +socat_pid=$! +/usr/sbin/sshd -D -e -f /etc/ssh/sshd_config & +sshd_pid=$! + +shutdown() { + kill "${socat_pid}" "${sshd_pid}" 2>/dev/null || true + wait "${socat_pid}" "${sshd_pid}" 2>/dev/null || true + exit 0 +} +trap shutdown HUP INT TERM + +while kill -0 "${socat_pid}" 2>/dev/null && kill -0 "${sshd_pid}" 2>/dev/null; do + sleep 2 +done + +kill "${socat_pid}" "${sshd_pid}" 2>/dev/null || true +wait "${socat_pid}" "${sshd_pid}" 2>/dev/null || true +exit 1 diff --git a/deploy/dbeaver-gateway/sshd_config b/deploy/dbeaver-gateway/sshd_config new file mode 100644 index 0000000..cb2fa68 --- /dev/null +++ b/deploy/dbeaver-gateway/sshd_config @@ -0,0 +1,35 @@ +Port 22 +ListenAddress 0.0.0.0 +HostKey /etc/ssh/host-keys/ssh_host_ed25519_key +HostKey /etc/ssh/host-keys/ssh_host_rsa_key +PidFile /run/sshd.pid + +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +AuthenticationMethods publickey +AuthorizedKeysFile /var/lib/travel-kg/dbeaver-access/authorized_keys +AllowUsers dbeaver +LoginGraceTime 30 +MaxAuthTries 3 +MaxSessions 1 +MaxStartups 10:30:30 +StrictModes yes + +AllowTcpForwarding local +AllowStreamLocalForwarding no +GatewayPorts no +PermitOpen 127.0.0.1:3307 +AllowAgentForwarding no +X11Forwarding no +PermitTTY no +PermitTunnel no +PermitUserRC no +PermitUserEnvironment no +ForceCommand /usr/local/bin/tunnel-only + +UsePAM no +PrintMotd no +LogLevel VERBOSE +Subsystem sftp internal-sftp diff --git a/deploy/dbeaver-gateway/tunnel-only.sh b/deploy/dbeaver-gateway/tunnel-only.sh new file mode 100755 index 0000000..6fa0209 --- /dev/null +++ b/deploy/dbeaver-gateway/tunnel-only.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +trap 'exit 0' HUP INT TERM +while :; do + sleep 3600 & + wait "$!" +done diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 096ea3d..3af7b8a 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -1,4 +1,32 @@ services: + mysql-storage-guard: + image: busybox:1.36 + restart: "no" + user: "${MYSQL_CONTAINER_UID:-999}:${MYSQL_CONTAINER_GID:-999}" + environment: + MYSQL_STORAGE_ID: ${MYSQL_STORAGE_ID:?必须设置 MYSQL_STORAGE_ID} + MYSQL_STORAGE_MARKER: /storage-id + MYSQL_DATA_PATH: /data + command: ["sh", "/usr/local/bin/mysql-storage-guard.sh"] + volumes: + - type: bind + source: ${MYSQL_STORAGE_MOUNT:?必须设置 MYSQL_STORAGE_MOUNT}/.nianxx-mysql-storage-id + target: /storage-id + read_only: true + bind: + create_host_path: false + - type: bind + source: ${MYSQL_DATA_DIR:?必须设置 MYSQL_DATA_DIR} + target: /data + bind: + create_host_path: false + - type: bind + source: ./docker/mysql-storage-guard.sh + target: /usr/local/bin/mysql-storage-guard.sh + read_only: true + bind: + create_host_path: false + postgres: environment: POSTGRES_USER: ${POSTGRES_USER:?必须设置 POSTGRES_USER} @@ -11,6 +39,29 @@ services: MYSQL_DATABASE: ${MYSQL_DATABASE:-platform_control} MYSQL_USER: ${MYSQL_USER:?必须设置 MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD:?必须设置 MYSQL_PASSWORD} + MYSQL_ACCESS_BROKER_USER: ${MYSQL_ACCESS_BROKER_USER:-access_broker} + MYSQL_ACCESS_BROKER_PASSWORD: ${MYSQL_ACCESS_BROKER_PASSWORD:?必须设置 MYSQL_ACCESS_BROKER_PASSWORD} + ports: !override + - "127.0.0.1:${MYSQL_PORT:-3307}:3306" + volumes: !override + - type: bind + source: ${MYSQL_DATA_DIR:?必须设置 MYSQL_DATA_DIR} + target: /var/lib/mysql + bind: + create_host_path: false + - type: bind + source: ./docker/mysql-init/01-data-center-grants.sh + target: /docker-entrypoint-initdb.d/01-data-center-grants.sh + read_only: true + bind: + create_host_path: false + depends_on: + mysql-storage-guard: + condition: service_completed_successfully + + dbeaver-gateway: + ports: !override + - "${DATA_MYSQL_SSH_BIND:-127.0.0.1}:${DATA_MYSQL_SSH_PORT:-2222}:22" api: network_mode: host @@ -27,7 +78,24 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:?必须设置后台 HTTPS 来源} TRUSTED_HOSTS: ${TRUSTED_HOSTS:?必须设置服务器域名} MYSQL_HOST_BIND: 127.0.0.1 + DATA_MYSQL_STORAGE_BACKEND: dedicated-block + DATA_MYSQL_STORAGE_ID: ${MYSQL_STORAGE_ID:?必须设置 MYSQL_STORAGE_ID} + DATA_MYSQL_STORAGE_MOUNT: ${MYSQL_STORAGE_MOUNT:?必须设置 MYSQL_STORAGE_MOUNT} + DATA_MYSQL_DATA_DIR: ${MYSQL_DATA_DIR:?必须设置 MYSQL_DATA_DIR} + DATA_BACKUP_ENABLED: ${DATA_BACKUP_ENABLED:?生产环境必须设置 DATA_BACKUP_ENABLED=true} + DATA_BACKUP_ENCRYPTION_REQUIRED: "true" + DATA_BACKUP_RETENTION_DAYS: ${DATA_BACKUP_RETENTION_DAYS:-30} + DATA_BACKUP_ROOT: ${DATA_BACKUP_ROOT:?必须设置项目外 DATA_BACKUP_ROOT} DATA_MYSQL_SSH_TUNNEL_REQUIRED: "true" + DATA_MYSQL_DIRECT_ACCESS_ENABLED: "true" + DATA_MYSQL_MANAGED_ACCESS_ENABLED: "true" + DATA_MYSQL_SSH_HOST: ${DATA_MYSQL_SSH_HOST:-} + DATA_MYSQL_SSH_PORT: ${DATA_MYSQL_SSH_PORT:-2222} + DATA_MYSQL_SSH_USERNAME: ${DATA_MYSQL_SSH_USERNAME:-dbeaver} + DATA_MYSQL_SSH_AUTHORIZED_KEYS_FILE: /var/lib/travel-kg/dbeaver-access/authorized_keys + DATA_MYSQL_SSH_HOST_PUBLIC_KEY_FILE: /var/lib/travel-kg/dbeaver-access/ssh_host_ed25519_key.pub + DATA_MYSQL_PROVISIONER_USER: ${MYSQL_ACCESS_BROKER_USER:-access_broker} + DATA_MYSQL_PROVISIONER_PASSWORD: ${MYSQL_ACCESS_BROKER_PASSWORD:?必须设置 MYSQL_ACCESS_BROKER_PASSWORD} DATA_SQL_CONSOLE_WRITE_ENABLED: "false" FALKORDB_HOST: 127.0.0.1 FALKORDB_PORT: ${FALKORDB_PORT:-6380} diff --git a/docker-compose.yml b/docker-compose.yml index 449992d..84b0041 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,8 @@ services: MYSQL_DATABASE: ${MYSQL_DATABASE:-platform_control} MYSQL_USER: ${MYSQL_USER:-data_center} MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-this-data-password} + MYSQL_ACCESS_BROKER_USER: ${MYSQL_ACCESS_BROKER_USER:-access_broker} + MYSQL_ACCESS_BROKER_PASSWORD: ${MYSQL_ACCESS_BROKER_PASSWORD:-change-this-access-broker-credential} command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_0900_ai_ci @@ -90,6 +92,26 @@ services: timeout: 5s retries: 10 + dbeaver-gateway: + build: + context: ./deploy/dbeaver-gateway + image: travel-knowledge-graph-dbeaver-gateway:local + container_name: travel-kg-dbeaver-gateway + restart: unless-stopped + ports: + - "${DATA_MYSQL_SSH_BIND:-127.0.0.1}:${DATA_MYSQL_SSH_PORT:-2222}:22" + volumes: + - dbeaver-access:/var/lib/travel-kg/dbeaver-access + - dbeaver-ssh-host-keys:/etc/ssh/host-keys + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 22 && nc -z 127.0.0.1 3307"] + interval: 10s + timeout: 5s + retries: 10 + api: build: context: . @@ -108,14 +130,20 @@ services: DATA_MYSQL_POOL_MAX_SIZE: ${DATA_MYSQL_POOL_MAX_SIZE:-20} DATA_SQL_CONSOLE_WRITE_ENABLED: ${DATA_SQL_CONSOLE_WRITE_ENABLED:-false} INTERFACE_API_SECRET: ${INTERFACE_API_SECRET:-change-me-interface-api-secret} - DATA_MYSQL_DIRECT_ACCESS_ENABLED: ${DATA_MYSQL_DIRECT_ACCESS_ENABLED:-false} + DATA_MYSQL_DIRECT_ACCESS_ENABLED: ${DATA_MYSQL_DIRECT_ACCESS_ENABLED:-true} DATA_MYSQL_PUBLIC_HOST: ${DATA_MYSQL_PUBLIC_HOST:-} DATA_MYSQL_PUBLIC_PORT: ${DATA_MYSQL_PUBLIC_PORT:-0} DATA_MYSQL_DIRECT_TRANSPORT: ${DATA_MYSQL_DIRECT_TRANSPORT:-SSH 隧道(强制)} DATA_MYSQL_SSH_TUNNEL_REQUIRED: ${DATA_MYSQL_SSH_TUNNEL_REQUIRED:-true} DATA_MYSQL_SSH_HOST: ${DATA_MYSQL_SSH_HOST:-} - DATA_MYSQL_SSH_PORT: ${DATA_MYSQL_SSH_PORT:-22} + DATA_MYSQL_SSH_PORT: ${DATA_MYSQL_SSH_PORT:-2222} + DATA_MYSQL_SSH_USERNAME: ${DATA_MYSQL_SSH_USERNAME:-dbeaver} DATA_MYSQL_SSH_AUTH_METHOD: ${DATA_MYSQL_SSH_AUTH_METHOD:-SSH 私钥 / SSH Agent} + DATA_MYSQL_MANAGED_ACCESS_ENABLED: ${DATA_MYSQL_MANAGED_ACCESS_ENABLED:-true} + DATA_MYSQL_SSH_AUTHORIZED_KEYS_FILE: ${DATA_MYSQL_SSH_AUTHORIZED_KEYS_FILE:-/var/lib/travel-kg/dbeaver-access/authorized_keys} + DATA_MYSQL_SSH_HOST_PUBLIC_KEY_FILE: ${DATA_MYSQL_SSH_HOST_PUBLIC_KEY_FILE:-/var/lib/travel-kg/dbeaver-access/ssh_host_ed25519_key.pub} + DATA_MYSQL_PROVISIONER_USER: ${MYSQL_ACCESS_BROKER_USER:-access_broker} + DATA_MYSQL_PROVISIONER_PASSWORD: ${MYSQL_ACCESS_BROKER_PASSWORD:-change-this-access-broker-credential} DATA_MYSQL_ADMIN_HOST: ${DATA_MYSQL_ADMIN_HOST:-127.0.0.1} DATA_MYSQL_ADMIN_PORT: ${DATA_MYSQL_ADMIN_PORT:-${MYSQL_PORT:-3307}} DATA_MYSQL_ADMIN_ACCOUNT_POLICY: ${DATA_MYSQL_ADMIN_ACCOUNT_POLICY:-一人一号 · 单库授权 · 禁止 root} @@ -161,6 +189,8 @@ services: GAODE_CRAWLER_PATH: ${GAODE_CRAWLER_PATH:-} ports: - "${API_HOST_BIND:-0.0.0.0}:${API_PORT:-8102}:8000" + volumes: + - dbeaver-access:/var/lib/travel-kg/dbeaver-access depends_on: postgres: condition: service_healthy @@ -168,6 +198,8 @@ services: condition: service_healthy falkordb: condition: service_healthy + dbeaver-gateway: + condition: service_healthy healthcheck: test: [ @@ -185,3 +217,5 @@ volumes: postgres-data: mysql-data: falkordb-data: + dbeaver-access: + dbeaver-ssh-host-keys: diff --git a/docker/mysql-init/01-data-center-grants.sh b/docker/mysql-init/01-data-center-grants.sh index 3bcff82..a6b9000 100755 --- a/docker/mysql-init/01-data-center-grants.sh +++ b/docker/mysql-init/01-data-center-grants.sh @@ -1,6 +1,36 @@ #!/bin/sh set -eu +: "${MYSQL_ACCESS_BROKER_USER:?MYSQL_ACCESS_BROKER_USER is required}" +: "${MYSQL_ACCESS_BROKER_PASSWORD:?MYSQL_ACCESS_BROKER_PASSWORD is required}" +: "${MYSQL_USER:?MYSQL_USER is required}" +: "${MYSQL_PASSWORD:?MYSQL_PASSWORD is required}" + +if ! printf '%s' "${MYSQL_ACCESS_BROKER_USER}" | grep -Eq '^[A-Za-z][A-Za-z0-9_]{0,31}$'; then + echo "MYSQL_ACCESS_BROKER_USER must be a safe MySQL account name" >&2 + exit 1 +fi +if ! printf '%s' "${MYSQL_USER}" | grep -Eq '^[A-Za-z][A-Za-z0-9_]{0,31}$'; then + echo "MYSQL_USER must be a safe MySQL account name" >&2 + exit 1 +fi +if [ "${MYSQL_ACCESS_BROKER_USER}" = "root" ] || [ "${MYSQL_ACCESS_BROKER_USER}" = "${MYSQL_USER}" ]; then + echo "MYSQL_ACCESS_BROKER_USER must be an isolated non-root account" >&2 + exit 1 +fi +if [ "${MYSQL_ACCESS_BROKER_PASSWORD}" = "${MYSQL_PASSWORD}" ]; then + echo "MYSQL_ACCESS_BROKER_PASSWORD must differ from MYSQL_PASSWORD" >&2 + exit 1 +fi +if ! printf '%s' "${MYSQL_DATABASE}" | grep -Eq '^[A-Za-z][A-Za-z0-9_]{0,63}$'; then + echo "MYSQL_DATABASE must be a safe MySQL database name" >&2 + exit 1 +fi +if ! printf '%s' "${MYSQL_ACCESS_BROKER_PASSWORD}" | grep -Eq '^[A-Za-z0-9._~!@#%^+=:-]{24,128}$'; then + echo "MYSQL_ACCESS_BROKER_PASSWORD must be 24-128 URL-safe characters" >&2 + exit 1 +fi + # The database code is the physical MySQL database name, so project databases # do not share a technical prefix. Grant the non-administrative DDL/DML set # globally, then use MySQL partial revokes to exclude the mutable mysql schema @@ -9,6 +39,15 @@ set -eu # dedicated platform_control database remains available to the service. mysql --protocol=socket -uroot -p"${MYSQL_ROOT_PASSWORD}" -e \ "GRANT CREATE, DROP, ALTER, INDEX, REFERENCES, SELECT, INSERT, UPDATE, DELETE, CREATE VIEW, SHOW VIEW, TRIGGER ON *.* TO '${MYSQL_USER}'@'%'; + GRANT SYSTEM_USER ON *.* TO '${MYSQL_USER}'@'%'; REVOKE CREATE, DROP, ALTER, INDEX, REFERENCES, SELECT, INSERT, UPDATE, DELETE, CREATE VIEW, SHOW VIEW, TRIGGER ON \`mysql\`.* FROM '${MYSQL_USER}'@'%'; REVOKE CREATE, DROP, ALTER, INDEX, REFERENCES, SELECT, INSERT, UPDATE, DELETE, CREATE VIEW, SHOW VIEW, TRIGGER ON \`sys\`.* FROM '${MYSQL_USER}'@'%'; + CREATE USER IF NOT EXISTS '${MYSQL_ACCESS_BROKER_USER}'@'%' IDENTIFIED BY '${MYSQL_ACCESS_BROKER_PASSWORD}'; + ALTER USER '${MYSQL_ACCESS_BROKER_USER}'@'%' IDENTIFIED BY '${MYSQL_ACCESS_BROKER_PASSWORD}' PASSWORD EXPIRE NEVER FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1; + GRANT SELECT, INSERT, UPDATE, DELETE, SHOW VIEW ON *.* TO '${MYSQL_ACCESS_BROKER_USER}'@'%' WITH GRANT OPTION; + GRANT CREATE USER ON *.* TO '${MYSQL_ACCESS_BROKER_USER}'@'%'; + REVOKE SELECT, INSERT, UPDATE, DELETE, SHOW VIEW ON \`${MYSQL_DATABASE}\`.* FROM '${MYSQL_ACCESS_BROKER_USER}'@'%'; + REVOKE SELECT, INSERT, UPDATE, DELETE, SHOW VIEW ON \`mysql\`.* FROM '${MYSQL_ACCESS_BROKER_USER}'@'%'; + REVOKE SELECT, INSERT, UPDATE, DELETE, SHOW VIEW ON \`performance_schema\`.* FROM '${MYSQL_ACCESS_BROKER_USER}'@'%'; + REVOKE SELECT, INSERT, UPDATE, DELETE, SHOW VIEW ON \`sys\`.* FROM '${MYSQL_ACCESS_BROKER_USER}'@'%'; FLUSH PRIVILEGES;" diff --git a/docker/mysql-storage-guard.sh b/docker/mysql-storage-guard.sh new file mode 100755 index 0000000..c4ebff8 --- /dev/null +++ b/docker/mysql-storage-guard.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +data_root="${MYSQL_DATA_PATH:-/data}" +expected_id="${MYSQL_STORAGE_ID:-}" +marker="${MYSQL_STORAGE_MARKER:-/storage-id}" + +if ! printf '%s' "${expected_id}" | grep -Eq '^[A-Za-z0-9._-]{16,128}$'; then + echo "MYSQL_STORAGE_ID is missing or invalid" >&2 + exit 20 +fi +if [ ! -d "${data_root}" ] || [ -L "${data_root}" ]; then + echo "MySQL durable data directory is missing or is a symbolic link" >&2 + exit 21 +fi +if [ -L "${marker}" ] || [ ! -f "${marker}" ]; then + echo "MySQL storage identity marker is missing; refusing to initialize an empty database" >&2 + exit 22 +fi +actual_id="$(cat "${marker}")" +if [ "${actual_id}" != "${expected_id}" ]; then + echo "MySQL storage identity mismatch; refusing to use the mounted directory" >&2 + exit 23 +fi +if [ ! -w "${data_root}" ]; then + echo "MySQL durable storage is not writable by the container database user" >&2 + exit 25 +fi + +# A marker-only directory is a valid first installation. Any other non-empty +# directory must already look like a MySQL data directory; partial or wrong +# mounts fail closed instead of being silently initialized. +first_entry="$(find "${data_root}" -mindepth 1 -maxdepth 1 -print -quit)" +if [ -n "${first_entry}" ] && { [ ! -f "${data_root}/auto.cnf" ] || [ ! -d "${data_root}/mysql" ]; }; then + echo "Mounted storage is non-empty but is not a complete MySQL data directory" >&2 + exit 26 +fi + +echo "MySQL durable storage verified: ${expected_id}" diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 9ffd136..0d764b0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -35,8 +35,8 @@ http://localhost:8102/docs | `/v1/openapi/knowledge-qa/query` | `POST` | 百姓惠智能客服第三方问答接口 | | `/v1/admin/travel/customer-service-query` | `POST` | 兼容旧版外部问答接口 | | `/v1/admin/super-agent/run` | `POST` | Super Agent 任务 | -| `/v1/admin/roles` | `GET/POST` | 角色管理 | -| `/v1/admin/users` | `GET/POST` | 用户管理 | +| `/v1/admin/users` | `GET/POST` | 用户管理;仅接受“数据管理”或“数据查看”权限 | +| `/v1/admin/users/{user_id}` | `PATCH` | 修改权限、状态或重置密码;用户不做物理删除 | ## 登录示例 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 98f1d4b..1d615dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,7 +49,7 @@ flowchart TB | `admin-web/src/panels/plaza/` | 数据大屏、业务问答、图谱浏览和 Super Agent | | `admin-web/src/panels/data-platform/` | 项目数据库、表、记录、CSV 与 SQL 工作区 | | `admin-web/src/panels/modeling/` | 当前项目 Schema 的只读展示 | -| `admin-web/src/panels/system/` | 用户、权限、Agent 任务和调用日志 | +| `admin-web/src/panels/system/` | 简化用户管理(数据管理/数据查看)和接口中心 | ## 数据层 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 72c6fcd..44ff7ab 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -39,7 +39,9 @@ docker compose -f docker-compose.yml -f docker-compose.server.yml up -d --build 2. 创建 PostgreSQL 数据卷。 3. PostgreSQL 初始化脚本恢复 `snapshots/postgres/kg_admin_new2.dump`。 4. `falkordb-seed` 把 `snapshots/falkordb/dump.rdb` 写入 FalkorDB 数据卷。 -5. FastAPI 服务等待 PostgreSQL 和 FalkorDB 健康后启动。 +5. 初始化独立 MySQL 数据中心与账号签发用户。 +6. 启动只允许数据库端口转发的 DBeaver SSH 网关。 +7. FastAPI 服务等待 PostgreSQL、MySQL、FalkorDB 与 SSH 网关健康后启动。 ## 常用命令 @@ -61,13 +63,16 @@ docker compose logs -f api docker compose down ``` -停止并删除数据卷,下一次启动将重新恢复快照: +仅在本地开发环境停止并删除数据卷,下一次启动将重新恢复快照: ```bash docker compose down -v docker compose up -d --build ``` +生产服务器禁止执行 `docker compose down -v`。生产 MySQL 使用独立磁盘绑定目录, +虽然该命令不会删除这个目录,但会删除 PostgreSQL、FalkorDB 等其他 Docker 数据卷。 + ## 无 sudo 服务器的 rootless Docker 启动 如果服务器账号不能使用 `sudo`,并且 `loginctl show-user <用户> -p Linger` 显示 `Linger=no`,用户级 `docker.service` 可能会在 SSH 退出后停止。此时可以使用项目脚本把 rootless Docker 的 socket 固定到用户目录: @@ -111,10 +116,111 @@ HTTPS 443 端口访问,不再直接开放 8102。可复制 - `443/tcp`:所有合法业务来源; - `22/tcp`:仅运维固定 IP 或 VPN 网段; +- `2222/tcp`:仅 DBeaver 数据管理员固定 IP 或 VPN 网段; - `80/tcp`:仅用于跳转 HTTPS 或证书签发。 严禁向公网开放 `8102`、`3307`、`5433`、`6380` 和 `3002`。 +## 生产 MySQL 持久化存储(首次部署必做) + +生产环境采用两层存储,职责不能混用: + +1. MySQL 运行目录使用服务器独立的本地块存储(例如云硬盘/SSD 挂载到 + `/mnt/sdr`),不放在代码仓库、容器可写层、临时目录、NFS 或对象存储中; +2. 加密逻辑备份写到另一个目录,并继续自动同步到异地对象存储或另一台服务器。 + +服务器运维人员先在云平台挂载并格式化数据盘、配置 `/etc/fstab`,确认服务器重启后 +仍能自动挂载。项目脚本不会格式化磁盘,也不会自动搬迁旧数据。设置 `.env`: + +```dotenv +MYSQL_STORAGE_MOUNT=/mnt/sdr +MYSQL_STORAGE_ID=prod-mysql-storage-2026-001 +MYSQL_DATA_DIR=/mnt/sdr/nianxx/mysql +MYSQL_CONTAINER_UID=999 +MYSQL_CONTAINER_GID=999 +DATA_BACKUP_ROOT=/mnt/backup/nianxx/mysql +DATA_BACKUP_RETENTION_DAYS=30 +DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase +DATA_BACKUP_ENABLED=false +``` + +脚本读取当前进程环境,因此把 `.env` 中以下非敏感路径变量一并导出后再准备目录 +(Linux 服务器): + +```bash +export MYSQL_STORAGE_MOUNT=/mnt/sdr +export MYSQL_STORAGE_ID=prod-mysql-storage-2026-001 +export MYSQL_DATA_DIR=/mnt/sdr/nianxx/mysql +export DATA_BACKUP_ROOT=/mnt/backup/nianxx/mysql +export MYSQL_CONTAINER_UID=999 +export MYSQL_CONTAINER_GID=999 +sudo -E ./scripts/prepare_mysql_production_storage.sh +``` + +该脚本会确认 `/mnt/sdr` 确实是独立、可写、本地持久化挂载,写入磁盘身份标识,并 +设置 MySQL 容器 UID/GID 权限。如果挂载丢失、磁盘身份不一致、目录出现半初始化 +数据,`mysql-storage-guard` 会阻止 MySQL 启动,避免系统在错误的空目录中悄悄创建 +一个新数据库。存储标识位于挂载根目录,MySQL 数据目录在首次初始化前保持为空。 + +先只启动 MySQL: + +```bash +docker compose -f docker-compose.yml -f docker-compose.server.yml up -d mysql +docker compose -f docker-compose.yml -f docker-compose.server.yml ps mysql mysql-storage-guard +``` + +然后安装每日加密备份。备份服务默认使用执行 `sudo` 的服务器账号;该账号必须有 +Docker 权限,并独占读取口令文件: + +```bash +sudo install -d -m 0750 -o root -g "$(id -gn)" /etc/travel-kg/secrets +sudo touch /etc/travel-kg/secrets/mysql-backup-passphrase +sudo chown "$USER:$(id -gn)" /etc/travel-kg/secrets/mysql-backup-passphrase +sudo chmod 600 /etc/travel-kg/secrets/mysql-backup-passphrase +openssl rand -base64 48 > /etc/travel-kg/secrets/mysql-backup-passphrase + +export DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase +export DATA_BACKUP_RETENTION_DAYS=30 +sudo -E ./scripts/install_mysql_backup_systemd.sh +``` + +安装程序会立即执行并验证第一份备份,成功后启用每天的 systemd timer。确认备份已 +同步到异地存储后,把 `.env` 中 `DATA_BACKUP_ENABLED=true`,再启动完整生产服务: + +```bash +docker compose -f docker-compose.yml -f docker-compose.server.yml up -d --build +``` + +生产覆盖配置把 MySQL 主机端口硬编码绑定到 `127.0.0.1`;即使误设 +`MYSQL_HOST_BIND=0.0.0.0` 也不会将 MySQL 暴露到公网。 + +### 从原 Docker named volume 迁移 + +已有数据不能通过重新初始化获得。先在旧 MySQL 仍运行时完成加密备份,再停写并冷 +拷贝;以下示例中的卷名要先通过 `docker volume ls` 和 `docker volume inspect` +核实: + +```bash +export DATA_BACKUP_ROOT=/mnt/backup/nianxx/mysql +export DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase +export DATA_BACKUP_RETENTION_DAYS=30 +./scripts/backup_mysql_encrypted.sh + +docker compose stop api dbeaver-gateway mysql +sudo -E ./scripts/prepare_mysql_production_storage.sh +docker run --rm \ + -v travel-knowledge-graph_mysql-data:/from:ro \ + -v /mnt/sdr/nianxx/mysql:/to \ + alpine:3.22 sh -c 'cd /from && tar cf - . | tar xpf - -C /to' + +docker compose -f docker-compose.yml -f docker-compose.server.yml up -d mysql +docker compose -f docker-compose.yml -f docker-compose.server.yml exec -T mysql sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqlcheck -uroot --all-databases' +``` + +完成表数量、记录数量和业务抽样核对前,不要删除原 Docker volume。若源目录不是完整 +MySQL 数据目录,准备脚本和启动守卫都会拒绝继续。 + ## 端口配置 可以在启动时覆盖端口: @@ -138,6 +244,8 @@ docker compose up -d --build | `POSTGRES_PORT` | `5433` | PostgreSQL 映射端口 | | `MYSQL_HOST_BIND` | `127.0.0.1` | MySQL 只绑定服务器本机,DBeaver 经 SSH 隧道接入 | | `MYSQL_PORT` | `3307` | MySQL 服务器本机映射端口,不向公网开放 | +| `DATA_MYSQL_SSH_BIND` | `127.0.0.1` | DBeaver 专用 SSH 网关监听地址;远程部署必须显式绑定可达网卡并配防火墙白名单 | +| `DATA_MYSQL_SSH_PORT` | `2222` | DBeaver 专用 SSH 网关端口,不提供服务器终端 | | `FALKORDB_HOST_BIND` | `127.0.0.1` | FalkorDB Redis 协议只绑定服务器本机 | | `FALKORDB_PORT` | `6380` | FalkorDB Redis 协议端口 | | `FALKORDB_BROWSER_HOST_BIND` | `127.0.0.1` | FalkorDB Browser 只绑定服务器本机 | @@ -161,9 +269,11 @@ cp .env.example .env AUTH_SECRET=换成32位以上随机字符串 AUTH_DEFAULT_PASSWORD=换成12位以上后台管理员密码 INTERFACE_API_SECRET=与AUTH_SECRET不同的32位以上随机字符串 +MYSQL_ACCESS_BROKER_PASSWORD=至少24位独立随机字符串 INGEST_API_KEYS=至少24位的外部系统接口Key CORS_ALLOWED_ORIGINS=https://你的后台域名 TRUSTED_HOSTS=你的后台域名 +DATA_MYSQL_SSH_HOST=你的服务器域名或可达IP LLM_API_BASE=你的OpenAI兼容模型地址 LLM_API_KEY=你的模型Key ``` @@ -177,10 +287,16 @@ LLM_API_KEY=你的模型Key | `DB_SCHEMA` | 默认 `kg_admin_new2` | | `DB_MIGRATIONS_ENABLED` | 快照部署默认 `false` | | `DATA_MYSQL_SSH_HOST`、`DATA_MYSQL_SSH_PORT` | DBeaver SSH 标签页使用的服务器地址和 SSH 端口 | +| `DATA_MYSQL_SSH_BIND`、`DATA_MYSQL_SSH_USERNAME` | 专用 SSH 网关监听地址与固定隧道用户;默认用户为 `dbeaver` | +| `MYSQL_ACCESS_BROKER_USER`、`MYSQL_ACCESS_BROKER_PASSWORD` | 接口中心签发/撤销个人 MySQL 账号使用的独立服务凭证,不得与 `data_center` 或 `root` 共用 | | `DATA_MYSQL_ADMIN_HOST`、`DATA_MYSQL_ADMIN_PORT` | DBeaver Main 标签页使用的隧道远端 MySQL 地址,默认 `127.0.0.1:3307` | | `DATA_MYSQL_AUDIT_ENABLED` | 只有数据库审计真实启用后才设置为 `true` | | `DATA_SQL_CONSOLE_WRITE_ENABLED` | 默认 `false`,生产 SQL 控制台保持只读 | -| `DATA_BACKUP_ENABLED` | 自动备份任务真实部署后才设置为 `true` | +| `MYSQL_STORAGE_MOUNT`、`MYSQL_STORAGE_ID` | 独立数据盘挂载点及其唯一身份标识,生产必填 | +| `MYSQL_DATA_DIR` | 独立数据盘内的 MySQL 运行目录,不能位于代码仓库内 | +| `DATA_BACKUP_ROOT` | 代码仓库和 MySQL 运行目录之外的加密备份目录 | +| `DATA_BACKUP_ENABLED` | 首次备份与自动任务验证成功后才设置为 `true`,生产为必填真值 | +| `DATA_BACKUP_RETENTION_DAYS` | 本机加密备份保留天数,允许 7–3650 天 | | `DATA_BACKUP_PASSPHRASE_FILE` | 仓库外的备份加密口令文件,权限必须为 600 | | `FALKORDB_HOST` | Docker 内默认 `falkordb` | | `FALKORDB_GRAPH` | 默认业务图 `guiyang_new2` | @@ -197,6 +313,40 @@ LLM_API_KEY=你的模型Key | `TRAVEL_DELIVERY_ROOT` | POI 交付 CSV 目录,仅运行采集/增强脚本时需要 | | `TRAVEL_KG_EXPORT_ROOT` | 采集/构图脚本导出目录 | +## DBeaver 远程管理接入 + +本地模拟服务器环境: + +```env +DATA_MYSQL_SSH_HOST=localhost +DATA_MYSQL_SSH_BIND=127.0.0.1 +DATA_MYSQL_SSH_PORT=2222 +``` + +```bash +docker compose up -d --build mysql dbeaver-gateway api +``` + +打开 `/admin/system/interfaces`,页面直接提供 DBeaver 管理接入,由页面生成管理员 +个人私钥、MySQL 用户名和一次性密码。DBeaver 的 SSH 标签页连接 +`localhost:2222`、用户 `dbeaver`;Main 标签页固定使用 `127.0.0.1:3307`。 + +服务器部署时把 `DATA_MYSQL_SSH_HOST` 改为服务器域名。如果管理员电脑需要通过 +网络连接,把 `DATA_MYSQL_SSH_BIND` 设置为服务器内网 IP;只有确实需要公网接入时 +才使用 `0.0.0.0`,并在云安全组和主机防火墙中将 `2222/tcp` 限定到管理员固定 IP +或 VPN 网段。MySQL 的 `3307/tcp` 始终保持回环绑定。 + +升级已有 MySQL 数据卷后,初始化脚本不会自动重跑,需要执行一次: + +```bash +./scripts/enable_managed_dbeaver_access.sh +docker compose up -d --build dbeaver-gateway api +``` + +专用 SSH 用户不能打开终端或 SFTP,只能转发到 MySQL;每位管理员的公钥与 MySQL +账号均可在接口中心一键撤销。完整私钥和 MySQL 密码只展示一次;DBeaver 首次 +连接时必须核对页面显示的服务器 SSH 主机指纹。 + ## 百姓惠智能客服接口 给外部系统对接时,只开放这个接口即可: @@ -232,26 +382,24 @@ curl https://你的域名/v1/openapi/knowledge-qa/query \ ## MySQL 加密备份 -先在代码仓库之外创建口令文件: - -```bash -sudo install -d -m 700 /etc/travel-kg/secrets -openssl rand -base64 48 | sudo tee /etc/travel-kg/secrets/mysql-backup-passphrase >/dev/null -sudo chmod 600 /etc/travel-kg/secrets/mysql-backup-passphrase -``` - -执行备份和校验: +口令文件、外部备份目录和自动任务应按“生产 MySQL 持久化存储”章节创建。手动执行 +与校验示例: ```bash +export DATA_BACKUP_ROOT=/mnt/backup/nianxx/mysql export DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase +export MYSQL_DATA_DIR=/mnt/sdr/nianxx/mysql +export MYSQL_STORAGE_ID=prod-mysql-你的唯一标识 +export DATA_BACKUP_RETENTION_DAYS=30 ./scripts/backup_mysql_encrypted.sh -./scripts/verify_mysql_backup.sh ./backups/mysql/mysql-all-时间.sql.gz.enc +./scripts/verify_mysql_backup.sh /mnt/backup/nianxx/mysql/mysql-all-时间.sql.gz.enc ``` -备份采用 `mysqldump --single-transaction`、gzip 和 AES-256/PBKDF2,并生成 -SHA-256 校验文件。`.enc`、`.sha256`、`.json` 三个文件必须同步到独立账号的 -异地对象存储。校验脚本不等于恢复演练;至少每月在隔离 MySQL 实例执行一次 -完整恢复,确认账号、表结构和业务数据均可恢复。 +脚本不再提供仓库内默认备份目录:目录缺失、位于仓库内、与运行数据重叠、口令权限 +过宽或保留周期不合规都会直接失败。备份采用 `mysqldump --single-transaction`、 +Binlog 坐标、gzip 和 AES-256/PBKDF2,生成 SHA-256 与清单,并在清理过期备份前 +自动解密验包。`.enc`、`.sha256`、`.json` 三个文件必须同步到独立账号的异地对象 +存储。验包不等于恢复演练;至少每月在隔离 MySQL 实例完整恢复一次。 默认问答链路需要配置问答环节 LLM;可在后台 `外部图谱问答 API -> 问答环节 LLM 模型` 单独配置,也可以继承全局 LLM。 diff --git a/docs/data-center-mysql-interface-center.md b/docs/data-center-mysql-interface-center.md index 470ffa4..daeba87 100644 --- a/docs/data-center-mysql-interface-center.md +++ b/docs/data-center-mysql-interface-center.md @@ -5,8 +5,8 @@ - PostgreSQL:继续保存账号、权限、图谱项目与 Schema 等系统元数据。 - FalkorDB:继续保存已发布知识图谱节点和关系。 - MySQL:只负责“数据中心”的关系型业务数据。 -- 接口中心:只通过受控 HTTPS API 访问 MySQL 数据中心,不允许外部用户执行任意 SQL。 -- DBeaver:仅供受信任的数据管理员通过 SSH 隧道管理 MySQL,使用独立 MySQL 用户和 `GRANT` 权限;不复用接口密钥,也不公开 MySQL 端口。 +- 接口中心当前页面只管理 DBeaver 管理接入:选择数据库、签发个人密钥与账号、查看状态和撤销权限。 +- DBeaver:仅供受信任的数据管理员通过专用 SSH 隧道管理 MySQL,使用独立 MySQL 用户和 `GRANT` 权限;不复用接口密钥,也不公开 MySQL 端口。 - SQL 控制台:生产默认只读;写入通过数据表页面执行,以保留记录级变更历史,物理删除始终禁止。 每个数据中心数据库都是真实 MySQL Database:中文名称只用于页面显示,英文数据库编码直接作为物理 MySQL 数据库名。前端数据中心的现有 URL、布局和 `/v1/admin/data-platform/*` 契约保持不变。 @@ -21,28 +21,53 @@ DATA_MYSQL_REQUIRED=true DATA_MYSQL_POOL_MIN_SIZE=2 DATA_MYSQL_POOL_MAX_SIZE=20 INTERFACE_API_SECRET=another-long-random-secret -DATA_MYSQL_DIRECT_ACCESS_ENABLED=false +DATA_MYSQL_DIRECT_ACCESS_ENABLED=true MYSQL_HOST_BIND=127.0.0.1 MYSQL_PORT=3307 DATA_MYSQL_DIRECT_TRANSPORT=SSH 隧道(强制) DATA_MYSQL_SSH_TUNNEL_REQUIRED=true DATA_MYSQL_SSH_HOST=server.example.com -DATA_MYSQL_SSH_PORT=22 +DATA_MYSQL_SSH_PORT=2222 +DATA_MYSQL_SSH_BIND=127.0.0.1 +DATA_MYSQL_SSH_USERNAME=dbeaver DATA_MYSQL_SSH_AUTH_METHOD=SSH 私钥 / SSH Agent +DATA_MYSQL_MANAGED_ACCESS_ENABLED=true +DATA_MYSQL_SSH_AUTHORIZED_KEYS_FILE=/var/lib/travel-kg/dbeaver-access/authorized_keys +DATA_MYSQL_SSH_HOST_PUBLIC_KEY_FILE=/var/lib/travel-kg/dbeaver-access/ssh_host_ed25519_key.pub +MYSQL_ACCESS_BROKER_USER=access_broker +MYSQL_ACCESS_BROKER_PASSWORD=至少24位独立随机字符串 DATA_MYSQL_ADMIN_HOST=127.0.0.1 DATA_MYSQL_ADMIN_PORT=3307 DATA_MYSQL_ADMIN_ACCOUNT_POLICY=一人一号 · 单库授权 · 禁止 root DATA_MYSQL_AUDIT_ENABLED=false DATA_SQL_CONSOLE_WRITE_ENABLED=false +MYSQL_STORAGE_MOUNT=/mnt/sdr +MYSQL_STORAGE_ID=prod-mysql-storage-2026-001 +MYSQL_DATA_DIR=/mnt/sdr/nianxx/mysql +DATA_BACKUP_ROOT=/mnt/backup/nianxx/mysql +DATA_BACKUP_RETENTION_DAYS=30 +DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase DATA_BACKUP_ENABLED=false ``` -`MYSQL_HOST_BIND` 在生产环境必须保持 `127.0.0.1`。`DATA_MYSQL_DIRECT_ACCESS_ENABLED` 只表示管理员接入是否完成运维配置,不会开放端口;本项目不提供公开 MySQL 直连模式。只有数据库审计真实启用后,才把 `DATA_MYSQL_AUDIT_ENABLED` 改成 `true`。 +`MYSQL_HOST_BIND` 在生产环境必须保持 `127.0.0.1`。远程电脑访问的是专用 SSH +网关端口,不是 MySQL 端口。`DATA_MYSQL_SSH_BIND=127.0.0.1` 只支持服务器本机 +测试;服务器上线后可设置为服务器内网地址,或设置为 `0.0.0.0` 并在安全组/防火墙 +中仅放行管理员固定 IP 或 VPN 网段。只有数据库审计真实启用后,才把 +`DATA_MYSQL_AUDIT_ENABLED` 改成 `true`。 + +`access_broker` 是仅供接口中心签发/撤销个人 MySQL 账号的独立服务账号。普通 +`data_center` 账号不持有 `CREATE USER` 或 `GRANT OPTION`,两者密码不得相同。 应用会记录 API 调用、记录新增/修改/软删除以及 SQL 控制台的成功、失败和拒绝 事件;SQL 审计只保存语句 SHA-256,不保存可能包含敏感值的原文。DBeaver 绕过 应用,因此仍必须由数据库审计能力单独留痕,未完成前不得开放长期写权限。 +生产 MySQL 数据目录绑定到服务器独立块存储,不使用项目目录或普通 Docker named +volume。Compose 启动前会核对独立磁盘身份标识、目录写权限和已有 MySQL 数据结构; +挂载丢失或路径错误时直接停止,不会初始化一个看似正常的空库。准备磁盘、迁移旧卷、 +安装每日加密备份的完整流程见 `docs/DEPLOYMENT.md`。 + 生产 MySQL 开启 ROW Binlog、`sync_binlog=1` 与 `innodb_flush_log_at_trx_commit=1`,用于时间点恢复与事务落盘;加密备份脚本和 恢复要求见 `docs/DEPLOYMENT.md`。 @@ -52,25 +77,33 @@ DATA_BACKUP_ENABLED=false ## 首次部署 ```bash -docker compose up -d mysql -docker compose up -d --build api +docker compose -f docker-compose.yml -f docker-compose.server.yml up -d --build mysql dbeaver-gateway api +``` + +已有 MySQL 数据卷不会重新运行初始化脚本。升级既有部署后执行一次: + +```bash +./scripts/enable_managed_dbeaver_access.sh ``` 接口中心页面:`/admin/system/interfaces`。 -页面以“服务器被访问”为方向:`DBeaver 管理接入`只给受信任的数据管理员展示 SSH 隧道参数,`HTTPS API`负责电脑、设备和业务系统的身份、密钥、最小权限与调用日志。接口中心不会主动连接外部数据库。 +页面以“服务器被访问”为方向:为受信任的数据管理员生成个人 SSH 密钥、独立 +MySQL 账号和最小权限。接口中心不会主动连接外部数据库,也不在这个页面混入 +HTTPS API 客户端、接口策略或调用日志等其他管理流程。 ## DBeaver 安全连接 DBeaver 不使用 URL 模式直接填写服务器公网地址。按两组参数配置: -1. `SSH` 标签页:`Host/IP=服务器域名`、`Port=22`、认证方式使用私钥或 SSH Agent; -2. `Main` 标签页:`Server Host=127.0.0.1`、`Port=3307`、`Database=获授权数据库编码`; -3. 关闭 `Show all databases`; -4. 使用运维为当前人员签发的独立 MySQL 账号,禁止使用 `root` 和后端服务账号 `data_center`; -5. 默认只授予 `SELECT`,写入和结构修改权限按需临时授予。 +1. 在接口中心选择数据库、管理员姓名和“只读/读写”,点击“生成 DBeaver 接入”; +2. 私钥由浏览器本地生成,平台只接收公钥。立即下载私钥并保存一次性 MySQL 密码; +3. `SSH` 标签页:`Host/IP=服务器域名`、`Port=2222`、`User Name=dbeaver`、认证方式选择 `Public Key` 并选择刚下载的私钥;不要启用 `Bypass host verification`,首次连接提示的 SSH 主机指纹必须与接口中心一致; +4. `Main` 标签页:`Server Host=127.0.0.1`、`Port=3307`、`Database=获授权数据库编码`,填写生成的个人 MySQL 用户名和密码; +5. 关闭 `Show all databases`;默认只授予查询权限,读写权限也不包含建库、建表、`ALTER`、`DROP` 或给他人授权。 -运维人员可在服务器上创建或撤销独立账号: +接口中心可以直接撤销管理接入,撤销会同时删除个人 MySQL 账号和 SSH 公钥。 +服务器脚本保留为故障处理备用方式: ```bash ./scripts/mysql_admin_account.sh create zhangsan tourism_data read @@ -83,14 +116,22 @@ DBeaver 不使用 URL 模式直接填写服务器公网地址。按两组参数 失败 5 次锁定一天,密码 90 天过期。MySQL 端口仍必须绑定回环地址,账号的 `'%'` 主机范围不能替代网络隔离。 -接口中心不会显示或复制 MySQL 密码。服务器安全组只允许受控来源访问 SSH 与 HTTPS,不开放 `3307`。 +完整私钥和 MySQL 密码只在生成成功时显示一次,服务器不保存私钥,也不保存可恢复 +的 MySQL 密码。专用 SSH 账号被禁止终端、PTY、SFTP、Agent/X11 转发,只允许将 +连接转发到 `127.0.0.1:3307`。服务器安全组只允许受控来源访问 HTTPS 与专用 SSH +端口,不开放 `3307`。 -外部数据接口前缀:`/v1/openapi/data`。调用时使用: +本地模拟服务器连接时使用: -```http -Authorization: Bearer YOUR_API_KEY +```dotenv +DATA_MYSQL_SSH_HOST=localhost +DATA_MYSQL_SSH_BIND=127.0.0.1 +DATA_MYSQL_SSH_PORT=2222 ``` +服务器部署后把 `DATA_MYSQL_SSH_HOST` 改为服务器域名;若管理员电脑不在同一内网, +还必须把 `DATA_MYSQL_SSH_BIND` 绑定到可达网卡,并用防火墙限定管理员来源 IP。 + ## 迁移旧数据中心 先预览,不写入: @@ -113,7 +154,9 @@ python3 scripts/migrate_data_center_postgres_to_mysql.py --execute --replace-tar 迁移会保留记录 UUID、创建/更新时间、软删除状态和数据变更审计,从而避免实体引用失效;不会修改 PostgreSQL 源数据,也不会操作 FalkorDB 或知识图谱。 -## 权限模型 +## 预留的数据接口权限模型 + +以下后端能力为后续 HTTPS 数据接口预留,当前 DBeaver 管理接入页面不显示: 一条接口策略包含: diff --git a/scripts/backup_mysql_encrypted.sh b/scripts/backup_mysql_encrypted.sh index e1d6f0b..8eb7e07 100755 --- a/scripts/backup_mysql_encrypted.sh +++ b/scripts/backup_mysql_encrypted.sh @@ -3,46 +3,95 @@ set -Eeuo pipefail umask 077 -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -BACKUP_ROOT="${DATA_BACKUP_ROOT:-${PROJECT_DIR}/backups/mysql}" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" +BACKUP_ROOT="${DATA_BACKUP_ROOT:-}" PASSPHRASE_FILE="${DATA_BACKUP_PASSPHRASE_FILE:-}" +DATA_DIR="${MYSQL_DATA_DIR:-}" +STORAGE_ID="${MYSQL_STORAGE_ID:-unknown}" +RETENTION_DAYS="${DATA_BACKUP_RETENTION_DAYS:-30}" +MYSQL_CONTAINER_NAME="${MYSQL_CONTAINER_NAME:-travel-kg-mysql}" -if [[ -z "${PASSPHRASE_FILE}" || ! -f "${PASSPHRASE_FILE}" || ! -r "${PASSPHRASE_FILE}" ]]; then - echo "DATA_BACKUP_PASSPHRASE_FILE 必须指向可读的独立密钥文件" >&2 +fail() { + echo "错误:$*" >&2 exit 2 -fi -if [[ "$(wc -c < "${PASSPHRASE_FILE}" | tr -d ' ')" -lt 32 ]]; then - echo "备份加密口令文件内容不能少于 32 个字符" >&2 - exit 2 -fi -if [[ -z "${BACKUP_ROOT}" || "${BACKUP_ROOT}" == "/" ]]; then - echo "拒绝使用不安全的备份目录" >&2 - exit 2 -fi +} -for command_name in docker gzip openssl; do - command -v "${command_name}" >/dev/null 2>&1 || { - echo "缺少命令:${command_name}" >&2 - exit 2 - } +path_is_within() { + local candidate="$1" + local parent="$2" + [[ "${candidate}" == "${parent}" || "${candidate}/" == "${parent}/"* ]] +} + +secure_secret_file() { + local secret_file="$1" + local permissions permission_digits permission_value + + [[ "${secret_file}" == /* ]] || fail "DATA_BACKUP_PASSPHRASE_FILE 必须是绝对路径" + [[ ! -L "${secret_file}" ]] || fail "备份加密口令文件不能是符号链接" + [[ -f "${secret_file}" && -r "${secret_file}" ]] || fail "备份加密口令文件不存在或不可读" + secret_file="$(realpath -e -- "${secret_file}")" + path_is_within "${secret_file}" "${PROJECT_DIR}" && fail "备份加密口令不能存放在代码仓库内" + path_is_within "${secret_file}" "${BACKUP_ROOT}" && fail "加密口令不能与备份文件存放在同一目录" + if [[ -n "${DATA_DIR}" ]] && path_is_within "${secret_file}" "${DATA_DIR}"; then + fail "加密口令不能存放在MySQL运行数据目录内" + fi + [[ "$(wc -c < "${secret_file}" | tr -d '[:space:]')" -ge 32 ]] || fail "备份加密口令不能少于32个字符" + + if permissions="$(stat -c '%a' -- "${secret_file}" 2>/dev/null)"; then + : + else + permissions="$(stat -f '%Lp' -- "${secret_file}")" + fi + permission_digits="${permissions: -3}" + [[ "${permission_digits}" =~ ^[0-7]{3}$ ]] || fail "无法识别备份口令文件权限" + permission_value=$((8#${permission_digits})) + (( (permission_value & 8#077) == 0 )) || fail "备份口令文件不能允许组用户或其他用户访问,请执行 chmod 600" + + PASSPHRASE_FILE="${secret_file}" +} + +for command_name in docker find gzip openssl realpath stat; do + command -v "${command_name}" >/dev/null 2>&1 || fail "缺少命令:${command_name}" done -mkdir -p -- "${BACKUP_ROOT}" +[[ "${BACKUP_ROOT}" == /* ]] || fail "DATA_BACKUP_ROOT 必须指向代码仓库外的绝对目录" +[[ "${BACKUP_ROOT}" != "/" && ! -L "${BACKUP_ROOT}" && -d "${BACKUP_ROOT}" ]] || fail "备份目录必须预先创建,且不能是根目录或符号链接" +BACKUP_ROOT="$(realpath -e -- "${BACKUP_ROOT}")" +path_is_within "${BACKUP_ROOT}" "${PROJECT_DIR}" && fail "拒绝将生产备份写入代码仓库" +[[ -w "${BACKUP_ROOT}" ]] || fail "DATA_BACKUP_ROOT 当前不可写" + +if [[ -n "${DATA_DIR}" ]]; then + [[ "${DATA_DIR}" == /* && ! -L "${DATA_DIR}" && -d "${DATA_DIR}" ]] || fail "MYSQL_DATA_DIR 必须是现有绝对目录" + DATA_DIR="$(realpath -e -- "${DATA_DIR}")" + if path_is_within "${BACKUP_ROOT}" "${DATA_DIR}" || path_is_within "${DATA_DIR}" "${BACKUP_ROOT}"; then + fail "MySQL运行数据目录和备份目录不能相互包含" + fi +fi + +[[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]] || fail "DATA_BACKUP_RETENTION_DAYS 必须是整数" +(( RETENTION_DAYS >= 7 && RETENTION_DAYS <= 3650 )) || fail "备份保留天数必须在7–3650天之间" +[[ "${MYSQL_CONTAINER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] || fail "MYSQL_CONTAINER_NAME格式错误" +secure_secret_file "${PASSPHRASE_FILE}" + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" archive_name="mysql-all-${timestamp}.sql.gz.enc" archive_path="${BACKUP_ROOT}/${archive_name}" -temporary_path="${BACKUP_ROOT}/.${archive_name}.tmp" checksum_path="${archive_path}.sha256" manifest_path="${archive_path}.json" +work_dir="$(mktemp -d "${BACKUP_ROOT}/.mysql-backup.${timestamp}.XXXXXX")" +temporary_archive="${work_dir}/${archive_name}" +temporary_checksum="${work_dir}/${archive_name}.sha256" +temporary_manifest="${work_dir}/${archive_name}.json" cleanup() { - rm -f -- "${temporary_path}" + rm -f -- "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}" + rmdir -- "${work_dir}" 2>/dev/null || true } trap cleanup EXIT INT TERM -cd -- "${PROJECT_DIR}" -docker compose exec -T mysql sh -eu -c ' +[[ "$(docker inspect --format '{{.State.Running}}' "${MYSQL_CONTAINER_NAME}" 2>/dev/null)" == "true" ]] || fail "MySQL容器未运行:${MYSQL_CONTAINER_NAME}" +docker exec -i "${MYSQL_CONTAINER_NAME}" sh -eu -c ' MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump \ --user=root \ --all-databases \ @@ -52,22 +101,40 @@ docker compose exec -T mysql sh -eu -c ' --events \ --triggers \ --hex-blob \ + --source-data=2 \ --set-gtid-purged=OFF ' | gzip -9 | openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ - -pass "file:${PASSPHRASE_FILE}" -out "${temporary_path}" + -pass "file:${PASSPHRASE_FILE}" -out "${temporary_archive}" -test -s "${temporary_path}" -mv -- "${temporary_path}" "${archive_path}" +[[ -s "${temporary_archive}" ]] || fail "备份文件为空" +openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ + -pass "file:${PASSPHRASE_FILE}" -in "${temporary_archive}" | gzip -t if command -v sha256sum >/dev/null 2>&1; then - checksum="$(sha256sum "${archive_path}" | awk '{print $1}')" + checksum="$(sha256sum "${temporary_archive}" | awk '{print $1}')" else - checksum="$(shasum -a 256 "${archive_path}" | awk '{print $1}')" + checksum="$(shasum -a 256 "${temporary_archive}" | awk '{print $1}')" fi -printf '%s %s\n' "${checksum}" "${archive_name}" > "${checksum_path}" -printf '{\n "created_at": "%s",\n "file": "%s",\n "sha256": "%s",\n "encrypted": true,\n "cipher": "AES-256-CBC/PBKDF2",\n "compression": "gzip"\n}\n' \ - "${timestamp}" "${archive_name}" "${checksum}" > "${manifest_path}" -chmod 600 "${archive_path}" "${checksum_path}" "${manifest_path}" +printf '%s %s\n' "${checksum}" "${archive_name}" > "${temporary_checksum}" +printf '{\n "created_at": "%s",\n "file": "%s",\n "sha256": "%s",\n "encrypted": true,\n "cipher": "AES-256-CBC/PBKDF2",\n "compression": "gzip",\n "scope": "all-databases",\n "storage_id": "%s",\n "retention_days": %s\n}\n' \ + "${timestamp}" "${archive_name}" "${checksum}" "${STORAGE_ID}" "${RETENTION_DAYS}" > "${temporary_manifest}" +chmod 600 "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}" -echo "加密备份已生成:${archive_path}" -echo "请将 .enc、.sha256 和 .json 同步到独立的异地存储。" +# Sidecars are published first and the archive last, so backup scanners never +# observe a final .enc file without its checksum and manifest. +mv -- "${temporary_checksum}" "${checksum_path}" +mv -- "${temporary_manifest}" "${manifest_path}" +mv -- "${temporary_archive}" "${archive_path}" + +DATA_BACKUP_PASSPHRASE_FILE="${PASSPHRASE_FILE}" \ + "${SCRIPT_DIR}/verify_mysql_backup.sh" "${archive_path}" + +deleted_count=0 +while IFS= read -r -d '' expired_archive; do + rm -f -- "${expired_archive}" "${expired_archive}.sha256" "${expired_archive}.json" + deleted_count=$((deleted_count + 1)) +done < <(find "${BACKUP_ROOT}" -maxdepth 1 -type f -name 'mysql-all-*.sql.gz.enc' -mtime "+${RETENTION_DAYS}" -print0) + +echo "加密备份已生成并验证:${archive_path}" +echo "已清理 ${deleted_count} 组超过 ${RETENTION_DAYS} 天的历史备份。" +echo "还必须将 .enc、.sha256 和 .json 自动同步到另一台服务器或对象存储。" diff --git a/scripts/enable_managed_dbeaver_access.sh b/scripts/enable_managed_dbeaver_access.sh new file mode 100755 index 0000000..a0fc533 --- /dev/null +++ b/scripts/enable_managed_dbeaver_access.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# MySQL only runs docker-entrypoint-initdb.d scripts for a new data volume. +# Run this once after upgrading an existing deployment so the dedicated +# account-provisioning user receives its narrowly scoped privileges. +docker compose exec -T mysql \ + sh /docker-entrypoint-initdb.d/01-data-center-grants.sh + +echo "DBeaver 管理接入的独立 MySQL 签发账号已配置。" diff --git a/scripts/install_mysql_backup_systemd.sh b/scripts/install_mysql_backup_systemd.sh new file mode 100755 index 0000000..428ae93 --- /dev/null +++ b/scripts/install_mysql_backup_systemd.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" +BACKUP_ROOT="${DATA_BACKUP_ROOT:-}" +PASSPHRASE_FILE="${DATA_BACKUP_PASSPHRASE_FILE:-}" +DATA_DIR="${MYSQL_DATA_DIR:-}" +STORAGE_ID="${MYSQL_STORAGE_ID:-}" +RETENTION_DAYS="${DATA_BACKUP_RETENTION_DAYS:-30}" +SERVICE_USER="${MYSQL_BACKUP_SERVICE_USER:-${SUDO_USER:-root}}" +DOCKER_SOCKET="${DOCKER_HOST:-}" +ENV_DIR="/etc/travel-kg" +ENV_FILE="${ENV_DIR}/mysql-backup.env" +SERVICE_FILE="/etc/systemd/system/nianxx-mysql-backup.service" +TIMER_FILE="/etc/systemd/system/nianxx-mysql-backup.timer" + +fail() { + echo "错误:$*" >&2 + exit 2 +} + +safe_path() { + [[ "$1" =~ ^/[A-Za-z0-9._/-]+$ ]] +} + +[[ "${EUID}" -eq 0 ]] || fail "请使用 sudo -E 执行" +command -v systemctl >/dev/null 2>&1 || fail "服务器没有systemd,不能安装定时任务" +command -v runuser >/dev/null 2>&1 || fail "缺少runuser命令" +id "${SERVICE_USER}" >/dev/null 2>&1 || fail "备份服务用户不存在:${SERVICE_USER}" + +safe_path "${PROJECT_DIR}" || fail "代码目录只能包含字母、数字、点、下划线、横线和斜线" +safe_path "${BACKUP_ROOT}" || fail "DATA_BACKUP_ROOT 必须是安全的绝对路径" +safe_path "${PASSPHRASE_FILE}" || fail "DATA_BACKUP_PASSPHRASE_FILE 必须是安全的绝对路径" +safe_path "${DATA_DIR}" || fail "MYSQL_DATA_DIR 必须是安全的绝对路径" +[[ "${STORAGE_ID}" =~ ^[A-Za-z0-9._-]{16,128}$ ]] || fail "MYSQL_STORAGE_ID格式错误" +[[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]] || fail "DATA_BACKUP_RETENTION_DAYS必须是整数" +(( RETENTION_DAYS >= 7 && RETENTION_DAYS <= 3650 )) || fail "备份保留天数必须在7–3650天之间" +[[ -x "${PROJECT_DIR}/scripts/backup_mysql_encrypted.sh" ]] || fail "备份脚本不存在或不可执行" +[[ -d "${BACKUP_ROOT}" && ! -L "${BACKUP_ROOT}" ]] || fail "备份目录不存在或是符号链接" +[[ -d "${DATA_DIR}" && ! -L "${DATA_DIR}" ]] || fail "MySQL数据目录不存在或是符号链接" +[[ -f "${PASSPHRASE_FILE}" && ! -L "${PASSPHRASE_FILE}" ]] || fail "备份口令文件不存在或是符号链接" + +runuser -u "${SERVICE_USER}" -- test -w "${BACKUP_ROOT}" || fail "${SERVICE_USER}不能写入备份目录" +runuser -u "${SERVICE_USER}" -- test -r "${PASSPHRASE_FILE}" || fail "${SERVICE_USER}不能读取备份口令文件" + +if [[ -n "${DOCKER_SOCKET}" && ! "${DOCKER_SOCKET}" =~ ^unix:///[A-Za-z0-9._/-]+$ ]]; then + fail "DOCKER_HOST只允许安全的本机Unix socket" +fi + +SERVICE_GROUP="$(id -gn "${SERVICE_USER}")" +install -d -m 0750 -o root -g "${SERVICE_GROUP}" "${ENV_DIR}" +install -m 0640 -o root -g "${SERVICE_GROUP}" /dev/null "${ENV_FILE}" +{ + printf 'DATA_BACKUP_ROOT=%s\n' "${BACKUP_ROOT}" + printf 'DATA_BACKUP_PASSPHRASE_FILE=%s\n' "${PASSPHRASE_FILE}" + printf 'DATA_BACKUP_RETENTION_DAYS=%s\n' "${RETENTION_DAYS}" + printf 'MYSQL_DATA_DIR=%s\n' "${DATA_DIR}" + printf 'MYSQL_STORAGE_ID=%s\n' "${STORAGE_ID}" + if [[ -n "${DOCKER_SOCKET}" ]]; then + printf 'DOCKER_HOST=%s\n' "${DOCKER_SOCKET}" + fi +} > "${ENV_FILE}" + +install -m 0644 /dev/null "${SERVICE_FILE}" +{ + printf '%s\n' '[Unit]' + printf '%s\n' 'Description=Encrypted MySQL backup for Nianxx Data Center' + printf '%s\n' 'Wants=network-online.target' + printf '%s\n' 'After=network-online.target docker.service' + printf '\n%s\n' '[Service]' + printf '%s\n' 'Type=oneshot' + printf 'User=%s\n' "${SERVICE_USER}" + printf 'Group=%s\n' "${SERVICE_GROUP}" + printf 'WorkingDirectory=%s\n' "${PROJECT_DIR}" + printf 'EnvironmentFile=%s\n' "${ENV_FILE}" + printf 'ExecStart=%s/scripts/backup_mysql_encrypted.sh\n' "${PROJECT_DIR}" + printf '%s\n' 'UMask=0077' + printf '%s\n' 'NoNewPrivileges=true' + printf '%s\n' 'PrivateTmp=true' + printf '%s\n' 'ProtectSystem=full' + printf '%s\n' 'ProtectHome=false' + printf '%s\n' 'LockPersonality=true' +} > "${SERVICE_FILE}" + +install -m 0644 /dev/null "${TIMER_FILE}" +{ + printf '%s\n' '[Unit]' + printf '%s\n' 'Description=Daily encrypted MySQL backup for Nianxx Data Center' + printf '\n%s\n' '[Timer]' + printf '%s\n' 'OnCalendar=*-*-* 02:17:00' + printf '%s\n' 'RandomizedDelaySec=15m' + printf '%s\n' 'Persistent=true' + printf '%s\n' 'Unit=nianxx-mysql-backup.service' + printf '\n%s\n' '[Install]' + printf '%s\n' 'WantedBy=timers.target' +} > "${TIMER_FILE}" + +systemctl daemon-reload +systemctl start nianxx-mysql-backup.service +systemctl enable --now nianxx-mysql-backup.timer + +echo "首次加密备份已验证,systemd每日备份已启用。" +echo "查看定时器:systemctl list-timers nianxx-mysql-backup.timer" +echo "查看日志:journalctl -u nianxx-mysql-backup.service" +echo "确认异地同步后,才可在.env中设置 DATA_BACKUP_ENABLED=true。" diff --git a/scripts/prepare_mysql_production_storage.sh b/scripts/prepare_mysql_production_storage.sh new file mode 100755 index 0000000..c211745 --- /dev/null +++ b/scripts/prepare_mysql_production_storage.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" +STORAGE_MOUNT="${MYSQL_STORAGE_MOUNT:-}" +DATA_DIR="${MYSQL_DATA_DIR:-}" +BACKUP_ROOT="${DATA_BACKUP_ROOT:-}" +STORAGE_ID="${MYSQL_STORAGE_ID:-}" +MYSQL_UID="${MYSQL_CONTAINER_UID:-999}" +MYSQL_GID="${MYSQL_CONTAINER_GID:-999}" +ADOPT_EXISTING="${ADOPT_EXISTING_MYSQL_DATA:-false}" + +fail() { + echo "错误:$*" >&2 + exit 2 +} + +if [[ "${EUID}" -ne 0 ]]; then + fail "请使用 sudo -E 执行,以便创建并设置持久化目录权限" +fi + +for command_name in find findmnt install mountpoint realpath; do + command -v "${command_name}" >/dev/null 2>&1 || fail "缺少命令:${command_name}" +done + +[[ "${STORAGE_MOUNT}" == /* ]] || fail "MYSQL_STORAGE_MOUNT 必须是独立数据盘的绝对挂载点" +[[ "${DATA_DIR}" == /* ]] || fail "MYSQL_DATA_DIR 必须是项目外绝对路径" +[[ "${BACKUP_ROOT}" == /* ]] || fail "DATA_BACKUP_ROOT 必须是项目外绝对路径" +[[ "${STORAGE_ID}" =~ ^[A-Za-z0-9._-]{16,128}$ ]] || fail "MYSQL_STORAGE_ID 必须为16–128位安全标识" +[[ "${MYSQL_UID}" =~ ^[0-9]+$ && "${MYSQL_GID}" =~ ^[0-9]+$ ]] || fail "MySQL容器UID/GID必须是数字" + +STORAGE_MOUNT="$(realpath -e -- "${STORAGE_MOUNT}")" +mountpoint -q -- "${STORAGE_MOUNT}" || fail "${STORAGE_MOUNT} 当前不是独立挂载点,拒绝写入系统盘" +filesystem_type="$(findmnt -n -o FSTYPE --target "${STORAGE_MOUNT}")" +mount_options="$(findmnt -n -o OPTIONS --target "${STORAGE_MOUNT}")" +case "${filesystem_type}" in + overlay|tmpfs|ramfs|nfs|nfs4|cifs|fuse.*) + fail "MySQL运行数据不能使用 ${filesystem_type} 文件系统;请使用本地持久化块存储" + ;; +esac +case ",${mount_options}," in + *,rw,*) ;; + *) fail "数据盘 ${STORAGE_MOUNT} 不是可写挂载" ;; +esac + +DATA_DIR="$(realpath -m -- "${DATA_DIR}")" +BACKUP_ROOT="$(realpath -m -- "${BACKUP_ROOT}")" + +case "${DATA_DIR}/" in + "${STORAGE_MOUNT}/"*) ;; + *) fail "MYSQL_DATA_DIR 必须位于已确认的数据盘 ${STORAGE_MOUNT} 下" ;; +esac +[[ "${DATA_DIR}" != "${STORAGE_MOUNT}" ]] || fail "MySQL数据目录不能直接使用磁盘挂载根目录" + +case "${DATA_DIR}/" in + "${PROJECT_DIR}/"*) fail "MySQL数据目录不能位于代码仓库内" ;; +esac +case "${BACKUP_ROOT}/" in + "${PROJECT_DIR}/"*) fail "备份目录不能位于代码仓库内" ;; +esac +if [[ "${DATA_DIR}" == "${BACKUP_ROOT}" || "${BACKUP_ROOT}/" == "${DATA_DIR}/"* || "${DATA_DIR}/" == "${BACKUP_ROOT}/"* ]]; then + fail "MySQL运行数据目录和备份目录不能相互包含" +fi + +install -d -m 0750 -o "${MYSQL_UID}" -g "${MYSQL_GID}" -- "${DATA_DIR}" + +backup_uid="${SUDO_UID:-0}" +backup_gid="${SUDO_GID:-0}" +install -d -m 0700 -o "${backup_uid}" -g "${backup_gid}" -- "${BACKUP_ROOT}" + +marker="${STORAGE_MOUNT}/.nianxx-mysql-storage-id" +[[ ! -L "${marker}" ]] || fail "存储标识文件不能是符号链接" +if [[ -f "${marker}" ]]; then + actual_id="$(<"${marker}")" + [[ "${actual_id}" == "${STORAGE_ID}" ]] || fail "数据盘标识不一致,拒绝覆盖或切换数据库" +else + existing_entry="$(find "${DATA_DIR}" -mindepth 1 -maxdepth 1 -print -quit)" + if [[ -n "${existing_entry}" ]]; then + [[ "${ADOPT_EXISTING}" == "true" ]] || fail "目标目录已有数据但缺少标识;确认备份后设置 ADOPT_EXISTING_MYSQL_DATA=true 再执行" + [[ -f "${DATA_DIR}/auto.cnf" && -d "${DATA_DIR}/mysql" ]] || fail "现有目录不是完整的MySQL数据目录" + fi + temporary_marker="$(mktemp "${STORAGE_MOUNT}/.storage-id.XXXXXX")" + printf '%s\n' "${STORAGE_ID}" > "${temporary_marker}" + chown root:root "${temporary_marker}" + chmod 0444 "${temporary_marker}" + mv -n -- "${temporary_marker}" "${marker}" +fi + +actual_id="$(<"${marker}")" +[[ "${actual_id}" == "${STORAGE_ID}" ]] || fail "数据盘标识写入后校验失败" + +existing_entry="$(find "${DATA_DIR}" -mindepth 1 -maxdepth 1 -print -quit)" +if [[ -n "${existing_entry}" && ( ! -f "${DATA_DIR}/auto.cnf" || ! -d "${DATA_DIR}/mysql" ) ]]; then + fail "MySQL数据目录非空但结构不完整,拒绝启动" +fi + +chown "${MYSQL_UID}:${MYSQL_GID}" "${DATA_DIR}" +chmod 0750 "${DATA_DIR}" + +echo "MySQL生产存储已准备完成:" +echo " 数据盘挂载点:${STORAGE_MOUNT}" +echo " 文件系统:${filesystem_type} (${mount_options})" +echo " MySQL数据目录:${DATA_DIR}" +echo " 加密备份目录:${BACKUP_ROOT}" +echo " 存储标识:${STORAGE_ID}" +echo "请将以上三个路径/标识写入服务器 .env,然后再启动生产 Compose。" diff --git a/scripts/verify_mysql_backup.sh b/scripts/verify_mysql_backup.sh index 713c570..8ee694f 100755 --- a/scripts/verify_mysql_backup.sh +++ b/scripts/verify_mysql_backup.sh @@ -4,27 +4,28 @@ set -Eeuo pipefail archive_path="${1:-}" passphrase_file="${DATA_BACKUP_PASSPHRASE_FILE:-}" -if [[ -z "${archive_path}" || ! -f "${archive_path}" ]]; then - echo "用法:DATA_BACKUP_PASSPHRASE_FILE=/安全路径/密钥 $0 /备份/mysql-all-*.enc" >&2 +fail() { + echo "错误:$*" >&2 exit 2 -fi -if [[ -z "${passphrase_file}" || ! -r "${passphrase_file}" ]]; then - echo "缺少可读的 DATA_BACKUP_PASSPHRASE_FILE" >&2 - exit 2 -fi +} +[[ -n "${archive_path}" ]] || fail "用法:DATA_BACKUP_PASSPHRASE_FILE=/安全路径/密钥 $0 /备份/mysql-all-*.enc" +[[ "${archive_path}" == /* && ! -L "${archive_path}" && -f "${archive_path}" ]] || fail "备份必须是现有绝对路径,且不能是符号链接" +[[ "${passphrase_file}" == /* && ! -L "${passphrase_file}" && -f "${passphrase_file}" && -r "${passphrase_file}" ]] || fail "缺少安全、可读的 DATA_BACKUP_PASSPHRASE_FILE" + +archive_path="$(realpath -e -- "${archive_path}")" +passphrase_file="$(realpath -e -- "${passphrase_file}")" checksum_file="${archive_path}.sha256" -if [[ ! -f "${checksum_file}" ]]; then - echo "缺少校验文件:${checksum_file}" >&2 - exit 2 -fi +[[ ! -L "${checksum_file}" && -f "${checksum_file}" ]] || fail "缺少安全的校验文件:${checksum_file}" + expected="$(awk 'NR==1 {print $1}' "${checksum_file}")" +[[ "${expected}" =~ ^[0-9a-fA-F]{64}$ ]] || fail "SHA-256校验文件格式错误" if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "${archive_path}" | awk '{print $1}')" else actual="$(shasum -a 256 "${archive_path}" | awk '{print $1}')" fi -if [[ -z "${expected}" || "${actual}" != "${expected}" ]]; then +if [[ "${actual,,}" != "${expected,,}" ]]; then echo "备份 SHA-256 校验失败" >&2 exit 1 fi @@ -32,5 +33,5 @@ fi openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ -pass "file:${passphrase_file}" -in "${archive_path}" | gzip -t -echo "备份加密、校验和与压缩结构验证通过。" -echo "生产环境仍应定期恢复到隔离 MySQL 实例进行完整恢复演练。" +echo "备份加密、SHA-256校验和与压缩结构验证通过。" +echo "生产环境仍应定期恢复到隔离MySQL实例进行完整恢复演练。" diff --git a/tests/test_management_surface.py b/tests/test_management_surface.py index 413e75c..effcd9f 100644 --- a/tests/test_management_surface.py +++ b/tests/test_management_surface.py @@ -33,6 +33,10 @@ class ManagementSurfaceTests(unittest.TestCase): "/v1/admin/areas", "/v1/admin/notifications", "/v1/admin/agent-settings", + "/v1/admin/agent-call-logs", + "/v1/admin/roles", + "/v1/admin/capabilities", + "/v1/admin/permission-matrix", ) for prefix in retired_prefixes: @@ -56,12 +60,19 @@ class ManagementSurfaceTests(unittest.TestCase): "/v1/admin/plaza/user-query", "/v1/admin/graph/overview", "/v1/admin/users", - "/v1/admin/roles", - "/v1/admin/permission-matrix", "/v1/admin/ocr/passport", ): self.assertIn(path, paths) + user_detail_methods = { + method + for route in app.routes + if route.path == "/v1/admin/users/{user_id}" + for method in (route.methods or set()) + } + self.assertIn("PATCH", user_detail_methods) + self.assertNotIn("DELETE", user_detail_methods) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mysql_data_center.py b/tests/test_mysql_data_center.py index 35008ca..6ad6bea 100644 --- a/tests/test_mysql_data_center.py +++ b/tests/test_mysql_data_center.py @@ -1,7 +1,10 @@ import unittest from unittest.mock import AsyncMock, Mock, patch +import base64 +from pathlib import Path +import tempfile -from app.data_platform import interface_service, mysql_service +from app.data_platform import interface_service, mysql_db, mysql_service from app.data_platform.mysql_service import ( _managed_index_name, _mysql_default, @@ -54,6 +57,19 @@ class _FakeConnectionContext: class MySQLDataCenterContractTests(unittest.TestCase): + def test_connection_options_use_database_from_mysql_url(self) -> None: + with patch.object( + mysql_db.settings, + "data_mysql_url", + "mysql://data_user:secret@mysql:3306/platform_control", + ): + options = mysql_db._connection_options(include_database=True) + + self.assertEqual(options["host"], "mysql") + self.assertEqual(options["port"], 3306) + self.assertEqual(options["db"], "platform_control") + self.assertEqual(mysql_db.control_database_name(), "platform_control") + def test_physical_database_name_is_stable_and_mysql_safe(self) -> None: self.assertEqual(project_database_name("yunyou_libo"), "yunyou_libo") long_name = "a" * 63 @@ -127,6 +143,8 @@ class MySQLDataCenterContractTests(unittest.TestCase): "/v1/admin/interface-center/policies", "/v1/admin/interface-center/logs", "/v1/admin/interface-center/security-readiness", + "/v1/admin/interface-center/dbeaver-access", + "/v1/admin/interface-center/dbeaver-access/{grant_id}/revoke", "/v1/admin/data-platform/security/audit-logs", "/v1/openapi/data/catalog", "/v1/openapi/data/databases/{database_id}/tables/{table_code}/records", @@ -185,6 +203,17 @@ class MySQLDatabaseProvisioningTests(unittest.IsolatedAsyncioTestCase): class InterfaceCenterSecuritySummaryTests(unittest.IsolatedAsyncioTestCase): + def test_parameterized_mysql_account_escapes_host_wildcard(self) -> None: + username = "dba_123456abcdef" + self.assertEqual( + interface_service._mysql_account_sql(username), + "'dba_123456abcdef'@'%'", + ) + self.assertEqual( + interface_service._mysql_account_sql(username, parameterized=True), + "'dba_123456abcdef'@'%%'", + ) + async def test_dbeaver_summary_separates_ssh_and_database_endpoints(self) -> None: class SummaryCursor: async def __aenter__(self): @@ -230,6 +259,53 @@ class InterfaceCenterSecuritySummaryTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(summary["database_port"], 3307) self.assertFalse(summary["mysql_publicly_bound"]) + def test_managed_ssh_key_is_restricted_to_mysql_tunnel(self) -> None: + def field(value: bytes) -> bytes: + return len(value).to_bytes(4, "big") + value + + rsa_modulus = b"\x00\x80" + (b"\x00" * 383) + blob = field(b"ssh-rsa") + field(b"\x01\x00\x01") + field(rsa_modulus) + encoded = base64.b64encode(blob).decode() + public_key = f"ssh-rsa {encoded} user-supplied-comment" + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "authorized_keys" + host_key = Path(directory) / "ssh_host_ed25519_key.pub" + host_key.write_text(public_key) + with ( + patch.object( + interface_service.settings, + "data_mysql_managed_access_enabled", + True, + ), + patch.object( + interface_service.settings, + "data_mysql_ssh_authorized_keys_file", + str(target), + ), + patch.object( + interface_service.settings, + "data_mysql_ssh_host_public_key_file", + str(host_key), + ), + ): + interface_service._write_authorized_keys( + [ + { + "id": "grant-1", + "mysql_username": "dba_123456abcdef", + "ssh_public_key": public_key, + } + ] + ) + host_fingerprint = interface_service._ssh_host_key_fingerprint() + content = target.read_text() + + self.assertIn("restrict,port-forwarding", content) + self.assertIn('permitopen="127.0.0.1:3307"', content) + self.assertNotIn("user-supplied-comment", content) + self.assertIn("nianxx:grant-1:dba_123456abcdef", content) + self.assertTrue(host_fingerprint.startswith("SHA256:")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_security_baseline.py b/tests/test_security_baseline.py index eef5d6f..779f0d4 100644 --- a/tests/test_security_baseline.py +++ b/tests/test_security_baseline.py @@ -10,7 +10,7 @@ from app.auth import ( create_access_token, get_current_user, require_admin, - require_data_operator, + require_data_viewer, ) from app.config import settings from app.data_platform import interface_service @@ -28,8 +28,17 @@ def _production_settings(**updates): "database_url": "postgresql://app:" + "p" * 32 + "@127.0.0.1:5432/kg", "data_mysql_url": "mysql://app:" + "m" * 32 + "@127.0.0.1:3306/control", "mysql_host_bind": "127.0.0.1", + "data_mysql_storage_backend": "dedicated-block", + "data_mysql_storage_id": "prod-mysql-storage-01", + "data_mysql_storage_mount": "/mnt/sdr", + "data_mysql_data_dir": "/mnt/sdr/nianxx/mysql", + "data_backup_enabled": True, + "data_backup_encryption_required": True, + "data_backup_retention_days": 30, + "data_backup_root": "/mnt/backup/nianxx/mysql", "data_mysql_direct_access_enabled": True, "data_mysql_ssh_tunnel_required": True, + "data_mysql_ssh_host": "db-admin.example.cn", "cors_allowed_origins": "https://data.example.cn", "trusted_hosts": "data.example.cn", "auth_algorithm": "HS256", @@ -69,6 +78,68 @@ class SecurityBaselineTests(unittest.TestCase): {item["code"] for item in report["findings"]}, ) + def test_production_rejects_project_local_or_unidentified_mysql_storage(self) -> None: + unsafe = _production_settings( + data_mysql_storage_backend="docker-volume", + data_mysql_storage_id="", + data_mysql_data_dir="/app/mysql-data", + ) + codes = {item["code"] for item in security_readiness(unsafe)["findings"]} + self.assertIn("MYSQL_STORAGE_BACKEND", codes) + + unsafe = _production_settings( + data_mysql_storage_id="short", + data_mysql_storage_mount="/app/mysql-storage", + data_mysql_data_dir="/app/mysql-data", + ) + codes = {item["code"] for item in security_readiness(unsafe)["findings"]} + self.assertIn("MYSQL_STORAGE_ID", codes) + self.assertIn("MYSQL_STORAGE_MOUNT", codes) + self.assertIn("MYSQL_DATA_DIR", codes) + + outside = _production_settings(data_mysql_data_dir="/srv/mysql") + self.assertIn( + "MYSQL_DATA_OUTSIDE_MOUNT", + {item["code"] for item in security_readiness(outside)["findings"]}, + ) + + def test_production_requires_encrypted_external_nonoverlapping_backups(self) -> None: + unsafe = _production_settings( + data_backup_enabled=False, + data_backup_encryption_required=False, + data_backup_root="/mnt/sdr/nianxx/mysql/backups", + ) + codes = {item["code"] for item in security_readiness(unsafe)["findings"]} + self.assertIn("BACKUP_DISABLED", codes) + + unsafe = _production_settings( + data_backup_encryption_required=False, + data_backup_retention_days=1, + data_backup_root="/mnt/sdr/nianxx/mysql/backups", + ) + codes = {item["code"] for item in security_readiness(unsafe)["findings"]} + self.assertIn("BACKUP_ENCRYPTION_DISABLED", codes) + self.assertIn("BACKUP_RETENTION", codes) + self.assertIn("BACKUP_DATA_OVERLAP", codes) + + def test_managed_dbeaver_access_requires_an_isolated_provisioner(self) -> None: + safe = _production_settings( + data_mysql_managed_access_enabled=True, + data_mysql_ssh_authorized_keys_file="/srv/dbeaver/authorized_keys", + data_mysql_ssh_host_public_key_file="/srv/dbeaver/host.pub", + data_mysql_provisioner_user="access_broker", + data_mysql_provisioner_password="z" * 32, + ) + self.assertEqual(security_readiness(safe)["critical_count"], 0) + + unsafe = safe.model_copy(update={ + "data_mysql_provisioner_user": "app", + "data_mysql_provisioner_password": "change-me", + }) + codes = {item["code"] for item in security_readiness(unsafe)["findings"]} + self.assertIn("DBEAVER_PROVISIONER_USER", codes) + self.assertIn("DBEAVER_PROVISIONER_PASSWORD", codes) + class AuthenticationSecurityTests(unittest.IsolatedAsyncioTestCase): async def test_token_uses_issuer_audience_and_live_database_roles(self) -> None: @@ -121,14 +192,22 @@ class AuthenticationSecurityTests(unittest.IsolatedAsyncioTestCase): require_admin({"username": "collector", "roles": ["collector"]}) self.assertEqual(raised.exception.status_code, 403) - def test_data_center_rejects_collector_role(self) -> None: + def test_data_center_view_permission_rejects_unassigned_accounts(self) -> None: with self.assertRaises(HTTPException) as raised: - require_data_operator({"username": "collector", "roles": ["collector"]}) + require_data_viewer({"username": "unassigned", "roles": []}) self.assertEqual(raised.exception.status_code, 403) - operator = require_data_operator( - {"username": "operator", "roles": ["operator"]} + viewer = require_data_viewer( + {"username": "viewer", "roles": ["operator"]} ) - self.assertEqual(operator["username"], "operator") + self.assertEqual(viewer["username"], "viewer") + + def test_retired_collector_accounts_are_read_only_compatible(self) -> None: + legacy = require_data_viewer( + {"username": "legacy", "roles": ["collector"]} + ) + self.assertEqual(legacy["username"], "legacy") + with self.assertRaises(HTTPException): + require_admin(legacy) class InterfaceCredentialSecurityTests(unittest.TestCase): diff --git a/tests/test_user_management.py b/tests/test_user_management.py new file mode 100644 index 0000000..a1bce32 --- /dev/null +++ b/tests/test_user_management.py @@ -0,0 +1,125 @@ +import unittest +import inspect +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException +from pydantic import ValidationError + +from app.api import rbac +from app.api import data_platform +from app.contracts import UserCreate, UserUpdate + + +class UserContractTests(unittest.TestCase): + def test_only_two_data_permissions_are_accepted(self) -> None: + for permission in ("data_manage", "data_view"): + body = UserCreate( + username="user@example.com", + password="Secure-Pass-2026", + permission=permission, + ) + self.assertEqual(body.permission, permission) + + with self.assertRaises(ValidationError): + UserCreate( + username="user@example.com", + password="Secure-Pass-2026", + permission="collector", + ) + + def test_data_view_is_read_only_at_the_api_boundary(self) -> None: + for function_name in ("records", "export_records"): + parameter = inspect.signature( + getattr(data_platform, function_name) + ).parameters["_user"] + self.assertEqual(parameter.annotation, "DataViewerUser") + + for function_name in ( + "preview_records_import", + "import_records", + "add_record", + "edit_record", + "remove_record", + ): + parameter = inspect.signature( + getattr(data_platform, function_name) + ).parameters["user"] + self.assertEqual(parameter.annotation, "AdminUser") + + +class UserApiTests(unittest.IsolatedAsyncioTestCase): + async def test_create_maps_public_permission_to_one_internal_role(self) -> None: + create_mock = AsyncMock( + return_value={ + "id": 8, + "username": "viewer@example.com", + "full_name": "Viewer", + "status": "active", + "roles": ["operator"], + } + ) + body = UserCreate( + username=" viewer@example.com ", + password="Secure-Pass-2026", + full_name="Viewer", + permission="data_view", + ) + with ( + patch.object(rbac, "create_user", create_mock), + patch.object(rbac, "hash_password", return_value="hashed"), + ): + result = await rbac._create_user( + body, + {"username": "admin@example.com", "roles": ["admin"]}, + ) + + create_mock.assert_awaited_once() + data, roles = create_mock.await_args.args + self.assertEqual(data["username"], "viewer@example.com") + self.assertEqual(roles, ["operator"]) + self.assertEqual(result["permission"], "data_view") + self.assertNotIn("roles", result) + + async def test_list_hides_internal_roles_behind_two_permissions(self) -> None: + rows = [ + {"id": 1, "username": "manager", "status": "active", "roles": ["admin"]}, + {"id": 2, "username": "legacy", "status": "active", "roles": ["collector"]}, + ] + with patch.object(rbac, "list_users", AsyncMock(return_value=rows)): + result = await rbac._list_users( + {"username": "manager", "roles": ["admin"]} + ) + + self.assertEqual( + [item["permission"] for item in result], + ["data_manage", "data_view"], + ) + self.assertTrue(all("roles" not in item for item in result)) + + async def test_current_manager_cannot_disable_or_demote_itself(self) -> None: + target = { + "id": 1, + "username": "manager@example.com", + "status": "active", + "roles": ["admin"], + } + current = {"username": "manager@example.com", "roles": ["admin"]} + update_mock = AsyncMock() + + for body in ( + UserUpdate(status="disabled"), + UserUpdate(permission="data_view"), + ): + with ( + patch.object(rbac, "get_user_by_id", AsyncMock(return_value=target)), + patch.object(rbac, "update_user", update_mock), + ): + with self.assertRaises(HTTPException) as raised: + await rbac._update_user(1, body, current) + self.assertEqual(raised.exception.status_code, 400) + + update_mock.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main()