Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1ef72c3
feat(plugins): add PluginPrincipalManagement contract for user and ro…
datlechin Jul 12, 2026
0dadd9f
feat(plugins): implement user and role management for PostgreSQL and …
datlechin Jul 12, 2026
2e7be60
feat(connections): add Users & Roles management for MySQL and PostgreSQL
datlechin Jul 12, 2026
a22f037
fix(connections): size the privilege grid to content and harden passw…
datlechin Jul 12, 2026
e86b66b
feat(connections): grant privileges at schema, table, and column level
datlechin Jul 12, 2026
46e67da
refactor(connections): centralize grant SQL construction and split us…
datlechin Jul 12, 2026
e455536
fix(connections): import Combine for the principals refresh subject
datlechin Jul 12, 2026
70a45fd
feat(users-roles): add scope cascade, browsing restriction, and scope…
datlechin Jul 12, 2026
0235911
refactor(users-roles): rebuild the change model on baseline plus delt…
datlechin Jul 12, 2026
2384c59
refactor(users-roles)!: rebuild the interface on a native split view …
datlechin Jul 12, 2026
5876151
fix(users-roles): disambiguate SwiftUI.TableRow and correct Section a…
datlechin Jul 12, 2026
fb51340
fix(users-roles): import os for the view model logger
datlechin Jul 12, 2026
c915f5e
fix(users-roles): stop the split view from forcing the window to its …
datlechin Jul 12, 2026
0c509c9
fix(users-roles): fit the privilege summary and status columns in the…
datlechin Jul 12, 2026
761bd0f
fix(users-roles): widen the split view maximums so the panes have use…
datlechin Jul 12, 2026
374619c
fix(users-roles): refetch grants after apply, save on close, and stop…
datlechin Jul 12, 2026
00786c1
fix(users-roles): give each mutation its own undo group instead of re…
datlechin Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Connect to Google Cloud SQL through the Cloud SQL Auth Proxy without starting it yourself. Enable it on a MySQL, PostgreSQL, or SQL Server connection, set the instance connection name, and TablePro runs and stops the proxy with the connection. Supports Application Default Credentials, a service account key, and IAM database authentication, and can download the proxy or use one already on your Mac. (#1728)
- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474)
- The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab.
- Manage database users, roles, and privileges on MySQL and PostgreSQL connections. Open **View > Users & Roles** to see the accounts on the server, pick an object from the server down through databases, schemas, tables, and columns, and grant or revoke privileges on it. The privilege list shows where access actually comes from, including privileges inherited from a role or from a parent object. Changes are staged, undoable with ⌘Z, and shown as the exact SQL before they run. (#1413)

### Fixed

Expand Down
67 changes: 67 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// MySQLPluginDriver+PrincipalSQL.swift
// MySQLDriverPlugin
//

import Foundation
import TableProPluginKit

extension MySQLPluginDriver {
func generateCreatePrincipalSQL(definition: PluginPrincipalDefinition) -> [String]? {
let account = grantAccount(definition.ref)
var statement = "CREATE USER \(account)"

if let password = definition.password, !password.isEmpty {
statement += " IDENTIFIED BY '\(escapeStringLiteral(password))'"
}
if let limit = definition.connectionLimit {
statement += " WITH MAX_USER_CONNECTIONS \(limit)"
}
return [statement]
}

func generateAlterPrincipalSQL(
old: PluginPrincipalDefinition,
new: PluginPrincipalDefinition
) -> [String]? {
var statements: [String] = []
let account = grantAccount(old.ref)

if old.connectionLimit != new.connectionLimit {
statements.append(
"ALTER USER \(account) WITH MAX_USER_CONNECTIONS \(new.connectionLimit ?? 0)"
)
}
if old.ref != new.ref {
statements.append("RENAME USER \(account) TO \(grantAccount(new.ref))")
}
return statements
}

func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? {
["ALTER USER \(grantAccount(principal)) IDENTIFIED BY '\(escapeStringLiteral(password))'"]
}

func generateDropPrincipalSQL(
principal: PluginPrincipalRef,
options: PluginPrincipalDropOptions
) -> [String]? {
["DROP USER \(grantAccount(principal))"]
}

func generateGrantSQL(changeSet: PluginPrincipalChangeSet) -> [String]? {
grantBuilder(for: changeSet.principal).grantStatements(changeSet.grantsToAdd)
}

func generateRevokeSQL(changeSet: PluginPrincipalChangeSet) -> [String]? {
grantBuilder(for: changeSet.principal).revokeStatements(changeSet.grantsToRemove)
}

private func grantBuilder(for principal: PluginPrincipalRef) -> PluginGrantSQLBuilder {
PluginGrantSQLBuilder(
grantee: grantAccount(principal),
quoteIdentifier: { self.quoteIdentifier($0) },
target: { self.grantTarget(for: $0) }
)
}
}
247 changes: 247 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//
// MySQLPluginDriver+Principals.swift
// MySQLDriverPlugin
//

import Foundation
import TableProPluginKit

extension MySQLPluginDriver: PluginPrincipalManagement {
var supportsPrincipalHostScoping: Bool { true }
var supportsOwnedObjectReassignment: Bool { false }
var supportsRoleMembership: Bool { false }
var restrictsGrantBrowsingToCurrentDatabase: Bool { false }
var supportsGrantableScopeSearch: Bool { true }
var rollsBackPrincipalStatements: Bool { false }

static let defaultHost = "%"

private static let excludedPrivileges: Set<String> = ["GRANT OPTION", "PROXY"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude MySQL USAGE from editable grants

When SHOW PRIVILEGES includes MySQL's USAGE pseudo-privilege, this catalog only filters GRANT OPTION and PROXY, so every no-privilege account parsed from SHOW GRANTS can show a server-level USAGE grant and the UI may stage meaningless/invalid REVOKE USAGE changes. Treat USAGE like the other non-editable privilege names so it does not appear as a grantable privilege.

Useful? React with 👍 / 👎.


private static let tableContextMarkers = ["TABLE", "INDEX", "VIEW", "TRIGGER"]
private static let databaseContextMarkers = [
"DATABASE", "TABLE", "INDEX", "VIEW", "TRIGGER", "EVENT", "FUNCTION", "PROCEDURE"
]
private static let columnGrantablePrivileges: Set<String> = [
"SELECT", "INSERT", "UPDATE", "REFERENCES"
]

func privilegeCascades(
from ancestor: PluginPrivilegeScope,
to descendant: PluginPrivilegeScope
) -> Bool {
ancestor.contains(descendant)
}

func fetchPrincipals() async throws -> [PluginPrincipalInfo] {
let query = "SELECT User, Host, max_user_connections FROM mysql.user ORDER BY User, Host"
let result = try await execute(query: query)

return result.rows.compactMap { row -> PluginPrincipalInfo? in
guard let name = row[safe: 0]?.asText else { return nil }
let host = row[safe: 1]?.asText ?? Self.defaultHost
let limit = row[safe: 2]?.asText.flatMap(Int.init)

return PluginPrincipalInfo(
ref: PluginPrincipalRef(name: name, host: host),
isRole: false,
canLogin: true,
connectionLimit: (limit ?? 0) == 0 ? nil : limit
)
}
}

func fetchPrivilegeCatalog() async throws -> PluginPrivilegeCatalog {
if let cached = cachedPrivilegeCatalog {
return cached
}
let catalog = try await loadPrivilegeCatalog()
cachedPrivilegeCatalog = catalog
return catalog
}

private func loadPrivilegeCatalog() async throws -> PluginPrivilegeCatalog {
let result = try await execute(query: "SHOW PRIVILEGES")

var server: [PluginPrivilegeDescriptor] = []
var database: [PluginPrivilegeDescriptor] = []
var table: [PluginPrivilegeDescriptor] = []
var column: [PluginPrivilegeDescriptor] = []
var hasDynamicPrivileges = false

for row in result.rows {
guard let rawName = row[safe: 0]?.asText,
let name = PluginPrivilegeName.sanitized(rawName),
!Self.excludedPrivileges.contains(name) else { continue }

let isDynamic = MySQLPrivilegeCatalog.isDynamic(name)
hasDynamicPrivileges = hasDynamicPrivileges || isDynamic

let descriptor = PluginPrivilegeDescriptor(
name: name,
label: rawName,
category: MySQLPrivilegeCatalog.category(for: name)
)
server.append(descriptor)

guard !isDynamic else { continue }
let context = (row[safe: 1]?.asText ?? "").uppercased()

if Self.databaseContextMarkers.contains(where: { context.contains($0) }) {
database.append(descriptor)
}
if Self.tableContextMarkers.contains(where: { context.contains($0) }) {
table.append(descriptor)
}
if Self.columnGrantablePrivileges.contains(name) {
column.append(descriptor)
}
}

return PluginPrivilegeCatalog(
serverPrivileges: server,
databasePrivileges: database,
schemaPrivileges: [],
tablePrivileges: table,
columnPrivileges: column,
supportsDynamicPrivileges: hasDynamicPrivileges
)
}

func fetchGrants(for principal: PluginPrincipalRef) async throws -> [PluginGrantInfo] {
let catalog = try await fetchPrivilegeCatalog()
let result = try await execute(query: "SHOW GRANTS FOR \(grantAccount(principal))")

return result.rows.flatMap { row -> [PluginGrantInfo] in
guard let line = row[safe: 0]?.asText,
let parsed = MySQLGrantParser.parseGrant(line) else { return [] }
return grants(from: parsed, catalog: catalog)
}
}

func fetchGrantableChildren(of scope: PluginPrivilegeScope) async throws -> [PluginPrivilegeScope] {
switch scope {

Check warning on line 123 in Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

switch covers known cases, but 'PluginPrivilegeScope' may have additional unknown values; this is an error in the Swift 6 language mode
case let .database(database):
try await tables(in: database)
case let .table(database, _, table):
try await columns(in: database, table: table)
case .server, .schema, .column:
[]
}
}

func searchGrantableScopes(
matching query: String,
limit: Int
) async throws -> [PluginPrivilegeScope] {
let pattern = escapeStringLiteral(MySQLGrantPatternEscaping.escapeDatabasePattern(query))
let sql = """
SELECT TABLE_SCHEMA, TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
AND TABLE_NAME LIKE '%\(pattern)%'
ORDER BY TABLE_SCHEMA, TABLE_NAME
LIMIT \(max(1, limit))
"""
let result = try await execute(query: sql)

return result.rows.compactMap { row in
guard let database = row[safe: 0]?.asText,
let table = row[safe: 1]?.asText else { return nil }
return .table(database: database, schema: nil, table: table)
}
}

func currentPrincipalRef() async throws -> PluginPrincipalRef? {
let result = try await execute(query: "SELECT CURRENT_USER()")
guard let value = result.rows.first?[safe: 0]?.asText else { return nil }

let parts = value.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false)
guard let name = parts.first else { return nil }
let host = parts.count > 1 ? String(parts[1]) : Self.defaultHost

return PluginPrincipalRef(
name: MySQLGrantParser.unquoteIdentifier(String(name)),
host: MySQLGrantParser.unquoteIdentifier(host)
)
}

private func tables(in database: String) async throws -> [PluginPrivilegeScope] {
let result = try await execute(query: "SHOW TABLES FROM \(quoteIdentifier(database))")
return result.rows.compactMap { row in
guard let table = row[safe: 0]?.asText else { return nil }
return .table(database: database, schema: nil, table: table)
}
}

private func columns(in database: String, table: String) async throws -> [PluginPrivilegeScope] {
let target = "\(quoteIdentifier(database)).\(quoteIdentifier(table))"
let result = try await execute(query: "SHOW COLUMNS FROM \(target)")
return result.rows.compactMap { row in
guard let column = row[safe: 0]?.asText else { return nil }
return .column(database: database, schema: nil, table: table, column: column)
}
}

private func grants(
from parsed: MySQLParsedGrant,
catalog: PluginPrivilegeCatalog
) -> [PluginGrantInfo] {
parsed.privileges.flatMap { privilege -> [PluginGrantInfo] in
guard privilege.columns.isEmpty else {
return columnGrants(privilege, in: parsed)
}
let names = privilege.name == MySQLGrantParser.allPrivileges
? catalog.privileges(for: parsed.scope).map(\.name)
: [privilege.name]

return names.map {
PluginGrantInfo(privilege: $0, scope: parsed.scope, isGrantable: parsed.isGrantable)
}
}
}

private func columnGrants(
_ privilege: MySQLParsedPrivilege,
in parsed: MySQLParsedGrant
) -> [PluginGrantInfo] {
guard let database = parsed.scope.databaseName,
let table = parsed.scope.tableName else { return [] }

return privilege.columns.map { column in
PluginGrantInfo(
privilege: privilege.name,
scope: .column(database: database, schema: nil, table: table, column: column),
isGrantable: parsed.isGrantable
)
}
}

func grantAccount(_ principal: PluginPrincipalRef) -> String {
let name = quoteIdentifier(principal.name)
let host = quoteIdentifier(principal.host ?? Self.defaultHost)
return "\(name)@\(host)"
}

// MySQL treats `_` and `%` in the database position as LIKE wildcards only for global and
// database-level grants. In a table-level target the database name is a literal identifier, so
// escaping it there would grant on a database whose name contains a backslash.
func grantTarget(for scope: PluginPrivilegeScope) -> String? {
switch scope {

Check warning on line 230 in Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

switch covers known cases, but 'PluginPrivilegeScope' may have additional unknown values; this is an error in the Swift 6 language mode
case .server:
"*.*"
case let .database(name):
"\(quotedDatabasePattern(name)).*"
case let .schema(database, _):
"\(quotedDatabasePattern(database)).*"
case let .table(database, _, table):
"\(quoteIdentifier(database)).\(quoteIdentifier(table))"
case let .column(database, _, table, _):
"\(quoteIdentifier(database)).\(quoteIdentifier(table))"
}
}

func quotedDatabasePattern(_ name: String) -> String {
quoteIdentifier(MySQLGrantPatternEscaping.escapeDatabasePattern(name))
}
}
3 changes: 3 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private var _serverVersion: String?
private var _activeDatabase: String

internal var cachedPrivilegeCatalog: PluginPrivilegeCatalog?

/// Detected server type from version string after connecting
private var isMariaDB = false

Expand All @@ -35,6 +37,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
.cancelQuery,
.storedProcedures,
.userFunctions,
.userManagement,
]
}

Expand Down
43 changes: 43 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPrivilegeCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// MySQLPrivilegeCatalog.swift
// MySQLDriverPlugin
//
// MySQL's static privileges are a closed set. Anything SHOW PRIVILEGES reports that is not in it
// is a dynamic privilege registered by a component or plugin (MySQL 8+). Classifying by name
// membership rather than by a character heuristic keeps a privilege like CREATE_TABLESPACE_ADMIN
// from being misfiled, and keeps a future static privilege from being called dynamic.
//

import Foundation
import TableProPluginKit

enum MySQLPrivilegeCatalog {
static let dataPrivileges: Set<String> = [
"SELECT", "INSERT", "UPDATE", "DELETE", "EXECUTE", "SHOW VIEW", "LOCK TABLES"
]

static let structurePrivileges: Set<String> = [
"CREATE", "DROP", "ALTER", "INDEX", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE",
"CREATE TEMPORARY TABLES", "EVENT", "TRIGGER", "REFERENCES", "CREATE TABLESPACE"
]

static let administrationPrivileges: Set<String> = [
"CREATE USER", "CREATE ROLE", "DROP ROLE", "FILE", "PROCESS", "RELOAD",
"REPLICATION CLIENT", "REPLICATION SLAVE", "SHOW DATABASES", "SHUTDOWN", "SUPER",
"GRANT OPTION", "PROXY", "USAGE"
]

static let staticPrivilegeNames: Set<String> =
dataPrivileges.union(structurePrivileges).union(administrationPrivileges)

static func isDynamic(_ name: String) -> Bool {
!staticPrivilegeNames.contains(name)
}

static func category(for name: String) -> String {
if dataPrivileges.contains(name) { return PluginPrivilegeCategoryKey.data }
if structurePrivileges.contains(name) { return PluginPrivilegeCategoryKey.structure }
if administrationPrivileges.contains(name) { return PluginPrivilegeCategoryKey.administration }
return PluginPrivilegeCategoryKey.dynamic
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct PostgreSQLCapabilities: Sendable, Equatable {
var hasMaterializedViewsCatalog: Bool { serverVersion >= 90_300 }
var hasForeignTablesCatalog: Bool { serverVersion >= 90_100 }
var hasSequencesCatalog: Bool { serverVersion >= 90_500 }
var hasBypassRLS: Bool { serverVersion >= 90_500 }

var hasIdentityColumns: Bool { serverVersion >= 100_000 }
var hasGeneratedColumns: Bool { serverVersion >= 120_000 }
Expand Down
Loading
Loading