Files
DBClient/app/ui/user_manager.py
2026-05-21 15:46:41 -04:00

632 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
User & Privilege Management panel.
Supports MySQL and PostgreSQL.
SQLite and MSSQL show an informational message (no user management via this UI).
Features
--------
• List all database users with host (MySQL) or connection limit (PostgreSQL)
• Create user (username + password + optional host for MySQL)
• Drop user with confirmation
• View per-database GRANT privileges for the selected user
• GRANT / REVOKE individual privileges on a database
All DDL operations execute synchronously in a background QThread to keep
the UI responsive.
"""
from __future__ import annotations
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
QTableWidget, QTableWidgetItem, QHeaderView,
QLabel, QPushButton, QComboBox, QCheckBox,
QDialog, QFormLayout, QLineEdit, QDialogButtonBox,
QMessageBox, QGroupBox, QAbstractItemView,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QColor, QFont
# ── Standard SQL privileges offered in the GRANT UI ──────────────────────────
_MYSQL_PRIVS = [
"SELECT", "INSERT", "UPDATE", "DELETE",
"CREATE", "DROP", "ALTER", "INDEX",
"CREATE VIEW", "SHOW VIEW",
"CREATE ROUTINE", "ALTER ROUTINE", "EXECUTE",
"REFERENCES", "TRIGGER", "LOCK TABLES",
"CREATE TEMPORARY TABLES",
]
_PG_PRIVS = [
"SELECT", "INSERT", "UPDATE", "DELETE",
"TRUNCATE", "REFERENCES", "TRIGGER",
"CREATE", "CONNECT", "TEMPORARY",
"EXECUTE", "USAGE",
]
# ── Background worker ─────────────────────────────────────────────────────────
class _UserWorker(QThread):
"""Run an arbitrary callable in a background thread."""
result = pyqtSignal(object)
error = pyqtSignal(str)
def __init__(self, fn, *args, parent=None):
super().__init__(parent)
self._fn = fn
self._args = args
def run(self):
try:
self.result.emit(self._fn(*self._args))
except Exception as e:
self.error.emit(str(e))
# ── Driver-level helpers (attached at runtime, no driver subclassing needed) ──
def _mysql_get_users(driver) -> list[dict]:
"""Return list of {user, host, super} from MySQL.user table."""
cols, rows, _ = driver.execute_query(
"SELECT User, Host, Super_priv FROM mysql.user ORDER BY User, Host"
)
return [{"user": r[0], "host": r[1], "super": r[2]} for r in rows]
def _mysql_get_grants(driver, user: str, host: str) -> list[str]:
cols, rows, _ = driver.execute_query(
f"SHOW GRANTS FOR %s@%s", (user, host) # type: ignore[arg-type]
)
return [r[0] for r in rows]
def _mysql_create_user(driver, user: str, host: str, password: str):
driver.execute_query(
"CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password)
)
def _mysql_drop_user(driver, user: str, host: str):
driver.execute_query(f"DROP USER %s@%s", (user, host))
def _mysql_grant(driver, privs: list[str], database: str,
user: str, host: str):
priv_str = ", ".join(privs) if privs else "USAGE"
driver.execute_query(
f"GRANT {priv_str} ON `{database}`.* TO %s@%s", (user, host)
)
driver.execute_query("FLUSH PRIVILEGES")
def _mysql_revoke(driver, privs: list[str], database: str,
user: str, host: str):
priv_str = ", ".join(privs) if privs else "USAGE"
driver.execute_query(
f"REVOKE {priv_str} ON `{database}`.* FROM %s@%s", (user, host)
)
driver.execute_query("FLUSH PRIVILEGES")
# ── PostgreSQL helpers ────────────────────────────────────────────────────────
def _pg_get_users(driver) -> list[dict]:
cols, rows, _ = driver.execute_query(
"SELECT usename, usesuper, usecreatedb FROM pg_user ORDER BY usename"
)
return [{"user": r[0], "host": "", "super": "Y" if r[1] else "N",
"createdb": "Y" if r[2] else "N"} for r in rows]
def _pg_get_grants(driver, user: str, _host: str) -> list[str]:
cols, rows, _ = driver.execute_query("""
SELECT grantee, table_catalog, table_schema, table_name,
string_agg(privilege_type, ', ') AS privileges
FROM information_schema.role_table_grants
WHERE grantee = %s
GROUP BY grantee, table_catalog, table_schema, table_name
ORDER BY table_catalog, table_schema, table_name
""", (user,))
return [
f"GRANT {r[4]} ON {r[1]}.{r[2]}.{r[3]} TO {r[0]}"
for r in rows
]
def _pg_create_user(driver, user: str, _host: str, password: str):
driver.execute_query(
f"CREATE USER \"{user}\" WITH PASSWORD %s", (password,)
)
def _pg_drop_user(driver, user: str, _host: str):
driver.execute_query(f'DROP USER "{user}"')
def _pg_grant(driver, privs: list[str], database: str, user: str, _host: str):
priv_str = ", ".join(privs) if privs else "CONNECT"
driver.execute_query(
f'GRANT {priv_str} ON DATABASE "{database}" TO "{user}"'
)
def _pg_revoke(driver, privs: list[str], database: str, user: str, _host: str):
priv_str = ", ".join(privs) if privs else "CONNECT"
driver.execute_query(
f'REVOKE {priv_str} ON DATABASE "{database}" FROM "{user}"'
)
# ── Dialogs ───────────────────────────────────────────────────────────────────
class _CreateUserDialog(QDialog):
def __init__(self, db_type: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Create User")
self.setModal(True)
self.setMinimumWidth(360)
self._db_type = db_type
root = QVBoxLayout(self)
form = QFormLayout()
form.setSpacing(8)
self._user_edit = QLineEdit()
self._user_edit.setPlaceholderText("e.g. app_user")
form.addRow("Username:", self._user_edit)
if db_type == "mysql":
self._host_edit = QLineEdit()
self._host_edit.setText("%")
self._host_edit.setPlaceholderText("% = any host")
form.addRow("Host:", self._host_edit)
self._pass_edit = QLineEdit()
self._pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
self._pass_edit.setPlaceholderText("Password")
form.addRow("Password:", self._pass_edit)
self._pass2_edit = QLineEdit()
self._pass2_edit.setEchoMode(QLineEdit.EchoMode.Password)
self._pass2_edit.setPlaceholderText("Confirm password")
form.addRow("Confirm:", self._pass2_edit)
root.addLayout(form)
bbox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel,
)
bbox.accepted.connect(self._validate)
bbox.rejected.connect(self.reject)
root.addWidget(bbox)
def _validate(self):
if not self._user_edit.text().strip():
QMessageBox.warning(self, "Validation", "Username is required.")
return
if self._pass_edit.text() != self._pass2_edit.text():
QMessageBox.warning(self, "Validation", "Passwords do not match.")
return
self.accept()
@property
def username(self) -> str:
return self._user_edit.text().strip()
@property
def host(self) -> str:
if self._db_type == "mysql":
return self._host_edit.text().strip() or "%"
return ""
@property
def password(self) -> str:
return self._pass_edit.text()
class _GrantDialog(QDialog):
"""Select privileges + target database and grant/revoke."""
def __init__(self, driver, db_type: str, databases: list[str],
user: str, host: str, mode: str = "grant", parent=None):
super().__init__(parent)
self._driver = driver
self._db_type = db_type
self._user = user
self._host = host
self._mode = mode # "grant" | "revoke"
self.setWindowTitle(f"{'Grant to' if mode == 'grant' else 'Revoke from'} {user}")
self.setModal(True)
self.setMinimumSize(420, 480)
self._build_ui(databases)
def _build_ui(self, databases: list[str]):
root = QVBoxLayout(self)
form = QFormLayout()
self._db_combo = QComboBox()
for db in databases:
self._db_combo.addItem(db)
form.addRow("Database:", self._db_combo)
root.addLayout(form)
# Privilege checkboxes
priv_box = QGroupBox("Privileges")
pb_lay = QVBoxLayout(priv_box)
privs = _MYSQL_PRIVS if self._db_type == "mysql" else _PG_PRIVS
self._priv_checks: list[QCheckBox] = []
for priv in privs:
cb = QCheckBox(priv)
pb_lay.addWidget(cb)
self._priv_checks.append(cb)
sel_row = QHBoxLayout()
sel_all = QPushButton("All")
sel_all.setFixedWidth(50)
sel_all.clicked.connect(lambda: [cb.setChecked(True)
for cb in self._priv_checks])
sel_none = QPushButton("None")
sel_none.setFixedWidth(50)
sel_none.clicked.connect(lambda: [cb.setChecked(False)
for cb in self._priv_checks])
sel_row.addWidget(sel_all)
sel_row.addWidget(sel_none)
sel_row.addStretch()
pb_lay.insertLayout(0, sel_row)
root.addWidget(priv_box, 1)
verb = "Grant" if self._mode == "grant" else "Revoke"
bbox = QDialogButtonBox()
ok = bbox.addButton(verb, QDialogButtonBox.ButtonRole.AcceptRole)
ok.clicked.connect(self._execute)
bbox.addButton(QDialogButtonBox.StandardButton.Cancel).clicked.connect(
self.reject)
root.addWidget(bbox)
def _execute(self):
privs = [cb.text() for cb in self._priv_checks if cb.isChecked()]
if not privs:
QMessageBox.warning(self, "No Privilege",
"Select at least one privilege.")
return
db = self._db_combo.currentText()
try:
if self._db_type == "mysql":
fn = _mysql_grant if self._mode == "grant" else _mysql_revoke
else:
fn = _pg_grant if self._mode == "grant" else _pg_revoke
fn(self._driver, privs, db, self._user, self._host)
self.accept()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
@property
def selected_privs(self) -> list[str]:
return [cb.text() for cb in self._priv_checks if cb.isChecked()]
# ── Main Panel ────────────────────────────────────────────────────────────────
class UserManagerPanel(QWidget):
"""
User & privilege management workspace tab.
Parameters
----------
driver : BaseDriver — must already be connected
parent : QWidget
"""
status_message = pyqtSignal(str)
def __init__(self, driver, parent=None):
super().__init__(parent)
self._driver = driver
self._db_type = getattr(driver, "db_type", "").lower()
self._databases: list[str] = []
self._current_user: str = ""
self._current_host: str = ""
self._worker: _UserWorker | None = None
self._build_ui()
self._load_users()
self._load_databases()
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# ── Header bar ────────────────────────────────────────────────────────
hdr = QHBoxLayout()
hdr.setContentsMargins(8, 6, 8, 6)
hdr.setSpacing(6)
title = QLabel("👤 User & Privilege Management")
f = title.font()
f.setBold(True)
f.setPointSize(11)
title.setFont(f)
title.setObjectName("structureTitle")
self._refresh_btn = QPushButton("🔄 Refresh")
self._refresh_btn.setFixedWidth(90)
self._refresh_btn.clicked.connect(self._load_users)
hdr.addWidget(title)
hdr.addStretch()
hdr.addWidget(self._refresh_btn)
root.addLayout(hdr)
# ── Unsupported banner (hidden for MySQL/PG) ──────────────────────────
self._unsupported_lbl = QLabel(
"️ User management is not available for this database type.\n"
" (Supports MySQL and PostgreSQL only.)"
)
self._unsupported_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._unsupported_lbl.setObjectName("emptyLabel")
root.addWidget(self._unsupported_lbl)
if self._db_type not in ("mysql", "postgresql"):
self._unsupported_lbl.setVisible(True)
return
self._unsupported_lbl.setVisible(False)
# ── Main splitter: user list (left) / grants detail (right) ──────────
splitter = QSplitter(Qt.Orientation.Horizontal)
# ── Left: user table ──────────────────────────────────────────────────
left = QWidget()
ll = QVBoxLayout(left)
ll.setContentsMargins(0, 0, 0, 0)
ll.addWidget(self._make_section_label("Users"))
# Toolbar
tb = QHBoxLayout()
tb.setContentsMargins(4, 0, 4, 4)
self._add_user_btn = QPushButton(" Create User")
self._drop_user_btn = QPushButton("🗑️ Drop User")
self._drop_user_btn.setObjectName("crudDeleteBtn")
self._drop_user_btn.setEnabled(False)
self._add_user_btn.clicked.connect(self._create_user)
self._drop_user_btn.clicked.connect(self._drop_user)
tb.addWidget(self._add_user_btn)
tb.addWidget(self._drop_user_btn)
tb.addStretch()
ll.addLayout(tb)
self._user_table = QTableWidget()
self._user_table.setAlternatingRowColors(True)
self._user_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._user_table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows)
self._user_table.setSelectionMode(
QAbstractItemView.SelectionMode.SingleSelection)
self._user_table.verticalHeader().setDefaultSectionSize(24)
self._user_table.itemSelectionChanged.connect(self._on_user_selected)
ll.addWidget(self._user_table)
splitter.addWidget(left)
# ── Right: grants panel ───────────────────────────────────────────────
right = QWidget()
rl = QVBoxLayout(right)
rl.setContentsMargins(0, 0, 0, 0)
rl.addWidget(self._make_section_label("Grants for selected user"))
# Grant toolbar
gtb = QHBoxLayout()
gtb.setContentsMargins(4, 0, 4, 4)
self._grant_btn = QPushButton(" Grant")
self._revoke_btn = QPushButton(" Revoke")
self._grant_btn.setEnabled(False)
self._revoke_btn.setEnabled(False)
self._grant_btn.clicked.connect(self._grant_privs)
self._revoke_btn.clicked.connect(self._revoke_privs)
gtb.addWidget(self._grant_btn)
gtb.addWidget(self._revoke_btn)
gtb.addStretch()
rl.addLayout(gtb)
self._grants_table = QTableWidget(0, 1)
self._grants_table.setHorizontalHeaderLabels(["GRANT Statement"])
self._grants_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch)
self._grants_table.setAlternatingRowColors(True)
self._grants_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._grants_table.verticalHeader().setDefaultSectionSize(24)
rl.addWidget(self._grants_table)
splitter.addWidget(right)
splitter.setSizes([340, 560])
root.addWidget(splitter, 1)
# ── Status bar ────────────────────────────────────────────────────────
self._status_lbl = QLabel("")
self._status_lbl.setObjectName("rowCountLbl")
self._status_lbl.setContentsMargins(8, 4, 0, 4)
root.addWidget(self._status_lbl)
@staticmethod
def _make_section_label(text: str) -> QLabel:
lbl = QLabel(f" {text}")
lbl.setObjectName("sidebarTitle")
lbl.setContentsMargins(0, 4, 0, 4)
f = lbl.font()
f.setBold(True)
lbl.setFont(f)
return lbl
# ── Data loading ──────────────────────────────────────────────────────────
def _load_users(self):
if self._db_type not in ("mysql", "postgresql"):
return
fn = _mysql_get_users if self._db_type == "mysql" else _pg_get_users
self._worker = _UserWorker(fn, self._driver, parent=self)
self._worker.result.connect(self._populate_users)
self._worker.error.connect(
lambda e: self._set_status(f"Error loading users: {e}"))
self._worker.start()
def _load_databases(self):
try:
self._databases = self._driver.get_databases()
except Exception:
self._databases = []
def _populate_users(self, users: list[dict]):
t = self._user_table
t.clear()
if self._db_type == "mysql":
t.setColumnCount(3)
t.setHorizontalHeaderLabels(["Username", "Host", "Super"])
else:
t.setColumnCount(3)
t.setHorizontalHeaderLabels(["Username", "Superuser", "CreateDB"])
t.setRowCount(len(users))
t.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.ResizeToContents)
t.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
for r, u in enumerate(users):
if self._db_type == "mysql":
vals = [u["user"], u["host"], u.get("super", "")]
else:
vals = [u["user"], u.get("super", ""), u.get("createdb", "")]
for c, v in enumerate(vals):
item = QTableWidgetItem(str(v))
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
t.setItem(r, c, item)
self._set_status(f"{len(users)} user(s)")
def _on_user_selected(self):
rows = self._user_table.selectionModel().selectedRows()
if not rows:
self._drop_user_btn.setEnabled(False)
self._grant_btn.setEnabled(False)
self._revoke_btn.setEnabled(False)
return
r = rows[0].row()
self._current_user = self._user_table.item(r, 0).text()
self._current_host = (
self._user_table.item(r, 1).text()
if self._db_type == "mysql"
else ""
)
self._drop_user_btn.setEnabled(True)
self._grant_btn.setEnabled(True)
self._revoke_btn.setEnabled(True)
self._load_grants()
def _load_grants(self):
if not self._current_user:
return
fn = _mysql_get_grants if self._db_type == "mysql" else _pg_get_grants
self._worker = _UserWorker(
fn, self._driver, self._current_user, self._current_host, parent=self
)
self._worker.result.connect(self._populate_grants)
self._worker.error.connect(
lambda e: self._set_status(f"Error loading grants: {e}"))
self._worker.start()
def _populate_grants(self, grants: list[str]):
t = self._grants_table
t.setRowCount(len(grants))
mono = QFont("Consolas", 10)
for r, g in enumerate(grants):
item = QTableWidgetItem(g)
item.setFont(mono)
t.setItem(r, 0, item)
self._set_status(
f"{len(grants)} grant statement(s) for {self._current_user}"
)
# ── CRUD operations ───────────────────────────────────────────────────────
def _create_user(self):
dlg = _CreateUserDialog(self._db_type, parent=self)
if not dlg.exec():
return
fn = _mysql_create_user if self._db_type == "mysql" else _pg_create_user
self._worker = _UserWorker(
fn, self._driver, dlg.username, dlg.host, dlg.password, parent=self
)
self._worker.result.connect(lambda _: (
self._set_status(f"User '{dlg.username}' created."),
self._load_users(),
))
self._worker.error.connect(
lambda e: QMessageBox.critical(self, "Create User Error", str(e))
)
self._worker.start()
def _drop_user(self):
if not self._current_user:
return
btn = QMessageBox.warning(
self, "Drop User",
f"Drop user '{self._current_user}' permanently?\n\n"
"All associated privileges will be revoked.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if btn != QMessageBox.StandardButton.Yes:
return
fn = _mysql_drop_user if self._db_type == "mysql" else _pg_drop_user
self._worker = _UserWorker(
fn, self._driver, self._current_user, self._current_host, parent=self
)
self._worker.result.connect(lambda _: (
self._set_status(f"User '{self._current_user}' dropped."),
self._load_users(),
))
self._worker.error.connect(
lambda e: QMessageBox.critical(self, "Drop User Error", str(e))
)
self._worker.start()
def _grant_privs(self):
dlg = _GrantDialog(
self._driver, self._db_type, self._databases,
self._current_user, self._current_host, mode="grant", parent=self,
)
if dlg.exec():
self._set_status(
f"Privileges granted to {self._current_user}."
)
self._load_grants()
def _revoke_privs(self):
dlg = _GrantDialog(
self._driver, self._db_type, self._databases,
self._current_user, self._current_host, mode="revoke", parent=self,
)
if dlg.exec():
self._set_status(
f"Privileges revoked from {self._current_user}."
)
self._load_grants()
# ── Helpers ───────────────────────────────────────────────────────────────
def _set_status(self, msg: str):
self._status_lbl.setText(msg)
self.status_message.emit(msg)