From 99c172b24de2476f296d529967d85762e5a063d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E6=A1=A5=E5=87=89=E4=BB=8B?= <113153113+HighBigSky@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:36:34 +0800 Subject: [PATCH] feat(users): visual database and table level grants for MySQL --- .../admin/AuthorizationScopeEditor.vue | 328 +++++++++++++++++ .../components/admin/DatabaseUserAdmin.vue | 341 +++++------------- apps/desktop/src/i18n/locales/az.ts | 5 + apps/desktop/src/i18n/locales/en.ts | 5 + apps/desktop/src/i18n/locales/es.ts | 5 + apps/desktop/src/i18n/locales/it.ts | 5 + apps/desktop/src/i18n/locales/ja.ts | 5 + apps/desktop/src/i18n/locales/ko.ts | 5 + apps/desktop/src/i18n/locales/pt-BR.ts | 5 + apps/desktop/src/i18n/locales/tr.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 5 + apps/desktop/src/i18n/locales/zh-TW.ts | 5 + .../DatabaseUserAdmin.component.spec.ts | 87 ++++- .../databaseAuthorizationPlan.spec.ts | 55 ++- .../lib/database/databaseAuthorizationPlan.ts | 53 ++- 15 files changed, 643 insertions(+), 271 deletions(-) create mode 100644 apps/desktop/src/components/admin/AuthorizationScopeEditor.vue diff --git a/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue b/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue new file mode 100644 index 0000000000..6aedf794d3 --- /dev/null +++ b/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue @@ -0,0 +1,328 @@ + + + diff --git a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue index 931cc4cc4f..475567af6a 100644 --- a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue +++ b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue @@ -1,14 +1,14 @@ -
{{ t("userAdmin.privileges") }}
-
+
{{ t("userAdmin.privileges") }}
+
-
+
- -
@@ -893,117 +830,7 @@ onMounted(loadUsers); {{ t("userAdmin.adminUserWarning") }} -
-
-
-
{{ t("userAdmin.databaseAccess") }}
-
{{ t("userAdmin.databaseAccessHint") }}
-
- {{ t("userAdmin.selectedDatabaseCount", { count: createDatabaseAuthorizations.length }) }} -
-
- - -
-
-
- - {{ t("userAdmin.loadingDatabases") }} -
-
{{ t("userAdmin.emptyDatabases") }}
-
-
- - - -
-
- {{ t("userAdmin.tableScope") }} -
- - -
- - - - - -
-
- - -
- -
-
-
- - {{ t("userAdmin.loadingTables") }} -
-
-

{{ t("userAdmin.loadTablesFailed", { message: createDatabaseTableErrors[database] }) }}

- -
-
- {{ t("userAdmin.emptyTables") }} -
- -
-
-
-
-

- {{ t("userAdmin.tableSelectionRequired") }} -

-
- -

- {{ t("userAdmin.customPrivilegeRequired") }} -

-
-

- {{ t("userAdmin.postgresDdlHint") }} -

-
-
-
+ diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index e5b5a985c9..2ce174fd8d 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -3993,6 +3993,8 @@ export default withEnglishFallback({ noGrants: "Verilmiş səlahiyyət tapılmadı", privilegeEditor: "Səlahiyyət redaktoru", privilegeHint: "Səlahiyyətləri, verilənlər bazasını və cədvəli seçin, sonra tətbiq etməzdən əvvəl SQL-ə önbaxış edin. * qlobal və ya bütün cədvəllər deməkdir.", + privilegeAppendHint: "Verilənlər bazalarını seçin və cədvəl əhatəsini təyin edin, sonra hər verilənlər bazası üçün imtiyazları konfiqurasiya edin. İmtiyazlar əlavə qaydada verilir və mövcud imtiyazlar geri alınmır.", + globalScope: "Qlobal (*.*)", database: "Verilənlər bazası", schema: "Sxem", table: "Cədvəl", @@ -4004,6 +4006,7 @@ export default withEnglishFallback({ memberRole: "Üzv rol", privileges: "Səlahiyyətlər", grantOption: "Səlahiyyətlərin başqalarına verilməsinə icazə ver (WITH GRANT OPTION)", + grantOptionHint: "Yalnızca GRANT ifadələri üçün tətbiq olunur; REVOKE bunu nəzərə almır.", adminOption: "Üzvlüyün idarə olunmasına icazə ver (WITH ADMIN OPTION)", grant: "Səlahiyyət ver", revoke: "Geri al", @@ -4068,6 +4071,8 @@ export default withEnglishFallback({ stepGrantAdmin: "{user} istifadəçisinə inzibatçı səlahiyyətləri ver", stepGrantDatabase: '{user} istifadəçisinə "{database}" verilənlər bazasına giriş icazəsi ver', stepGrantTable: '{user} istifadəçisinə "{database}.{table}" cədvəlinə giriş icazəsi ver', + stepRevokeDatabase: '{user} istifadəçisinin "{database}" verilənlər bazasına girişini geri al', + stepRevokeTable: '{user} istifadəçisinin "{database}.{table}" cədvəlinə girişini geri al', stepGrantObjects: '{user} istifadəçisinə "{database}" verilənlər bazasında sxem və obyekt icazələri ver', stepGrantCurrentObjects: '"{database}" verilənlər bazasındakı "{schema}" sxemində mövcud olan {scope} üçün {user} istifadəçisinə icazə ver', stepGrantFutureObjects: '"{database}" verilənlər bazasında cari rolun gələcəkdə yaradacağı {scope} üçün {user} istifadəçisinə icazə ver', diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3dfa1265f8..af02e0a96c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4215,6 +4215,8 @@ export default { noGrants: "No grants found", privilegeEditor: "Privilege Editor", privilegeHint: "Choose privileges, database, and table, then preview SQL before applying. * means global or all tables.", + privilegeAppendHint: "Choose databases and specify table scope, then configure privileges per database. Grants are additive and never revoke existing privileges.", + globalScope: "Global (*.*)", database: "Database", schema: "Schema", table: "Table", @@ -4226,6 +4228,7 @@ export default { memberRole: "Member role", privileges: "Privileges", grantOption: "Allow further grants (WITH GRANT OPTION)", + grantOptionHint: "Applies to GRANT statements only; REVOKE ignores it.", adminOption: "Allow membership management (WITH ADMIN OPTION)", grant: "Grant", revoke: "Revoke", @@ -4290,6 +4293,8 @@ export default { stepGrantAdmin: "Grant administrative privileges to {user}", stepGrantDatabase: 'Grant {user} access to database "{database}"', stepGrantTable: 'Grant {user} access to table "{database}.{table}"', + stepRevokeDatabase: 'Revoke {user} access to database "{database}"', + stepRevokeTable: 'Revoke {user} access to table "{database}.{table}"', stepGrantObjects: 'Grant {user} schema and object privileges in "{database}"', stepGrantCurrentObjects: 'Grant current {scope} in schema "{schema}" to {user} in "{database}"', stepGrantFutureObjects: 'Grant future {scope} created by the current role to {user} in "{database}"', diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 85db7ac09b..10a87c13e3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3912,6 +3912,8 @@ export default withEnglishFallback({ noGrants: "No se encontraron permisos", privilegeEditor: "Editor de privilegios", privilegeHint: "Elige privilegios, base de datos y tabla, luego previsualiza el SQL antes de aplicar. * significa global o todas las tablas.", + privilegeAppendHint: "Elige bases de datos y especifica el ámbito de tablas, luego configura los privilegios por base de datos. La concesión es acumulativa y no revoca los privilegios existentes.", + globalScope: "Global (*.*)", database: "Base de datos", schema: "Esquema", table: "Tabla", @@ -3923,6 +3925,7 @@ export default withEnglishFallback({ memberRole: "Rol miembro", privileges: "Privilegios", grantOption: "Permitir más concesiones (WITH GRANT OPTION)", + grantOptionHint: "Solo se aplica a las sentencias GRANT; REVOKE lo ignora.", adminOption: "Permitir gestión de membresía (WITH ADMIN OPTION)", grant: "Conceder", revoke: "Revocar", @@ -3986,6 +3989,8 @@ export default withEnglishFallback({ stepGrantAdmin: "Conceder privilegios administrativos a {user}", stepGrantDatabase: 'Conceder a {user} acceso a la base de datos "{database}"', stepGrantTable: 'Conceder a {user} acceso a la tabla "{database}.{table}"', + stepRevokeDatabase: 'Revocar a {user} el acceso a la base de datos "{database}"', + stepRevokeTable: 'Revocar a {user} el acceso a la tabla "{database}.{table}"', stepGrantObjects: 'Conceder a {user} privilegios de esquema y objetos en "{database}"', stepGrantCurrentObjects: 'Conceder a {user} privilegios sobre {scope} existentes del esquema "{schema}" en "{database}"', stepGrantFutureObjects: 'Conceder a {user} privilegios sobre futuros {scope} creados por el rol actual en "{database}"', diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 1233e242d3..d78a05b62f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3909,6 +3909,8 @@ export default withEnglishFallback({ noGrants: "Nessun privilegio trovato", privilegeEditor: "Editor Privilegi", privilegeHint: "Scegli privilegi, database e tabella, quindi visualizza l'anteprima SQL prima di applicare. * indica globale o tutte le tabelle.", + privilegeAppendHint: "Seleziona i database e specifica l'ambito delle tabelle, poi configura i privilegi per ogni database. La concessione è aggiuntiva e non revoca i privilegi esistenti.", + globalScope: "Globale (*.*)", database: "Database", schema: "Schema", table: "Tabella", @@ -3920,6 +3922,7 @@ export default withEnglishFallback({ memberRole: "Ruolo membro", privileges: "Privilegi", grantOption: "Consenti ulteriori concessioni (WITH GRANT OPTION)", + grantOptionHint: "Si applica solo alle istruzioni GRANT; REVOKE lo ignora.", adminOption: "Consenti gestione appartenenza (WITH ADMIN OPTION)", grant: "Concedi", revoke: "Revoca", @@ -3983,6 +3986,8 @@ export default withEnglishFallback({ stepGrantAdmin: "Concedi privilegi amministrativi a {user}", stepGrantDatabase: 'Concedi a {user} l’accesso al database "{database}"', stepGrantTable: 'Concedi a {user} l’accesso alla tabella "{database}.{table}"', + stepRevokeDatabase: 'Revoca a {user} l’accesso al database "{database}"', + stepRevokeTable: 'Revoca a {user} l’accesso alla tabella "{database}.{table}"', stepGrantObjects: 'Concedi a {user} privilegi su schema e oggetti in "{database}"', stepGrantCurrentObjects: 'Concedi a {user} i privilegi sui {scope} esistenti nello schema "{schema}" di "{database}"', stepGrantFutureObjects: 'Concedi a {user} i privilegi sui futuri {scope} creati dal ruolo corrente in "{database}"', diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 4e59320bff..3ca4f5dc38 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3972,6 +3972,8 @@ export default withEnglishFallback({ noGrants: "権限が見つかりません", privilegeEditor: "権限エディタ", privilegeHint: "権限、データベース、テーブルを選択し、適用前にSQLをプレビューしてください。* はグローバルまたは全テーブルを意味します。", + privilegeAppendHint: "データベースを選択してテーブル範囲を指定し、データベースごとに権限を設定します。権限の付与は追加式で、既存の権限は取り消されません。", + globalScope: "グローバル (*.*)", database: "データベース", schema: "スキーマ", table: "テーブル", @@ -3983,6 +3985,7 @@ export default withEnglishFallback({ memberRole: "メンバーロール", privileges: "権限", grantOption: "さらなる権限付与を許可 (WITH GRANT OPTION)", + grantOptionHint: "GRANT 文にのみ適用されます(REVOKE では無視されます)。", adminOption: "メンバーシップ管理を許可 (WITH ADMIN OPTION)", grant: "付与", revoke: "取り消し", @@ -4046,6 +4049,8 @@ export default withEnglishFallback({ stepGrantAdmin: "{user} に管理権限を付与", stepGrantDatabase: "{user} にデータベース「{database}」の権限を付与", stepGrantTable: "{user} にテーブル「{database}.{table}」の権限を付与", + stepRevokeDatabase: "{user} のデータベース「{database}」の権限をはく奪", + stepRevokeTable: "{user} のテーブル「{database}.{table}」の権限をはく奪", stepGrantObjects: "{user} にデータベース「{database}」のスキーマとオブジェクト権限を付与", stepGrantCurrentObjects: "{user} にデータベース「{database}」のスキーマ「{schema}」にある既存の{scope}権限を付与", stepGrantFutureObjects: "現在のロールがデータベース「{database}」で今後作成する{scope}の権限を {user} に付与", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 18d442fa94..3b8f229f35 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3910,6 +3910,8 @@ export default withEnglishFallback({ noGrants: "권한이 없습니다", privilegeEditor: "권한 편집기", privilegeHint: "권한, 데이터베이스, 테이블을 선택한 다음 적용 전에 SQL을 미리보세요. *는 전역 또는 모든 테이블을 의미합니다.", + privilegeAppendHint: "데이터베이스를 선택하고 테이블 범위를 지정한 뒤 데이터베이스별로 권한을 설정합니다. 권한 부여는 추가 방식이며 기존 권한은 회수하지 않습니다.", + globalScope: "전역 (*.*)", database: "데이터베이스", schema: "스키마", table: "테이블", @@ -3921,6 +3923,7 @@ export default withEnglishFallback({ memberRole: "멤버 역할", privileges: "권한", grantOption: "추가 권한 부여 허용 (WITH GRANT OPTION)", + grantOptionHint: "GRANT 문에만 적용되며 REVOKE에는 적용되지 않습니다.", adminOption: "멤버십 관리 허용 (WITH ADMIN OPTION)", grant: "권한 부여", revoke: "권한 회수", @@ -3984,6 +3987,8 @@ export default withEnglishFallback({ stepGrantAdmin: "{user}에게 관리자 권한 부여", stepGrantDatabase: '{user}에게 데이터베이스 "{database}" 접근 권한 부여', stepGrantTable: '{user}에게 테이블 "{database}.{table}" 접근 권한 부여', + stepRevokeDatabase: '{user}의 데이터베이스 "{database}" 접근 권한 회수', + stepRevokeTable: '{user}의 테이블 "{database}.{table}" 접근 권한 회수', stepGrantObjects: '"{database}"에서 {user}에게 스키마 및 객체 권한 부여', stepGrantCurrentObjects: '"{database}"에서 "{schema}" 스키마의 현재 {scope}을(를) {user}에게 부여', stepGrantFutureObjects: '"{database}"에서 현재 역할이 생성할 향후 {scope}을(를) {user}에게 부여', diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d5ad99d877..10b6b02b7c 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3911,6 +3911,8 @@ export default withEnglishFallback({ noGrants: "Nenhuma permissão encontrada", privilegeEditor: "Editor de Privilégios", privilegeHint: "Escolha privilégios, banco de dados e tabela, então visualize o SQL antes de aplicar. * significa global ou todas as tabelas.", + privilegeAppendHint: "Escolha os bancos de dados e especifique o escopo das tabelas, depois configure os privilégios por banco de dados. A concessão é aditiva e não revoga privilégios existentes.", + globalScope: "Global (*.*)", database: "Banco de dados", schema: "Schema", table: "Tabela", @@ -3922,6 +3924,7 @@ export default withEnglishFallback({ memberRole: "Função membro", privileges: "Privilégios", grantOption: "Permitir mais concessões (WITH GRANT OPTION)", + grantOptionHint: "Aplica-se apenas às instruções GRANT; o REVOKE o ignora.", adminOption: "Permitir gerenciamento de associação (WITH ADMIN OPTION)", grant: "Conceder", revoke: "Revogar", @@ -3985,6 +3988,8 @@ export default withEnglishFallback({ stepGrantAdmin: "Conceder privilégios administrativos a {user}", stepGrantDatabase: 'Conceder a {user} acesso ao banco de dados "{database}"', stepGrantTable: 'Conceder a {user} acesso à tabela "{database}.{table}"', + stepRevokeDatabase: 'Revogar o acesso de {user} ao banco de dados "{database}"', + stepRevokeTable: 'Revogar o acesso de {user} à tabela "{database}.{table}"', stepGrantObjects: 'Conceder a {user} privilégios de schema e objeto em "{database}"', stepGrantCurrentObjects: 'Conceder a {user} privilégios sobre {scope} existentes no schema "{schema}" de "{database}"', stepGrantFutureObjects: 'Conceder a {user} privilégios sobre futuros {scope} criados pela função atual em "{database}"', diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 1663f367fa..5e1740cb0b 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -3897,6 +3897,8 @@ export default withEnglishFallback({ noGrants: "Yetki bulunamadı", privilegeEditor: "Yetki Düzenleyici", privilegeHint: "Yetkileri, veritabanını ve tabloyu seçin, ardından uygulamadan önce SQL'i önizleyin. * genel ya da tüm tablolar anlamına gelir.", + privilegeAppendHint: "Veritabanlarını seçin ve tablo kapsamını belirleyin, ardından her veritabanı için yetkileri yapılandırın. Yetkilendirme eklemeli olarak yapılır ve mevcut yetkileri geri almaz.", + globalScope: "Genel (*.*)", database: "Veritabanı", schema: "Şema", table: "Tablo", @@ -3908,6 +3910,7 @@ export default withEnglishFallback({ memberRole: "Üye rol", privileges: "Yetkiler", grantOption: "Yetki devrine izin ver (WITH GRANT OPTION)", + grantOptionHint: "Yalnızca GRANT ifadeleri için geçerlidir; REVOKE bunu yok sayar.", adminOption: "Üyelik yönetimine izin ver (WITH ADMIN OPTION)", grant: "Yetki ver", revoke: "Yetkiyi geri al", @@ -3972,6 +3975,8 @@ export default withEnglishFallback({ stepGrantAdmin: "{user} kullanıcısına yönetici yetkileri ver", stepGrantDatabase: '{user} kullanıcısına "{database}" veritabanına erişim ver', stepGrantTable: '{user} kullanıcısına "{database}.{table}" tablosuna erişim ver', + stepRevokeDatabase: '{user} kullanıcısının "{database}" veritabanına erişimini geri al', + stepRevokeTable: '{user} kullanıcısının "{database}.{table}" tablosuna erişimini geri al', stepGrantObjects: '{user} kullanıcısına "{database}" içinde şema ve nesne yetkileri ver', stepGrantCurrentObjects: '"{database}" içindeki "{schema}" şemasında mevcut {scope} üzerinde {user} kullanıcısına yetki ver', stepGrantFutureObjects: '"{database}" içinde geçerli rolün ileride oluşturacağı {scope} üzerinde {user} kullanıcısına yetki ver', diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6d54501fa5..fee216348b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4191,6 +4191,8 @@ export default withEnglishFallback({ noGrants: "暂无授权信息", privilegeEditor: "权限编辑", privilegeHint: "选择权限、数据库和表,预览 SQL 后执行。* 表示全局或全部表。", + privilegeAppendHint: "选择数据库并指定表范围,按每个数据库配置权限;授权为追加式,不会回收该用户已有的权限。", + globalScope: "全局 (*.*)", database: "数据库", schema: "Schema", table: "表", @@ -4202,6 +4204,7 @@ export default withEnglishFallback({ memberRole: "成员角色", privileges: "权限", grantOption: "允许继续授权 (WITH GRANT OPTION)", + grantOptionHint: "仅对授权语句生效,撤权时不生效。", adminOption: "允许管理成员关系 (WITH ADMIN OPTION)", grant: "授权", revoke: "撤权", @@ -4265,6 +4268,8 @@ export default withEnglishFallback({ stepGrantAdmin: "为 {user} 授予管理权限", stepGrantDatabase: "为 {user} 授予数据库「{database}」权限", stepGrantTable: "为 {user} 授予表「{database}.{table}」权限", + stepRevokeDatabase: "回收 {user} 在数据库「{database}」的权限", + stepRevokeTable: "回收 {user} 在表「{database}.{table}」的权限", stepGrantObjects: "为 {user} 授予数据库「{database}」的 Schema 与对象权限", stepGrantCurrentObjects: "为 {user} 授予数据库「{database}」中 Schema「{schema}」的现有{scope}权限", stepGrantFutureObjects: "为 {user} 授予当前角色在数据库「{database}」中未来创建的{scope}权限", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 001db51daa..c0e7c4dcc7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7950,6 +7950,8 @@ export default withEnglishFallback({ noGrants: "未找到授權", privilegeEditor: "權限編輯器", privilegeHint: "選擇權限、資料庫和資料表,然後在套用前預覽 SQL。* 表示全域或所有資料表。", + privilegeAppendHint: "選擇資料庫並指定資料表範圍,為每個資料庫個別設定權限;授權為累加式,不會回收該使用者既有的權限。", + globalScope: "全域 (*.*)", database: "資料庫", schema: "Schema", table: "資料表", @@ -7961,6 +7963,7 @@ export default withEnglishFallback({ memberRole: "成員角色", privileges: "權限", grantOption: "允許進一步授權(WITH GRANT OPTION)", + grantOptionHint: "僅對授權語句生效,撤權時不生效。", adminOption: "允許成員管理(WITH ADMIN OPTION)", grant: "授予", revoke: "撤銷", @@ -8024,6 +8027,8 @@ export default withEnglishFallback({ stepGrantAdmin: "為 {user} 授予管理權限", stepGrantDatabase: "為 {user} 授予資料庫「{database}」權限", stepGrantTable: "為 {user} 授予資料表「{database}.{table}」權限", + stepRevokeDatabase: "回收 {user} 在資料庫「{database}」的權限", + stepRevokeTable: "回收 {user} 在資料表「{database}.{table}」的權限", stepGrantObjects: "為 {user} 授予資料庫「{database}」的 Schema 與物件權限", stepGrantCurrentObjects: "為 {user} 授予資料庫「{database}」中 Schema「{schema}」的既有{scope}權限", stepGrantFutureObjects: "為 {user} 授予目前角色在資料庫「{database}」中未來建立的{scope}權限", diff --git a/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts b/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts index 3fc56b6dac..f475ab3280 100644 --- a/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts @@ -42,7 +42,7 @@ function modelDialog(): Component { return defineComponent({ inheritAttrs: false, setup(_, { attrs, slots }) { - return () => (attrs.open ? h("div", attrs, slots.default?.()) : null); + return () => (attrs.open ? h("div", { ...attrs, "data-test-dialog": "true" }, slots.default?.()) : null); }, }); } @@ -163,7 +163,7 @@ afterEach(() => { }); describe("DatabaseUserAdmin MySQL grant loading", () => { - it("syncs privilege buttons and grant option from loaded SHOW GRANTS rows", async () => { + it("syncs the grant option from loaded SHOW GRANTS rows and renders the database scope editor", async () => { mocks.ensureConnected.mockResolvedValue(undefined); mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "mysql_native_password"]] }).mockResolvedValueOnce({ columns: ["Grants for root@%"], rows: [["GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION"]] }); @@ -175,12 +175,12 @@ describe("DatabaseUserAdmin MySQL grant loading", () => { await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2)); await nextTick(); - const privilegeButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === "INSERT"); const grantOptionLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("userAdmin.grantOption")); const grantOptionInput = grantOptionLabel?.querySelector('input[type="checkbox"]'); - expect(privilegeButton?.className).toContain("border-primary"); expect(grantOptionInput?.checked).toBe(true); + // MySQL 的权限编辑改为按数据库/表配置授权范围(与新增用户一致),固定的权限按钮由范围编辑器取代 + expect(root.querySelector('input[placeholder="userAdmin.searchDatabase"]')).not.toBeNull(); }); it("falls back to the current Doris user when SHOW ALL GRANTS requires GRANT_PRIV", async () => { @@ -219,6 +219,12 @@ function findButton(text: string): HTMLButtonElement | undefined { return Array.from(root?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text); } +// 权限编辑面板与新增用户弹窗复用同一个授权范围编辑器,断言弹窗内的控件时需要限定在弹窗范围内 +function findDialogButton(text: string): HTMLButtonElement | undefined { + const dialog = root?.querySelector('[data-test-dialog="true"]'); + return Array.from(dialog?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text); +} + describe("DatabaseUserAdmin MySQL create-user table grants", () => { it("loads tables, requires a selection, and previews only the selected table grant", async () => { mocks.ensureConnected.mockResolvedValue(undefined); @@ -237,22 +243,22 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => { findButton("userAdmin.newUser")?.click(); await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("native-mysql")); - await nextTick(); - findButton("scope_test")?.click(); + await vi.waitFor(() => expect(findDialogButton("scope_test")).toBeDefined()); + findDialogButton("scope_test")?.click(); const password = root.querySelector('[data-password-input="true"]'); password!.value = "test-password"; password!.dispatchEvent(new Event("input", { bubbles: true })); await nextTick(); - findButton("userAdmin.specificTables")?.click(); + findDialogButton("userAdmin.specificTables")?.click(); await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", "")); await nextTick(); - expect(findButton("userAdmin.previewSql")?.disabled).toBe(true); + expect(findDialogButton("userAdmin.previewSql")?.disabled).toBe(true); - findButton("allowed_table")?.click(); + findDialogButton("allowed_table")?.click(); await nextTick(); - expect(findButton("userAdmin.previewSql")?.disabled).toBe(false); - findButton("userAdmin.previewSql")?.click(); + expect(findDialogButton("userAdmin.previewSql")?.disabled).toBe(false); + findDialogButton("userAdmin.previewSql")?.click(); await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`allowed_table` TO 'app_user'@'%';")); expect(root.textContent).not.toContain("ON `scope_test`.*"); @@ -260,6 +266,65 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => { }); }); +describe("DatabaseUserAdmin MySQL privilege editing", () => { + it("previews one grant per selected table for the selected user", async () => { + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'root'@'%'"]] }); + mocks.listDatabases.mockResolvedValue([{ name: "scope_test" }]); + mocks.listTables.mockResolvedValue([ + { name: "allowed_table", table_type: "BASE TABLE" }, + { name: "blocked_table", table_type: "BASE TABLE" }, + ]); + + root = document.createElement("div"); + document.body.append(root); + app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection }); + app.mount(root); + await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(findButton("scope_test")).toBeDefined()); + + findButton("scope_test")?.click(); + await nextTick(); + findButton("userAdmin.specificTables")?.click(); + await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", "")); + await nextTick(); + + findButton("allowed_table")?.click(); + findButton("blocked_table")?.click(); + await nextTick(); + findButton("userAdmin.grant")?.click(); + + await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`allowed_table` TO 'root'@'%';")); + expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`blocked_table` TO 'root'@'%';"); + }); +}); + +describe("DatabaseUserAdmin MySQL global scope", () => { + it("grants the global scope through the fixed entry and hides table scope", async () => { + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'root'@'%'"]] }); + mocks.listDatabases.mockResolvedValue([{ name: "scope_test" }]); + + root = document.createElement("div"); + document.body.append(root); + app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection }); + app.mount(root); + await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(findButton("userAdmin.globalScope")).toBeDefined()); + + findButton("userAdmin.globalScope")?.click(); + await nextTick(); + + // 全局作用域没有表级粒度,不应出现表范围控件 + expect(findButton("userAdmin.specificTables")).toBeUndefined(); + + findButton("userAdmin.grant")?.click(); + + await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON *.* TO 'root'@'%';")); + expect(root?.textContent).not.toContain("ON `scope_test`.*"); + }); +}); + async function mountNativeMysqlUserAdmin() { mocks.ensureConnected.mockResolvedValue(undefined); mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "old-host", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'same-user'@'old-host'"]] }); diff --git a/apps/desktop/src/lib/__tests__/database/databaseAuthorizationPlan.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseAuthorizationPlan.spec.ts index 1cf47c6f6b..99053ceb1d 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseAuthorizationPlan.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseAuthorizationPlan.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { mysqlUserAdminProvider, postgresUserAdminProvider } from "@/lib/database/databaseUserAdmin"; -import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan"; +import { dorisUserAdminProvider, mysqlUserAdminProvider, postgresUserAdminProvider, starrocksUserAdminProvider } from "@/lib/database/databaseUserAdmin"; +import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, buildGrantAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan"; describe("database authorization plans", () => { it("grants PostgreSQL presets across user schemas and future objects", () => { @@ -184,4 +184,55 @@ describe("database authorization plans", () => { expect(results.map((result) => result.status)).toEqual(["failed", "skipped"]); }); + + it("expands an existing user's privilege change into one statement per selected table", () => { + const plan = buildGrantAuthorizationPlan({ + provider: mysqlUserAdminProvider, + user: { user: "app_user", host: "%" }, + databases: [{ database: "app_db", preset: "readOnly", tables: ["orders", "audit_log"] }], + grantOption: true, + }); + const sql = authorizationPlanSql(plan); + + expect(plan.steps).toHaveLength(2); + expect(plan.steps.map((step) => step.operation)).toEqual(["grantDatabase", "grantDatabase"]); + expect(plan.steps.map((step) => step.targetTable)).toEqual(["orders", "audit_log"]); + expect(sql).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.`orders` TO 'app_user'@'%' WITH GRANT OPTION;"); + expect(sql).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.`audit_log` TO 'app_user'@'%' WITH GRANT OPTION;"); + expect(sql).not.toContain("CREATE ROUTINE"); + }); + + it("keeps the global scope as *.* for grant and revoke", () => { + const selected = [{ database: "*", preset: "readOnly" as const }]; + const grant = buildGrantAuthorizationPlan({ provider: mysqlUserAdminProvider, user: { user: "local_admin", host: "%" }, databases: selected }); + const revoke = buildGrantAuthorizationPlan({ provider: mysqlUserAdminProvider, user: { user: "local_admin", host: "%" }, databases: selected, revoke: true }); + + expect(authorizationPlanSql(grant)).toContain("GRANT SELECT, SHOW VIEW ON *.* TO 'local_admin'@'%';"); + expect(authorizationPlanSql(revoke)).toContain("REVOKE SELECT, SHOW VIEW ON *.* FROM 'local_admin'@'%';"); + }); + + it("builds REVOKE steps without grant options", () => { + const plan = buildGrantAuthorizationPlan({ + provider: mysqlUserAdminProvider, + user: { user: "app_user", host: "%" }, + databases: [{ database: "app_db", preset: "readWrite", tables: ["orders"] }], + grantOption: true, + revoke: true, + }); + + expect(plan.steps.map((step) => step.operation)).toEqual(["revokePrivileges"]); + expect(plan.steps[0].targetTable).toBe("orders"); + expect(authorizationPlanSql(plan)).toBe("-- revoke app_user@% app_db.orders (connection scope)\nREVOKE SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES, SHOW VIEW, CREATE VIEW, TRIGGER ON `app_db`.`orders` FROM 'app_user'@'%';"); + expect(authorizationPlanSql(plan)).not.toContain("GRANT OPTION"); + }); + + it("leaves Doris and StarRocks to their own privilege names", () => { + for (const provider of [dorisUserAdminProvider, starrocksUserAdminProvider]) { + // 两者都能生成 GRANT SQL,但权限名与 MySQL 预设不同,因此不应由本计划构造语句 + expect(provider.grantPrivilegesSql).toBeTypeOf("function"); + const plan = buildGrantAuthorizationPlan({ provider, user: { user: "app_user", host: "%" }, databases: [{ database: "app_db", preset: "readOnly" }] }); + + expect(plan.steps).toEqual([]); + } + }); }); diff --git a/apps/desktop/src/lib/database/databaseAuthorizationPlan.ts b/apps/desktop/src/lib/database/databaseAuthorizationPlan.ts index 3668fe19a0..bee9ba42fa 100644 --- a/apps/desktop/src/lib/database/databaseAuthorizationPlan.ts +++ b/apps/desktop/src/lib/database/databaseAuthorizationPlan.ts @@ -4,7 +4,7 @@ import { mysqlUserAccount, quoteMySqlIdentifier, quotePostgresIdentifier, type C export type AuthorizationAccountType = "standard" | "admin"; export type AuthorizationPreset = "readWrite" | "readOnly" | "ddl" | "dml" | "custom"; export type AuthorizationTargetScope = "database" | "table"; -export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "grantCurrentObjects" | "grantFutureObjects"; +export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "revokePrivileges" | "grantCurrentObjects" | "grantFutureObjects"; export type AuthorizationObjectScope = "schemas" | "tables" | "sequences" | "functions"; export interface DatabaseAuthorizationSelection { @@ -149,6 +149,57 @@ export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationP return { steps }; } +export interface GrantAuthorizationPlanInput { + provider: DatabaseUserAdminProvider; + /** 目标用户身份 */ + user: DatabaseUserIdentity; + /** 需要授权(或撤权)的库/表选择 */ + databases: DatabaseAuthorizationSelection[]; + /** 是否携带 WITH GRANT OPTION,仅对授权语句生效 */ + grantOption?: boolean; + /** 为 true 时生成 REVOKE,默认生成 GRANT */ + revoke?: boolean; +} + +/** + * 为“已存在的用户”构造授权计划:把多库/多表选择展开为多条独立的 GRANT / REVOKE。 + * 与 buildCreateUserAuthorizationPlan 的差异: + * 1. 不包含创建用户步骤,因此各步骤之间没有依赖关系; + * 2. 采用追加式语义,只对勾选的库/表生成语句,不做差异比对,也不会回收用户已有的多余权限; + * 3. 目前仅用于 MySQL 表级/库级授权,其它方言返回空计划,由调用方沿用原有的单条 SQL 逻辑。 + */ +export function buildGrantAuthorizationPlan(input: GrantAuthorizationPlanInput): AuthorizationPlan { + const changePrivilegesSql = input.revoke ? input.provider.revokePrivilegesSql : input.provider.grantPrivilegesSql; + if (!changePrivilegesSql) return { steps: [] }; + // 以 provider 的库/表级授权能力作为判据:Doris / StarRocks 的 dialect 同为 "mysql", + // 但它们的权限名与 MySQL 预设不同(SELECT_PRIV 等),仅比较 dialect 会生成无效语句。 + if (!input.provider.supportsTableGrantsOnCreate) return { steps: [] }; + const steps: AuthorizationPlanStep[] = []; + for (const selection of input.databases) { + const database = selection.database.trim(); + if (!database) continue; + // 指定了表则按表级生效,否则作用于该库下的全部表 + const targetScope = input.provider.supportsTableGrantsOnCreate && selection.tables !== undefined ? "table" : "database"; + const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope); + if (privileges.length === 0) continue; + const tables = targetScope === "database" ? ["*"] : uniqueNames(selection.tables ?? []); + for (const table of tables) { + const targetTable = table === "*" ? undefined : table; + steps.push({ + id: `change-${steps.length}`, + label: `${input.revoke ? "revoke" : "grant"} ${input.provider.label(input.user)} ${targetTable ? `${database}.${targetTable}` : database}`, + database: "", + sql: changePrivilegesSql({ user: input.user, privileges, database, table, grantOption: input.grantOption, scope: "mysql" }), + operation: input.revoke ? "revokePrivileges" : "grantDatabase", + subject: input.provider.label(input.user), + targetDatabase: database, + targetTable, + }); + } + } + return { steps }; +} + export function buildCreateDatabaseAuthorizationPlan(input: CreateDatabaseAuthorizationPlanInput): AuthorizationPlan { const createStepId = "create-database"; const steps: AuthorizationPlanStep[] = [