-
-
Notifications
You must be signed in to change notification settings - Fork 315
feat(users-roles): manage database users, roles, and privileges for MySQL and PostgreSQL #1853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 0dadd9f
feat(plugins): implement user and role management for PostgreSQL and …
datlechin 2e7be60
feat(connections): add Users & Roles management for MySQL and PostgreSQL
datlechin a22f037
fix(connections): size the privilege grid to content and harden passw…
datlechin e86b66b
feat(connections): grant privileges at schema, table, and column level
datlechin 46e67da
refactor(connections): centralize grant SQL construction and split us…
datlechin e455536
fix(connections): import Combine for the principals refresh subject
datlechin 70a45fd
feat(users-roles): add scope cascade, browsing restriction, and scope…
datlechin 0235911
refactor(users-roles): rebuild the change model on baseline plus delt…
datlechin 2384c59
refactor(users-roles)!: rebuild the interface on a native split view …
datlechin 5876151
fix(users-roles): disambiguate SwiftUI.TableRow and correct Section a…
datlechin fb51340
fix(users-roles): import os for the view model logger
datlechin c915f5e
fix(users-roles): stop the split view from forcing the window to its …
datlechin 0c509c9
fix(users-roles): fit the privilege summary and status columns in the…
datlechin 761bd0f
fix(users-roles): widen the split view maximums so the panes have use…
datlechin 374619c
fix(users-roles): refetch grants after apply, save on close, and stop…
datlechin 00786c1
fix(users-roles): give each mutation its own undo group instead of re…
datlechin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
67 changes: 67 additions & 0 deletions
67
Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
247
Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] | ||
|
|
||
| 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 { | ||
| 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 { | ||
| 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)) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
SHOW PRIVILEGESincludes MySQL'sUSAGEpseudo-privilege, this catalog only filtersGRANT OPTIONandPROXY, so every no-privilege account parsed fromSHOW GRANTScan show a server-levelUSAGEgrant and the UI may stage meaningless/invalidREVOKE USAGEchanges. TreatUSAGElike the other non-editable privilege names so it does not appear as a grantable privilege.Useful? React with 👍 / 👎.