diff --git a/src/common/network/odcPrivilegeElevation/index.ts b/src/common/network/odcPrivilegeElevation/index.ts new file mode 100644 index 000000000..71dccd8b7 --- /dev/null +++ b/src/common/network/odcPrivilegeElevation/index.ts @@ -0,0 +1,88 @@ +import request from '@/util/request/service'; + +export type ODCPrivilegePermission = { + schema?: string; + object?: string; + object_type?: 'table' | 'schema' | 'database' | 'unknown'; + privilege?: string; +}; + +export type ODCPrivilegeApprover = { + uid: string; + name: string; +}; + +export type CreateODCPrivilegeElevationApplicationParams = { + datasource_uid: string; + odc_session_id?: string; + current_account_uid: string; + current_account_name_masked?: string; + sql: string; + sql_digest: string; + db_error_code?: string; + db_error_message?: string; + requested_permissions: ODCPrivilegePermission[]; + reason: string; + selected_approver_uids?: string[]; +}; + +export type ODCPrivilegeElevationStatus = + | 'SUBMITTED' + | 'APPROVED' + | 'REJECTED' + | 'EXPIRED' + | 'PROVISIONING' + | 'ELEVATED' + | 'PROVISION_FAILED'; + +export type ODCPrivilegeElevationApplication = { + application_uid: string; + applicant_uid?: string; + applicant_name?: string; + datasource_uid?: string; + datasource_name?: string; + project_uid?: string; + current_account_uid?: string; + current_account_name_masked?: string; + requested_permissions?: ODCPrivilegePermission[]; + confirmed_permissions?: ODCPrivilegePermission[]; + reason?: string; + status: ODCPrivilegeElevationStatus; + reject_reason?: string; + provision_task_uid?: string; + elevated_account_uid?: string; + elevated_account_name_masked?: string; + failure_reason?: string; + created_at?: string; + updated_at?: string; + expire_at?: string; +}; + +type DMSResponse = { + data?: T; + code?: number; + message?: string; + errCode?: string; + errMsg?: string; + isError?: boolean; +}; + +export const createODCPrivilegeElevationApplication = ( + params: CreateODCPrivilegeElevationApplicationParams +) => + request.post< + DMSResponse<{ + application_uid: string; + status: ODCPrivilegeElevationStatus; + approvers?: ODCPrivilegeApprover[]; + expire_at?: string; + }> + >('/dms/v1/odc/privilege-elevation/applications', params, { + params: { ignoreError: true } + }); + +export const getODCPrivilegeElevationApplication = (applicationUID: string) => + request.get>( + `/dms/v1/odc/privilege-elevation/applications/${applicationUID}`, + { params: { ignoreError: true } } + ); diff --git a/src/common/network/sql/executeSQL.tsx b/src/common/network/sql/executeSQL.tsx index 7942a658a..e236a97a6 100644 --- a/src/common/network/sql/executeSQL.tsx +++ b/src/common/network/sql/executeSQL.tsx @@ -136,7 +136,14 @@ class Task { executingSQL: data.sql, executingSQLId: data.sqlId }); - if (data?.finished) { + const hasTerminalResult = data?.results?.some((result) => + [ + ISqlExecuteResultStatus.FAILED, + ISqlExecuteResultStatus.CANCELED, + ISqlExecuteResultStatus.SUCCESS + ].includes(result?.status) + ); + if (data?.finished || hasTerminalResult) { callback(this.result); return; } else { diff --git a/src/page/Workspace/components/SQLPage/index.tsx b/src/page/Workspace/components/SQLPage/index.tsx index 8c446c47b..8efceb5b9 100644 --- a/src/page/Workspace/components/SQLPage/index.tsx +++ b/src/page/Workspace/components/SQLPage/index.tsx @@ -41,7 +41,11 @@ import { ITableColumn, IUserConfig } from '@/d.ts'; -import { IUnauthorizedDBResources } from '@/d.ts/table'; +import { + IUnauthorizedDBResources, + TablePermissionType, + UnauthorizedPermissionTypeInSQLExecute +} from '@/d.ts/table'; import { debounceUpdatePageScriptText, ISQLPageParams, @@ -79,6 +83,34 @@ import ExecDetail from './ExecDetail'; import ExecPlan from './ExecPlan'; import styles from './index.less'; +const isDBPermissionDeniedResult = (result?: ISqlExecuteResult) => { + const errorCode = String(result?.errorCode ?? ''); + const errorText = [ + errorCode, + result?.track, + result?.messages, + result?.statementWarnings + ] + .filter(Boolean) + .join('\n'); + + return ( + result?.status === ISqlExecuteResultStatus.FAILED && + (errorCode === '1142' || + /\b1142\b|command denied|access denied|permission denied|权限不足|拒绝访问/i.test( + errorText + )) + ); +}; + +const parseDeniedTableName = (sql?: string) => { + const normalizedSql = sql?.replace(/`/g, '').trim(); + const matched = normalizedSql?.match( + /\b(?:from|join|update|into|table)\s+([\w$]+(?:\.[\w$]+)?)/i + ); + return matched?.[1]?.split('.').pop() || ''; +}; + interface ISQLPageState { resultHeight: number; initialSQL: string; @@ -777,9 +809,31 @@ export class SQLPage extends Component { }; public handleCheckDatabasePermission = (result: IExecuteTaskResult) => { + const deniedResult = result?.executeResult?.find(isDBPermissionDeniedResult); + const deniedSql = deniedResult?.executeSql || deniedResult?.originSql; + const session = this.getSession(); + const fallbackResource: IUnauthorizedDBResources[] = deniedResult + ? [ + { + unauthorizedPermissionTypes: [TablePermissionType.QUERY] as any, + dataSourceId: session?.connection?.id, + projectId: null, + projectName: '', + databaseId: session?.odcDatabase?.id, + databaseName: session?.odcDatabase?.name, + tableName: parseDeniedTableName(deniedSql), + tableId: null, + applicable: false, + type: UnauthorizedPermissionTypeInSQLExecute.ODC_TABLE + } + ] + : null; + this.setState({ - unauthorizedResource: result?.unauthorizedDBResources, - unauthorizedSql: result?.unauthorizedSql + unauthorizedResource: result?.unauthorizedDBResources?.length + ? result.unauthorizedDBResources + : fallbackResource, + unauthorizedSql: result?.unauthorizedSql || deniedSql }); }; diff --git a/src/page/Workspace/components/SQLResultSet/DBPermissionTable.tsx b/src/page/Workspace/components/SQLResultSet/DBPermissionTable.tsx index 3b8969081..cdbc6620d 100644 --- a/src/page/Workspace/components/SQLResultSet/DBPermissionTable.tsx +++ b/src/page/Workspace/components/SQLResultSet/DBPermissionTable.tsx @@ -18,7 +18,7 @@ import { formatMessage } from '@/util/intl'; import MultiLineOverflowText from '@/component/MultiLineOverflowText'; import { IUnauthorizedDBResources, TablePermissionType } from '@/d.ts/table'; import { CloseCircleFilled } from '@ant-design/icons'; -import { Space, Tabs, Typography } from 'antd'; +import { Button, Space, Tabs, Typography } from 'antd'; import styles from './index.less'; import DBPermissionTableContent from '../DBPermissionTableContent'; @@ -29,9 +29,12 @@ const PERMISSION_TAB_KEY = 'LOG'; interface IProps { sql?: string; dataSource: IUnauthorizedDBResources[]; + showPrivilegeElevation?: boolean; + onApplyPrivilegeElevation?: () => void; } const DBPermissionTable: React.FC = (props) => { - const { sql, dataSource } = props; + const { sql, dataSource, showPrivilegeElevation, onApplyPrivilegeElevation } = + props; return ( = (props) => { })} + {showPrivilegeElevation && ( +
+ +
+ )}
diff --git a/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/__tests__/utils.test.ts b/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/__tests__/utils.test.ts new file mode 100644 index 000000000..9a3bdfc5a --- /dev/null +++ b/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/__tests__/utils.test.ts @@ -0,0 +1,40 @@ +import { + IUnauthorizedDBResources, + UnauthorizedPermissionTypeInSQLExecute +} from '@/d.ts/table'; +import { buildRequestedPermissions, digestSql } from '../utils'; + +describe('PrivilegeElevationDrawer utils', () => { + it('maps unauthorized resources to requested permissions', () => { + expect( + buildRequestedPermissions([ + ({ + unauthorizedPermissionTypes: ['QUERY' as any], + dataSourceId: 1, + projectId: 1, + projectName: 'project', + databaseId: 1, + databaseName: 'db1', + tableName: 't1', + tableId: 1, + applicable: true, + type: UnauthorizedPermissionTypeInSQLExecute.ODC_TABLE + } as unknown as IUnauthorizedDBResources) + ]) + ).toEqual([ + { + schema: 'db1', + object: 't1', + object_type: 'table', + privilege: 'SELECT' + } + ]); + }); + + it('falls back to unknown permission and truncates sql digest', () => { + expect(buildRequestedPermissions([])).toEqual([ + { object_type: 'unknown', privilege: 'UNKNOWN' } + ]); + expect(digestSql(` ${'a'.repeat(200)} `)).toHaveLength(128); + }); +}); diff --git a/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/index.tsx b/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/index.tsx new file mode 100644 index 000000000..1a44e5f1b --- /dev/null +++ b/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/index.tsx @@ -0,0 +1,429 @@ +import { formatMessage } from '@/util/intl'; +import { IUnauthorizedDBResources } from '@/d.ts/table'; +import SessionStore from '@/store/sessionManager/session'; +import { + Button, + Descriptions, + Drawer, + Form, + Input, + Space, + Tag, + Typography, + message +} from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + createODCPrivilegeElevationApplication, + getODCPrivilegeElevationApplication, + ODCPrivilegeElevationApplication, + ODCPrivilegeElevationStatus +} from '@/common/network/odcPrivilegeElevation'; +import { buildRequestedPermissions, digestSql } from './utils'; + +const { Text, Paragraph } = Typography; + +const terminalStatuses: ODCPrivilegeElevationStatus[] = [ + 'REJECTED', + 'EXPIRED', + 'ELEVATED', + 'PROVISION_FAILED' +]; + +const statusColorMap: Record = { + SUBMITTED: 'processing', + APPROVED: 'blue', + PROVISIONING: 'processing', + ELEVATED: 'success', + REJECTED: 'error', + EXPIRED: 'warning', + PROVISION_FAILED: 'error' +}; + +const statusTextMap: Record = { + SUBMITTED: formatMessage({ + id: 'odc.privilegeElevation.status.submitted', + defaultMessage: '已提交' + }), + APPROVED: formatMessage({ + id: 'odc.privilegeElevation.status.approved', + defaultMessage: '已审批' + }), + PROVISIONING: formatMessage({ + id: 'odc.privilegeElevation.status.provisioning', + defaultMessage: '换发中' + }), + ELEVATED: formatMessage({ + id: 'odc.privilegeElevation.status.elevated', + defaultMessage: '已生效' + }), + REJECTED: formatMessage({ + id: 'odc.privilegeElevation.status.rejected', + defaultMessage: '已驳回' + }), + EXPIRED: formatMessage({ + id: 'odc.privilegeElevation.status.expired', + defaultMessage: '已过期' + }), + PROVISION_FAILED: formatMessage({ + id: 'odc.privilegeElevation.status.provisionFailed', + defaultMessage: '换发失败' + }) +}; + +const readDatasourceUID = (session?: SessionStore) => { + const connection = session?.connection as any; + return String( + connection?.dataSourceUid || + connection?.db_service_uid || + connection?.dbServiceUid || + connection?.uid || + connection?.sid || + connection?.id || + '' + ); +}; + +const readCurrentAccount = (session?: SessionStore) => { + const connection = session?.connection as any; + const uid = + connection?.currentAccountUid || + connection?.dbAccountUid || + connection?.accountUid || + connection?.authorizedAccountUid || + connection?.account?.uid || + connection?.sid || + connection?.id; + const name = + connection?.currentAccountNameMasked || + connection?.accountNameMasked || + connection?.dbAccountNameMasked || + connection?.username || + connection?.account?.name; + return { + uid: uid ? String(uid) : '', + name: name ? String(name) : '' + }; +}; + +const PrivilegeElevationDrawer: React.FC<{ + open: boolean; + session?: SessionStore; + sql?: string; + unauthorizedResources?: IUnauthorizedDBResources[]; + onClose: () => void; +}> = ({ open, session, sql, unauthorizedResources, onClose }) => { + const [form] = Form.useForm<{ reason: string }>(); + const [submitting, setSubmitting] = useState(false); + const [application, setApplication] = + useState(); + const [approvers, setApprovers] = useState([]); + const [messageApi, contextHolder] = message.useMessage(); + + const datasourceUID = readDatasourceUID(session); + const currentAccount = readCurrentAccount(session); + const permissions = useMemo( + () => buildRequestedPermissions(unauthorizedResources), + [unauthorizedResources] + ); + const errorMessage = formatMessage({ + id: 'odc.privilegeElevation.defaultDbError', + defaultMessage: 'SQL 执行返回权限不足,请申请目标权限后重试。' + }); + + useEffect(() => { + if ( + !open || + !application?.application_uid || + terminalStatuses.includes(application.status) + ) { + return; + } + const timer = window.setInterval(async () => { + const result = await getODCPrivilegeElevationApplication( + application.application_uid + ); + const detail = result?.data?.data; + if (detail?.application_uid) { + setApplication(detail); + } + }, 5000); + return () => window.clearInterval(timer); + }, [application?.application_uid, application?.status, open]); + + const submit = async () => { + const values = await form.validateFields(); + if (!datasourceUID || !currentAccount.uid) { + messageApi.error( + formatMessage({ + id: 'odc.privilegeElevation.missingContext', + defaultMessage: + '当前工作台缺少数据源或授权账号上下文,请重新进入工作台。' + }) + ); + return; + } + setSubmitting(true); + try { + const response = await createODCPrivilegeElevationApplication({ + datasource_uid: datasourceUID, + odc_session_id: session?.sessionId, + current_account_uid: currentAccount.uid, + current_account_name_masked: currentAccount.name, + sql: sql || '', + sql_digest: digestSql(sql), + db_error_code: 'DB_PERMISSION_DENIED', + db_error_message: errorMessage, + requested_permissions: permissions, + reason: values.reason, + selected_approver_uids: [] + }); + const data = response?.data?.data; + if (data?.application_uid) { + setApplication({ + application_uid: data.application_uid, + status: data.status, + expire_at: data.expire_at + }); + setApprovers( + (data.approvers || []).map((item) => item.name || item.uid) + ); + messageApi.success( + formatMessage({ + id: 'odc.privilegeElevation.submitSuccess', + defaultMessage: '提权申请已提交' + }) + ); + return; + } + const errorCode = + (response?.data as any)?.errCode || (response?.data as any)?.code; + const errorMsg = + (response?.data as any)?.errMsg || (response?.data as any)?.message; + if (errorCode === 'NO_APPROVER') { + messageApi.error( + formatMessage({ + id: 'odc.privilegeElevation.noApprover', + defaultMessage: '未匹配到审批人,请配置审批人或联系项目管理员。' + }) + ); + } else if (errorCode === 'ACTIVE_APPLICATION_EXISTS') { + messageApi.warning( + formatMessage({ + id: 'odc.privilegeElevation.activeExists', + defaultMessage: '已存在进行中的提权申请,请查看已有申请状态。' + }) + ); + } else if (errorCode === 'NO_DATASOURCE_ACCESS') { + messageApi.error( + formatMessage({ + id: 'odc.privilegeElevation.noDatasourceAccess', + defaultMessage: '当前用户无该数据源可见或授权权限。' + }) + ); + } else { + messageApi.error( + errorMsg || + formatMessage({ + id: 'odc.privilegeElevation.submitFailed', + defaultMessage: '提交提权申请失败' + }) + ); + } + } finally { + setSubmitting(false); + } + }; + + const status = application?.status; + + return ( + + + {!application && ( + + )} + + } + > + {contextHolder} + + + + {(session?.connection as any)?.name || datasourceUID || '-'} + + + {currentAccount.name || currentAccount.uid || '-'} + + + + {sql || '-'} + + + + {errorMessage} + + + + {permissions.map((item, index) => ( + + {[item.schema, item.object, item.privilege] + .filter(Boolean) + .join('.') || 'UNKNOWN'} + + ))} + + + + + {!application && ( +
+ + + +
+ )} + + {application && ( + + + {application.application_uid} + + + + {statusTextMap[status || ''] || status} + + + + {approvers.length ? approvers.join('、') : '-'} + + + {application.expire_at || '-'} + + {(status === 'REJECTED' || application.reject_reason) && ( + + {application.reject_reason || '-'} + + )} + {(status === 'PROVISION_FAILED' || application.failure_reason) && ( + + {application.failure_reason || '-'} + + )} + {status === 'ELEVATED' && ( + + + {formatMessage({ + id: 'odc.privilegeElevation.elevatedGuide', + defaultMessage: + '权限已生效,请刷新连接或重新进入工作台后继续执行原 SQL。' + })} + + + )} + + )} +
+
+ ); +}; + +export default PrivilegeElevationDrawer; diff --git a/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/utils.ts b/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/utils.ts new file mode 100644 index 000000000..a459ee7ca --- /dev/null +++ b/src/page/Workspace/components/SQLResultSet/PrivilegeElevationDrawer/utils.ts @@ -0,0 +1,42 @@ +import { IUnauthorizedDBResources } from '@/d.ts/table'; +import { ODCPrivilegePermission } from '@/common/network/odcPrivilegeElevation'; + +export const digestSql = (sql?: string) => (sql || '').trim().slice(0, 128); + +export const toPrivilege = (type?: string) => { + switch (type) { + case 'QUERY': + return 'SELECT'; + case 'CHANGE': + return 'UPDATE'; + case 'EXPORT': + return 'SELECT'; + default: + return 'UNKNOWN'; + } +}; + +export const buildRequestedPermissions = ( + resources: IUnauthorizedDBResources[] = [] +): ODCPrivilegePermission[] => { + const permissions = resources.reduce( + (result, item) => + result.concat( + (item.unauthorizedPermissionTypes || []).map((type) => ({ + schema: item.databaseName, + object: item.tableName || item.databaseName, + object_type: item.tableName ? 'table' : 'database', + privilege: toPrivilege(type) + })) + ), + [] + ); + return permissions.length + ? permissions + : [ + { + object_type: 'unknown', + privilege: 'UNKNOWN' + } + ]; +}; diff --git a/src/page/Workspace/components/SQLResultSet/index.tsx b/src/page/Workspace/components/SQLResultSet/index.tsx index 822e044ba..de8e5da4e 100644 --- a/src/page/Workspace/components/SQLResultSet/index.tsx +++ b/src/page/Workspace/components/SQLResultSet/index.tsx @@ -49,6 +49,7 @@ import type { MenuInfo } from 'rc-menu/lib/interface'; import DDLResultSet from '../DDLResultSet'; import { SqlExecuteResultStatusLabel } from './const'; import DBPermissionTable from './DBPermissionTable'; +import PrivilegeElevationDrawer from './PrivilegeElevationDrawer'; import ExecuteHistory from './ExecuteHistory'; import LintResultTable from './LintResultTable'; import SQLResultLog from './SQLResultLog'; @@ -217,6 +218,8 @@ const SQLResultSet: React.FC = function (props) { const [showLockResultSetHint, setShowLockResultSetHint] = useState(false); const [unmaskDrawerVisible, setUnmaskDrawerVisible] = useState(false); + const [privilegeElevationDrawerVisible, setPrivilegeElevationDrawerVisible] = + useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [viewOriginalLoading, setViewOriginalLoading] = useState(false); const [appliedResultSetMap, setAppliedResultSetMap] = useState< @@ -575,10 +578,23 @@ const SQLResultSet: React.FC = function (props) { let resultTabCount = 0; if (unauthorizedResource?.length) { return ( - + <> + + setPrivilegeElevationDrawerVisible(true) + } + /> + setPrivilegeElevationDrawerVisible(false)} + /> + ); } const stopRunning = () => {