diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cd4ccab..6cf4b0609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift new file mode 100644 index 000000000..39424912a --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift @@ -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) } + ) + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift new file mode 100644 index 000000000..1b3c07b53 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift @@ -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 = ["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 = [ + "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)) + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 723bd989a..69de23420 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -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 @@ -35,6 +37,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .cancelQuery, .storedProcedures, .userFunctions, + .userManagement, ] } diff --git a/Plugins/MySQLDriverPlugin/MySQLPrivilegeCatalog.swift b/Plugins/MySQLDriverPlugin/MySQLPrivilegeCatalog.swift new file mode 100644 index 000000000..17750b437 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPrivilegeCatalog.swift @@ -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 = [ + "SELECT", "INSERT", "UPDATE", "DELETE", "EXECUTE", "SHOW VIEW", "LOCK TABLES" + ] + + static let structurePrivileges: Set = [ + "CREATE", "DROP", "ALTER", "INDEX", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE", + "CREATE TEMPORARY TABLES", "EVENT", "TRIGGER", "REFERENCES", "CREATE TABLESPACE" + ] + + static let administrationPrivileges: Set = [ + "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 = + 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 + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift index 51f79fd93..dd9c098b0 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -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 } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift new file mode 100644 index 000000000..f7eccc60a --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+PrincipalSQL.swift @@ -0,0 +1,152 @@ +// +// PostgreSQLPluginDriver+PrincipalSQL.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension PostgreSQLPluginDriver { + func generateCreatePrincipalSQL(definition: PluginPrincipalDefinition) -> [String]? { + let role = quoteIdentifier(definition.ref.name) + var options = [definition.canLogin ? "LOGIN" : "NOLOGIN"] + options.append(contentsOf: attributeKeywords(definition.attributes)) + + if let password = definition.password, !password.isEmpty { + options.append("PASSWORD '\(escapeStringLiteral(password))'") + } + if let limit = definition.connectionLimit { + options.append("CONNECTION LIMIT \(limit)") + } + + var statements = ["CREATE ROLE \(role) WITH \(options.joined(separator: " "))"] + statements.append(contentsOf: definition.memberOf.map { + "GRANT \(quoteIdentifier($0)) TO \(role)" + }) + if let comment = definition.comment, !comment.isEmpty { + statements.append("COMMENT ON ROLE \(role) IS '\(escapeStringLiteral(comment))'") + } + return statements + } + + func generateAlterPrincipalSQL( + old: PluginPrincipalDefinition, + new: PluginPrincipalDefinition + ) -> [String]? { + var statements: [String] = [] + let role = quoteIdentifier(old.ref.name) + + var options: [String] = [] + if old.canLogin != new.canLogin { + options.append(new.canLogin ? "LOGIN" : "NOLOGIN") + } + options.append(contentsOf: changedAttributeKeywords(old: old.attributes, new: new.attributes)) + if old.connectionLimit != new.connectionLimit { + options.append("CONNECTION LIMIT \(new.connectionLimit ?? -1)") + } + if !options.isEmpty { + statements.append("ALTER ROLE \(role) WITH \(options.joined(separator: " "))") + } + + statements.append(contentsOf: membershipStatements(old: old, new: new, role: role)) + + if old.comment != new.comment { + let comment = new.comment ?? "" + let value = comment.isEmpty ? "NULL" : "'\(escapeStringLiteral(comment))'" + statements.append("COMMENT ON ROLE \(role) IS \(value)") + } + if old.ref.name != new.ref.name { + statements.append("ALTER ROLE \(role) RENAME TO \(quoteIdentifier(new.ref.name))") + } + return statements + } + + func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? { + let role = quoteIdentifier(principal.name) + return ["ALTER ROLE \(role) WITH PASSWORD '\(escapeStringLiteral(password))'"] + } + + func generateDropPrincipalSQL( + principal: PluginPrincipalRef, + options: PluginPrincipalDropOptions + ) -> [String]? { + let role = quoteIdentifier(principal.name) + var statements: [String] = [] + + if let reassignTarget = options.reassignOwnedTo { + statements.append("REASSIGN OWNED BY \(role) TO \(quoteIdentifier(reassignTarget.name))") + statements.append("DROP OWNED BY \(role)") + } else if options.dropOwned { + statements.append("DROP OWNED BY \(role)") + } + + statements.append("DROP ROLE \(role)") + return statements + } + + 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: quoteIdentifier(principal.name), + quoteIdentifier: { self.quoteIdentifier($0) }, + target: { self.grantTarget(for: $0) } + ) + } + + private func grantTarget(for scope: PluginPrivilegeScope) -> String? { + switch scope { + case .server: + nil + case let .database(name): + "DATABASE \(quoteIdentifier(name))" + case let .schema(_, schema): + "SCHEMA \(quoteIdentifier(schema))" + case let .table(_, schema, table), let .column(_, schema, table, _): + if let schema { + "TABLE \(quoteIdentifier(schema)).\(quoteIdentifier(table))" + } else { + "TABLE \(quoteIdentifier(table))" + } + } + } + + private func attributeKeywords(_ attributes: [PluginPrincipalAttribute]) -> [String] { + attributes.compactMap { attribute in + guard let known = PostgreSQLRoleAttribute(rawValue: attribute.key) else { return nil } + return known.keyword(isEnabled: attribute.isEnabled) + } + } + + private func changedAttributeKeywords( + old: [PluginPrincipalAttribute], + new: [PluginPrincipalAttribute] + ) -> [String] { + let oldByKey = Dictionary(uniqueKeysWithValues: old.map { ($0.key, $0.isEnabled) }) + return new.compactMap { attribute in + guard let known = PostgreSQLRoleAttribute(rawValue: attribute.key) else { return nil } + guard oldByKey[attribute.key] != attribute.isEnabled else { return nil } + return known.keyword(isEnabled: attribute.isEnabled) + } + } + + private func membershipStatements( + old: PluginPrincipalDefinition, + new: PluginPrincipalDefinition, + role: String + ) -> [String] { + let oldRoles = Set(old.memberOf) + let newRoles = Set(new.memberOf) + let granted = newRoles.subtracting(oldRoles).sorted() + let revoked = oldRoles.subtracting(newRoles).sorted() + + return revoked.map { "REVOKE \(quoteIdentifier($0)) FROM \(role)" } + + granted.map { "GRANT \(quoteIdentifier($0)) TO \(role)" } + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift new file mode 100644 index 000000000..1b7a434fc --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Principals.swift @@ -0,0 +1,253 @@ +// +// PostgreSQLPluginDriver+Principals.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension PostgreSQLPluginDriver: PluginPrincipalManagement { + var supportsPrincipalHostScoping: Bool { false } + var supportsOwnedObjectReassignment: Bool { true } + var supportsRoleMembership: Bool { true } + var restrictsGrantBrowsingToCurrentDatabase: Bool { true } + var supportsGrantableScopeSearch: Bool { true } + var rollsBackPrincipalStatements: Bool { true } + + func privilegeCascades( + from ancestor: PluginPrivilegeScope, + to descendant: PluginPrivilegeScope + ) -> Bool { + guard case .table = ancestor, case .column = descendant else { return false } + return ancestor.contains(descendant) + } + + func searchGrantableScopes( + matching query: String, + limit: Int + ) async throws -> [PluginPrivilegeScope] { + let database = try await currentDatabaseName() + let sql = PostgreSQLPrincipalQueries.searchObjects( + patternLiteral: escapeStringLiteral(query), + limit: limit + ) + let result = try await execute(query: sql) + + return result.rows.compactMap { row in + guard let schema = row[safe: 0]?.asText, + let table = row[safe: 1]?.asText else { return nil } + return .table(database: database, schema: schema, table: table) + } + } + + func fetchPrincipals() async throws -> [PluginPrincipalInfo] { + let memberships = try await fetchMemberships() + let query = PostgreSQLPrincipalQueries.principals( + includeBypassRLS: versionedCapabilities.hasBypassRLS + ) + 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 canLogin = Self.decodeBoolean(row[safe: 1]?.asText) + let attributes = Self.decodeAttributes(row: row) + let connectionLimit = row[safe: 8]?.asText.flatMap(Int.init) + + return PluginPrincipalInfo( + ref: PluginPrincipalRef(name: name), + isRole: !canLogin, + canLogin: canLogin, + attributes: attributes, + memberOf: memberships[name] ?? [], + connectionLimit: connectionLimit == -1 ? nil : connectionLimit, + comment: row[safe: 9]?.asText + ) + } + } + + func fetchPrivilegeCatalog() async throws -> PluginPrivilegeCatalog { + PluginPrivilegeCatalog( + serverPrivileges: [], + databasePrivileges: PostgreSQLPrincipalQueries.databasePrivileges, + schemaPrivileges: PostgreSQLPrincipalQueries.schemaPrivileges, + tablePrivileges: PostgreSQLPrincipalQueries.tablePrivileges, + columnPrivileges: PostgreSQLPrincipalQueries.columnPrivileges, + supportsDynamicPrivileges: false + ) + } + + func fetchGrants(for principal: PluginPrincipalRef) async throws -> [PluginGrantInfo] { + let roleLiteral = escapeStringLiteral(principal.name) + let database = try await currentDatabaseName() + + let databaseGrants = try await fetchDatabaseGrants(roleLiteral: roleLiteral) + let schemaGrants = try await fetchSchemaGrants(roleLiteral: roleLiteral, database: database) + let tableGrants = try await fetchTableGrants(roleLiteral: roleLiteral, database: database) + let columnGrants = try await fetchColumnGrants(roleLiteral: roleLiteral, database: database) + + return databaseGrants + schemaGrants + tableGrants + columnGrants + } + + func fetchGrantableChildren(of scope: PluginPrivilegeScope) async throws -> [PluginPrivilegeScope] { + let currentDatabase = try await currentDatabaseName() + + switch scope { + case let .database(database): + guard database == currentDatabase else { return [] } + return try await schemas(in: database) + case let .schema(database, schema): + guard database == currentDatabase else { return [] } + return try await tables(in: database, schema: schema) + case let .table(database, schema, table): + guard database == currentDatabase, let schema else { return [] } + return try await columns(in: database, schema: schema, table: table) + case .server, .column: + return [] + } + } + + private func schemas(in database: String) async throws -> [PluginPrivilegeScope] { + let result = try await execute(query: PostgreSQLPrincipalQueries.schemas) + return result.rows.compactMap { row in + guard let schema = row[safe: 0]?.asText else { return nil } + return .schema(database: database, schema: schema) + } + } + + private func tables(in database: String, schema: String) async throws -> [PluginPrivilegeScope] { + let query = PostgreSQLPrincipalQueries.tables(schemaLiteral: escapeStringLiteral(schema)) + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let table = row[safe: 0]?.asText else { return nil } + return .table(database: database, schema: schema, table: table) + } + } + + private func columns( + in database: String, + schema: String, + table: String + ) async throws -> [PluginPrivilegeScope] { + let query = PostgreSQLPrincipalQueries.columns( + schemaLiteral: escapeStringLiteral(schema), + tableLiteral: escapeStringLiteral(table) + ) + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let column = row[safe: 0]?.asText else { return nil } + return .column(database: database, schema: schema, table: table, column: column) + } + } + + private func fetchColumnGrants(roleLiteral: String, database: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.columnGrants(roleLiteral: roleLiteral) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginGrantInfo? in + guard let schema = row[safe: 0]?.asText, + let table = row[safe: 1]?.asText, + let column = row[safe: 2]?.asText, + let privilege = row[safe: 3]?.asText else { return nil } + return PluginGrantInfo( + privilege: privilege, + scope: .column(database: database, schema: schema, table: table, column: column), + isGrantable: Self.decodeBoolean(row[safe: 4]?.asText) + ) + } + } + + func currentPrincipalRef() async throws -> PluginPrincipalRef? { + let result = try await execute(query: PostgreSQLPrincipalQueries.currentPrincipal) + guard let name = result.rows.first?[safe: 0]?.asText else { return nil } + return PluginPrincipalRef(name: name) + } + + func principalOwnsObjects(_ principal: PluginPrincipalRef) async throws -> Bool { + let query = PostgreSQLPrincipalQueries.ownsObjects( + roleLiteral: escapeStringLiteral(principal.name) + ) + let result = try await execute(query: query) + return Self.decodeBoolean(result.rows.first?[safe: 0]?.asText) + } + + private func fetchMemberships() async throws -> [String: [String]] { + let result = try await execute(query: PostgreSQLPrincipalQueries.memberships) + var memberships: [String: [String]] = [:] + for row in result.rows { + guard let member = row[safe: 0]?.asText, + let grantedRole = row[safe: 1]?.asText else { continue } + memberships[member, default: []].append(grantedRole) + } + return memberships + } + + private func currentDatabaseName() async throws -> String { + let result = try await execute(query: PostgreSQLPrincipalQueries.currentDatabase) + return result.rows.first?[safe: 0]?.asText ?? "" + } + + private func fetchDatabaseGrants(roleLiteral: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.databaseGrants(roleLiteral: roleLiteral) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginGrantInfo? in + guard let database = row[safe: 0]?.asText, + let privilege = row[safe: 1]?.asText else { return nil } + return PluginGrantInfo( + privilege: privilege, + scope: .database(database), + isGrantable: Self.decodeBoolean(row[safe: 2]?.asText) + ) + } + } + + private func fetchSchemaGrants(roleLiteral: String, database: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.schemaGrants(roleLiteral: roleLiteral) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginGrantInfo? in + guard let schema = row[safe: 0]?.asText, + let privilege = row[safe: 1]?.asText else { return nil } + return PluginGrantInfo( + privilege: privilege, + scope: .schema(database: database, schema: schema), + isGrantable: Self.decodeBoolean(row[safe: 2]?.asText) + ) + } + } + + private func fetchTableGrants(roleLiteral: String, database: String) async throws -> [PluginGrantInfo] { + let query = PostgreSQLPrincipalQueries.tableGrants(roleLiteral: roleLiteral) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginGrantInfo? in + guard let schema = row[safe: 0]?.asText, + let table = row[safe: 1]?.asText, + let privilege = row[safe: 2]?.asText else { return nil } + return PluginGrantInfo( + privilege: privilege, + scope: .table(database: database, schema: schema, table: table), + isGrantable: Self.decodeBoolean(row[safe: 3]?.asText) + ) + } + } + + private static func decodeAttributes(row: [PluginCellValue]) -> [PluginPrincipalAttribute] { + let columnOffsets: [(PostgreSQLRoleAttribute, Int)] = [ + (.superuser, 2), + (.createdb, 3), + (.createrole, 4), + (.replication, 5), + (.bypassrls, 6), + (.inherit, 7) + ] + return columnOffsets.map { attribute, offset in + PluginPrincipalAttribute( + key: attribute.rawValue, + label: attribute.label, + isEnabled: decodeBoolean(row[safe: offset]?.asText) + ) + } + } + + private static func decodeBoolean(_ value: String?) -> Bool { + guard let value else { return false } + return value == "t" || value == "true" || value == "1" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 8381c07d1..1e3d27bc8 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -36,7 +36,8 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { .materializedViews, .foreignTables, .storedProcedures, - .userFunctions + .userFunctions, + .userManagement ] } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift new file mode 100644 index 000000000..14633fbdf --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift @@ -0,0 +1,220 @@ +// +// PostgreSQLPrincipalQueries.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLRoleAttribute: String, CaseIterable { + case superuser = "SUPERUSER" + case createdb = "CREATEDB" + case createrole = "CREATEROLE" + case replication = "REPLICATION" + case bypassrls = "BYPASSRLS" + case inherit = "INHERIT" + + var label: String { + switch self { + case .superuser: "Superuser" + case .createdb: "Create databases" + case .createrole: "Create roles" + case .replication: "Replication" + case .bypassrls: "Bypass row level security" + case .inherit: "Inherit privileges" + } + } + + var catalogColumn: String { + switch self { + case .superuser: "rolsuper" + case .createdb: "rolcreatedb" + case .createrole: "rolcreaterole" + case .replication: "rolreplication" + case .bypassrls: "rolbypassrls" + case .inherit: "rolinherit" + } + } + + var negatedKeyword: String { "NO\(rawValue)" } + + func keyword(isEnabled: Bool) -> String { + isEnabled ? rawValue : negatedKeyword + } +} + +enum PostgreSQLPrincipalQueries { + private static let data = PluginPrivilegeCategoryKey.data + private static let structure = PluginPrivilegeCategoryKey.structure + private static let administration = PluginPrivilegeCategoryKey.administration + + static let databasePrivileges = [ + PluginPrivilegeDescriptor(name: "CONNECT", label: "Connect", category: administration), + PluginPrivilegeDescriptor(name: "CREATE", label: "Create", category: structure), + PluginPrivilegeDescriptor(name: "TEMPORARY", label: "Temporary tables", category: structure) + ] + + static let schemaPrivileges = [ + PluginPrivilegeDescriptor(name: "USAGE", label: "Usage", category: administration), + PluginPrivilegeDescriptor(name: "CREATE", label: "Create", category: structure) + ] + + static let tablePrivileges = [ + PluginPrivilegeDescriptor(name: "SELECT", label: "Select", category: data), + PluginPrivilegeDescriptor(name: "INSERT", label: "Insert", category: data), + PluginPrivilegeDescriptor(name: "UPDATE", label: "Update", category: data), + PluginPrivilegeDescriptor(name: "DELETE", label: "Delete", category: data), + PluginPrivilegeDescriptor(name: "TRUNCATE", label: "Truncate", category: structure), + PluginPrivilegeDescriptor(name: "REFERENCES", label: "References", category: structure), + PluginPrivilegeDescriptor(name: "TRIGGER", label: "Trigger", category: structure) + ] + + static let columnPrivileges = [ + PluginPrivilegeDescriptor(name: "SELECT", label: "Select", category: data), + PluginPrivilegeDescriptor(name: "INSERT", label: "Insert", category: data), + PluginPrivilegeDescriptor(name: "UPDATE", label: "Update", category: data), + PluginPrivilegeDescriptor(name: "REFERENCES", label: "References", category: structure) + ] + + static func searchObjects(patternLiteral: String, limit: Int) -> String { + """ + SELECT n.nspname, c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'v', 'm', 'p', 'f') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND c.relname ILIKE '%\(patternLiteral)%' + ORDER BY n.nspname, c.relname + LIMIT \(max(1, limit)) + """ + } + + static let schemas = """ + SELECT n.nspname + FROM pg_namespace n + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\\_%' + ORDER BY n.nspname + """ + + static func tables(schemaLiteral: String) -> String { + """ + SELECT c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '\(schemaLiteral)' + AND c.relkind IN ('r', 'v', 'm', 'p', 'f') + ORDER BY c.relname + """ + } + + static func columns(schemaLiteral: String, tableLiteral: String) -> String { + """ + SELECT a.attname + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '\(schemaLiteral)' + AND c.relname = '\(tableLiteral)' + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY a.attnum + """ + } + + static func columnGrants(roleLiteral: String) -> String { + """ + SELECT n.nspname, c.relname, a.attname, acl.privilege_type, acl.is_grantable + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + CROSS JOIN LATERAL aclexplode(a.attacl) AS acl + JOIN pg_roles r ON r.oid = acl.grantee + WHERE r.rolname = '\(roleLiteral)' + AND a.attnum > 0 + AND NOT a.attisdropped + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY n.nspname, c.relname, a.attname, acl.privilege_type + """ + } + + static func principals(includeBypassRLS: Bool) -> String { + let bypassColumn = includeBypassRLS ? "r.rolbypassrls" : "false AS rolbypassrls" + return """ + SELECT r.rolname, + r.rolcanlogin, + r.rolsuper, + r.rolcreatedb, + r.rolcreaterole, + r.rolreplication, + \(bypassColumn), + r.rolinherit, + r.rolconnlimit, + pg_catalog.shobj_description(r.oid, 'pg_authid') + FROM pg_roles r + WHERE r.rolname NOT LIKE 'pg\\_%' + ORDER BY r.rolname + """ + } + + static let memberships = """ + SELECT member.rolname, grantedRole.rolname + FROM pg_auth_members m + JOIN pg_roles member ON member.oid = m.member + JOIN pg_roles grantedRole ON grantedRole.oid = m.roleid + ORDER BY member.rolname, grantedRole.rolname + """ + + static func databaseGrants(roleLiteral: String) -> String { + """ + SELECT d.datname, a.privilege_type, a.is_grantable + FROM pg_database d + CROSS JOIN LATERAL aclexplode(d.datacl) AS a + JOIN pg_roles r ON r.oid = a.grantee + WHERE r.rolname = '\(roleLiteral)' + AND NOT d.datistemplate + ORDER BY d.datname, a.privilege_type + """ + } + + static func schemaGrants(roleLiteral: String) -> String { + """ + SELECT n.nspname, a.privilege_type, a.is_grantable + FROM pg_namespace n + CROSS JOIN LATERAL aclexplode(n.nspacl) AS a + JOIN pg_roles r ON r.oid = a.grantee + WHERE r.rolname = '\(roleLiteral)' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY n.nspname, a.privilege_type + """ + } + + static func tableGrants(roleLiteral: String) -> String { + """ + SELECT n.nspname, c.relname, a.privilege_type, a.is_grantable + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + CROSS JOIN LATERAL aclexplode(c.relacl) AS a + JOIN pg_roles r ON r.oid = a.grantee + WHERE r.rolname = '\(roleLiteral)' + AND c.relkind IN ('r', 'v', 'm', 'p', 'f') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY n.nspname, c.relname, a.privilege_type + """ + } + + static func ownsObjects(roleLiteral: String) -> String { + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_shdepend s + JOIN pg_roles r ON r.oid = s.refobjid + WHERE r.rolname = '\(roleLiteral)' + AND s.deptype IN ('o', 'a') + ) + """ + } + + static let currentPrincipal = "SELECT current_user" + static let currentDatabase = "SELECT current_database()" +} diff --git a/Plugins/TableProPluginKit/MySQLGrantParser.swift b/Plugins/TableProPluginKit/MySQLGrantParser.swift new file mode 100644 index 000000000..33a8c3d37 --- /dev/null +++ b/Plugins/TableProPluginKit/MySQLGrantParser.swift @@ -0,0 +1,238 @@ +// +// MySQLGrantParser.swift +// MySQLDriverPlugin +// +// Parses SHOW GRANTS output, the only authoritative source for a MySQL account's +// privileges. The mysql.user boolean columns miss dynamic privileges entirely. +// + +import Foundation + +public struct MySQLParsedPrivilege: Equatable, Sendable { + public let name: String + public let columns: [String] + + public init(name: String, columns: [String] = []) { + self.name = name + self.columns = columns + } +} + +public struct MySQLParsedGrant: Equatable, Sendable { + public let privileges: [MySQLParsedPrivilege] + public let scope: PluginPrivilegeScope + public let isGrantable: Bool + + public var privilegeNames: [String] { privileges.map(\.name) } + public var isColumnScoped: Bool { privileges.contains { !$0.columns.isEmpty } } +} + +public enum MySQLGrantParser { + public static let allPrivileges = "ALL PRIVILEGES" + + public static func parseGrant(_ line: String) -> MySQLParsedGrant? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard let afterGrant = dropKeyword("GRANT", from: trimmed) else { return nil } + guard let onRange = rangeOfKeyword("ON", in: afterGrant) else { return nil } + + let privilegeText = String(afterGrant[afterGrant.startIndex.. [String]? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard let afterGrant = dropKeyword("GRANT", from: trimmed) else { return nil } + guard rangeOfKeyword("ON", in: afterGrant) == nil else { return nil } + guard let toRange = rangeOfKeyword("TO", in: afterGrant) else { return nil } + + let roleText = String(afterGrant[afterGrant.startIndex.. String? in + let name = splitTopLevel(entry.trimmingCharacters(in: .whitespaces), separator: "@").first + guard let name else { return nil } + let unquoted = unquoteIdentifier(name.trimmingCharacters(in: .whitespaces)) + return unquoted.isEmpty ? nil : unquoted + } + return roles.isEmpty ? nil : roles + } + + private static func normalizePrivilege(_ raw: String) -> MySQLParsedPrivilege? { + let collapsed = raw.prefix { $0 != "(" } + .trimmingCharacters(in: .whitespaces) + .split(separator: " ", omittingEmptySubsequences: true) + .joined(separator: " ") + guard let name = PluginPrivilegeName.sanitized(collapsed) else { return nil } + + return MySQLParsedPrivilege(name: name, columns: parseColumnList(raw)) + } + + private static func parseColumnList(_ raw: String) -> [String] { + guard let open = raw.firstIndex(of: "("), let close = raw.lastIndex(of: ")"), open < close else { + return [] + } + let inner = String(raw[raw.index(after: open).. PluginPrivilegeScope? { + let parts = splitTopLevel(target, separator: ".") + guard parts.count == 2 else { return nil } + + let databasePart = parts[0].trimmingCharacters(in: .whitespaces) + let objectPart = parts[1].trimmingCharacters(in: .whitespaces) + + if databasePart == "*" { + return objectPart == "*" ? .server : nil + } + + let quotedDatabase = unquoteIdentifier(databasePart) + guard !quotedDatabase.isEmpty else { return nil } + + // Only a database-level target carries a LIKE pattern. A table-level target's database name + // is a literal identifier and must not be unescaped, or a name containing a backslash is + // silently rewritten. + if objectPart == "*" { + return .database( + MySQLGrantPatternEscaping.unescapeDatabasePattern(quotedDatabase) + ) + } + + let table = unquoteIdentifier(objectPart) + guard !table.isEmpty else { return nil } + return .table(database: quotedDatabase, schema: nil, table: table) + } + + private static func hasGrantOption(_ granteeText: String) -> Bool { + granteeText.uppercased().contains("WITH GRANT OPTION") + } + + public static func unquoteIdentifier(_ value: String) -> String { + guard let first = value.first, let last = value.last, value.count >= 2 else { return value } + guard first == last, first == "`" || first == "'" || first == "\"" else { return value } + + let inner = String(value.dropFirst().dropLast()) + return inner.replacingOccurrences(of: String(repeating: String(first), count: 2), with: String(first)) + } + + private static func dropKeyword(_ keyword: String, from text: String) -> String? { + guard let range = rangeOfKeyword(keyword, in: text), range.lowerBound == text.startIndex else { + return nil + } + return String(text[range.upperBound...]) + } + + private static func rangeOfKeyword(_ keyword: String, in text: String) -> Range? { + let scalars = Array(text) + let target = Array(keyword.uppercased()) + var quote: Character? + var depth = 0 + var index = 0 + + while index < scalars.count { + let character = scalars[index] + + if let active = quote { + if character == active { + quote = nil + } + index += 1 + continue + } + if character == "`" || character == "'" || character == "\"" { + quote = character + index += 1 + continue + } + if character == "(" { + depth += 1 + index += 1 + continue + } + if character == ")" { + depth = max(0, depth - 1) + index += 1 + continue + } + guard depth == 0, isBoundary(scalars, at: index - 1) else { + index += 1 + continue + } + + let end = index + target.count + guard end <= scalars.count else { + index += 1 + continue + } + let candidate = scalars[index.. Bool { + guard index >= 0, index < scalars.count else { return true } + return scalars[index] == " " || scalars[index] == "\t" + } + + private static func splitTopLevel(_ text: String, separator: Character) -> [String] { + var parts: [String] = [] + var current = "" + var quote: Character? + var depth = 0 + + for character in text { + if let active = quote { + current.append(character) + if character == active { + quote = nil + } + continue + } + switch character { + case "`", "'", "\"": + quote = character + current.append(character) + case "(": + depth += 1 + current.append(character) + case ")": + depth = max(0, depth - 1) + current.append(character) + case separator where depth == 0: + parts.append(current) + current = "" + default: + current.append(character) + } + } + parts.append(current) + return parts.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + } +} diff --git a/Plugins/TableProPluginKit/MySQLGrantPatternEscaping.swift b/Plugins/TableProPluginKit/MySQLGrantPatternEscaping.swift new file mode 100644 index 000000000..8100a3698 --- /dev/null +++ b/Plugins/TableProPluginKit/MySQLGrantPatternEscaping.swift @@ -0,0 +1,51 @@ +// +// MySQLGrantPatternEscaping.swift +// MySQLDriverPlugin +// +// In a MySQL GRANT, the database-name position is a LIKE pattern: `_` and `%` are +// wildcards unless backslash-escaped. Escaping here is therefore distinct from, and +// applied before, backtick identifier quoting. +// + +import Foundation + +public enum MySQLGrantPatternEscaping { + public static func escapeDatabasePattern(_ value: String) -> String { + var result = "" + result.reserveCapacity(value.count) + for character in value { + switch character { + case "\\", "_", "%": + result.append("\\") + result.append(character) + default: + result.append(character) + } + } + return result + } + + public static func unescapeDatabasePattern(_ value: String) -> String { + var result = "" + result.reserveCapacity(value.count) + var isEscaped = false + + for character in value { + if isEscaped { + result.append(character) + isEscaped = false + continue + } + if character == "\\" { + isEscaped = true + continue + } + result.append(character) + } + + if isEscaped { + result.append("\\") + } + return result + } +} diff --git a/Plugins/TableProPluginKit/PluginCapabilities.swift b/Plugins/TableProPluginKit/PluginCapabilities.swift index 57436c21d..99ce00baa 100644 --- a/Plugins/TableProPluginKit/PluginCapabilities.swift +++ b/Plugins/TableProPluginKit/PluginCapabilities.swift @@ -21,4 +21,5 @@ public struct PluginCapabilities: OptionSet, Sendable { public static let cancelQuery = PluginCapabilities(rawValue: 1 << 9) public static let batchExecute = PluginCapabilities(rawValue: 1 << 10) public static let transactions = PluginCapabilities(rawValue: 1 << 11) + public static let userManagement = PluginCapabilities(rawValue: 1 << 12) } diff --git a/Plugins/TableProPluginKit/PluginGrantGrouping.swift b/Plugins/TableProPluginKit/PluginGrantGrouping.swift new file mode 100644 index 000000000..a8c2ac64e --- /dev/null +++ b/Plugins/TableProPluginKit/PluginGrantGrouping.swift @@ -0,0 +1,92 @@ +// +// PluginGrantGrouping.swift +// TableProPluginKit +// +// Collapses a flat grant list into one group per target object. Column-scoped grants +// fold onto their parent table so a driver can emit +// GRANT SELECT (a, b), UPDATE (a) ON TABLE t TO r as a single statement. +// + +import Foundation + +public struct PluginColumnPrivilege: Equatable, Sendable { + public let privilege: String + public let columns: [String] + + public init(privilege: String, columns: [String]) { + self.privilege = privilege + self.columns = columns + } +} + +public struct PluginGrantGroup: Equatable, Sendable { + public let scope: PluginPrivilegeScope + public let privileges: [String] + public let columnPrivileges: [PluginColumnPrivilege] + public let isGrantable: Bool + + public init( + scope: PluginPrivilegeScope, + privileges: [String], + columnPrivileges: [PluginColumnPrivilege], + isGrantable: Bool + ) { + self.scope = scope + self.privileges = privileges + self.columnPrivileges = columnPrivileges + self.isGrantable = isGrantable + } +} + +public enum PluginGrantGrouping { + public static func group(_ grants: [PluginGrantInfo]) -> [PluginGrantGroup] { + var order: [PluginPrivilegeScope] = [] + var wholeObject: [PluginPrivilegeScope: [String]] = [:] + var byColumn: [PluginPrivilegeScope: [String: [String]]] = [:] + var grantable: Set = [] + + for grant in grants { + let target = targetScope(for: grant.scope) + if wholeObject[target] == nil, byColumn[target] == nil { + order.append(target) + } + if grant.isGrantable { + grantable.insert(target) + } + + if let column = grant.scope.columnName { + var privileges = byColumn[target] ?? [:] + var columns = privileges[grant.privilege] ?? [] + if !columns.contains(column) { + columns.append(column) + } + privileges[grant.privilege] = columns + byColumn[target] = privileges + } else { + var privileges = wholeObject[target] ?? [] + if !privileges.contains(grant.privilege) { + privileges.append(grant.privilege) + } + wholeObject[target] = privileges + } + } + + return order.map { scope in + let columnPrivileges = (byColumn[scope] ?? [:]) + .sorted { $0.key < $1.key } + .map { PluginColumnPrivilege(privilege: $0.key, columns: $0.value) } + + return PluginGrantGroup( + scope: scope, + privileges: wholeObject[scope] ?? [], + columnPrivileges: columnPrivileges, + isGrantable: grantable.contains(scope) + ) + } + } + + private static func targetScope(for scope: PluginPrivilegeScope) -> PluginPrivilegeScope { + guard case let .column(database, schema, table, _) = scope else { return scope } + return .table(database: database, schema: schema, table: table) + } +} diff --git a/Plugins/TableProPluginKit/PluginGrantSQLBuilder.swift b/Plugins/TableProPluginKit/PluginGrantSQLBuilder.swift new file mode 100644 index 000000000..4a39f5a5f --- /dev/null +++ b/Plugins/TableProPluginKit/PluginGrantSQLBuilder.swift @@ -0,0 +1,63 @@ +// +// PluginGrantSQLBuilder.swift +// TableProPluginKit +// +// GRANT and REVOKE share one shape across dialects: a privilege clause, a target, and a +// grantee. Only the target and grantee rendering are dialect-specific, so drivers supply +// those and the clause (including column lists) is built here once. +// + +import Foundation + +public struct PluginGrantSQLBuilder { + private let grantee: String + private let quoteIdentifier: (String) -> String + private let target: (PluginPrivilegeScope) -> String? + + public init( + grantee: String, + quoteIdentifier: @escaping (String) -> String, + target: @escaping (PluginPrivilegeScope) -> String? + ) { + self.grantee = grantee + self.quoteIdentifier = quoteIdentifier + self.target = target + } + + public func grantStatements(_ grants: [PluginGrantInfo]) -> [String] { + statements(grants) { clause, target, group in + let grantOption = group.isGrantable ? " WITH GRANT OPTION" : "" + return "GRANT \(clause) ON \(target) TO \(grantee)\(grantOption)" + } + } + + public func revokeStatements(_ grants: [PluginGrantInfo]) -> [String] { + statements(grants) { clause, target, _ in + "REVOKE \(clause) ON \(target) FROM \(grantee)" + } + } + + private func statements( + _ grants: [PluginGrantInfo], + render: (String, String, PluginGrantGroup) -> String + ) -> [String] { + PluginGrantGrouping.group(grants).compactMap { group in + guard let target = target(group.scope), + let clause = privilegeClause(for: group) else { return nil } + return render(clause, target, group) + } + } + + public func privilegeClause(for group: PluginGrantGroup) -> String? { + var parts = group.privileges.compactMap(PluginPrivilegeName.sanitized) + + parts += group.columnPrivileges.compactMap { entry -> String? in + guard let privilege = PluginPrivilegeName.sanitized(entry.privilege), + !entry.columns.isEmpty else { return nil } + let columns = entry.columns.map(quoteIdentifier).joined(separator: ", ") + return "\(privilege) (\(columns))" + } + + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } +} diff --git a/Plugins/TableProPluginKit/PluginPrincipalManagement.swift b/Plugins/TableProPluginKit/PluginPrincipalManagement.swift new file mode 100644 index 000000000..0516ab20c --- /dev/null +++ b/Plugins/TableProPluginKit/PluginPrincipalManagement.swift @@ -0,0 +1,62 @@ +import Foundation + +public protocol PluginPrincipalManagement: AnyObject, Sendable { + var supportsPrincipalHostScoping: Bool { get } + var supportsOwnedObjectReassignment: Bool { get } + var supportsRoleMembership: Bool { get } + var restrictsGrantBrowsingToCurrentDatabase: Bool { get } + var supportsGrantableScopeSearch: Bool { get } + var rollsBackPrincipalStatements: Bool { get } + + func fetchPrincipals() async throws -> [PluginPrincipalInfo] + func fetchPrivilegeCatalog() async throws -> PluginPrivilegeCatalog + func fetchGrants(for principal: PluginPrincipalRef) async throws -> [PluginGrantInfo] + func fetchGrantableChildren(of scope: PluginPrivilegeScope) async throws -> [PluginPrivilegeScope] + func searchGrantableScopes(matching query: String, limit: Int) async throws -> [PluginPrivilegeScope] + func currentPrincipalRef() async throws -> PluginPrincipalRef? + func principalOwnsObjects(_ principal: PluginPrincipalRef) async throws -> Bool + + func privilegeCascades( + from ancestor: PluginPrivilegeScope, + to descendant: PluginPrivilegeScope + ) -> Bool + + func generateCreatePrincipalSQL(definition: PluginPrincipalDefinition) -> [String]? + func generateAlterPrincipalSQL( + old: PluginPrincipalDefinition, + new: PluginPrincipalDefinition + ) -> [String]? + func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? + func generateDropPrincipalSQL( + principal: PluginPrincipalRef, + options: PluginPrincipalDropOptions + ) -> [String]? + func generateGrantSQL(changeSet: PluginPrincipalChangeSet) -> [String]? + func generateRevokeSQL(changeSet: PluginPrincipalChangeSet) -> [String]? +} + +public extension PluginPrincipalManagement { + var supportsPrincipalHostScoping: Bool { false } + var supportsOwnedObjectReassignment: Bool { false } + var supportsRoleMembership: Bool { false } + var restrictsGrantBrowsingToCurrentDatabase: Bool { false } + var supportsGrantableScopeSearch: Bool { false } + var rollsBackPrincipalStatements: Bool { false } + + func currentPrincipalRef() async throws -> PluginPrincipalRef? { nil } + func principalOwnsObjects(_ principal: PluginPrincipalRef) async throws -> Bool { false } + + func fetchGrantableChildren( + of scope: PluginPrivilegeScope + ) async throws -> [PluginPrivilegeScope] { [] } + + func searchGrantableScopes( + matching query: String, + limit: Int + ) async throws -> [PluginPrivilegeScope] { [] } + + func privilegeCascades( + from ancestor: PluginPrivilegeScope, + to descendant: PluginPrivilegeScope + ) -> Bool { false } +} diff --git a/Plugins/TableProPluginKit/PrincipalTypes.swift b/Plugins/TableProPluginKit/PrincipalTypes.swift new file mode 100644 index 000000000..0a420e634 --- /dev/null +++ b/Plugins/TableProPluginKit/PrincipalTypes.swift @@ -0,0 +1,277 @@ +import Foundation + +public struct PluginPrincipalRef: Hashable, Sendable { + public let name: String + public let host: String? + + public init(name: String, host: String? = nil) { + self.name = name + self.host = host + } +} + +public struct PluginPrincipalAttribute: Hashable, Sendable { + public let key: String + public let label: String + public let isEnabled: Bool + + public init(key: String, label: String, isEnabled: Bool) { + self.key = key + self.label = label + self.isEnabled = isEnabled + } +} + +public struct PluginPrincipalInfo: Hashable, Sendable { + public let ref: PluginPrincipalRef + public let isRole: Bool + public let canLogin: Bool + public let attributes: [PluginPrincipalAttribute] + public let memberOf: [String] + public let connectionLimit: Int? + public let comment: String? + + public init( + ref: PluginPrincipalRef, + isRole: Bool = false, + canLogin: Bool = true, + attributes: [PluginPrincipalAttribute] = [], + memberOf: [String] = [], + connectionLimit: Int? = nil, + comment: String? = nil + ) { + self.ref = ref + self.isRole = isRole + self.canLogin = canLogin + self.attributes = attributes + self.memberOf = memberOf + self.connectionLimit = connectionLimit + self.comment = comment + } +} + +public struct PluginPrincipalDefinition: Hashable, Sendable { + public let ref: PluginPrincipalRef + public let password: String? + public let canLogin: Bool + public let attributes: [PluginPrincipalAttribute] + public let memberOf: [String] + public let connectionLimit: Int? + public let comment: String? + + public init( + ref: PluginPrincipalRef, + password: String? = nil, + canLogin: Bool = true, + attributes: [PluginPrincipalAttribute] = [], + memberOf: [String] = [], + connectionLimit: Int? = nil, + comment: String? = nil + ) { + self.ref = ref + self.password = password + self.canLogin = canLogin + self.attributes = attributes + self.memberOf = memberOf + self.connectionLimit = connectionLimit + self.comment = comment + } +} + +public enum PluginPrivilegeScope: Hashable, Sendable { + case server + case database(String) + case schema(database: String, schema: String) + case table(database: String, schema: String?, table: String) + case column(database: String, schema: String?, table: String, column: String) +} + +public extension PluginPrivilegeScope { + var databaseName: String? { + switch self { + case .server: + nil + case let .database(name): + name + case let .schema(database, _): + database + case let .table(database, _, _): + database + case let .column(database, _, _, _): + database + } + } + + var schemaName: String? { + switch self { + case .server, .database: + nil + case let .schema(_, schema): + schema + case let .table(_, schema, _): + schema + case let .column(_, schema, _, _): + schema + } + } + + var tableName: String? { + switch self { + case .server, .database, .schema: + nil + case let .table(_, _, table): + table + case let .column(_, _, table, _): + table + } + } + + var columnName: String? { + guard case let .column(_, _, _, column) = self else { return nil } + return column + } + + var parent: PluginPrivilegeScope? { + switch self { + case .server: + nil + case .database: + .server + case let .schema(database, _): + .database(database) + case let .table(database, schema, _): + if let schema { + .schema(database: database, schema: schema) + } else { + .database(database) + } + case let .column(database, schema, table, _): + .table(database: database, schema: schema, table: table) + } + } + + func contains(_ other: PluginPrivilegeScope) -> Bool { + var candidate = other.parent + while let scope = candidate { + if scope == self { return true } + candidate = scope.parent + } + return false + } +} + +public enum PluginPrivilegeCategoryKey { + public static let data = "data" + public static let structure = "structure" + public static let administration = "administration" + public static let dynamic = "dynamic" +} + +public struct PluginPrivilegeDescriptor: Hashable, Sendable { + public let name: String + public let label: String + public let category: String? + + public init(name: String, label: String, category: String? = nil) { + self.name = name + self.label = label + self.category = category + } +} + +public struct PluginPrivilegeCatalog: Sendable { + public let serverPrivileges: [PluginPrivilegeDescriptor] + public let databasePrivileges: [PluginPrivilegeDescriptor] + public let schemaPrivileges: [PluginPrivilegeDescriptor] + public let tablePrivileges: [PluginPrivilegeDescriptor] + public let columnPrivileges: [PluginPrivilegeDescriptor] + public let supportsDynamicPrivileges: Bool + + public init( + serverPrivileges: [PluginPrivilegeDescriptor] = [], + databasePrivileges: [PluginPrivilegeDescriptor] = [], + schemaPrivileges: [PluginPrivilegeDescriptor] = [], + tablePrivileges: [PluginPrivilegeDescriptor] = [], + columnPrivileges: [PluginPrivilegeDescriptor] = [], + supportsDynamicPrivileges: Bool = false + ) { + self.serverPrivileges = serverPrivileges + self.databasePrivileges = databasePrivileges + self.schemaPrivileges = schemaPrivileges + self.tablePrivileges = tablePrivileges + self.columnPrivileges = columnPrivileges + self.supportsDynamicPrivileges = supportsDynamicPrivileges + } + + public func privileges(for scope: PluginPrivilegeScope) -> [PluginPrivilegeDescriptor] { + switch scope { + case .server: serverPrivileges + case .database: databasePrivileges + case .schema: schemaPrivileges + case .table: tablePrivileges + case .column: columnPrivileges + } + } + + public var allPrivileges: [PluginPrivilegeDescriptor] { + var seen = Set() + return ( + serverPrivileges + databasePrivileges + schemaPrivileges + + tablePrivileges + columnPrivileges + ).filter { seen.insert($0.name).inserted } + } +} + +public struct PluginGrantInfo: Hashable, Sendable { + public let privilege: String + public let scope: PluginPrivilegeScope + public let isGrantable: Bool + + public init(privilege: String, scope: PluginPrivilegeScope, isGrantable: Bool = false) { + self.privilege = privilege + self.scope = scope + self.isGrantable = isGrantable + } +} + +public enum PluginPrivilegeName { + private static let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZ _") + + public static func sanitized(_ raw: String) -> String? { + let normalized = raw.uppercased() + guard !normalized.isEmpty, normalized.allSatisfy({ allowed.contains($0) }) else { return nil } + return normalized + } +} + +public struct PluginPrincipalChangeSet: Sendable { + public let principal: PluginPrincipalRef + public let grantsToAdd: [PluginGrantInfo] + public let grantsToRemove: [PluginGrantInfo] + + public init( + principal: PluginPrincipalRef, + grantsToAdd: [PluginGrantInfo] = [], + grantsToRemove: [PluginGrantInfo] = [] + ) { + self.principal = principal + self.grantsToAdd = grantsToAdd + self.grantsToRemove = grantsToRemove + } +} + +public struct PluginPrincipalDropOptions: Sendable { + public let cascade: Bool + public let reassignOwnedTo: PluginPrincipalRef? + public let dropOwned: Bool + + public init( + cascade: Bool = false, + reassignOwnedTo: PluginPrincipalRef? = nil, + dropOwned: Bool = false + ) { + self.cascade = cascade + self.reassignOwnedTo = reassignOwnedTo + self.dropOwned = dropOwned + } +} diff --git a/TablePro/Core/Database/DatabaseManager+Principals.swift b/TablePro/Core/Database/DatabaseManager+Principals.swift new file mode 100644 index 000000000..882b6a1ef --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+Principals.swift @@ -0,0 +1,122 @@ +import Combine +import Foundation +import os +import TableProPluginKit + +extension DatabaseManager { + func principalDriver( + for connectionId: UUID + ) -> (any PluginDatabaseDriver & PluginPrincipalManagement)? { + guard let adapter = driver(for: connectionId) as? PluginDriverAdapter else { return nil } + return adapter.schemaPluginDriver as? any PluginDatabaseDriver & PluginPrincipalManagement + } + + func executePrincipalChanges( + changes: [PrincipalChange], + databaseType: DatabaseType, + connectionId: UUID + ) async throws { + guard let driver = driver(for: connectionId) else { + throw DatabaseError.notConnected + } + guard let principalDriver = principalDriver(for: connectionId) else { + throw DatabaseError.unsupportedOperation + } + + try await trackOperation(sessionId: connectionId) { + let generator = PrincipalStatementGenerator(driver: principalDriver) + let statements = try generator.generate(changes: changes) + guard !statements.isEmpty else { return } + + let combinedSQL = statements.map(\.sql).joined(separator: "\n") + let kind: OperationKind = statements.contains(where: \.isDestructive) + ? .destructiveQuery + : .schemaMutation + + let authorization = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connectionId, + databaseType: databaseType, + sql: combinedSQL, + kind: kind, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Apply User and Role Changes") + ) + ) + guard case .authorized = authorization else { + throw DatabaseError.queryFailed( + authorization.deniedReason + ?? String(localized: "User and role changes were not authorized") + ) + } + + try await runPrincipalStatements( + statements, + driver: driver, + rollsBack: principalDriver.rollsBackPrincipalStatements, + connectionId: connectionId + ) + } + } + + private func runPrincipalStatements( + _ statements: [SchemaStatement], + driver: any DatabaseDriver, + rollsBack: Bool, + connectionId: UUID + ) async throws { + let useTransaction = driver.supportsTransactions && rollsBack + if useTransaction { + try await driver.beginTransaction() + } + + var appliedCount = 0 + do { + for statement in statements { + _ = try await driver.execute(query: statement.sql) + appliedCount += 1 + } + if useTransaction { + try await driver.commitTransaction() + } + } catch { + var rolledBack = false + if useTransaction { + do { + try await driver.rollbackTransaction() + rolledBack = true + } catch { + Self.logger.error( + "Rollback failed after principal change error: \(error.localizedDescription)" + ) + } + } + throw PrincipalApplyError( + failedStatement: statements[min(appliedCount, statements.count - 1)], + appliedCount: appliedCount, + totalCount: statements.count, + rolledBack: rolledBack, + underlying: error + ) + } + + let databaseName = activeSessions[connectionId]?.activeDatabase ?? "" + // Query history is stored unencrypted on disk. A CREATE USER / ALTER USER statement embeds + // the plaintext password, so it is never recorded. + for statement in statements where !statement.carriesCredentials { + QueryHistoryManager.shared.recordQuery( + query: statement.sql.hasSuffix(";") ? statement.sql : statement.sql + ";", + connectionId: connectionId, + databaseName: databaseName, + executionTime: 0, + rowCount: 0, + wasSuccessful: true + ) + } + + await MainActor.run { + AppCommands.shared.refreshPrincipals.send(connectionId) + } + } +} diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index d78f826bc..dd5f21add 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -13,6 +13,7 @@ final class AppCommands { // MARK: - Refresh let refreshData = PassthroughSubject() + let refreshPrincipals = PassthroughSubject() // MARK: - File / Connection Import-Export diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index b760504a3..c8312d7f1 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -60,6 +60,7 @@ private extension TabType { case .createTable: "createTable" case .erDiagram: "erDiagram" case .serverDashboard: "serverDashboard" + case .usersRoles: "usersRoles" } } } diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 0b7c35332..edf40a339 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -9,11 +9,14 @@ import Foundation import TableProPluginKit -/// A schema SQL statement with metadata +/// A schema SQL statement with metadata. +/// `carriesCredentials` marks statements whose SQL embeds a plaintext password, so they are never +/// written to the on-disk query history. struct SchemaStatement { let sql: String let description: String let isDestructive: Bool + var carriesCredentials = false } /// Generates SQL statements for schema modifications by delegating to the plugin driver. diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 8c9729794..625783227 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -67,6 +67,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi switch payload?.tabType { case .serverDashboard: return String(localized: "Server Dashboard") + case .usersRoles: + return String(localized: "Users & Roles") case .erDiagram: return String(localized: "ER Diagram") case .createTable: diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index e88d6abf6..734af6c54 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -158,6 +158,8 @@ enum SessionStateFactory { ) case .serverDashboard: tabMgr.addServerDashboardTab() + case .usersRoles: + tabMgr.addUsersRolesTab() } case .newEmptyTab: let allTabs = MainContentCoordinator.allTabs(for: connection.id) diff --git a/TablePro/Core/UsersRoles/PasswordGenerator.swift b/TablePro/Core/UsersRoles/PasswordGenerator.swift new file mode 100644 index 000000000..79e2c627a --- /dev/null +++ b/TablePro/Core/UsersRoles/PasswordGenerator.swift @@ -0,0 +1,32 @@ +import Foundation + +enum PasswordGenerator { + static let defaultLength = 20 + + private static let alphabet = Array("abcdefghkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789") + + static func generate(length: Int = defaultLength) -> String { + var generator = SystemRandomNumberGenerator() + return generate(length: length, using: &generator) + } + + static func generate( + length: Int, + using generator: inout G + ) -> String { + guard length > 0 else { return "" } + + let bound = UInt64(alphabet.count) + let limit = UInt64.max - (UInt64.max % bound) + + var password = "" + password.reserveCapacity(length) + + while password.count < length { + let value = generator.next() + guard value < limit else { continue } + password.append(alphabet[Int(value % bound)]) + } + return password + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalApplyError.swift b/TablePro/Core/UsersRoles/PrincipalApplyError.swift new file mode 100644 index 000000000..c1f6da2c6 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalApplyError.swift @@ -0,0 +1,27 @@ +import Foundation + +struct PrincipalApplyError: LocalizedError { + let failedStatement: SchemaStatement + let appliedCount: Int + let totalCount: Int + let rolledBack: Bool + let underlying: Error + + var errorDescription: String? { + underlying.localizedDescription + } + + var partialApplicationMessage: String? { + guard !rolledBack, appliedCount > 0 else { return nil } + return String( + format: String( + localized: """ + %1$lld of %2$lld statements were applied. \ + This connection does not roll back user and role changes. + """ + ), + appliedCount, + totalCount + ) + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalChange.swift b/TablePro/Core/UsersRoles/PrincipalChange.swift new file mode 100644 index 000000000..54eabc3b3 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalChange.swift @@ -0,0 +1,53 @@ +import Foundation +import TableProPluginKit + +struct PrincipalGrantKey: Hashable { + let privilege: String + let scope: PluginPrivilegeScope +} + +enum PrincipalChange { + case create(PluginPrincipalDefinition) + case alter(old: PluginPrincipalDefinition, new: PluginPrincipalDefinition) + case setPassword(ref: PluginPrincipalRef, password: String) + case modifyGrants(PluginPrincipalChangeSet) + case drop(ref: PluginPrincipalRef, options: PluginPrincipalDropOptions) + + var principal: PluginPrincipalRef { + switch self { + case let .create(definition): definition.ref + case let .alter(old, _): old.ref + case let .setPassword(ref, _): ref + case let .modifyGrants(changeSet): changeSet.principal + case let .drop(ref, _): ref + } + } + + var isDestructive: Bool { + switch self { + case .drop: + true + case let .modifyGrants(changeSet): + !changeSet.grantsToRemove.isEmpty + case .create, .alter, .setPassword: + false + } + } + + var executionRank: Int { + switch self { + case .create: 0 + case .alter: 1 + case .setPassword: 2 + case .modifyGrants: 3 + case .drop: 4 + } + } +} + +extension PluginPrincipalRef { + var displayName: String { + guard let host, !host.isEmpty else { return name } + return "\(name)@\(host)" + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalChangeManager+Effectiveness.swift b/TablePro/Core/UsersRoles/PrincipalChangeManager+Effectiveness.swift new file mode 100644 index 000000000..e7f5d4001 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalChangeManager+Effectiveness.swift @@ -0,0 +1,74 @@ +import Foundation +import TableProPluginKit + +extension PrincipalChangeManager { + func effectiveness( + privilege: String, + scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef + ) -> PrivilegeEffectiveness { + PrivilegeEffectivenessResolver.resolve( + privilege: privilege, + scope: scope, + directGrants: resolvedGrantKeys(for: principal), + context: inheritanceContext(for: principal) + ) + } + + func inheritanceContext(for principal: PluginPrincipalRef) -> PrivilegeInheritanceContext { + let closure = PrivilegeEffectivenessResolver.roleClosure( + for: principal, + principals: principals + ) + var grantsByPrincipal: [PluginPrincipalRef: Set] = [:] + for role in closure { + let ref = PluginPrincipalRef(name: role) + guard hasLoadedGrants(for: ref) else { continue } + grantsByPrincipal[ref] = resolvedGrantKeys(for: ref) + } + + return PrivilegeInheritanceContext( + grantsByPrincipal: grantsByPrincipal, + roleClosure: closure, + inheritsAutomatically: inheritsAutomatically(principal), + cascades: cascades + ) + } + + func roleClosure(for principal: PluginPrincipalRef) -> [String] { + PrivilegeEffectivenessResolver.roleClosure(for: principal, principals: principals) + } + + private func inheritsAutomatically(_ principal: PluginPrincipalRef) -> Bool { + guard let info = principals.first(where: { $0.ref == principal }) else { return true } + guard let inherit = info.attributes.first(where: { $0.key == "INHERIT" }) else { return true } + return inherit.isEnabled + } + + func summary( + at scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef, + isBrowsingRestricted: Bool + ) -> ScopeSummary { + let granted = grantedPrivileges(at: scope, for: principal) + let grantable = catalog?.privileges(for: scope) ?? [] + let hasGrantOption = granted.contains { + isGrantable($0, scope: scope, for: principal) + } + + return ScopeSummary.make( + granted: granted, + grantable: grantable, + descendantCount: descendantGrantCount(under: scope, for: principal), + hasGrantOption: hasGrantOption, + isBrowsingRestricted: isBrowsingRestricted + ) + } + + func sections(for scope: PluginPrivilegeScope) -> [PrivilegeSection] { + let descriptors = catalog?.privileges(for: scope) ?? [] + return PrivilegeCategory.group(descriptors).map { + PrivilegeSection(category: $0.category, descriptors: $0.descriptors) + } + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalChangeManager.swift b/TablePro/Core/UsersRoles/PrincipalChangeManager.swift new file mode 100644 index 000000000..e886d2491 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalChangeManager.swift @@ -0,0 +1,554 @@ +import Foundation +import Observation +import TableProPluginKit + +@MainActor +@Observable +final class PrincipalChangeManager { + private(set) var principals: [PluginPrincipalInfo] = [] + private(set) var catalog: PluginPrivilegeCatalog? + + private(set) var baselineGrants: [PluginPrincipalRef: [PluginGrantInfo]] = [:] + private(set) var grantDeltas: [PluginPrincipalRef: PrincipalGrantDelta] = [:] + + private(set) var pendingCreates: [PluginPrincipalDefinition] = [] + private(set) var pendingDrops: [PluginPrincipalRef: PluginPrincipalDropOptions] = [:] + private(set) var pendingPasswords: [PluginPrincipalRef: String] = [:] + private(set) var pendingAlters: [PluginPrincipalRef: PluginPrincipalDefinition] = [:] + + private(set) var changeCount = 0 + private(set) var grantClosureVersion = 0 + + /// `groupsByEvent` is off: with it on, NSUndoManager coalesces every registration made in the + /// same run-loop event into one group, so undo granularity would depend on how fast the user + /// clicked. Each mutation opens and closes its own group instead. + @ObservationIgnored + let undoManager: UndoManager = { + let manager = UndoManager() + manager.groupsByEvent = false + manager.levelsOfUndo = 100 + return manager + }() + + @ObservationIgnored + private var baselineKeys: [PluginPrincipalRef: Set] = [:] + + @ObservationIgnored + private var closureCache: [PluginPrincipalRef: Set] = [:] + + @ObservationIgnored + var cascades: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool = { _, _ in false } + + var hasChanges: Bool { changeCount > 0 } + + // MARK: - Loading + + func load(principals: [PluginPrincipalInfo], catalog: PluginPrivilegeCatalog) { + self.principals = principals + self.catalog = catalog + baselineGrants = [:] + baselineKeys = [:] + grantDeltas = [:] + pendingCreates = [] + pendingDrops = [:] + pendingPasswords = [:] + pendingAlters = [:] + undoManager.removeAllActions() + invalidateClosures() + recomputeChangeCount() + } + + func reload(principals: [PluginPrincipalInfo], catalog: PluginPrivilegeCatalog) { + self.principals = principals + self.catalog = catalog + + let liveRefs = Set(principals.map(\.ref)) + pendingCreates = pendingCreates.filter { !liveRefs.contains($0.ref) } + + let createdRefs = Set(pendingCreates.map(\.ref)) + let retained = liveRefs.union(createdRefs) + + // Server truth is stale after a reload. Drop it for everything that exists on the server so + // the next loadGrants refetches; a staged create keeps its seeded empty baseline because + // there is nothing on the server to fetch for it yet. Deltas are rebased when the fresh + // baseline lands, not here. + baselineGrants = baselineGrants.filter { createdRefs.contains($0.key) } + baselineKeys = baselineKeys.filter { createdRefs.contains($0.key) } + + grantDeltas = grantDeltas.filter { retained.contains($0.key) && !$0.value.isEmpty } + pendingDrops = pendingDrops.filter { liveRefs.contains($0.key) } + pendingPasswords = pendingPasswords.filter { retained.contains($0.key) } + pendingAlters = pendingAlters.filter { liveRefs.contains($0.key) } + + invalidateClosures() + recomputeChangeCount() + } + + func loadGrants(_ grants: [PluginGrantInfo], for principal: PluginPrincipalRef) { + baselineGrants[principal] = grants + baselineKeys[principal] = Self.keys(from: grants) + grantDeltas[principal]?.rebase(onto: baselineKeys[principal] ?? []) + invalidateClosures() + recomputeChangeCount() + } + + func hasLoadedGrants(for principal: PluginPrincipalRef) -> Bool { + baselineGrants[principal] != nil + } + + // MARK: - Reading + + func isGranted( + _ privilege: String, + scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef + ) -> Bool { + let key = PrincipalGrantKey(privilege: privilege, scope: scope) + let baseline = baselineKeys[principal]?.contains(key) ?? false + guard let delta = grantDeltas[principal] else { return baseline } + return delta.resolves(key, baselineHasKey: baseline) + } + + func isGrantable( + _ privilege: String, + scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef + ) -> Bool { + baselineGrants[principal]?.contains { + $0.privilege == privilege && $0.scope == scope && $0.isGrantable + } ?? false + } + + func resolvedGrantKeys(for principal: PluginPrincipalRef) -> Set { + let baseline = baselineKeys[principal] ?? [] + guard let delta = grantDeltas[principal] else { return baseline } + return baseline.subtracting(delta.removed).union(delta.added) + } + + func grantedScopeClosure(for principal: PluginPrincipalRef) -> Set { + if let cached = closureCache[principal] { + return cached + } + + var closure: Set = [] + for key in resolvedGrantKeys(for: principal) { + closure.insert(key.scope) + var ancestor = key.scope.parent + while let scope = ancestor { + closure.insert(scope) + ancestor = scope.parent + } + } + closureCache[principal] = closure + return closure + } + + func grantedPrivileges( + at scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef + ) -> [String] { + resolvedGrantKeys(for: principal) + .filter { $0.scope == scope } + .map(\.privilege) + } + + func descendantGrantCount( + under scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef + ) -> Int { + resolvedGrantKeys(for: principal).count { scope.contains($0.scope) } + } + + func hasDescendantGrant( + _ privilege: String, + under scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef + ) -> Bool { + resolvedGrantKeys(for: principal).contains { + $0.privilege == privilege && scope.contains($0.scope) + } + } + + func stage(of principal: PluginPrincipalRef) -> PrincipalRow.Stage { + if pendingDrops[principal] != nil { return .dropped } + if pendingCreates.contains(where: { $0.ref == principal }) { return .created } + + let isModified = !(grantDeltas[principal]?.isEmpty ?? true) + || pendingPasswords[principal] != nil + || pendingAlters[principal] != nil + return isModified ? .modified : .unchanged + } + + // MARK: - Mutating + + func setGranted( + _ isGranted: Bool, + privilege: String, + scope: PluginPrivilegeScope, + for principal: PluginPrincipalRef, + actionName: String? = nil + ) { + setGranted( + isGranted, + privileges: [privilege], + scopes: [scope], + for: principal, + actionName: actionName + ) + } + + func setGranted( + _ isGranted: Bool, + privileges: [String], + scopes: [PluginPrivilegeScope], + for principal: PluginPrincipalRef, + actionName: String? = nil + ) { + guard !privileges.isEmpty, !scopes.isEmpty else { return } + + let previous = grantDeltas[principal] ?? PrincipalGrantDelta() + var delta = previous + let baseline = baselineKeys[principal] ?? [] + + for scope in scopes { + for privilege in privileges { + let key = PrincipalGrantKey(privilege: privilege, scope: scope) + delta.stage(key, granted: isGranted, baselineHasKey: baseline.contains(key)) + } + } + guard delta != previous else { return } + + applyDelta(delta, for: principal, previous: previous, actionName: actionName) + } + + private func applyDelta( + _ delta: PrincipalGrantDelta, + for principal: PluginPrincipalRef, + previous: PrincipalGrantDelta, + actionName: String? + ) { + grantDeltas[principal] = delta.isEmpty ? nil : delta + invalidateClosures() + recomputeChangeCount() + + registerUndo(actionName: actionName) { manager in + manager.applyDelta( + previous, + for: principal, + previous: delta, + actionName: actionName + ) + } + } + + func copyGrants(from source: PluginPrincipalRef, to target: PluginPrincipalRef) { + let sourceKeys = resolvedGrantKeys(for: source) + let targetKeys = resolvedGrantKeys(for: target) + let baseline = baselineKeys[target] ?? [] + + let previous = grantDeltas[target] ?? PrincipalGrantDelta() + var delta = previous + + for key in sourceKeys.subtracting(targetKeys) { + delta.stage(key, granted: true, baselineHasKey: baseline.contains(key)) + } + guard delta != previous else { return } + + applyDelta( + delta, + for: target, + previous: previous, + actionName: String( + format: String(localized: "Copy Privileges from %@"), + source.displayName + ) + ) + } + + func stageCreate(_ definition: PluginPrincipalDefinition) { + pendingCreates.append(definition) + baselineGrants[definition.ref] = [] + baselineKeys[definition.ref] = [] + invalidateClosures() + recomputeChangeCount() + + registerUndo( + actionName: String(format: String(localized: "Create %@"), definition.ref.displayName) + ) { manager in + manager.unstageCreate(definition.ref) + } + } + + func unstageCreate(_ ref: PluginPrincipalRef) { + guard let definition = pendingCreates.first(where: { $0.ref == ref }) else { return } + + let delta = grantDeltas[ref] + let password = pendingPasswords[ref] + + pendingCreates.removeAll { $0.ref == ref } + baselineGrants.removeValue(forKey: ref) + baselineKeys.removeValue(forKey: ref) + grantDeltas.removeValue(forKey: ref) + pendingPasswords.removeValue(forKey: ref) + pendingAlters.removeValue(forKey: ref) + invalidateClosures() + recomputeChangeCount() + + registerUndo( + actionName: String(format: String(localized: "Remove %@"), ref.displayName) + ) { manager in + manager.restoreCreate(definition, delta: delta, password: password) + } + } + + private func restoreCreate( + _ definition: PluginPrincipalDefinition, + delta: PrincipalGrantDelta?, + password: String? + ) { + stageCreate(definition) + if let delta { + grantDeltas[definition.ref] = delta + } + if let password { + pendingPasswords[definition.ref] = password + } + invalidateClosures() + recomputeChangeCount() + } + + func stageDrop(_ ref: PluginPrincipalRef, options: PluginPrincipalDropOptions) { + guard pendingDrops[ref] == nil else { return } + pendingDrops[ref] = options + recomputeChangeCount() + + registerUndo( + actionName: String(format: String(localized: "Drop %@"), ref.displayName) + ) { manager in + manager.unstageDrop(ref) + } + } + + func unstageDrop(_ ref: PluginPrincipalRef) { + guard let options = pendingDrops.removeValue(forKey: ref) else { return } + recomputeChangeCount() + + registerUndo { manager in + manager.stageDrop(ref, options: options) + } + } + + func stageSetPassword(_ password: String, for ref: PluginPrincipalRef) { + let previous = pendingPasswords[ref] + pendingPasswords[ref] = password + recomputeChangeCount() + + registerUndo( + actionName: String( + format: String(localized: "Change Password for %@"), + ref.displayName + ) + ) { manager in + if let previous { + manager.stageSetPassword(previous, for: ref) + } else { + manager.clearPassword(for: ref) + } + } + } + + func clearPassword(for ref: PluginPrincipalRef) { + guard let previous = pendingPasswords.removeValue(forKey: ref) else { return } + recomputeChangeCount() + + registerUndo { manager in + manager.stageSetPassword(previous, for: ref) + } + } + + func stageAlter(_ definition: PluginPrincipalDefinition, for ref: PluginPrincipalRef) { + // A principal that only exists as a staged create has nothing to ALTER. Fold the edit into + // the CREATE instead, or it would be counted as a change and then silently dropped. + if let index = pendingCreates.firstIndex(where: { $0.ref == ref }) { + let previous = pendingCreates[index] + guard previous != definition else { return } + + pendingCreates[index] = definition + recomputeChangeCount() + + registerUndo(actionName: String(localized: "Change Attributes")) { manager in + manager.stageAlter(previous, for: ref) + } + return + } + + let previous = pendingAlters[ref] + + if let original = principals.first(where: { $0.ref == ref }), + definition == Self.definition(from: original) { + pendingAlters.removeValue(forKey: ref) + } else { + pendingAlters[ref] = definition + } + guard pendingAlters[ref] != previous else { return } + recomputeChangeCount() + + registerUndo(actionName: String(localized: "Change Attributes")) { manager in + if let previous { + manager.stageAlter(previous, for: ref) + } else { + manager.unstageAlter(ref) + } + } + } + + func unstageAlter(_ ref: PluginPrincipalRef) { + guard let previous = pendingAlters.removeValue(forKey: ref) else { return } + recomputeChangeCount() + + registerUndo { manager in + manager.stageAlter(previous, for: ref) + } + } + + func discardChanges() { + grantDeltas = [:] + pendingCreates = [] + pendingDrops = [:] + pendingPasswords = [:] + pendingAlters = [:] + undoManager.removeAllActions() + invalidateClosures() + recomputeChangeCount() + } + + // MARK: - Output + + func pendingChanges() -> [PrincipalChange] { + var changes: [PrincipalChange] = pendingCreates.map { .create($0) } + + changes += pendingAlters.compactMap { ref, definition in + guard let original = principals.first(where: { $0.ref == ref }) else { return nil } + return .alter(old: Self.definition(from: original), new: definition) + } + changes += pendingPasswords.map { .setPassword(ref: $0.key, password: $0.value) } + changes += grantChangeSets().map { .modifyGrants($0) } + changes += pendingDrops.map { .drop(ref: $0.key, options: $0.value) } + return changes + } + + func grantChangeSets() -> [PluginPrincipalChangeSet] { + grantDeltas.compactMap { principal, delta in + guard pendingDrops[principal] == nil, !delta.isEmpty else { return nil } + + let baseline = baselineKeys[principal] ?? [] + let added = delta.added.subtracting(baseline) + let removed = delta.removed.intersection(baseline) + guard !added.isEmpty || !removed.isEmpty else { return nil } + + return PluginPrincipalChangeSet( + principal: principal, + grantsToAdd: added.map { grantInfo(for: $0, principal: principal) }, + grantsToRemove: removed.map { grantInfo(for: $0, principal: principal) } + ) + } + } + + private func grantInfo( + for key: PrincipalGrantKey, + principal: PluginPrincipalRef + ) -> PluginGrantInfo { + PluginGrantInfo( + privilege: key.privilege, + scope: key.scope, + isGrantable: isGrantable(key.privilege, scope: key.scope, for: principal) + ) + } + + func selfImpact(connected: PluginPrincipalRef?) -> String? { + guard let connected else { return nil } + + let matches = { (ref: PluginPrincipalRef) in + ref.name.compare(connected.name, options: .caseInsensitive) == .orderedSame + } + + if pendingDrops.keys.contains(where: matches) { + return String( + format: String(localized: "These changes drop %@, the account this connection uses."), + connected.name + ) + } + if pendingAlters.keys.contains(where: matches) { + return String( + format: String( + localized: "These changes alter %@, the account this connection uses." + ), + connected.name + ) + } + let losesEverything = grantChangeSets().contains { changeSet in + matches(changeSet.principal) + && resolvedGrantKeys(for: changeSet.principal).isEmpty + && !(baselineKeys[changeSet.principal] ?? []).isEmpty + } + guard losesEverything else { return nil } + + return String( + format: String( + localized: "These changes revoke every privilege from %@, the account this connection uses." + ), + connected.name + ) + } + + // MARK: - Bookkeeping + + /// While the manager is undoing or redoing, NSUndoManager has already opened a group for the + /// inverse registration, so opening another would nest a group inside it. + private func registerUndo( + actionName: String? = nil, + _ body: @escaping (PrincipalChangeManager) -> Void + ) { + let opensGroup = !undoManager.isUndoing && !undoManager.isRedoing + if opensGroup { + undoManager.beginUndoGrouping() + } + if let actionName { + undoManager.setActionName(actionName) + } + undoManager.registerUndo(withTarget: self, handler: body) + if opensGroup { + undoManager.endUndoGrouping() + } + } + + private func recomputeChangeCount() { + let grantChanges = grantDeltas.values.reduce(0) { $0 + $1.count } + changeCount = pendingCreates.count + + pendingDrops.count + + pendingPasswords.count + + pendingAlters.count + + grantChanges + } + + private func invalidateClosures() { + closureCache = [:] + grantClosureVersion &+= 1 + } + + private static func keys(from grants: [PluginGrantInfo]) -> Set { + Set(grants.map { PrincipalGrantKey(privilege: $0.privilege, scope: $0.scope) }) + } + + static func definition(from info: PluginPrincipalInfo) -> PluginPrincipalDefinition { + PluginPrincipalDefinition( + ref: info.ref, + password: nil, + canLogin: info.canLogin, + attributes: info.attributes, + memberOf: info.memberOf, + connectionLimit: info.connectionLimit, + comment: info.comment + ) + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalDropPrompt.swift b/TablePro/Core/UsersRoles/PrincipalDropPrompt.swift new file mode 100644 index 000000000..b07c4efe2 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalDropPrompt.swift @@ -0,0 +1,38 @@ +import Foundation +import TableProPluginKit + +struct PrincipalDropPrompt: Identifiable, Equatable { + enum Disposition: Equatable { + case reassign(to: PluginPrincipalRef) + case dropOwned + } + + let principals: [PluginPrincipalInfo] + let reassignCandidates: [PluginPrincipalRef] + + var id: String { + principals.map(\.ref.displayName).joined(separator: ",") + } + + var title: String { + guard principals.count == 1, let principal = principals.first else { + return String( + format: String(localized: "%lld roles own database objects"), + principals.count + ) + } + return String( + format: String(localized: "“%@” owns database objects"), + principal.ref.displayName + ) + } + + static func dropOptions(for disposition: Disposition) -> PluginPrincipalDropOptions { + switch disposition { + case let .reassign(target): + PluginPrincipalDropOptions(reassignOwnedTo: target) + case .dropOwned: + PluginPrincipalDropOptions(dropOwned: true) + } + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalGrantDelta.swift b/TablePro/Core/UsersRoles/PrincipalGrantDelta.swift new file mode 100644 index 000000000..271072a63 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalGrantDelta.swift @@ -0,0 +1,36 @@ +import Foundation +import TableProPluginKit + +struct PrincipalGrantDelta: Equatable { + var added: Set = [] + var removed: Set = [] + + var isEmpty: Bool { added.isEmpty && removed.isEmpty } + var count: Int { added.count + removed.count } + + mutating func stage(_ key: PrincipalGrantKey, granted: Bool, baselineHasKey: Bool) { + guard granted != baselineHasKey else { + added.remove(key) + removed.remove(key) + return + } + if granted { + removed.remove(key) + added.insert(key) + } else { + added.remove(key) + removed.insert(key) + } + } + + func resolves(_ key: PrincipalGrantKey, baselineHasKey: Bool) -> Bool { + if added.contains(key) { return true } + if removed.contains(key) { return false } + return baselineHasKey + } + + mutating func rebase(onto baseline: Set) { + added = added.filter { !baseline.contains($0) } + removed = removed.filter { baseline.contains($0) } + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalListLoader.swift b/TablePro/Core/UsersRoles/PrincipalListLoader.swift new file mode 100644 index 000000000..a37a94524 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalListLoader.swift @@ -0,0 +1,76 @@ +import Foundation +import TableProPluginKit + +actor PrincipalListLoader { + struct Snapshot: Sendable { + let principals: [PluginPrincipalInfo] + let catalog: PluginPrivilegeCatalog + } + + private let driver: any PluginDatabaseDriver & PluginPrincipalManagement + private var loadTask: Task? + + init(driver: any PluginDatabaseDriver & PluginPrincipalManagement) { + self.driver = driver + } + + func databases() async throws -> [String] { + try await driver.fetchDatabases() + } + + func load(forceReload: Bool = false) async throws -> Snapshot { + if forceReload { + loadTask = nil + } + if let loadTask { + return try await loadTask.value + } + + let task = Task { [driver] in + let principals = try await driver.fetchPrincipals() + let catalog = try await driver.fetchPrivilegeCatalog() + return Snapshot(principals: principals, catalog: catalog) + } + loadTask = task + + do { + return try await task.value + } catch { + loadTask = nil + throw error + } + } + + func grants(for principal: PluginPrincipalRef) async throws -> [PluginGrantInfo] { + try await driver.fetchGrants(for: principal) + } + + func grantableChildren(of scope: PluginPrivilegeScope) async throws -> [PluginPrivilegeScope] { + try await driver.fetchGrantableChildren(of: scope) + } + + func searchScopes(matching query: String, limit: Int) async throws -> [PluginPrivilegeScope] { + try await driver.searchGrantableScopes(matching: query, limit: limit) + } + + var supportsScopeSearch: Bool { + driver.supportsGrantableScopeSearch + } + + var restrictsBrowsingToCurrentDatabase: Bool { + driver.restrictsGrantBrowsingToCurrentDatabase + } + + func cascadeRule() -> @Sendable (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool { + let driver = driver + return { driver.privilegeCascades(from: $0, to: $1) } + } + + func ownsObjects(_ principal: PluginPrincipalRef) async throws -> Bool { + try await driver.principalOwnsObjects(principal) + } + + func currentPrincipal() async throws -> PluginPrincipalRef? { + try await driver.currentPrincipalRef() + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalRow.swift b/TablePro/Core/UsersRoles/PrincipalRow.swift new file mode 100644 index 000000000..1317b8967 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalRow.swift @@ -0,0 +1,57 @@ +import Foundation +import TableProPluginKit + +struct PrincipalRow: Identifiable, Hashable { + enum Stage: Equatable { + case unchanged + case created + case modified + case dropped + } + + let info: PluginPrincipalInfo + let stage: Stage + + var id: PluginPrincipalRef { info.ref } + var ref: PluginPrincipalRef { info.ref } + var displayName: String { info.ref.displayName } + var sortName: String { info.ref.name.lowercased() } + var isRole: Bool { info.isRole } + + var kindTitle: String { + info.isRole ? String(localized: "Role") : String(localized: "User") + } + + var attributeSummary: String { + info.attributes + .filter(\.isEnabled) + .map(\.label) + .formatted(.list(type: .and, width: .narrow)) + } + + var symbolName: String { + info.isRole ? "person.2" : "person" + } + + var statusSymbol: String? { + switch stage { + case .unchanged: nil + case .created: "plus.circle" + case .modified: "pencil.circle" + case .dropped: "minus.circle" + } + } + + var statusDescription: String? { + switch stage { + case .unchanged: + nil + case .created: + String(localized: "Will be created when you apply changes") + case .modified: + String(localized: "Has unsaved changes") + case .dropped: + String(localized: "Will be dropped when you apply changes") + } + } +} diff --git a/TablePro/Core/UsersRoles/PrincipalStatementGenerator.swift b/TablePro/Core/UsersRoles/PrincipalStatementGenerator.swift new file mode 100644 index 000000000..114c86b68 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrincipalStatementGenerator.swift @@ -0,0 +1,111 @@ +import Foundation +import TableProPluginKit + +struct PrincipalStatementGenerator { + private let driver: any PluginPrincipalManagement + + init(driver: any PluginPrincipalManagement) { + self.driver = driver + } + + func generate(changes: [PrincipalChange]) throws -> [SchemaStatement] { + try changes + .sorted { $0.executionRank < $1.executionRank } + .flatMap { try statements(for: $0) } + } + + private func statements(for change: PrincipalChange) throws -> [SchemaStatement] { + switch change { + case let .create(definition): + try wrap( + driver.generateCreatePrincipalSQL(definition: definition), + description: String( + format: String(localized: "Create %@"), + definition.ref.displayName + ), + isDestructive: false, + carriesCredentials: !(definition.password ?? "").isEmpty + ) + + case let .alter(old, new): + try wrap( + driver.generateAlterPrincipalSQL(old: old, new: new), + description: String( + format: String(localized: "Update %@"), + old.ref.displayName + ), + isDestructive: false, + carriesCredentials: !(new.password ?? "").isEmpty + ) + + case let .setPassword(ref, password): + try wrap( + driver.generateSetPasswordSQL(principal: ref, password: password), + description: String( + format: String(localized: "Change password for %@"), + ref.displayName + ), + isDestructive: false, + carriesCredentials: true + ) + + case let .modifyGrants(changeSet): + try grantStatements(changeSet) + + case let .drop(ref, options): + try wrap( + driver.generateDropPrincipalSQL(principal: ref, options: options), + description: String( + format: String(localized: "Drop %@"), + ref.displayName + ), + isDestructive: true + ) + } + } + + private func grantStatements(_ changeSet: PluginPrincipalChangeSet) throws -> [SchemaStatement] { + var statements: [SchemaStatement] = [] + + if !changeSet.grantsToRemove.isEmpty { + statements += try wrap( + driver.generateRevokeSQL(changeSet: changeSet), + description: String( + format: String(localized: "Revoke privileges from %@"), + changeSet.principal.displayName + ), + isDestructive: true + ) + } + if !changeSet.grantsToAdd.isEmpty { + statements += try wrap( + driver.generateGrantSQL(changeSet: changeSet), + description: String( + format: String(localized: "Grant privileges to %@"), + changeSet.principal.displayName + ), + isDestructive: false + ) + } + return statements + } + + private func wrap( + _ sql: [String]?, + description: String, + isDestructive: Bool, + carriesCredentials: Bool = false + ) throws -> [SchemaStatement] { + guard let sql else { + throw DatabaseError.unsupportedOperation + } + return sql.map { + SchemaStatement( + sql: $0, + description: description, + isDestructive: isDestructive, + carriesCredentials: carriesCredentials + ) + } + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeCategory.swift b/TablePro/Core/UsersRoles/PrivilegeCategory.swift new file mode 100644 index 000000000..44f9da2b1 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeCategory.swift @@ -0,0 +1,82 @@ +import Foundation +import TableProPluginKit + +struct PrivilegeCategory: Hashable, Identifiable { + let key: String + let title: String + let sortOrder: Int + let isCollapsedByDefault: Bool + + var id: String { key } + + static let other = PrivilegeCategory( + key: "other", + title: String(localized: "Other"), + sortOrder: 4, + isCollapsedByDefault: false + ) + + static func resolve(_ key: String?) -> PrivilegeCategory { + guard let key, !key.isEmpty else { return .other } + + switch key { + case PluginPrivilegeCategoryKey.data: + return PrivilegeCategory( + key: key, + title: String(localized: "Data"), + sortOrder: 0, + isCollapsedByDefault: false + ) + case PluginPrivilegeCategoryKey.structure: + return PrivilegeCategory( + key: key, + title: String(localized: "Structure"), + sortOrder: 1, + isCollapsedByDefault: false + ) + case PluginPrivilegeCategoryKey.administration: + return PrivilegeCategory( + key: key, + title: String(localized: "Administration"), + sortOrder: 2, + isCollapsedByDefault: true + ) + case PluginPrivilegeCategoryKey.dynamic: + return PrivilegeCategory( + key: key, + title: String(localized: "Dynamic"), + sortOrder: 3, + isCollapsedByDefault: true + ) + default: + return PrivilegeCategory( + key: key, + title: key, + sortOrder: 5, + isCollapsedByDefault: true + ) + } + } + + static func group( + _ descriptors: [PluginPrivilegeDescriptor] + ) -> [(category: PrivilegeCategory, descriptors: [PluginPrivilegeDescriptor])] { + var order: [PrivilegeCategory] = [] + var buckets: [PrivilegeCategory: [PluginPrivilegeDescriptor]] = [:] + + for descriptor in descriptors { + let category = resolve(descriptor.category) + if buckets[category] == nil { + order.append(category) + } + buckets[category, default: []].append(descriptor) + } + + return order + .sorted { $0.sortOrder < $1.sortOrder } + .compactMap { category in + guard let descriptors = buckets[category] else { return nil } + return (category, descriptors) + } + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeEffectiveness.swift b/TablePro/Core/UsersRoles/PrivilegeEffectiveness.swift new file mode 100644 index 000000000..6e1b40931 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeEffectiveness.swift @@ -0,0 +1,107 @@ +import Foundation +import TableProPluginKit + +enum PrivilegeEffectiveness: Equatable { + case direct + case viaScope(PluginPrivilegeScope) + case viaRole(name: String, isAutomatic: Bool) + case notEffective + + var isEffective: Bool { + self != .notEffective + } +} + +struct PrivilegeInheritanceContext { + let grantsByPrincipal: [PluginPrincipalRef: Set] + let roleClosure: [String] + let inheritsAutomatically: Bool + let cascades: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool + + init( + grantsByPrincipal: [PluginPrincipalRef: Set], + roleClosure: [String], + inheritsAutomatically: Bool, + cascades: @escaping (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool + ) { + self.grantsByPrincipal = grantsByPrincipal + self.roleClosure = roleClosure + self.inheritsAutomatically = inheritsAutomatically + self.cascades = cascades + } +} + +enum PrivilegeEffectivenessResolver { + static func resolve( + privilege: String, + scope: PluginPrivilegeScope, + directGrants: Set, + context: PrivilegeInheritanceContext + ) -> PrivilegeEffectiveness { + if directGrants.contains(PrincipalGrantKey(privilege: privilege, scope: scope)) { + return .direct + } + if let ancestor = cascadingAncestor( + privilege: privilege, + scope: scope, + grants: directGrants, + cascades: context.cascades + ) { + return .viaScope(ancestor) + } + + for role in context.roleClosure { + let ref = PluginPrincipalRef(name: role) + guard let roleGrants = context.grantsByPrincipal[ref] else { continue } + + if roleGrants.contains(PrincipalGrantKey(privilege: privilege, scope: scope)) + || cascadingAncestor( + privilege: privilege, + scope: scope, + grants: roleGrants, + cascades: context.cascades + ) != nil { + return .viaRole(name: role, isAutomatic: context.inheritsAutomatically) + } + } + return .notEffective + } + + private static func cascadingAncestor( + privilege: String, + scope: PluginPrivilegeScope, + grants: Set, + cascades: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool + ) -> PluginPrivilegeScope? { + var candidate = scope.parent + while let ancestor = candidate { + let key = PrincipalGrantKey(privilege: privilege, scope: ancestor) + if grants.contains(key), cascades(ancestor, scope) { + return ancestor + } + candidate = ancestor.parent + } + return nil + } + + static func roleClosure( + for principal: PluginPrincipalRef, + principals: [PluginPrincipalInfo] + ) -> [String] { + let membershipByName = Dictionary( + principals.map { ($0.ref.name, $0.memberOf) }, + uniquingKeysWith: { first, _ in first } + ) + + var visited: Set = [principal.name] + var frontier = membershipByName[principal.name] ?? [] + var closure: [String] = [] + + while let role = frontier.popLast() { + guard visited.insert(role).inserted else { continue } + closure.append(role) + frontier.append(contentsOf: membershipByName[role] ?? []) + } + return closure + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeExpansionStore.swift b/TablePro/Core/UsersRoles/PrivilegeExpansionStore.swift new file mode 100644 index 000000000..0720782d1 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeExpansionStore.swift @@ -0,0 +1,37 @@ +import Foundation + +struct PrivilegeExpansionStore { + private static let prefix = "com.TablePro.usersRoles.expanded." + + private let defaults: UserDefaults + private let key: String + + init(connectionId: UUID, defaults: UserDefaults = .standard) { + self.defaults = defaults + key = Self.prefix + connectionId.uuidString + } + + func load() -> [String] { + defaults.stringArray(forKey: key) ?? [] + } + + func save(_ keys: [String]) { + guard !keys.isEmpty else { + defaults.removeObject(forKey: key) + return + } + defaults.set(keys, forKey: key) + } + + func insert(_ persistentKey: String) { + var keys = load() + guard !keys.contains(persistentKey) else { return } + keys.append(persistentKey) + save(keys) + } + + func remove(_ persistentKey: String) { + let keys = load().filter { $0 != persistentKey && !$0.hasPrefix(persistentKey + "/") } + save(keys) + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeNode.swift b/TablePro/Core/UsersRoles/PrivilegeNode.swift new file mode 100644 index 000000000..363650e77 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeNode.swift @@ -0,0 +1,93 @@ +import Foundation +import TableProPluginKit + +@MainActor +final class PrivilegeNode: NSObject { + enum ChildrenAvailability { + case available + case restrictedToCurrentDatabase + case none + } + + let scope: PluginPrivilegeScope + let childrenAvailability: ChildrenAvailability + + private(set) var children: [PrivilegeNode]? + private(set) var isLoading = false + private(set) var loadError: String? + + init(scope: PluginPrivilegeScope, childrenAvailability: ChildrenAvailability) { + self.scope = scope + self.childrenAvailability = childrenAvailability + super.init() + } + + var title: String { scope.displayName } + var symbolName: String { scope.symbolName } + var persistentKey: String { scope.persistentKey } + var hasLoadedChildren: Bool { children != nil } + + var isExpandable: Bool { + guard childrenAvailability == .available else { return false } + guard let children else { return true } + return !children.isEmpty + } + + func beginLoading() { + isLoading = true + loadError = nil + } + + func setChildren(_ children: [PrivilegeNode]) { + self.children = children + isLoading = false + loadError = nil + } + + func failLoading(_ error: String) { + children = [] + isLoading = false + loadError = error + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? PrivilegeNode else { return false } + return scope == other.scope + } + + override var hash: Int { + scope.hashValue + } + + static func make( + for scope: PluginPrivilegeScope, + restrictsBrowsing: Bool, + currentDatabase: String? + ) -> PrivilegeNode { + PrivilegeNode( + scope: scope, + childrenAvailability: availability( + for: scope, + restrictsBrowsing: restrictsBrowsing, + currentDatabase: currentDatabase + ) + ) + } + + private static func availability( + for scope: PluginPrivilegeScope, + restrictsBrowsing: Bool, + currentDatabase: String? + ) -> ChildrenAvailability { + switch scope { + case .server, .column: + .none + case .database, .schema, .table: + if restrictsBrowsing, let database = scope.databaseName, database != currentDatabase { + .restrictedToCurrentDatabase + } else { + .available + } + } + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeRow.swift b/TablePro/Core/UsersRoles/PrivilegeRow.swift new file mode 100644 index 000000000..2d9ca27ee --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeRow.swift @@ -0,0 +1,49 @@ +import Foundation +import TableProPluginKit + +struct PrivilegeRow: Identifiable, Hashable { + enum Kind: Hashable { + case category(PrivilegeCategory) + case privilege(PluginPrivilegeDescriptor) + } + + let kind: Kind + + var id: String { + switch kind { + case let .category(category): "category:\(category.key)" + case let .privilege(descriptor): "privilege:\(descriptor.name)" + } + } + + var title: String { + switch kind { + case let .category(category): category.title + case let .privilege(descriptor): descriptor.label + } + } + + var descriptor: PluginPrivilegeDescriptor? { + guard case let .privilege(descriptor) = kind else { return nil } + return descriptor + } + + var category: PrivilegeCategory? { + guard case let .category(category) = kind else { return nil } + return category + } +} + +struct PrivilegeSection: Identifiable { + let category: PrivilegeCategory + let headerRow: PrivilegeRow + let rows: [PrivilegeRow] + + var id: String { category.key } + + init(category: PrivilegeCategory, descriptors: [PluginPrivilegeDescriptor]) { + self.category = category + headerRow = PrivilegeRow(kind: .category(category)) + rows = descriptors.map { PrivilegeRow(kind: .privilege($0)) } + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeScopeKey.swift b/TablePro/Core/UsersRoles/PrivilegeScopeKey.swift new file mode 100644 index 000000000..957093c3b --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeScopeKey.swift @@ -0,0 +1,84 @@ +import Foundation +import TableProPluginKit + +extension PluginPrivilegeScope { + enum Level: Int, Comparable { + case server + case database + case schema + case table + case column + + static func < (lhs: Level, rhs: Level) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + var level: Level { + switch self { + case .server: .server + case .database: .database + case .schema: .schema + case .table: .table + case .column: .column + } + } + + var persistentKey: String { + switch self { + case .server: + "server" + case let .database(name): + "db:\(name)" + case let .schema(database, schema): + "db:\(database)/schema:\(schema)" + case let .table(database, schema, table): + schema.map { "db:\(database)/schema:\($0)/table:\(table)" } + ?? "db:\(database)/table:\(table)" + case let .column(database, schema, table, column): + schema.map { "db:\(database)/schema:\($0)/table:\(table)/column:\(column)" } + ?? "db:\(database)/table:\(table)/column:\(column)" + } + } + + var displayPath: String { + switch self { + case .server: + String(localized: "Server") + case let .database(name): + name + case let .schema(database, schema): + "\(database) › \(schema)" + case let .table(database, schema, table): + schema.map { "\(database) › \($0) › \(table)" } ?? "\(database) › \(table)" + case let .column(database, schema, table, column): + schema.map { "\(database) › \($0) › \(table) › \(column)" } + ?? "\(database) › \(table) › \(column)" + } + } + + var displayName: String { + switch self { + case .server: + String(localized: "Server") + case let .database(name): + name + case let .schema(_, schema): + schema + case let .table(_, _, table): + table + case let .column(_, _, _, column): + column + } + } + + var symbolName: String { + switch self { + case .server: "server.rack" + case .database: "cylinder" + case .schema: "folder" + case .table: "tablecells" + case .column: "rectangle.split.3x1" + } + } +} diff --git a/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift b/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift new file mode 100644 index 000000000..13e99ace5 --- /dev/null +++ b/TablePro/Core/UsersRoles/PrivilegeTreeModel.swift @@ -0,0 +1,141 @@ +import Foundation +import Observation +import TableProPluginKit + +@MainActor +@Observable +final class PrivilegeTreeModel { + enum Mode: Equatable { + case hierarchy + case granted + case searchResults + } + + private(set) var roots: [PrivilegeNode] = [] + private(set) var mode: Mode = .hierarchy + private(set) var structureVersion = 0 + + @ObservationIgnored + private var databases: [String] = [] + + @ObservationIgnored + private var hasServerScope = false + + @ObservationIgnored + private var restrictsBrowsing = false + + @ObservationIgnored + private var currentDatabase: String? + + @ObservationIgnored + private var loader: PrincipalListLoader? + + func configure( + databases: [String], + catalog: PluginPrivilegeCatalog, + restrictsBrowsing: Bool, + currentDatabase: String?, + loader: PrincipalListLoader + ) { + self.databases = databases + self.restrictsBrowsing = restrictsBrowsing + self.currentDatabase = currentDatabase + self.loader = loader + hasServerScope = !catalog.serverPrivileges.isEmpty + rebuildHierarchy() + } + + func rebuildHierarchy() { + mode = .hierarchy + roots = makeRoots() + bumpVersion() + } + + func showGrantedOnly(scopes: Set) { + mode = .granted + roots = buildStaticTree(from: scopes) + bumpVersion() + } + + func showSearchResults(_ scopes: [PluginPrivilegeScope]) { + mode = .searchResults + roots = scopes.map(makeNode) + bumpVersion() + } + + func expand(_ node: PrivilegeNode) async throws { + guard mode == .hierarchy, + !node.hasLoadedChildren, + !node.isLoading, + node.childrenAvailability == .available, + let loader else { return } + + node.beginLoading() + bumpVersion() + + do { + let children = try await loader.grantableChildren(of: node.scope) + node.setChildren(children.map(makeNode)) + bumpVersion() + } catch { + node.failLoading(error.localizedDescription) + bumpVersion() + throw error + } + } + + func node(matching scope: PluginPrivilegeScope) -> PrivilegeNode? { + var frontier = roots + while let node = frontier.popLast() { + if node.scope == scope { return node } + frontier.append(contentsOf: node.children ?? []) + } + return nil + } + + private func makeRoots() -> [PrivilegeNode] { + var roots: [PrivilegeNode] = [] + if hasServerScope { + roots.append(makeNode(.server)) + } + roots.append(contentsOf: databases.map { makeNode(.database($0)) }) + return roots + } + + private func makeNode(_ scope: PluginPrivilegeScope) -> PrivilegeNode { + PrivilegeNode.make( + for: scope, + restrictsBrowsing: restrictsBrowsing, + currentDatabase: currentDatabase + ) + } + + private func buildStaticTree(from scopes: Set) -> [PrivilegeNode] { + var nodes: [PluginPrivilegeScope: PrivilegeNode] = [:] + var childScopes: [PluginPrivilegeScope: [PluginPrivilegeScope]] = [:] + var rootScopes: [PluginPrivilegeScope] = [] + + for scope in scopes.sorted(by: { $0.persistentKey < $1.persistentKey }) { + nodes[scope] = PrivilegeNode(scope: scope, childrenAvailability: .available) + + if let parent = scope.parent, scopes.contains(parent) { + childScopes[parent, default: []].append(scope) + } else { + rootScopes.append(scope) + } + } + + for (parent, children) in childScopes { + nodes[parent]?.setChildren(children.compactMap { nodes[$0] }) + } + for scope in scopes where childScopes[scope] == nil { + nodes[scope]?.setChildren([]) + } + + return rootScopes.compactMap { nodes[$0] } + } + + private func bumpVersion() { + structureVersion &+= 1 + } +} diff --git a/TablePro/Core/UsersRoles/ScopeSummary.swift b/TablePro/Core/UsersRoles/ScopeSummary.swift new file mode 100644 index 000000000..11e9db63e --- /dev/null +++ b/TablePro/Core/UsersRoles/ScopeSummary.swift @@ -0,0 +1,42 @@ +import Foundation +import TableProPluginKit + +enum ScopeSummary: Equatable { + case notGrantable + case none + case all(count: Int) + case some(names: [String], overflow: Int, hasGrantOption: Bool) + case descendantsOnly(count: Int) + case browsingRestricted(direct: [String]) + + static let maximumNamesShown = 2 + + static func make( + granted: [String], + grantable: [PluginPrivilegeDescriptor], + descendantCount: Int, + hasGrantOption: Bool, + isBrowsingRestricted: Bool + ) -> ScopeSummary { + if isBrowsingRestricted { + return .browsingRestricted(direct: granted.sorted()) + } + guard !grantable.isEmpty else { + return descendantCount > 0 ? .descendantsOnly(count: descendantCount) : .notGrantable + } + guard !granted.isEmpty else { + return descendantCount > 0 ? .descendantsOnly(count: descendantCount) : .none + } + if granted.count == grantable.count { + return .all(count: granted.count) + } + + let sorted = granted.sorted() + let shown = Array(sorted.prefix(maximumNamesShown)) + return .some( + names: shown, + overflow: sorted.count - shown.count, + hasGrantOption: hasGrantOption + ) + } +} diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 43b91c0a1..3f75983e4 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -159,6 +159,8 @@ final class ConnectionToolbarState { /// Whether the Create Table tab has a committable definition (name + valid column) var hasCreateTablePending: Bool = false + var hasPrincipalChanges: Bool = false + /// Whether the current editor has non-empty query text var hasQueryText: Bool = false diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 492cc7b04..05e64608f 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -239,6 +239,19 @@ final class QueryTabManager { selectedTabId = newTab.id } + func addUsersRolesTab() { + if let existing = tabs.first(where: { $0.tabType == .usersRoles }) { + selectedTabId = existing.id + return + } + let tabTitle = String(localized: "Users & Roles") + var newTab = QueryTab(title: tabTitle, tabType: .usersRoles) + newTab.tableContext.isEditable = false + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + } + func addPreviewTableTab( tableName: String, databaseType: DatabaseType = .mysql, diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index b2b15b4d7..57a3e6864 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -18,6 +18,7 @@ enum TabType: Equatable, Codable, Hashable { case createTable case erDiagram case serverDashboard + case usersRoles } /// Minimal representation of a tab for persistence diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 1e6360c4b..74b6f6cc1 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -692,6 +692,11 @@ struct AppMenuCommands: Commands { } .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsServerDashboard ?? false)) + Button(String(localized: "Users & Roles")) { + actions?.showUsersAndRoles() + } + .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsUserManagement ?? false)) + Divider() Button("Increase Text Size") { diff --git a/TablePro/ViewModels/UsersRolesViewModel+Changes.swift b/TablePro/ViewModels/UsersRolesViewModel+Changes.swift new file mode 100644 index 000000000..beac466de --- /dev/null +++ b/TablePro/ViewModels/UsersRolesViewModel+Changes.swift @@ -0,0 +1,290 @@ +import Foundation +import os +import TableProPluginKit + +extension UsersRolesViewModel { + // MARK: - Principals + + func createPrincipal(_ definition: PluginPrincipalDefinition) { + changeManager.stageCreate(definition) + selection = definition.ref + selectedRefs = [definition.ref] + } + + func setPassword(_ password: String, for ref: PluginPrincipalRef) { + changeManager.stageSetPassword(password, for: ref) + } + + func stageAttributes(_ definition: PluginPrincipalDefinition, for ref: PluginPrincipalRef) { + changeManager.stageAlter(definition, for: ref) + } + + func copyPrivileges(from source: PluginPrincipalRef, to target: PluginPrincipalRef) { + changeManager.copyGrants(from: source, to: target) + } + + // MARK: - Grants + + func setGranted(_ isGranted: Bool, privilege: String) { + guard let principal = selection, !selectedScopes.isEmpty, !isMixedScopeSelection else { return } + + changeManager.setGranted( + isGranted, + privileges: [privilege], + scopes: Array(selectedScopes), + for: principal, + actionName: grantActionName(isGranted: isGranted, privileges: [privilege]) + ) + } + + func setGranted(_ isGranted: Bool, section: PrivilegeSection) { + setGranted(isGranted, privileges: section.rows.compactMap(\.descriptor).map(\.name)) + } + + /// One manager call, so a bulk action is a single undo group. + func setGranted(_ isGranted: Bool, privileges: [String]) { + guard let principal = selection, + !selectedScopes.isEmpty, + !isMixedScopeSelection, + !privileges.isEmpty else { return } + + changeManager.setGranted( + isGranted, + privileges: privileges, + scopes: Array(selectedScopes), + for: principal, + actionName: grantActionName(isGranted: isGranted, privileges: privileges) + ) + } + + func grantState(for privilege: String) -> TristateCheckbox.State { + guard let principal = selection, !selectedScopes.isEmpty else { return .unchecked } + + let granted = selectedScopes.count { + changeManager.isGranted(privilege, scope: $0, for: principal) + } + if granted == 0 { return .unchecked } + if granted == selectedScopes.count { return .checked } + return .mixed + } + + func sectionState(_ section: PrivilegeSection) -> TristateCheckbox.State { + let states = section.rows.compactMap(\.descriptor).map { grantState(for: $0.name) } + guard !states.isEmpty else { return .unchecked } + if states.allSatisfy({ $0 == .checked }) { return .checked } + if states.allSatisfy({ $0 == .unchecked }) { return .unchecked } + return .mixed + } + + func effectiveness(for privilege: String) -> PrivilegeEffectiveness { + guard let principal = selection, let scope = singleSelectedScope else { return .notEffective } + return changeManager.effectiveness(privilege: privilege, scope: scope, for: principal) + } + + func isGrantable(_ privilege: String) -> Bool { + guard let principal = selection, let scope = singleSelectedScope else { return false } + return changeManager.isGrantable(privilege, scope: scope, for: principal) + } + + private func grantActionName(isGranted: Bool, privileges: [String]) -> String { + let scopeName = singleSelectedScope?.displayPath + ?? String(format: String(localized: "%lld objects"), selectedScopes.count) + let privilegeName = privileges.count == 1 + ? privileges[0] + : String(format: String(localized: "%lld privileges"), privileges.count) + + return isGranted + ? String(format: String(localized: "Grant %1$@ on %2$@"), privilegeName, scopeName) + : String(format: String(localized: "Revoke %1$@ on %2$@"), privilegeName, scopeName) + } + + // MARK: - Scope tree + + func expand(_ node: PrivilegeNode) async { + do { + try await privilegeTree.expand(node) + } catch { + Self.logger.error("Failed to expand scope: \(error.localizedDescription)") + } + } + + func applyScopeMode() { + switch scopeMode { + case .all: + privilegeTree.rebuildHierarchy() + case .granted: + guard let principal = selection else { + privilegeTree.showGrantedOnly(scopes: []) + return + } + privilegeTree.showGrantedOnly( + scopes: changeManager.grantedScopeClosure(for: principal) + ) + } + } + + func searchScopes() { + scopeSearchTask?.cancel() + + let query = scopeFilter.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { + applyScopeMode() + return + } + guard capabilities.scopeSearch, let loader else { return } + + scopeSearchTask = Task { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + + do { + let scopes = try await loader.searchScopes( + matching: query, + limit: Self.scopeSearchLimit + ) + guard !Task.isCancelled else { return } + privilegeTree.showSearchResults(scopes) + } catch { + guard !Task.isCancelled else { return } + report(error, context: "search objects") + } + } + } + + // MARK: - Drop + + func requestDrop(_ refs: Set) async { + let targets = principalRows + .filter { refs.contains($0.ref) && $0.stage != .created } + .map(\.info) + + for row in principalRows where refs.contains(row.ref) && row.stage == .created { + changeManager.unstageCreate(row.ref) + } + guard !targets.isEmpty else { return } + + guard capabilities.ownedObjectReassignment, let loader else { + targets.forEach { changeManager.stageDrop($0.ref, options: PluginPrincipalDropOptions()) } + return + } + + isResolvingDrop = true + defer { isResolvingDrop = false } + + var owning: [PluginPrincipalInfo] = [] + for target in targets { + do { + if try await loader.ownsObjects(target.ref) { + owning.append(target) + } else { + changeManager.stageDrop(target.ref, options: PluginPrincipalDropOptions()) + } + } catch { + report(error, context: "check owned objects") + return + } + } + guard !owning.isEmpty else { return } + + let owningRefs = Set(owning.map(\.ref)) + let candidates = changeManager.principals + .map(\.ref) + .filter { !owningRefs.contains($0) } + .sorted { $0.name < $1.name } + + activeSheet = .drop( + PrincipalDropPrompt(principals: owning, reassignCandidates: candidates) + ) + } + + func confirmDrop(_ prompt: PrincipalDropPrompt, disposition: PrincipalDropPrompt.Disposition) { + let options = PrincipalDropPrompt.dropOptions(for: disposition) + prompt.principals.forEach { changeManager.stageDrop($0.ref, options: options) } + activeSheet = nil + } + + func undoStagedDrop(_ ref: PluginPrincipalRef) { + changeManager.unstageDrop(ref) + } + + // MARK: - Undo + + func undo() { + changeManager.undoManager.undo() + } + + func redo() { + changeManager.undoManager.redo() + } + + var canUndo: Bool { changeManager.undoManager.canUndo } + var canRedo: Bool { changeManager.undoManager.canRedo } + var undoMenuTitle: String { changeManager.undoManager.undoMenuItemTitle } + var redoMenuTitle: String { changeManager.undoManager.redoMenuItemTitle } + + // MARK: - Apply + + func requestApply() { + guard hasChanges else { return } + guard let driver = DatabaseManager.shared.principalDriver(for: connectionId) else { + actionError = String( + localized: "This connection does not support user and role management." + ) + return + } + + do { + previewStatements = try PrincipalStatementGenerator(driver: driver) + .generate(changes: changeManager.pendingChanges()) + } catch { + report(error, context: "generate SQL") + return + } + + applyFailure = nil + activeSheet = .review + } + + var lockoutWarning: String? { + changeManager.selfImpact(connected: connectedPrincipal) + } + + var previewSQL: [String] { + previewStatements.map(\.sql) + } + + var hasDestructiveStatements: Bool { + previewStatements.contains(where: \.isDestructive) + } + + func executePendingChanges() async { + let changes = changeManager.pendingChanges() + guard !changes.isEmpty else { return } + + applyFailure = nil + do { + try await DatabaseManager.shared.executePrincipalChanges( + changes: changes, + databaseType: databaseType, + connectionId: connectionId + ) + activeSheet = nil + changeManager.discardChanges() + // The reload is driven by AppCommands.refreshPrincipals, which executePrincipalChanges + // posts on success. Reloading here as well would run the whole fetch twice. + } catch let error as PrincipalApplyError { + applyFailure = [error.localizedDescription, error.partialApplicationMessage] + .compactMap { $0 } + .joined(separator: "\n") + await load(forceReload: true) + } catch { + applyFailure = error.localizedDescription + } + } + + func discardChanges() { + changeManager.discardChanges() + previewStatements = [] + applyFailure = nil + } +} diff --git a/TablePro/ViewModels/UsersRolesViewModel.swift b/TablePro/ViewModels/UsersRolesViewModel.swift new file mode 100644 index 000000000..5b0248ef6 --- /dev/null +++ b/TablePro/ViewModels/UsersRolesViewModel.swift @@ -0,0 +1,305 @@ +import Foundation +import Observation +import os +import TableProPluginKit + +@MainActor +@Observable +final class UsersRolesViewModel { + enum DetailSegment: String, CaseIterable, Identifiable { + case privileges + case attributes + + var id: String { rawValue } + + var title: String { + switch self { + case .privileges: String(localized: "Privileges") + case .attributes: String(localized: "Attributes") + } + } + } + + enum ScopeMode: String, CaseIterable, Identifiable { + case all + case granted + + var id: String { rawValue } + + var title: String { + switch self { + case .all: String(localized: "All Objects") + case .granted: String(localized: "Granted") + } + } + } + + enum ActiveSheet: Identifiable { + case create + case changePassword(PluginPrincipalRef) + case drop(PrincipalDropPrompt) + case roleMembership(PluginPrincipalRef) + case copyPrivileges(PluginPrincipalRef) + case review + + var id: String { + switch self { + case .create: "create" + case let .changePassword(ref): "password:\(ref.displayName)" + case let .drop(prompt): "drop:\(prompt.id)" + case let .roleMembership(ref): "membership:\(ref.displayName)" + case let .copyPrivileges(ref): "copy:\(ref.displayName)" + case .review: "review" + } + } + } + + struct Capabilities { + var hostScoping = false + var roleMembership = false + var ownedObjectReassignment = false + var scopeSearch = false + var restrictsBrowsing = false + } + + static let logger = Logger(subsystem: "com.TablePro", category: "UsersRolesViewModel") + static let scopeSearchLimit = 200 + + let connectionId: UUID + let databaseType: DatabaseType + + let changeManager = PrincipalChangeManager() + let privilegeTree = PrivilegeTreeModel() + + private(set) var capabilities = Capabilities() + private(set) var databases: [String] = [] + private(set) var connectedPrincipal: PluginPrincipalRef? + private(set) var loadError: String? + private(set) var grantsError: String? + + var isLoading = false + var isResolvingDrop = false + var previewStatements: [SchemaStatement] = [] + var applyFailure: String? + + var selection: PluginPrincipalRef? + var selectedRefs: Set = [] + var selectedScopes: Set = [] + var detailSegment: DetailSegment = .privileges + var scopeMode: ScopeMode = .all + var principalFilter = "" + var scopeFilter = "" + var privilegeFilter = "" + var activeSheet: ActiveSheet? + var actionError: String? + + @ObservationIgnored + private(set) var loader: PrincipalListLoader? + + @ObservationIgnored + let expansionStore: PrivilegeExpansionStore + + @ObservationIgnored + var scopeSearchTask: Task? + + init(connectionId: UUID, databaseType: DatabaseType) { + self.connectionId = connectionId + self.databaseType = databaseType + expansionStore = PrivilegeExpansionStore(connectionId: connectionId) + } + + // MARK: - Derived + + var principalRows: [PrincipalRow] { + let existing = changeManager.principals.map { + PrincipalRow(info: $0, stage: changeManager.stage(of: $0.ref)) + } + let created = changeManager.pendingCreates.map { + PrincipalRow( + info: PluginPrincipalInfo( + ref: $0.ref, + isRole: !$0.canLogin, + canLogin: $0.canLogin, + attributes: $0.attributes, + memberOf: $0.memberOf, + connectionLimit: $0.connectionLimit + ), + stage: .created + ) + } + let rows = existing + created + guard !principalFilter.isEmpty else { return rows } + + return SidebarNameFilter.ranked(rows, query: principalFilter, name: \.displayName) + } + + var selectedPrincipal: PluginPrincipalInfo? { + guard let selection else { return nil } + return principalRows.first { $0.ref == selection }?.info + } + + var hasChanges: Bool { changeManager.hasChanges } + var changeCount: Int { changeManager.changeCount } + + var pendingChangesTitle: String { + guard changeCount > 0 else { + let users = changeManager.principals.count { !$0.isRole } + let roles = changeManager.principals.count { $0.isRole } + return String( + format: String(localized: "%1$lld users, %2$lld roles"), + users, + roles + ) + } + return String(format: String(localized: "%lld pending changes"), changeCount) + } + + var singleSelectedScope: PluginPrivilegeScope? { + selectedScopes.count == 1 ? selectedScopes.first : nil + } + + var isMixedScopeSelection: Bool { + guard selectedScopes.count > 1 else { return false } + return Set(selectedScopes.map(\.level)).count > 1 + } + + var privilegeSections: [PrivilegeSection] { + guard let scope = selectedScopes.first, !isMixedScopeSelection else { return [] } + + return changeManager.sections(for: scope).compactMap { section in + guard !privilegeFilter.isEmpty else { return section } + let matches = section.rows.filter { + $0.title.localizedCaseInsensitiveContains(privilegeFilter) + } + guard !matches.isEmpty else { return nil } + return PrivilegeSection( + category: section.category, + descriptors: matches.compactMap(\.descriptor) + ) + } + } + + // MARK: - Loading + + func load(forceReload: Bool = false) async { + guard let driver = DatabaseManager.shared.principalDriver(for: connectionId) else { + loadError = String( + localized: "This connection does not support user and role management." + ) + return + } + if loader == nil || forceReload { + loader = PrincipalListLoader(driver: driver) + } + guard let loader else { return } + + capabilities = Capabilities( + hostScoping: driver.supportsPrincipalHostScoping, + roleMembership: driver.supportsRoleMembership, + ownedObjectReassignment: driver.supportsOwnedObjectReassignment, + scopeSearch: driver.supportsGrantableScopeSearch, + restrictsBrowsing: driver.restrictsGrantBrowsingToCurrentDatabase + ) + changeManager.cascades = { driver.privilegeCascades(from: $0, to: $1) } + + isLoading = true + loadError = nil + defer { isLoading = false } + + do { + let snapshot = try await loader.load(forceReload: forceReload) + databases = try await loader.databases() + connectedPrincipal = try await loader.currentPrincipal() + + if forceReload { + changeManager.reload(principals: snapshot.principals, catalog: snapshot.catalog) + } else { + changeManager.load(principals: snapshot.principals, catalog: snapshot.catalog) + } + + privilegeTree.configure( + databases: databases, + catalog: snapshot.catalog, + restrictsBrowsing: capabilities.restrictsBrowsing, + currentDatabase: DatabaseManager.shared.activeSessions[connectionId]?.activeDatabase, + loader: loader + ) + + if let selection, !principalRows.contains(where: { $0.ref == selection }) { + self.selection = nil + selectedRefs = [] + } + if let selection { + await loadGrants(for: selection) + } + restoreScopePresentation() + } catch { + loadError = error.localizedDescription + Self.logger.error("Failed to load principals: \(error.localizedDescription)") + } + } + + func loadGrants(for ref: PluginPrincipalRef) async { + guard let loader else { return } + grantsError = nil + + do { + if !changeManager.hasLoadedGrants(for: ref) { + changeManager.loadGrants(try await loader.grants(for: ref), for: ref) + } + await loadRoleGrants(for: ref) + + if ref == selection, scopeMode == .granted, scopeFilter.isEmpty { + applyScopeMode() + } + } catch { + grantsError = error.localizedDescription + Self.logger.error("Failed to load grants: \(error.localizedDescription)") + } + } + + /// A reload rebuilds the tree in hierarchy mode. Put back whatever the user was actually + /// looking at, so the mode picker and the search field do not lie about what is on screen. + private func restoreScopePresentation() { + guard scopeFilter.isEmpty else { + searchScopes() + return + } + guard scopeMode != .all else { return } + applyScopeMode() + } + + private func loadRoleGrants(for ref: PluginPrincipalRef) async { + guard capabilities.roleMembership, let loader else { return } + + let missing = changeManager.roleClosure(for: ref) + .map { PluginPrincipalRef(name: $0) } + .filter { !changeManager.hasLoadedGrants(for: $0) } + guard !missing.isEmpty else { return } + + let loaded = await withTaskGroup( + of: (PluginPrincipalRef, [PluginGrantInfo])?.self + ) { group in + for role in missing { + group.addTask { + guard let grants = try? await loader.grants(for: role) else { return nil } + return (role, grants) + } + } + var results: [(PluginPrincipalRef, [PluginGrantInfo])] = [] + for await result in group { + if let result { results.append(result) } + } + return results + } + + for (role, grants) in loaded { + changeManager.loadGrants(grants, for: role) + } + } + + func report(_ error: Error, context: String) { + actionError = error.localizedDescription + Self.logger.error("Failed to \(context, privacy: .public): \(error.localizedDescription)") + } +} diff --git a/TablePro/Views/Components/AddRemoveControlGroup.swift b/TablePro/Views/Components/AddRemoveControlGroup.swift new file mode 100644 index 000000000..5e2aaae2b --- /dev/null +++ b/TablePro/Views/Components/AddRemoveControlGroup.swift @@ -0,0 +1,45 @@ +// +// AddRemoveControlGroup.swift +// TablePro +// +// The add/remove pair that sits under a list. Extracted from MainStatusBarView so the +// structure footer and the Users & Roles list share one implementation. +// + +import SwiftUI + +struct AddRemoveControlGroup: View { + let addLabel: String + let removeLabel: String + var canAdd = true + var canRemove = true + var addIdentifier: String? + var removeIdentifier: String? + let onAdd: () -> Void + let onRemove: () -> Void + + var body: some View { + ControlGroup { + Button(action: onAdd) { + Label(addLabel, systemImage: "plus") + .labelStyle(.iconOnly) + } + .help(addLabel) + .accessibilityLabel(addLabel) + .accessibilityIdentifier(addIdentifier ?? "") + .disabled(!canAdd) + + Button(action: onRemove) { + Label(removeLabel, systemImage: "minus") + .labelStyle(.iconOnly) + } + .help(removeLabel) + .accessibilityLabel(removeLabel) + .accessibilityIdentifier(removeIdentifier ?? "") + .disabled(!canRemove) + } + .controlGroupStyle(.navigation) + .controlSize(.small) + .fixedSize() + } +} diff --git a/TablePro/Views/Components/AutosavingSplitView.swift b/TablePro/Views/Components/AutosavingSplitView.swift new file mode 100644 index 000000000..576a1482f --- /dev/null +++ b/TablePro/Views/Components/AutosavingSplitView.swift @@ -0,0 +1,70 @@ +// +// AutosavingSplitView.swift +// TablePro +// +// A split view whose divider position persists via NSSplitView.autosaveName. +// autosaveName is assigned after the items are added; setting it earlier does not record +// the divider, and adjustSubviews then resets it. +// + +import AppKit +import SwiftUI + +struct AutosavingSplitView: NSViewControllerRepresentable { + let autosaveName: String + var isVertical = true + var primaryMinimum: CGFloat + var primaryMaximum: CGFloat? + var secondaryMinimum: CGFloat + var primaryHoldingPriority: NSLayoutConstraint.Priority = .defaultHigh + @ViewBuilder let primary: () -> Primary + @ViewBuilder let secondary: () -> Secondary + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSViewController(context: Context) -> NSSplitViewController { + let controller = NSSplitViewController() + controller.splitView.isVertical = isVertical + controller.splitView.dividerStyle = .thin + + let primaryController = NSHostingController(rootView: primary()) + let secondaryController = NSHostingController(rootView: secondary()) + + // Without this the hosting controllers report their SwiftUI content's ideal size as a + // preferredContentSize, which an enclosing NSSplitViewController forwards to the window: + // the window shrinks to fit the content and stops being resizable. + primaryController.sizingOptions = [] + secondaryController.sizingOptions = [] + + context.coordinator.primaryController = primaryController + context.coordinator.secondaryController = secondaryController + + let primaryItem = NSSplitViewItem(viewController: primaryController) + primaryItem.minimumThickness = primaryMinimum + primaryItem.canCollapse = false + primaryItem.holdingPriority = primaryHoldingPriority + if let primaryMaximum { + primaryItem.maximumThickness = primaryMaximum + } + + let secondaryItem = NSSplitViewItem(viewController: secondaryController) + secondaryItem.minimumThickness = secondaryMinimum + secondaryItem.canCollapse = false + secondaryItem.holdingPriority = .defaultLow + + controller.addSplitViewItem(primaryItem) + controller.addSplitViewItem(secondaryItem) + controller.splitView.autosaveName = NSSplitView.AutosaveName(autosaveName) + return controller + } + + func updateNSViewController(_ controller: NSSplitViewController, context: Context) { + context.coordinator.primaryController?.rootView = primary() + context.coordinator.secondaryController?.rootView = secondary() + } + + final class Coordinator { + var primaryController: NSHostingController? + var secondaryController: NSHostingController? + } +} diff --git a/TablePro/Views/Components/AutosavingVSplitView.swift b/TablePro/Views/Components/AutosavingVSplitView.swift deleted file mode 100644 index b80380587..000000000 --- a/TablePro/Views/Components/AutosavingVSplitView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// AutosavingVSplitView.swift -// TablePro -// -// A vertically stacked split view whose divider position persists via NSSplitView.autosaveName. -// - -import AppKit -import SwiftUI - -struct AutosavingVSplitView: NSViewControllerRepresentable { - let autosaveName: String - let topMinimumHeight: CGFloat - let bottomMinimumHeight: CGFloat - @ViewBuilder let top: () -> Top - @ViewBuilder let bottom: () -> Bottom - - func makeCoordinator() -> Coordinator { Coordinator() } - - func makeNSViewController(context: Context) -> NSSplitViewController { - let controller = NSSplitViewController() - controller.splitView.isVertical = false - controller.splitView.dividerStyle = .thin - - let topController = NSHostingController(rootView: top()) - let bottomController = NSHostingController(rootView: bottom()) - context.coordinator.topController = topController - context.coordinator.bottomController = bottomController - - let topItem = NSSplitViewItem(viewController: topController) - topItem.minimumThickness = topMinimumHeight - topItem.canCollapse = false - let bottomItem = NSSplitViewItem(viewController: bottomController) - bottomItem.minimumThickness = bottomMinimumHeight - bottomItem.canCollapse = false - - controller.addSplitViewItem(topItem) - controller.addSplitViewItem(bottomItem) - controller.splitView.autosaveName = NSSplitView.AutosaveName(autosaveName) - return controller - } - - func updateNSViewController(_ controller: NSSplitViewController, context: Context) { - context.coordinator.topController?.rootView = top() - context.coordinator.bottomController?.rootView = bottom() - } - - final class Coordinator { - var topController: NSHostingController? - var bottomController: NSHostingController? - } -} diff --git a/TablePro/Views/Components/NewPasswordField.swift b/TablePro/Views/Components/NewPasswordField.swift new file mode 100644 index 000000000..0065420dd --- /dev/null +++ b/TablePro/Views/Components/NewPasswordField.swift @@ -0,0 +1,42 @@ +// +// NewPasswordField.swift +// TablePro +// +// A password entry field with a reveal toggle and a generator. The value is a database +// credential, not the user's own, so it is never tagged for AutoFill saving. +// + +import SwiftUI + +struct NewPasswordField: View { + @Binding var password: String + var prompt: String? + + @State private var isRevealed = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Group { + if isRevealed { + TextField("", text: $password, prompt: prompt.map { Text($0) }) + } else { + SecureField("", text: $password, prompt: prompt.map { Text($0) }) + } + } + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "Password")) + + Button(String(localized: "Generate")) { + password = PasswordGenerator.generate() + isRevealed = true + } + .controlSize(.small) + } + + Toggle(String(localized: "Show password"), isOn: $isRevealed) + .toggleStyle(.checkbox) + .controlSize(.small) + } + } +} diff --git a/TablePro/Views/Components/SQLReviewSheet.swift b/TablePro/Views/Components/SQLReviewSheet.swift index c715923dc..a16866dc9 100644 --- a/TablePro/Views/Components/SQLReviewSheet.swift +++ b/TablePro/Views/Components/SQLReviewSheet.swift @@ -10,15 +10,33 @@ import SwiftUI import TableProPluginKit struct SQLReviewSheet: View { + struct PrimaryAction { + let title: String + let isDestructive: Bool + let perform: () async -> Void + + init(title: String, isDestructive: Bool, perform: @escaping () async -> Void) { + self.title = title + self.isDestructive = isDestructive + self.perform = perform + } + } + @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss let statements: [String] let databaseType: DatabaseType + var warning: String? + var failure: String? + var primaryAction: PrimaryAction? + var onOpenInEditor: (() -> Void)? + @State private var prepared: Prepared? @State private var copied = false @State private var editorState: SourceEditorState? + @State private var isExecuting = false enum DisplayMode { case rich @@ -213,19 +231,67 @@ struct SQLReviewSheet: View { ) } + @ViewBuilder private var footer: some View { - HStack(spacing: 12) { - if prepared?.mode == .truncated { - Label( - String(localized: "Output truncated for display"), - systemImage: "info.circle" - ) - .font(.caption) - .foregroundStyle(.secondary) + VStack(spacing: 8) { + if let failure { + InlineErrorBanner(message: failure) } - Spacer() - Button(String(localized: "Done")) { dismiss() } - .keyboardShortcut(.cancelAction) + HStack(spacing: 12) { + if let onOpenInEditor { + Button(String(localized: "Open in Query Editor"), action: onOpenInEditor) + .controlSize(.small) + .disabled(statements.isEmpty || isExecuting) + } + if prepared?.mode == .truncated { + Label( + String(localized: "Output truncated for display"), + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + if let warning { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + if isExecuting { + ProgressView().controlSize(.small) + } + if let primaryAction { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + .disabled(isExecuting) + executeButton(primaryAction) + } else { + Button(String(localized: "Done")) { dismiss() } + .keyboardShortcut(.cancelAction) + } + } + } + } + + @ViewBuilder + private func executeButton(_ action: PrimaryAction) -> some View { + let button = Button(action.title, role: action.isDestructive ? .destructive : nil) { + isExecuting = true + Task { + await action.perform() + isExecuting = false + } + } + .disabled(statements.isEmpty || isExecuting) + .accessibilityIdentifier("sql-review-execute") + + if action.isDestructive { + button + } else { + button.keyboardShortcut(.defaultAction) } } diff --git a/TablePro/Views/Components/TristateCheckbox.swift b/TablePro/Views/Components/TristateCheckbox.swift index 6b9073026..56d03303f 100644 --- a/TablePro/Views/Components/TristateCheckbox.swift +++ b/TablePro/Views/Components/TristateCheckbox.swift @@ -20,6 +20,8 @@ struct TristateCheckbox: NSViewRepresentable { } let state: State + var accessibilityLabel: String? + var accessibilityValue: String? let action: () -> Void func makeNSView(context: Context) -> NSButton { @@ -36,6 +38,12 @@ struct TristateCheckbox: NSViewRepresentable { case .checked: button.state = .on case .mixed: button.state = .mixed } + if let accessibilityLabel { + button.setAccessibilityLabel(accessibilityLabel) + } + if let accessibilityValue { + button.setAccessibilityValue(accessibilityValue) + } context.coordinator.action = action } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index f1363c235..d44724420 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -56,6 +56,7 @@ struct MainEditorContentView: View { @State private var cachedChangeManager: AnyChangeManager? @State private var erDiagramViewModels: [UUID: ERDiagramViewModel] = [:] @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] + @State private var usersRolesViewModels: [UUID: UsersRolesViewModel] = [:] @State private var dataTabDelegate = DataTabGridDelegate() @Bindable private var treeService = DatabaseTreeMetadataService.shared @@ -137,6 +138,7 @@ struct MainEditorContentView: View { coordinator.cleanupTabCaches(openTabIds: openTabIds) erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) } serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } + usersRolesViewModels = usersRolesViewModels.filter { openTabIds.contains($0.key) } } .onChange(of: tabManager.selectedTabId) { _, _ in updateHasQueryText() @@ -214,9 +216,34 @@ struct MainEditorContentView: View { erDiagramContent(tab: tab) case .serverDashboard: serverDashboardContent(tab: tab) + case .usersRoles: + usersRolesContent(tab: tab) } } + // MARK: - Users & Roles Tab Content + + @ViewBuilder + private func usersRolesContent(tab: QueryTab) -> some View { + Group { + if let vm = usersRolesViewModels[tab.id] { + UsersRolesTabView(viewModel: vm, coordinator: coordinator) + } else { + ProgressView(String(localized: "Loading users and roles...")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + guard usersRolesViewModels[tab.id] == nil else { return } + let vm = UsersRolesViewModel( + connectionId: connection.id, + databaseType: connection.type + ) + usersRolesViewModels[tab.id] = vm + } + } + } + .id(tab.id) + } + // MARK: - Server Dashboard Tab Content @ViewBuilder diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 678e9cbf2..700acb621 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -244,29 +244,13 @@ struct MainStatusBarView: View { @ViewBuilder private func structureFooterControls(state: StructureFooterState) -> some View { - ControlGroup { - Button { - structureState.onAdd() - } label: { - Label(state.addLabel, systemImage: "plus") - .labelStyle(.iconOnly) - } - .help(state.addLabel) - .accessibilityLabel(state.addLabel) - .disabled(!state.canAdd) - - Button { - structureState.onRemove() - } label: { - Label(state.removeLabel, systemImage: "minus") - .labelStyle(.iconOnly) - } - .help(state.removeLabel) - .accessibilityLabel(state.removeLabel) - .disabled(!state.canRemove) - } - .controlGroupStyle(.navigation) - .controlSize(.small) - .fixedSize() + AddRemoveControlGroup( + addLabel: state.addLabel, + removeLabel: state.removeLabel, + canAdd: state.canAdd, + canRemove: state.canRemove, + onAdd: { structureState.onAdd() }, + onRemove: { structureState.onRemove() } + ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift new file mode 100644 index 000000000..f04a3c4be --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift @@ -0,0 +1,25 @@ +import AppKit +import Foundation + +extension MainContentCoordinator { + func showUsersAndRoles() { + if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { + $0.tabType == .usersRoles + }) { + existing.contentWindow?.makeKeyAndOrderFront(nil) + return + } + + if tabManager.tabs.isEmpty { + tabManager.addUsersRolesTab() + return + } + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .usersRoles, + databaseName: activeDatabaseName + ) + WindowManager.shared.openTab(payload: payload) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index cc7db39c7..810ead6b1 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -288,6 +288,8 @@ extension MainContentView { let selectedTab = tabManager.selectedTab if selectedTab?.tabType == .serverDashboard { windowTitle = String(localized: "Server Dashboard") + } else if selectedTab?.tabType == .usersRoles { + windowTitle = String(localized: "Users & Roles") } else if selectedTab?.tabType == .createTable { windowTitle = String(localized: "Create Table") } else if selectedTab?.tabType == .erDiagram { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 7384fac21..da2a47653 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -311,6 +311,9 @@ final class MainContentCommandActions { // MARK: - Unsaved Changes Check private var hasUnsavedChanges: Bool { + if isUsersRolesTab { + return coordinator?.usersRolesActions?.hasChanges() ?? false + } let hasEditedCells = coordinator?.changeManager.hasChanges ?? false let hasPendingTableOps = !pendingTruncates.wrappedValue.isEmpty || !pendingDeletes.wrappedValue.isEmpty @@ -319,6 +322,24 @@ final class MainContentCommandActions { return hasEditedCells || hasPendingTableOps || hasSidebarEdits || hasFileDirty } + private var isUsersRolesTab: Bool { + coordinator?.tabManager.selectedTab?.tabType == .usersRoles + } + + var undoMenuTitle: String { + guard isUsersRolesTab, let actions = coordinator?.usersRolesActions, actions.canUndo() else { + return String(localized: "Undo") + } + return actions.undoMenuTitle() + } + + var redoMenuTitle: String { + guard isUsersRolesTab, let actions = coordinator?.usersRolesActions, actions.canRedo() else { + return String(localized: "Redo") + } + return actions.redoMenuTitle() + } + // MARK: - Editor Query Loading (Group A — Called Directly) func loadQueryIntoEditor(_ query: String) { @@ -413,6 +434,14 @@ final class MainContentCommandActions { return } + // User and role changes can only be applied after the SQL is reviewed, so Save opens the + // review sheet and cancels the close. Falling through here would close the window and + // destroy every staged change. + if isUsersRolesTab, coordinator.usersRolesActions?.hasChanges() == true { + coordinator.usersRolesActions?.reviewAndApply() + return + } + // Structure view saves via direct coordinator call if coordinator.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator.structureActions?.saveChanges?() @@ -559,6 +588,17 @@ final class MainContentCommandActions { return ServerDashboardQueryProviderFactory.provider(for: type) != nil } + func showUsersAndRoles() { + coordinator?.showUsersAndRoles() + } + + var supportsUserManagement: Bool { + guard let connectionId = coordinator?.connectionId, + let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter + else { return false } + return adapter.schemaPluginDriver.capabilities.contains(.userManagement) + } + // MARK: - Tab Navigation (Group A — Called Directly) /// Selects the Nth native window tab. Wrapping the `selectedWindow` @@ -593,6 +633,10 @@ final class MainContentCommandActions { // MARK: - Data Operations (Group A — Called Directly) func saveChanges() { + if isUsersRolesTab { + coordinator?.usersRolesActions?.reviewAndApply() + return + } if coordinator?.tabManager.selectedTab?.tabType == .createTable { coordinator?.createTableActions?.createTable?() return @@ -868,6 +912,10 @@ final class MainContentCommandActions { // MARK: - Undo/Redo (Group A — Called Directly) func undoChange() { + if isUsersRolesTab { + coordinator?.usersRolesActions?.undo() + return + } if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.undo?() return @@ -879,6 +927,10 @@ final class MainContentCommandActions { } func redoChange() { + if isUsersRolesTab { + coordinator?.usersRolesActions?.redo() + return + } if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.redo?() return diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 22ae74677..2c9689969 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -121,6 +121,8 @@ final class MainContentCoordinator { /// (Cmd+S) routes to table creation. Set by `CreateTableView` on appear. weak var createTableActions: CreateTableActionHandler? + weak var usersRolesActions: UsersRolesActionHandler? + /// Published capability/labels for the structure-mode footer in the bottom status bar. /// `TableStructureView` writes to this; `MainStatusBarView` reads from it. let structureFooterState = StructureFooterState() diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index aeaf5dff8..0e5bc43ec 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -84,13 +84,14 @@ struct TriggerDetailView: View { TriggerActionBar(triggers: triggers, state: state, canEdit: canEdit, onNew: newTrigger, onEdit: editTrigger, onDelete: { pendingDelete = $0 }) Divider() } - AutosavingVSplitView( + AutosavingSplitView( autosaveName: "com.TablePro.triggerSplit", - topMinimumHeight: 120, - bottomMinimumHeight: 180 + isVertical: false, + primaryMinimum: 120, + secondaryMinimum: 180 ) { TriggerListPane(triggers: triggers, state: state) - } bottom: { + } secondary: { TriggerDetailPane(triggers: triggers, state: state, databaseType: connection.type, onOpenInEditor: onOpenInEditor) } } diff --git a/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift b/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift new file mode 100644 index 000000000..6b0ed2541 --- /dev/null +++ b/TablePro/Views/UsersRoles/PrincipalAttributesForm.swift @@ -0,0 +1,145 @@ +import SwiftUI +import TableProPluginKit + +struct PrincipalAttributesForm: View { + @Bindable var viewModel: UsersRolesViewModel + let principal: PluginPrincipalInfo + + private var draft: PluginPrincipalDefinition { + viewModel.changeManager.pendingAlters[principal.ref] + ?? PrincipalChangeManager.definition(from: principal) + } + + var body: some View { + Form { + Section(String(localized: "Identity")) { + LabeledContent(String(localized: "Name"), value: principal.ref.name) + if let host = principal.ref.host { + LabeledContent(String(localized: "Host"), value: host) + } + } + + Section { + Toggle(String(localized: "Can log in"), isOn: canLoginBinding) + .disabled(!viewModel.capabilities.roleMembership) + + Button(String(localized: "Change Password…")) { + viewModel.activeSheet = .changePassword(principal.ref) + } + } header: { + Text("Authentication") + } footer: { + if viewModel.changeManager.pendingPasswords[principal.ref] != nil { + Text("A new password will be set when you apply changes.") + } + } + + if !principal.attributes.isEmpty { + Section { + ForEach(principal.attributes, id: \.key) { attribute in + Toggle(attribute.label, isOn: attributeBinding(attribute)) + } + } header: { + Text("Role Attributes") + } footer: { + if isSuperuser { + Text("A superuser bypasses all permission checks.") + } + } + } + + if viewModel.capabilities.roleMembership { + Section(String(localized: "Membership")) { + LabeledContent(String(localized: "Member of")) { + HStack { + Text(membershipSummary) + .foregroundStyle(draft.memberOf.isEmpty ? .secondary : .primary) + Spacer() + Button(String(localized: "Edit…")) { + viewModel.activeSheet = .roleMembership(principal.ref) + } + } + } + } + } + + Section(String(localized: "Limits")) { + LabeledContent(String(localized: "Connection limit")) { + TextField( + "", + value: connectionLimitBinding, + format: .number, + prompt: Text("Unlimited") + ) + .frame(width: 90) + .labelsHidden() + } + } + } + .formStyle(.grouped) + } + + private var isSuperuser: Bool { + draft.attributes.contains { $0.key == "SUPERUSER" && $0.isEnabled } + } + + private var membershipSummary: String { + draft.memberOf.isEmpty + ? String(localized: "None") + : draft.memberOf.formatted(.list(type: .and)) + } + + private var canLoginBinding: Binding { + Binding( + get: { draft.canLogin }, + set: { stage(canLogin: $0) } + ) + } + + private var connectionLimitBinding: Binding { + Binding( + get: { draft.connectionLimit }, + set: { stage(connectionLimit: $0) } + ) + } + + private func attributeBinding(_ attribute: PluginPrincipalAttribute) -> Binding { + Binding( + get: { + draft.attributes.first { $0.key == attribute.key }?.isEnabled ?? attribute.isEnabled + }, + set: { isEnabled in + let attributes = draft.attributes.map { + $0.key == attribute.key + ? PluginPrincipalAttribute( + key: $0.key, + label: $0.label, + isEnabled: isEnabled + ) + : $0 + } + stage(attributes: attributes) + } + ) + } + + private func stage( + canLogin: Bool? = nil, + attributes: [PluginPrincipalAttribute]? = nil, + connectionLimit: Int?? = nil + ) { + let current = draft + viewModel.stageAttributes( + PluginPrincipalDefinition( + ref: current.ref, + password: nil, + canLogin: canLogin ?? current.canLogin, + attributes: attributes ?? current.attributes, + memberOf: current.memberOf, + connectionLimit: connectionLimit ?? current.connectionLimit, + comment: current.comment + ), + for: principal.ref + ) + } +} diff --git a/TablePro/Views/UsersRoles/PrincipalDetailPane.swift b/TablePro/Views/UsersRoles/PrincipalDetailPane.swift new file mode 100644 index 000000000..a348e8053 --- /dev/null +++ b/TablePro/Views/UsersRoles/PrincipalDetailPane.swift @@ -0,0 +1,102 @@ +import SwiftUI +import TableProPluginKit + +struct PrincipalDetailPane: View { + @Bindable var viewModel: UsersRolesViewModel + + var body: some View { + VStack(spacing: 0) { + if let principal = viewModel.selectedPrincipal { + header(principal) + Divider() + content(principal) + } else { + ContentUnavailableView( + String(localized: "No Selection"), + systemImage: "person.2", + description: Text("Select a user or role to view its privileges.") + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func header(_ principal: PluginPrincipalInfo) -> some View { + HStack(spacing: 8) { + Label( + principal.ref.displayName, + systemImage: principal.isRole ? "person.2" : "person" + ) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + if viewModel.changeManager.stage(of: principal.ref) != .unchanged { + Text(String(localized: "Modified")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Picker("", selection: $viewModel.detailSegment) { + ForEach(UsersRolesViewModel.DetailSegment.allCases) { segment in + Text(segment.title).tag(segment) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .accessibilityIdentifier("usersroles-detail-segment") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + } + + @ViewBuilder + private func content(_ principal: PluginPrincipalInfo) -> some View { + switch viewModel.detailSegment { + case .privileges: + PrivilegeEditorPane(viewModel: viewModel) + case .attributes: + PrincipalAttributesForm(viewModel: viewModel, principal: principal) + } + } +} + +struct PendingChangesBar: View { + @Bindable var viewModel: UsersRolesViewModel + + var body: some View { + HStack(spacing: 12) { + if viewModel.isResolvingDrop { + ProgressView() + .controlSize(.small) + Text("Checking owned objects…") + .foregroundStyle(.secondary) + } else { + Label(viewModel.pendingChangesTitle, systemImage: "square.and.pencil") + .foregroundStyle(.secondary) + } + + Spacer() + + Button(String(localized: "Discard")) { + viewModel.discardChanges() + } + .disabled(!viewModel.hasChanges) + .accessibilityIdentifier("usersroles-discard") + + Button(String(localized: "Review & Apply…")) { + viewModel.requestApply() + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.hasChanges) + .accessibilityIdentifier("usersroles-review") + } + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } +} diff --git a/TablePro/Views/UsersRoles/PrincipalListPane.swift b/TablePro/Views/UsersRoles/PrincipalListPane.swift new file mode 100644 index 000000000..f00a24856 --- /dev/null +++ b/TablePro/Views/UsersRoles/PrincipalListPane.swift @@ -0,0 +1,178 @@ +import SwiftUI +import TableProPluginKit + +struct PrincipalListPane: View { + @Bindable var viewModel: UsersRolesViewModel + + @State private var sortOrder = [KeyPathComparator(\PrincipalRow.sortName)] + + private var rows: [PrincipalRow] { + viewModel.principalRows.sorted(using: sortOrder) + } + + var body: some View { + VStack(spacing: 0) { + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + VStack(spacing: 0) { + NativeSearchField( + text: $viewModel.principalFilter, + placeholder: String(localized: "Filter") + ) + .accessibilityIdentifier("usersroles-principal-filter") + .padding(.horizontal, 10) + .padding(.vertical, 8) + Divider() + } + .background(Color(nsColor: .controlBackgroundColor)) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + Divider() + HStack { + AddRemoveControlGroup( + addLabel: String(localized: "New User or Role"), + removeLabel: String(localized: "Drop"), + canRemove: !viewModel.selectedRefs.isEmpty && !viewModel.isResolvingDrop, + addIdentifier: "usersroles-add", + removeIdentifier: "usersroles-remove", + onAdd: { viewModel.activeSheet = .create }, + onRemove: { Task { await viewModel.requestDrop(viewModel.selectedRefs) } } + ) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .background(Color(nsColor: .controlBackgroundColor)) + } + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading, viewModel.principalRows.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.loadError { + EmptyStateView( + icon: "exclamationmark.triangle", + title: String(localized: "Unable to Load Users and Roles"), + description: error, + actionTitle: String(localized: "Retry"), + action: { Task { await viewModel.load(forceReload: true) } } + ) + } else if rows.isEmpty, !viewModel.principalFilter.isEmpty { + ContentUnavailableView.search(text: viewModel.principalFilter) + } else if rows.isEmpty { + EmptyStateView( + icon: "person.2", + title: String(localized: "No Users or Roles"), + description: String(localized: "This server has no users or roles you can manage."), + actionTitle: String(localized: "New User or Role"), + action: { viewModel.activeSheet = .create } + ) + } else { + table + } + } + + private var table: some View { + principalTable + .tableStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .accessibilityIdentifier("usersroles-principal-list") + .contextMenu(forSelectionType: PluginPrincipalRef.self) { refs in + rowMenu(refs) + } + .onDeleteCommand { + Task { await viewModel.requestDrop(viewModel.selectedRefs) } + } + .onChange(of: viewModel.selectedRefs) { _, refs in + viewModel.selection = refs.count == 1 ? refs.first : nil + } + .task(id: viewModel.selection) { + guard let selection = viewModel.selection else { return } + await viewModel.loadGrants(for: selection) + } + } + + private var principalTable: some View { + Table(rows, selection: $viewModel.selectedRefs, sortOrder: $sortOrder) { + TableColumn(String(localized: "Name"), value: \PrincipalRow.sortName) { row in + nameCell(row) + } + + TableColumn(String(localized: "Kind")) { (row: PrincipalRow) in + kindCell(row) + } + .width(min: 44, ideal: 52, max: 64) + } + } + + @ViewBuilder + private func kindCell(_ row: PrincipalRow) -> some View { + if viewModel.capabilities.roleMembership { + Text(row.kindTitle) + .foregroundStyle(.secondary) + } + } + + private func nameCell(_ row: PrincipalRow) -> some View { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 1) { + Label(row.displayName, systemImage: row.symbolName) + .strikethrough(row.stage == .dropped) + .foregroundStyle(row.stage == .dropped ? .secondary : .primary) + .lineLimit(1) + .truncationMode(.middle) + + if !row.attributeSummary.isEmpty { + Text(row.attributeSummary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + + Spacer(minLength: 0) + statusGlyph(row) + } + } + + @ViewBuilder + private func statusGlyph(_ row: PrincipalRow) -> some View { + if let symbol = row.statusSymbol, let description = row.statusDescription { + Image(systemName: symbol) + .foregroundStyle(.secondary) + .help(description) + .accessibilityLabel(description) + } + } + + @ViewBuilder + private func rowMenu(_ refs: Set) -> some View { + if let ref = refs.first { + Button(String(localized: "Change Password…")) { + viewModel.activeSheet = .changePassword(ref) + } + Button(String(localized: "Copy Privileges From…")) { + viewModel.activeSheet = .copyPrivileges(ref) + } + Button(String(localized: "Copy Name")) { + ClipboardService.shared.writeText(ref.displayName) + } + Divider() + if viewModel.changeManager.stage(of: ref) == .dropped { + Button(String(localized: "Undo Staged Drop")) { + viewModel.undoStagedDrop(ref) + } + } + Button(String(localized: "Drop…"), role: .destructive) { + Task { await viewModel.requestDrop(refs) } + } + } + } +} diff --git a/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift b/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift new file mode 100644 index 000000000..b54ee10ba --- /dev/null +++ b/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift @@ -0,0 +1,287 @@ +import SwiftUI +import TableProPluginKit + +struct PrivilegeChecklistView: View { + @Bindable var viewModel: UsersRolesViewModel + + @State private var expansion: [String: Bool] = [:] + + private var hasEditableScope: Bool { + viewModel.selection != nil + && !viewModel.selectedScopes.isEmpty + && !viewModel.isMixedScopeSelection + } + + var body: some View { + VStack(spacing: 0) { + if hasEditableScope { + header + Divider() + } + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(scopeTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .truncationMode(.head) + Spacer(minLength: 0) + bulkMenu + } + NativeSearchField( + text: $viewModel.privilegeFilter, + placeholder: String(localized: "Filter privileges") + ) + .accessibilityIdentifier("usersroles-privilege-filter") + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + private var scopeTitle: String { + if viewModel.selectedScopes.isEmpty { + return String(localized: "No Object Selected") + } + if let scope = viewModel.singleSelectedScope { + return scope.displayPath + } + return String( + format: String(localized: "%lld objects selected"), + viewModel.selectedScopes.count + ) + } + + private var bulkMenu: some View { + Menu { + Button(String(localized: "Grant All")) { setAll(true) } + Button(String(localized: "Revoke All")) { setAll(false) } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .disabled(viewModel.privilegeSections.isEmpty) + .help(String(localized: "Bulk actions")) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if viewModel.selection == nil { + ContentUnavailableView( + String(localized: "No Selection"), + systemImage: "person.2", + description: Text("Select a user or role to view its privileges.") + ) + } else if viewModel.isMixedScopeSelection { + ContentUnavailableView( + String(localized: "Mixed Selection"), + systemImage: "square.stack.3d.up.slash", + description: Text("Select objects of the same kind to edit their privileges.") + ) + } else if viewModel.selectedScopes.isEmpty { + ContentUnavailableView( + String(localized: "No Object Selected"), + systemImage: "hand.tap", + description: Text("Select an object on the left to edit its privileges.") + ) + } else if let error = viewModel.grantsError { + EmptyStateView( + icon: "exclamationmark.triangle", + title: String(localized: "Unable to Load Privileges"), + description: error, + actionTitle: String(localized: "Retry"), + action: { Task { await reloadGrants() } } + ) + } else if viewModel.privilegeSections.isEmpty { + emptyPrivileges + } else { + table + } + } + + @ViewBuilder + private var emptyPrivileges: some View { + if !viewModel.privilegeFilter.isEmpty { + ContentUnavailableView.search(text: viewModel.privilegeFilter) + } else { + ContentUnavailableView( + String(localized: "No Privileges"), + systemImage: "lock", + description: Text("No privileges can be granted at this level.") + ) + } + } + + private var table: some View { + Table(of: PrivilegeRow.self) { + TableColumn(String(localized: "Granted")) { row in + grantedCell(row) + } + .width(60) + + TableColumn(String(localized: "Privilege")) { row in + privilegeCell(row) + } + .width(min: 140, ideal: 220) + + TableColumn(String(localized: "Effective")) { row in + effectiveCell(row) + } + .width(min: 100, ideal: 180) + } rows: { + ForEach(viewModel.privilegeSections) { section in + DisclosureTableRow( + section.headerRow, + isExpanded: expansionBinding(for: section) + ) { + ForEach(section.rows) { SwiftUI.TableRow($0) } + } + } + } + .tableStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .accessibilityIdentifier("usersroles-privilege-table") + } + + // MARK: - Cells + + @ViewBuilder + private func grantedCell(_ row: PrivilegeRow) -> some View { + switch row.kind { + case let .category(category): + let section = viewModel.privilegeSections.first { $0.category == category } + TristateCheckbox( + state: section.map { viewModel.sectionState($0) } ?? .unchecked, + accessibilityLabel: category.title, + accessibilityValue: stateDescription(section.map { viewModel.sectionState($0) }) + ) { + guard let section else { return } + viewModel.setGranted(viewModel.sectionState(section) != .checked, section: section) + } + + case let .privilege(descriptor): + if viewModel.selectedScopes.count > 1 { + TristateCheckbox( + state: viewModel.grantState(for: descriptor.name), + accessibilityLabel: descriptor.label, + accessibilityValue: stateDescription(viewModel.grantState(for: descriptor.name)) + ) { + viewModel.setGranted( + viewModel.grantState(for: descriptor.name) != .checked, + privilege: descriptor.name + ) + } + } else { + Toggle( + descriptor.label, + isOn: Binding( + get: { viewModel.grantState(for: descriptor.name) == .checked }, + set: { viewModel.setGranted($0, privilege: descriptor.name) } + ) + ) + .toggleStyle(.checkbox) + .labelsHidden() + } + } + } + + @ViewBuilder + private func privilegeCell(_ row: PrivilegeRow) -> some View { + HStack(spacing: 4) { + Text(row.title) + .fontWeight(isStaged(row) ? .semibold : .regular) + if let descriptor = row.descriptor, viewModel.isGrantable(descriptor.name) { + Image(systemName: "arrow.up.forward.square") + .foregroundStyle(.secondary) + .help(String(localized: "This user can grant this privilege to others.")) + .accessibilityLabel(String(localized: "Can grant to others")) + } + Spacer(minLength: 0) + } + } + + @ViewBuilder + private func effectiveCell(_ row: PrivilegeRow) -> some View { + if let descriptor = row.descriptor { + switch viewModel.effectiveness(for: descriptor.name) { + case .direct, .notEffective: + EmptyView() + + case let .viaScope(scope): + Label( + String(format: String(localized: "Granted on %@"), scope.displayName), + systemImage: "arrow.turn.left.up" + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + case let .viaRole(name, isAutomatic): + Label( + String(format: String(localized: "Inherited from %@"), name), + systemImage: "person.2" + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .help( + isAutomatic + ? String(localized: "Inherited automatically.") + : String(format: String(localized: "Available after SET ROLE %@."), name) + ) + } + } + } + + // MARK: - Helpers + + private func isStaged(_ row: PrivilegeRow) -> Bool { + guard let descriptor = row.descriptor, + let principal = viewModel.selection, + let scope = viewModel.singleSelectedScope else { return false } + + let delta = viewModel.changeManager.grantDeltas[principal] + let key = PrincipalGrantKey(privilege: descriptor.name, scope: scope) + return delta?.added.contains(key) == true || delta?.removed.contains(key) == true + } + + private func stateDescription(_ state: TristateCheckbox.State?) -> String { + switch state { + case .checked: String(localized: "Granted") + case .mixed: String(localized: "Partly granted") + default: String(localized: "Not granted") + } + } + + private func expansionBinding(for section: PrivilegeSection) -> Binding { + Binding( + get: { expansion[section.category.key] ?? !section.category.isCollapsedByDefault }, + set: { expansion[section.category.key] = $0 } + ) + } + + private func setAll(_ isGranted: Bool) { + let privileges = viewModel.privilegeSections + .flatMap(\.rows) + .compactMap(\.descriptor) + .map(\.name) + viewModel.setGranted(isGranted, privileges: privileges) + } + + private func reloadGrants() async { + guard let principal = viewModel.selection else { return } + await viewModel.loadGrants(for: principal) + } +} diff --git a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift new file mode 100644 index 000000000..23b7a3f68 --- /dev/null +++ b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift @@ -0,0 +1,66 @@ +import SwiftUI +import TableProPluginKit + +struct PrivilegeEditorPane: View { + @Bindable var viewModel: UsersRolesViewModel + + var body: some View { + AutosavingSplitView( + autosaveName: "com.TablePro.usersRoles.privilegeSplit", + primaryMinimum: 240, + primaryMaximum: 640, + secondaryMinimum: 300 + ) { + scopePane + } secondary: { + PrivilegeChecklistView(viewModel: viewModel) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var scopePane: some View { + VStack(spacing: 0) { + VStack(spacing: 6) { + NativeSearchField( + text: $viewModel.scopeFilter, + placeholder: String(localized: "Filter objects") + ) + .accessibilityIdentifier("usersroles-scope-filter") + + Picker("", selection: $viewModel.scopeMode) { + ForEach(UsersRolesViewModel.ScopeMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .accessibilityIdentifier("usersroles-scope-mode") + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + + Divider() + + PrivilegeScopeOutlineView( + viewModel: viewModel, + structureVersion: viewModel.privilegeTree.structureVersion, + grantVersion: viewModel.changeManager.grantClosureVersion, + principal: viewModel.selection + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + .onChange(of: viewModel.scopeMode) { _, _ in + viewModel.applyScopeMode() + } + .onChange(of: viewModel.scopeFilter) { _, _ in + viewModel.searchScopes() + } + .onChange(of: viewModel.selection) { _, _ in + if viewModel.scopeMode == .granted { + viewModel.applyScopeMode() + } + } + } +} diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift new file mode 100644 index 000000000..ea3ae3943 --- /dev/null +++ b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift @@ -0,0 +1,204 @@ +import AppKit +import SwiftUI +import TableProPluginKit + +@MainActor +final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + static let scopeColumn = NSUserInterfaceItemIdentifier("scope") + static let summaryColumn = NSUserInterfaceItemIdentifier("summary") + + var viewModel: UsersRolesViewModel + weak var outlineView: NSOutlineView? + + var structureVersion = -1 + var grantVersion = -1 + var principal: PluginPrincipalRef? + + private var isRestoringExpansion = false + private var pendingExpansions: Set = [] + + init(viewModel: UsersRolesViewModel) { + self.viewModel = viewModel + } + + func configureColumns(on outlineView: NSOutlineView) { + guard outlineView.tableColumns.isEmpty else { return } + + // The pane's minimum thickness is 240pt, so the two columns must fit inside that or the + // summary is clipped away entirely. + let scope = NSTableColumn(identifier: Self.scopeColumn) + scope.title = String(localized: "Object") + scope.width = 150 + scope.minWidth = 110 + outlineView.addTableColumn(scope) + + let summary = NSTableColumn(identifier: Self.summaryColumn) + summary.title = String(localized: "Privileges") + summary.width = 130 + summary.minWidth = 90 + outlineView.addTableColumn(summary) + + outlineView.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + outlineView.sizeLastColumnToFit() + } + + // MARK: - Data source + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + children(of: item).count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + children(of: item)[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + (item as? PrivilegeNode)?.isExpandable ?? false + } + + private func children(of item: Any?) -> [PrivilegeNode] { + guard let node = item as? PrivilegeNode else { return viewModel.privilegeTree.roots } + return node.children ?? [] + } + + // MARK: - Expansion + + func outlineViewItemWillExpand(_ notification: Notification) { + guard !isRestoringExpansion, + let node = notification.userInfo?["NSObject"] as? PrivilegeNode, + !node.hasLoadedChildren, + !node.isLoading else { return } + + pendingExpansions.insert(node.persistentKey) + + Task { @MainActor in + await viewModel.expand(node) + guard let outlineView else { return } + + // The user can collapse the node again while its children are loading. Honour that + // instead of re-expanding underneath them. + guard pendingExpansions.remove(node.persistentKey) != nil else { + outlineView.reloadItem(node, reloadChildren: true) + return + } + outlineView.reloadItem(node, reloadChildren: true) + outlineView.expandItem(node) + } + } + + func outlineViewItemDidExpand(_ notification: Notification) { + guard !isRestoringExpansion, + let node = notification.userInfo?["NSObject"] as? PrivilegeNode else { return } + viewModel.expansionStore.insert(node.persistentKey) + } + + func outlineViewItemDidCollapse(_ notification: Notification) { + guard !isRestoringExpansion, + let node = notification.userInfo?["NSObject"] as? PrivilegeNode else { return } + pendingExpansions.remove(node.persistentKey) + viewModel.expansionStore.remove(node.persistentKey) + } + + func restoreExpansion() { + guard viewModel.privilegeTree.mode == .hierarchy else { return } + + let saved = Set(viewModel.expansionStore.load()) + guard !saved.isEmpty else { return } + + Task { @MainActor in + isRestoringExpansion = true + defer { isRestoringExpansion = false } + + var frontier = viewModel.privilegeTree.roots + while !frontier.isEmpty { + var next: [PrivilegeNode] = [] + for node in frontier where saved.contains(node.persistentKey) { + if !node.hasLoadedChildren { + await viewModel.expand(node) + } + outlineView?.reloadItem(node, reloadChildren: true) + outlineView?.expandItem(node) + next.append(contentsOf: node.children ?? []) + } + frontier = next + } + } + } + + // MARK: - Selection + + func outlineViewSelectionDidChange(_ notification: Notification) { + guard let outlineView else { return } + + let scopes = outlineView.selectedRowIndexes.compactMap { + (outlineView.item(atRow: $0) as? PrivilegeNode)?.scope + } + viewModel.selectedScopes = Set(scopes) + } + + // MARK: - Cells + + func outlineView( + _ outlineView: NSOutlineView, + viewFor tableColumn: NSTableColumn?, + item: Any + ) -> NSView? { + guard let tableColumn, let node = item as? PrivilegeNode else { return nil } + + if tableColumn.identifier == Self.scopeColumn { + return hostingCell( + identifier: Self.scopeColumn, + outlineView: outlineView, + content: AnyView( + PrivilegeScopeRowView( + title: node.title, + symbolName: node.symbolName, + isLoading: node.isLoading, + loadError: node.loadError, + isRestricted: node.childrenAvailability == .restrictedToCurrentDatabase + ) + ) + ) + } + + return hostingCell( + identifier: Self.summaryColumn, + outlineView: outlineView, + content: AnyView(ScopeSummaryView(summary: summary(for: node))) + ) + } + + private func summary(for node: PrivilegeNode) -> ScopeSummary { + guard let principal = viewModel.selection else { return .none } + return viewModel.changeManager.summary( + at: node.scope, + for: principal, + isBrowsingRestricted: node.childrenAvailability == .restrictedToCurrentDatabase + ) + } + + private func hostingCell( + identifier: NSUserInterfaceItemIdentifier, + outlineView: NSOutlineView, + content: AnyView + ) -> NSView { + if let reused = outlineView.makeView(withIdentifier: identifier, owner: self) as? NSHostingView { + reused.rootView = content + return reused + } + let hosting = NSHostingView(rootView: content) + hosting.identifier = identifier + return hosting + } + + func refreshVisibleSummaries() { + guard let outlineView else { return } + let rows = outlineView.rows(in: outlineView.visibleRect) + guard rows.length > 0 else { return } + + outlineView.reloadData( + forRowIndexes: IndexSet(integersIn: rows.location ..< rows.location + rows.length), + columnIndexes: IndexSet(integersIn: 0 ..< outlineView.numberOfColumns) + ) + } +} diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift new file mode 100644 index 000000000..167cdbb2e --- /dev/null +++ b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift @@ -0,0 +1,67 @@ +import AppKit +import SwiftUI +import TableProPluginKit + +struct PrivilegeScopeOutlineView: NSViewRepresentable { + @Bindable var viewModel: UsersRolesViewModel + + let structureVersion: Int + let grantVersion: Int + let principal: PluginPrincipalRef? + + func makeCoordinator() -> PrivilegeScopeOutlineCoordinator { + PrivilegeScopeOutlineCoordinator(viewModel: viewModel) + } + + func makeNSView(context: Context) -> NSScrollView { + let outlineView = NSOutlineView() + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + outlineView.style = .fullWidth + outlineView.rowSizeStyle = .custom + outlineView.rowHeight = 24 + outlineView.indentationPerLevel = 14 + outlineView.allowsMultipleSelection = true + outlineView.allowsEmptySelection = true + outlineView.usesAlternatingRowBackgroundColors = true + outlineView.headerView = NSTableHeaderView() + outlineView.allowsColumnResizing = true + outlineView.autosaveName = "com.TablePro.usersRoles.scopeOutline" + outlineView.autosaveTableColumns = true + outlineView.autosaveExpandedItems = false + outlineView.setAccessibilityIdentifier("usersroles-scope-tree") + + context.coordinator.configureColumns(on: outlineView) + outlineView.outlineTableColumn = outlineView.tableColumns.first + context.coordinator.outlineView = outlineView + + let scrollView = NSScrollView() + scrollView.documentView = outlineView + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let coordinator = context.coordinator + coordinator.viewModel = viewModel + + guard let outlineView = nsView.documentView as? NSOutlineView else { return } + + if coordinator.structureVersion != structureVersion { + coordinator.structureVersion = structureVersion + coordinator.grantVersion = grantVersion + coordinator.principal = principal + outlineView.reloadData() + coordinator.restoreExpansion() + } else if coordinator.grantVersion != grantVersion || coordinator.principal != principal { + // The summary column renders the selected principal's grants, so a change of principal + // must refresh it even when the tree structure and the grant closure are unchanged. + coordinator.grantVersion = grantVersion + coordinator.principal = principal + coordinator.refreshVisibleSummaries() + } + } +} diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift b/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift new file mode 100644 index 000000000..420f93b73 --- /dev/null +++ b/TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import TableProPluginKit + +struct PrivilegeScopeRowView: View { + let title: String + let symbolName: String + let isLoading: Bool + let loadError: String? + let isRestricted: Bool + + var body: some View { + HStack(spacing: 6) { + Label(title, systemImage: symbolName) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + if isLoading { + ProgressView() + .controlSize(.small) + } + if isRestricted { + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + .help( + String( + localized: """ + Schema and table privileges in this database are only visible \ + when the connection is using it. + """ + ) + ) + .accessibilityLabel(String(localized: "Not browsable on this connection")) + } + if let loadError { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .help(loadError) + .accessibilityLabel(String(localized: "Failed to load")) + } + } + } +} + +struct ScopeSummaryView: View { + let summary: ScopeSummary + + var body: some View { + switch summary { + case .notGrantable, .none: + Text("None") + .foregroundStyle(.tertiary) + .accessibilityLabel(String(localized: "No privileges")) + + case let .all(count): + Text(String(format: String(localized: "All (%lld)"), count)) + + case let .some(names, overflow, hasGrantOption): + HStack(spacing: 4) { + Text(overflow > 0 + ? String( + format: String(localized: "%1$@ +%2$lld"), + names.joined(separator: ", "), + overflow + ) + : names.joined(separator: ", ")) + .lineLimit(1) + if hasGrantOption { + Image(systemName: "arrow.up.forward.square") + .foregroundStyle(.secondary) + .help(String(localized: "Can grant these privileges to others.")) + } + } + + case let .descendantsOnly(count): + Label( + String(format: String(localized: "%lld inside"), count), + systemImage: "arrow.turn.down.right" + ) + .foregroundStyle(.secondary) + + case let .browsingRestricted(direct): + Text(direct.isEmpty + ? String(localized: "Not visible") + : direct.joined(separator: ", ")) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} diff --git a/TablePro/Views/UsersRoles/UsersRolesActionHandler.swift b/TablePro/Views/UsersRoles/UsersRolesActionHandler.swift new file mode 100644 index 000000000..99140b47e --- /dev/null +++ b/TablePro/Views/UsersRoles/UsersRolesActionHandler.swift @@ -0,0 +1,19 @@ +import Foundation + +@MainActor +final class UsersRolesActionHandler { + var hasChanges: () -> Bool = { false } + var canUndo: () -> Bool = { false } + var canRedo: () -> Bool = { false } + var undoMenuTitle: () -> String = { String(localized: "Undo") } + var redoMenuTitle: () -> String = { String(localized: "Redo") } + + var undo: () -> Void = {} + var redo: () -> Void = {} + var addPrincipal: () -> Void = {} + var dropSelected: () -> Void = {} + var discard: () -> Void = {} + var reviewAndApply: () -> Void = {} + var previewSQL: () -> Void = {} + var refresh: () -> Void = {} +} diff --git a/TablePro/Views/UsersRoles/UsersRolesSheets.swift b/TablePro/Views/UsersRoles/UsersRolesSheets.swift new file mode 100644 index 000000000..c4495e9ef --- /dev/null +++ b/TablePro/Views/UsersRoles/UsersRolesSheets.swift @@ -0,0 +1,399 @@ +import SwiftUI +import TableProPluginKit + +struct SheetChrome: View { + let title: String + var subtitle: String? + @ViewBuilder let content: () -> Content + @ViewBuilder let footer: () -> Footer + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body.weight(.semibold)) + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + + content() + .padding(16) + + Divider() + + HStack(spacing: 12) { + Spacer() + footer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(Color(nsColor: .windowBackgroundColor)) + .onExitCommand { dismiss() } + } +} + +// MARK: - Create + +struct CreatePrincipalSheet: View { + @Bindable var viewModel: UsersRolesViewModel + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var host = "%" + @State private var password = "" + @State private var isRole = false + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var duplicateName: Bool { + viewModel.principalRows.contains { $0.info.ref.name == trimmedName } + } + + private var isValid: Bool { + !trimmedName.isEmpty && !duplicateName && (isRole || !password.isEmpty) + } + + var body: some View { + SheetChrome(title: String(localized: "New User or Role")) { + Form { + if viewModel.capabilities.roleMembership { + Picker(String(localized: "Kind:"), selection: $isRole) { + Text("User").tag(false) + Text("Role").tag(true) + } + .pickerStyle(.segmented) + } + + TextField(String(localized: "Name:"), text: $name) + + if duplicateName, !trimmedName.isEmpty { + LabeledContent("") { + Text("A user or role with this name already exists.") + .font(.caption) + .foregroundStyle(.red) + } + } + + if viewModel.capabilities.hostScoping { + TextField(String(localized: "Host:"), text: $host) + LabeledContent("") { + Text("Use % to allow any host.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !isRole { + LabeledContent(String(localized: "Password:")) { + NewPasswordField(password: $password) + } + } + } + .formStyle(.columns) + } footer: { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Create")) { + viewModel.createPrincipal(definition()) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .frame(minWidth: 460, idealWidth: 480) + .accessibilityIdentifier("usersroles-create-sheet") + } + + private func definition() -> PluginPrincipalDefinition { + PluginPrincipalDefinition( + ref: PluginPrincipalRef( + name: trimmedName, + host: viewModel.capabilities.hostScoping ? host : nil + ), + password: password.isEmpty ? nil : password, + canLogin: !isRole + ) + } +} + +// MARK: - Change password + +struct ChangePasswordSheet: View { + @Bindable var viewModel: UsersRolesViewModel + let principal: PluginPrincipalRef + + @Environment(\.dismiss) private var dismiss + + @State private var password = "" + @State private var verify = "" + + private var mismatch: Bool { + !verify.isEmpty && password != verify + } + + private var isValid: Bool { + !password.isEmpty && password == verify + } + + var body: some View { + SheetChrome( + title: String(localized: "Change Password"), + subtitle: principal.displayName + ) { + Form { + LabeledContent(String(localized: "New password:")) { + NewPasswordField(password: $password) + } + SecureField(String(localized: "Verify:"), text: $verify) + + if mismatch { + LabeledContent("") { + Text("The passwords do not match.") + .font(.caption) + .foregroundStyle(.red) + } + } + } + .formStyle(.columns) + } footer: { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Set Password")) { + viewModel.setPassword(password, for: principal) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .frame(minWidth: 460, idealWidth: 480) + } +} + +// MARK: - Drop + +struct DropPrincipalSheet: View { + @Bindable var viewModel: UsersRolesViewModel + let prompt: PrincipalDropPrompt + + @Environment(\.dismiss) private var dismiss + + @State private var reassigns = true + @State private var target: PluginPrincipalRef? + + private var disposition: PrincipalDropPrompt.Disposition? { + guard reassigns else { return .dropOwned } + return target.map { .reassign(to: $0) } + } + + var body: some View { + SheetChrome( + title: prompt.title, + subtitle: String( + localized: "Choose what happens to them when the role is dropped." + ) + ) { + VStack(alignment: .leading, spacing: 12) { + Picker("", selection: $reassigns) { + Text("Reassign owned objects").tag(true) + Text("Drop owned objects").tag(false) + } + .pickerStyle(.radioGroup) + .labelsHidden() + + if !reassigns { + Text("Tables, views, and functions owned by this role will be deleted.") + .font(.caption) + .foregroundStyle(.secondary) + } + + LabeledContent(String(localized: "Reassign to")) { + Picker("", selection: $target) { + ForEach(prompt.reassignCandidates, id: \.self) { candidate in + Text(candidate.displayName).tag(Optional(candidate)) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + .disabled(!reassigns) + } + } footer: { + Button(String(localized: "Cancel"), role: .cancel) { + viewModel.activeSheet = nil + } + .keyboardShortcut(.cancelAction) + + Button(String(localized: "Drop Role"), role: .destructive) { + guard let disposition else { return } + viewModel.confirmDrop(prompt, disposition: disposition) + } + .disabled(disposition == nil) + } + .frame(minWidth: 460, idealWidth: 480) + .onAppear { + target = viewModel.connectedPrincipal ?? prompt.reassignCandidates.first + } + } +} + +// MARK: - Role membership + +struct RoleMembershipSheet: View { + @Bindable var viewModel: UsersRolesViewModel + let principal: PluginPrincipalRef + + @Environment(\.dismiss) private var dismiss + + @State private var filter = "" + @State private var selected: Set = [] + + private var roles: [String] { + let names = viewModel.changeManager.principals + .filter { $0.isRole && $0.ref != principal } + .map(\.ref.name) + guard !filter.isEmpty else { return names } + return names.filter { $0.localizedCaseInsensitiveContains(filter) } + } + + var body: some View { + SheetChrome( + title: String(localized: "Member Of"), + subtitle: principal.displayName + ) { + VStack(spacing: 8) { + NativeSearchField(text: $filter, placeholder: String(localized: "Filter roles")) + + List(roles, id: \.self) { role in + Toggle(role, isOn: binding(for: role)) + .toggleStyle(.checkbox) + } + .listStyle(.plain) + .alternatingRowBackgrounds(.enabled) + } + } footer: { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Done")) { + apply() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + .frame(minWidth: 420, idealWidth: 440, minHeight: 320, idealHeight: 380) + .onAppear { + let current = viewModel.changeManager.pendingAlters[principal]?.memberOf + ?? viewModel.selectedPrincipal?.memberOf + ?? [] + selected = Set(current) + } + } + + private func binding(for role: String) -> Binding { + Binding( + get: { selected.contains(role) }, + set: { isOn in + if isOn { + selected.insert(role) + } else { + selected.remove(role) + } + } + ) + } + + private func apply() { + guard let info = viewModel.changeManager.principals.first(where: { $0.ref == principal }) + else { return } + + let current = viewModel.changeManager.pendingAlters[principal] + ?? PrincipalChangeManager.definition(from: info) + + viewModel.stageAttributes( + PluginPrincipalDefinition( + ref: current.ref, + password: nil, + canLogin: current.canLogin, + attributes: current.attributes, + memberOf: selected.sorted(), + connectionLimit: current.connectionLimit, + comment: current.comment + ), + for: principal + ) + } +} + +// MARK: - Copy privileges + +struct CopyPrivilegesSheet: View { + @Bindable var viewModel: UsersRolesViewModel + let target: PluginPrincipalRef + + @Environment(\.dismiss) private var dismiss + + @State private var filter = "" + @State private var source: PluginPrincipalRef? + + private var candidates: [PrincipalRow] { + let rows = viewModel.principalRows.filter { $0.ref != target } + guard !filter.isEmpty else { return rows } + return rows.filter { $0.displayName.localizedCaseInsensitiveContains(filter) } + } + + private var isSourceLoaded: Bool { + guard let source else { return false } + return viewModel.changeManager.hasLoadedGrants(for: source) + } + + var body: some View { + SheetChrome( + title: String(localized: "Copy Privileges"), + subtitle: String( + format: String(localized: "Copy privileges to %@"), + target.displayName + ) + ) { + VStack(spacing: 8) { + NativeSearchField(text: $filter, placeholder: String(localized: "Filter")) + + List(candidates, selection: $source) { row in + Label(row.displayName, systemImage: row.symbolName) + .tag(row.ref) + } + .listStyle(.plain) + .alternatingRowBackgrounds(.enabled) + } + } footer: { + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + if source != nil, !isSourceLoaded { + ProgressView().controlSize(.small) + } + Button(String(localized: "Copy")) { + guard let source else { return } + viewModel.copyPrivileges(from: source, to: target) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(source == nil || !isSourceLoaded) + } + .frame(minWidth: 420, idealWidth: 440, minHeight: 320, idealHeight: 380) + .task(id: source) { + guard let source else { return } + await viewModel.loadGrants(for: source) + } + } +} diff --git a/TablePro/Views/UsersRoles/UsersRolesTabView.swift b/TablePro/Views/UsersRoles/UsersRolesTabView.swift new file mode 100644 index 000000000..ffed92ebf --- /dev/null +++ b/TablePro/Views/UsersRoles/UsersRolesTabView.swift @@ -0,0 +1,137 @@ +import Combine +import SwiftUI +import TableProPluginKit + +struct UsersRolesTabView: View { + @Bindable var viewModel: UsersRolesViewModel + let coordinator: MainContentCoordinator? + + @State private var actions = UsersRolesActionHandler() + + var body: some View { + VStack(spacing: 0) { + AutosavingSplitView( + autosaveName: "com.TablePro.usersRoles.mainSplit", + primaryMinimum: 200, + primaryMaximum: 520, + secondaryMinimum: 560 + ) { + PrincipalListPane(viewModel: viewModel) + } secondary: { + PrincipalDetailPane(viewModel: viewModel) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + PendingChangesBar(viewModel: viewModel) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { await viewModel.load() } + .onReceive(AppCommands.shared.refreshPrincipals) { connectionId in + guard connectionId == viewModel.connectionId else { return } + Task { await viewModel.load(forceReload: true) } + } + .sheet(item: $viewModel.activeSheet) { sheet in + sheetContent(sheet) + } + .alert( + String(localized: "Action Failed"), + isPresented: actionErrorBinding, + presenting: viewModel.actionError + ) { _ in + Button(String(localized: "OK"), role: .cancel) { viewModel.actionError = nil } + } message: { message in + Text(message) + } + .onAppear { install() } + .onDisappear { teardown() } + .onChange(of: viewModel.changeCount) { _, _ in + coordinator?.toolbarState.hasPrincipalChanges = viewModel.hasChanges + } + } + + @ViewBuilder + private func sheetContent(_ sheet: UsersRolesViewModel.ActiveSheet) -> some View { + switch sheet { + case .create: + CreatePrincipalSheet(viewModel: viewModel) + + case let .changePassword(ref): + ChangePasswordSheet(viewModel: viewModel, principal: ref) + + case let .drop(prompt): + DropPrincipalSheet(viewModel: viewModel, prompt: prompt) + + case let .roleMembership(ref): + RoleMembershipSheet(viewModel: viewModel, principal: ref) + + case let .copyPrivileges(ref): + CopyPrivilegesSheet(viewModel: viewModel, target: ref) + + case .review: + SQLReviewSheet( + isPresented: reviewBinding, + statements: viewModel.previewSQL, + databaseType: viewModel.databaseType, + warning: viewModel.lockoutWarning, + failure: viewModel.applyFailure, + primaryAction: SQLReviewSheet.PrimaryAction( + title: String(localized: "Execute"), + isDestructive: viewModel.hasDestructiveStatements + ) { + await viewModel.executePendingChanges() + }, + onOpenInEditor: { + coordinator?.loadQueryIntoEditor( + viewModel.previewSQL + .map { $0.hasSuffix(";") ? $0 : $0 + ";" } + .joined(separator: "\n\n") + ) + viewModel.activeSheet = nil + } + ) + } + } + + private var reviewBinding: Binding { + Binding( + get: { viewModel.activeSheet?.id == "review" }, + set: { if !$0 { viewModel.activeSheet = nil } } + ) + } + + private var actionErrorBinding: Binding { + Binding( + get: { viewModel.actionError != nil }, + set: { if !$0 { viewModel.actionError = nil } } + ) + } + + private func install() { + actions.hasChanges = { viewModel.hasChanges } + actions.canUndo = { viewModel.canUndo } + actions.canRedo = { viewModel.canRedo } + actions.undoMenuTitle = { viewModel.undoMenuTitle } + actions.redoMenuTitle = { viewModel.redoMenuTitle } + actions.undo = { viewModel.undo() } + actions.redo = { viewModel.redo() } + actions.addPrincipal = { viewModel.activeSheet = .create } + actions.dropSelected = { + Task { await viewModel.requestDrop(viewModel.selectedRefs) } + } + actions.discard = { viewModel.discardChanges() } + actions.reviewAndApply = { viewModel.requestApply() } + actions.previewSQL = { viewModel.requestApply() } + actions.refresh = { + Task { await viewModel.load(forceReload: true) } + } + coordinator?.usersRolesActions = actions + coordinator?.toolbarState.hasPrincipalChanges = viewModel.hasChanges + } + + private func teardown() { + guard coordinator?.usersRolesActions === actions else { return } + coordinator?.usersRolesActions = nil + coordinator?.toolbarState.hasPrincipalChanges = false + } +} diff --git a/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift b/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift new file mode 100644 index 000000000..57ddb8666 --- /dev/null +++ b/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift @@ -0,0 +1,334 @@ +// +// PrincipalChangeManagerTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Principal change manager", .serialized) +@MainActor +struct PrincipalChangeManagerTests { + private let alice = PluginPrincipalRef(name: "alice") + private let app = PluginPrivilegeScope.database("app") + + private func makeManager() -> PrincipalChangeManager { + let manager = PrincipalChangeManager() + manager.load( + principals: [PluginPrincipalInfo(ref: alice)], + catalog: PluginPrivilegeCatalog( + databasePrivileges: [ + PluginPrivilegeDescriptor(name: "CONNECT", label: "Connect"), + PluginPrivilegeDescriptor(name: "CREATE", label: "Create") + ] + ) + ) + manager.loadGrants( + [PluginGrantInfo(privilege: "CONNECT", scope: .database("app"), isGrantable: true)], + for: alice + ) + return manager + } + + @Test("A freshly loaded principal has no pending changes") + func startsClean() { + let manager = makeManager() + #expect(manager.hasChanges == false) + #expect(manager.changeCount == 0) + } + + @Test("Granting produces an add, revoking produces a remove") + func producesDiff() { + let manager = makeManager() + manager.setGranted(true, privilege: "CREATE", scope: app, for: alice) + + let sets = manager.grantChangeSets() + #expect(sets.count == 1) + #expect(sets[0].grantsToAdd.map(\.privilege) == ["CREATE"]) + #expect(sets[0].grantsToRemove.isEmpty) + #expect(manager.changeCount == 1) + } + + @Test("Toggling back to the baseline clears the change entirely") + func togglingBackIsNoOp() { + let manager = makeManager() + manager.setGranted(false, privilege: "CONNECT", scope: app, for: alice) + #expect(manager.changeCount == 1) + + manager.setGranted(true, privilege: "CONNECT", scope: app, for: alice) + #expect(manager.grantChangeSets().isEmpty) + #expect(manager.changeCount == 0) + } + + @Test("Re-granting a baseline grant never strips its grant option") + func preservesGrantOption() { + let manager = makeManager() + manager.setGranted(false, privilege: "CONNECT", scope: app, for: alice) + manager.setGranted(true, privilege: "CONNECT", scope: app, for: alice) + + #expect(manager.grantChangeSets().isEmpty) + #expect(manager.isGrantable("CONNECT", scope: app, for: alice)) + } + + @Test("A staged grant carries the grant option from the baseline") + func rebuildsGrantOption() { + let manager = makeManager() + manager.setGranted(false, privilege: "CONNECT", scope: app, for: alice) + + let removed = manager.grantChangeSets().first?.grantsToRemove.first + #expect(removed?.isGrantable == true) + } + + @Test("Reload rebases and drops intent the server already satisfies") + func reloadRebases() { + let manager = makeManager() + manager.setGranted(true, privilege: "CREATE", scope: app, for: alice) + #expect(manager.changeCount == 1) + + manager.reload( + principals: [PluginPrincipalInfo(ref: alice)], + catalog: PluginPrivilegeCatalog( + databasePrivileges: [PluginPrivilegeDescriptor(name: "CREATE", label: "Create")] + ) + ) + manager.loadGrants( + [ + PluginGrantInfo(privilege: "CONNECT", scope: app), + PluginGrantInfo(privilege: "CREATE", scope: app) + ], + for: alice + ) + + #expect(manager.changeCount == 0) + #expect(manager.isGranted("CREATE", scope: app, for: alice)) + } + + @Test("Reload drops the stale grant baseline so it is refetched from the server") + func reloadInvalidatesGrantBaseline() { + let manager = makeManager() + #expect(manager.hasLoadedGrants(for: alice)) + + manager.reload( + principals: [PluginPrincipalInfo(ref: alice)], + catalog: PluginPrivilegeCatalog() + ) + + // If the baseline survived, loadGrants would skip the refetch and a privilege applied this + // session would render unchecked and could never be revoked. + #expect(manager.hasLoadedGrants(for: alice) == false) + } + + @Test("Reload keeps the seeded baseline of a staged create, which has nothing to fetch") + func reloadKeepsStagedCreateBaseline() { + let carol = PluginPrincipalRef(name: "carol") + let manager = makeManager() + manager.stageCreate(PluginPrincipalDefinition(ref: carol)) + + manager.reload( + principals: [PluginPrincipalInfo(ref: alice)], + catalog: PluginPrivilegeCatalog() + ) + + #expect(manager.hasLoadedGrants(for: carol)) + #expect(manager.stage(of: carol) == .created) + } + + @Test("Reload drops a delta for a principal that no longer exists on the server") + func reloadDropsDeadDeltas() { + let manager = makeManager() + manager.setGranted(true, privilege: "CREATE", scope: app, for: alice) + #expect(manager.changeCount == 1) + + manager.reload(principals: [], catalog: PluginPrivilegeCatalog()) + + #expect(manager.changeCount == 0) + #expect(manager.grantChangeSets().isEmpty) + } + + @Test("An attribute edit on a staged create folds into the CREATE instead of a dropped ALTER") + func foldsAlterIntoStagedCreate() { + let carol = PluginPrincipalRef(name: "carol") + let manager = makeManager() + manager.stageCreate(PluginPrincipalDefinition(ref: carol, canLogin: true)) + + manager.stageAlter( + PluginPrincipalDefinition(ref: carol, canLogin: false), + for: carol + ) + + let changes = manager.pendingChanges() + #expect(changes.count == 1) + guard case let .create(definition) = changes[0] else { + Issue.record("expected a single create carrying the edit") + return + } + #expect(definition.canLogin == false) + } + + @Test("Removing a staged create takes its password and grants with it") + func unstageCreateClearsEverything() { + let carol = PluginPrincipalRef(name: "carol") + let manager = makeManager() + manager.stageCreate(PluginPrincipalDefinition(ref: carol)) + manager.stageSetPassword("secret", for: carol) + manager.setGranted(true, privilege: "CONNECT", scope: app, for: carol) + + manager.unstageCreate(carol) + + #expect(manager.changeCount == 0) + #expect(manager.pendingChanges().isEmpty) + } + + @Test("Each mutation is its own undo group, independent of run-loop timing") + func undoIsPerMutation() { + let manager = makeManager() + manager.setGranted(true, privilege: "CREATE", scope: app, for: alice) + manager.stageDrop(PluginPrincipalRef(name: "bob"), options: PluginPrincipalDropOptions()) + #expect(manager.changeCount == 2) + + // With NSUndoManager's default groupsByEvent, both registrations would coalesce into one + // run-loop group and a single undo would reverse both. + manager.undoManager.undo() + #expect(manager.changeCount == 1) + #expect(manager.isGranted("CREATE", scope: app, for: alice)) + + manager.undoManager.undo() + #expect(manager.changeCount == 0) + } + + @Test("A bulk grant is a single undo group") + func bulkGrantIsOneUndoGroup() { + let manager = makeManager() + manager.setGranted( + true, + privileges: ["CONNECT", "CREATE"], + scopes: [app, .database("other")], + for: alice + ) + #expect(manager.changeCount > 1) + + manager.undoManager.undo() + #expect(manager.changeCount == 0) + } + + @Test("Undoing the removal of a staged create restores its grants") + func undoUnstageCreateRestoresGrants() { + let carol = PluginPrincipalRef(name: "carol") + let manager = makeManager() + manager.stageCreate(PluginPrincipalDefinition(ref: carol)) + manager.setGranted(true, privilege: "CONNECT", scope: app, for: carol) + + manager.unstageCreate(carol) + manager.undoManager.undo() + + #expect(manager.stage(of: carol) == .created) + #expect(manager.isGranted("CONNECT", scope: app, for: carol)) + } + + @Test("Reload preserves intent the server has not satisfied") + func reloadPreservesIntent() { + let manager = makeManager() + manager.setGranted(true, privilege: "CREATE", scope: app, for: alice) + + manager.reload( + principals: [PluginPrincipalInfo(ref: alice)], + catalog: PluginPrivilegeCatalog() + ) + manager.loadGrants([PluginGrantInfo(privilege: "CONNECT", scope: app)], for: alice) + + #expect(manager.changeCount == 1) + #expect(manager.isGranted("CREATE", scope: app, for: alice)) + } + + @Test("Undo restores the previous grant state") + func undoRestoresState() { + let manager = makeManager() + manager.setGranted(false, privilege: "CONNECT", scope: app, for: alice) + #expect(manager.isGranted("CONNECT", scope: app, for: alice) == false) + + manager.undoManager.undo() + #expect(manager.isGranted("CONNECT", scope: app, for: alice)) + #expect(manager.changeCount == 0) + } + + @Test("A no-op alter is never staged") + func dropsNoOpAlter() { + let info = PluginPrincipalInfo(ref: alice) + let manager = PrincipalChangeManager() + manager.load(principals: [info], catalog: PluginPrivilegeCatalog()) + + manager.stageAlter(PrincipalChangeManager.definition(from: info), for: alice) + #expect(manager.changeCount == 0) + #expect(manager.pendingChanges().isEmpty) + } + + @Test("A real alter is staged and reaches the change list") + func stagesAlter() { + let info = PluginPrincipalInfo(ref: alice, canLogin: true) + let manager = PrincipalChangeManager() + manager.load(principals: [info], catalog: PluginPrivilegeCatalog()) + + manager.stageAlter( + PluginPrincipalDefinition(ref: alice, canLogin: false), + for: alice + ) + #expect(manager.changeCount == 1) + #expect(manager.stage(of: alice) == .modified) + } + + @Test("Copying privileges stages the source's grants on the target") + func copiesGrants() { + let bob = PluginPrincipalRef(name: "bob") + let manager = makeManager() + manager.loadGrants([], for: bob) + + manager.copyGrants(from: alice, to: bob) + + #expect(manager.isGranted("CONNECT", scope: app, for: bob)) + #expect(manager.changeCount == 1) + } + + @Test("Dropping the connected account is reported as self-impact") + func reportsSelfImpact() { + let manager = makeManager() + manager.stageDrop(alice, options: PluginPrincipalDropOptions()) + + #expect(manager.selfImpact(connected: alice) != nil) + #expect(manager.selfImpact(connected: PluginPrincipalRef(name: "other")) == nil) + } + + @Test("Granted scope closure includes every ancestor of a grant") + func buildsScopeClosure() { + let manager = makeManager() + let column = PluginPrivilegeScope.column( + database: "app", + schema: "public", + table: "orders", + column: "total" + ) + manager.setGranted(true, privilege: "UPDATE", scope: column, for: alice) + + let closure = manager.grantedScopeClosure(for: alice) + #expect(closure.contains(column)) + #expect(closure.contains(.table(database: "app", schema: "public", table: "orders"))) + #expect(closure.contains(.schema(database: "app", schema: "public"))) + #expect(closure.contains(.database("app"))) + #expect(closure.contains(.server)) + } + + @Test("Discard restores the loaded state") + func discardResets() { + let manager = makeManager() + manager.setGranted(true, privilege: "CREATE", scope: app, for: alice) + manager.stageDrop(alice, options: PluginPrincipalDropOptions()) + + manager.discardChanges() + + #expect(manager.changeCount == 0) + #expect(manager.isGranted("CONNECT", scope: app, for: alice)) + #expect(manager.isGranted("CREATE", scope: app, for: alice) == false) + } +} diff --git a/TableProTests/Core/UsersRoles/PrincipalStatementGeneratorTests.swift b/TableProTests/Core/UsersRoles/PrincipalStatementGeneratorTests.swift new file mode 100644 index 000000000..9f7755a3a --- /dev/null +++ b/TableProTests/Core/UsersRoles/PrincipalStatementGeneratorTests.swift @@ -0,0 +1,135 @@ +// +// PrincipalStatementGeneratorTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +private final class MockPrincipalDriver: PluginPrincipalManagement, @unchecked Sendable { + var supportsPrincipalHostScoping = false + var supportsOwnedObjectReassignment = true + var supportsRoleMembership = true + + func fetchPrincipals() async throws -> [PluginPrincipalInfo] { [] } + func fetchPrivilegeCatalog() async throws -> PluginPrivilegeCatalog { PluginPrivilegeCatalog() } + func fetchGrants(for principal: PluginPrincipalRef) async throws -> [PluginGrantInfo] { [] } + + func generateCreatePrincipalSQL(definition: PluginPrincipalDefinition) -> [String]? { + ["CREATE ROLE \(definition.ref.name)"] + } + + var alterResult: [String]? = ["ALTER ROLE alice"] + + func generateAlterPrincipalSQL( + old: PluginPrincipalDefinition, + new: PluginPrincipalDefinition + ) -> [String]? { + alterResult + } + + func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? { + ["ALTER ROLE \(principal.name) PASSWORD"] + } + + func generateDropPrincipalSQL( + principal: PluginPrincipalRef, + options: PluginPrincipalDropOptions + ) -> [String]? { + options.dropOwned + ? ["DROP OWNED BY \(principal.name)", "DROP ROLE \(principal.name)"] + : ["DROP ROLE \(principal.name)"] + } + + func generateGrantSQL(changeSet: PluginPrincipalChangeSet) -> [String]? { + ["GRANT TO \(changeSet.principal.name)"] + } + + func generateRevokeSQL(changeSet: PluginPrincipalChangeSet) -> [String]? { + ["REVOKE FROM \(changeSet.principal.name)"] + } +} + +@Suite("Principal statement generation") +struct PrincipalStatementGeneratorTests { + private let alice = PluginPrincipalRef(name: "alice") + + @Test("Creates run before grants, revokes before grants, drops last") + func ordersStatements() throws { + let generator = PrincipalStatementGenerator(driver: MockPrincipalDriver()) + let changes: [PrincipalChange] = [ + .drop(ref: PluginPrincipalRef(name: "bob"), options: PluginPrincipalDropOptions()), + .modifyGrants( + PluginPrincipalChangeSet( + principal: alice, + grantsToAdd: [PluginGrantInfo(privilege: "CONNECT", scope: .database("app"))], + grantsToRemove: [PluginGrantInfo(privilege: "CREATE", scope: .database("app"))] + ) + ), + .create(PluginPrincipalDefinition(ref: alice)) + ] + + let sql = try generator.generate(changes: changes).map(\.sql) + + #expect(sql == [ + "CREATE ROLE alice", + "REVOKE FROM alice", + "GRANT TO alice", + "DROP ROLE bob" + ]) + } + + @Test("Drops and revokes are marked destructive") + func marksDestructive() throws { + let generator = PrincipalStatementGenerator(driver: MockPrincipalDriver()) + let statements = try generator.generate(changes: [ + .drop(ref: alice, options: PluginPrincipalDropOptions()), + .create(PluginPrincipalDefinition(ref: PluginPrincipalRef(name: "carol"))) + ]) + + let destructive = statements.filter(\.isDestructive).map(\.sql) + #expect(destructive == ["DROP ROLE alice"]) + } + + @Test("Drop options reach the driver") + func passesDropOptions() throws { + let generator = PrincipalStatementGenerator(driver: MockPrincipalDriver()) + let statements = try generator.generate(changes: [ + .drop(ref: alice, options: PluginPrincipalDropOptions(dropOwned: true)) + ]) + + #expect(statements.map(\.sql) == ["DROP OWNED BY alice", "DROP ROLE alice"]) + } + + @Test("A driver returning no statements for an alter is not an error") + func emptyAlterYieldsNoStatements() throws { + let driver = MockPrincipalDriver() + driver.alterResult = [] + + let statements = try PrincipalStatementGenerator(driver: driver).generate(changes: [ + .alter( + old: PluginPrincipalDefinition(ref: alice), + new: PluginPrincipalDefinition(ref: alice, canLogin: false) + ) + ]) + #expect(statements.isEmpty) + } + + @Test("A driver that cannot alter at all still throws") + func unsupportedAlterThrows() { + let driver = MockPrincipalDriver() + driver.alterResult = nil + + #expect(throws: DatabaseError.self) { + try PrincipalStatementGenerator(driver: driver).generate(changes: [ + .alter( + old: PluginPrincipalDefinition(ref: alice), + new: PluginPrincipalDefinition(ref: alice, canLogin: false) + ) + ]) + } + } +} diff --git a/TableProTests/Core/UsersRoles/PrivilegeEffectivenessTests.swift b/TableProTests/Core/UsersRoles/PrivilegeEffectivenessTests.swift new file mode 100644 index 000000000..b782401fa --- /dev/null +++ b/TableProTests/Core/UsersRoles/PrivilegeEffectivenessTests.swift @@ -0,0 +1,249 @@ +// +// PrivilegeEffectivenessTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Privilege effectiveness") +struct PrivilegeEffectivenessTests { + private let table = PluginPrivilegeScope.table(database: "app", schema: "public", table: "orders") + private let column = PluginPrivilegeScope.column( + database: "app", + schema: "public", + table: "orders", + column: "total" + ) + + private let mysqlCascade: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool = { + $0.contains($1) + } + + private let postgresCascade: (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool = { ancestor, descendant in + guard case .table = ancestor, case .column = descendant else { return false } + return ancestor.contains(descendant) + } + + private func context( + cascade: @escaping (PluginPrivilegeScope, PluginPrivilegeScope) -> Bool, + roles: [String] = [], + grants: [PluginPrincipalRef: Set] = [:], + inheritsAutomatically: Bool = true + ) -> PrivilegeInheritanceContext { + PrivilegeInheritanceContext( + grantsByPrincipal: grants, + roleClosure: roles, + inheritsAutomatically: inheritsAutomatically, + cascades: cascade + ) + } + + @Test("A direct grant outranks every inherited source") + func directWins() { + let result = PrivilegeEffectivenessResolver.resolve( + privilege: "SELECT", + scope: table, + directGrants: [PrincipalGrantKey(privilege: "SELECT", scope: table)], + context: context(cascade: mysqlCascade) + ) + #expect(result == .direct) + } + + @Test("MySQL cascades a database grant down to a table") + func mysqlCascadesFromDatabase() { + let result = PrivilegeEffectivenessResolver.resolve( + privilege: "SELECT", + scope: table, + directGrants: [PrincipalGrantKey(privilege: "SELECT", scope: .database("app"))], + context: context(cascade: mysqlCascade) + ) + #expect(result == .viaScope(.database("app"))) + } + + @Test("PostgreSQL does not cascade a database grant to a table") + func postgresDoesNotCascadeFromDatabase() { + let result = PrivilegeEffectivenessResolver.resolve( + privilege: "SELECT", + scope: table, + directGrants: [PrincipalGrantKey(privilege: "SELECT", scope: .database("app"))], + context: context(cascade: postgresCascade) + ) + #expect(result == .notEffective) + } + + @Test("PostgreSQL does cascade a table grant to a column") + func postgresCascadesToColumn() { + let result = PrivilegeEffectivenessResolver.resolve( + privilege: "SELECT", + scope: column, + directGrants: [PrincipalGrantKey(privilege: "SELECT", scope: table)], + context: context(cascade: postgresCascade) + ) + #expect(result == .viaScope(table)) + } + + @Test("A privilege held by a granted role is reported as inherited") + func inheritsFromRole() { + let role = PluginPrincipalRef(name: "app_ro") + let result = PrivilegeEffectivenessResolver.resolve( + privilege: "SELECT", + scope: table, + directGrants: [], + context: context( + cascade: postgresCascade, + roles: ["app_ro"], + grants: [role: [PrincipalGrantKey(privilege: "SELECT", scope: table)]] + ) + ) + #expect(result == .viaRole(name: "app_ro", isAutomatic: true)) + } + + @Test("A NOINHERIT role is reported as needing SET ROLE") + func reportsNonAutomaticInheritance() { + let role = PluginPrincipalRef(name: "app_ro") + let result = PrivilegeEffectivenessResolver.resolve( + privilege: "SELECT", + scope: table, + directGrants: [], + context: context( + cascade: postgresCascade, + roles: ["app_ro"], + grants: [role: [PrincipalGrantKey(privilege: "SELECT", scope: table)]], + inheritsAutomatically: false + ) + ) + #expect(result == .viaRole(name: "app_ro", isAutomatic: false)) + } + + @Test("Role closure is transitive and terminates on a cycle") + func buildsTransitiveClosure() { + let principals = [ + PluginPrincipalInfo(ref: PluginPrincipalRef(name: "alice"), memberOf: ["dev"]), + PluginPrincipalInfo(ref: PluginPrincipalRef(name: "dev"), memberOf: ["readonly"]), + PluginPrincipalInfo(ref: PluginPrincipalRef(name: "readonly"), memberOf: ["alice"]) + ] + let closure = PrivilegeEffectivenessResolver.roleClosure( + for: PluginPrincipalRef(name: "alice"), + principals: principals + ) + #expect(Set(closure) == ["dev", "readonly"]) + } +} + +@Suite("Scope summary") +struct ScopeSummaryTests { + private let descriptors = [ + PluginPrivilegeDescriptor(name: "SELECT", label: "Select"), + PluginPrivilegeDescriptor(name: "INSERT", label: "Insert") + ] + + @Test("A database we cannot browse is never reported as having no privileges") + func browsingRestrictedIsDistinct() { + let summary = ScopeSummary.make( + granted: ["CONNECT"], + grantable: descriptors, + descendantCount: 0, + hasGrantOption: false, + isBrowsingRestricted: true + ) + #expect(summary == .browsingRestricted(direct: ["CONNECT"])) + #expect(summary != .none) + } + + @Test("Every grantable privilege granted collapses to All") + func collapsesToAll() { + let summary = ScopeSummary.make( + granted: ["SELECT", "INSERT"], + grantable: descriptors, + descendantCount: 0, + hasGrantOption: false, + isBrowsingRestricted: false + ) + #expect(summary == .all(count: 2)) + } + + @Test("Descendant grants surface when the scope itself has none") + func reportsDescendants() { + let summary = ScopeSummary.make( + granted: [], + grantable: descriptors, + descendantCount: 3, + hasGrantOption: false, + isBrowsingRestricted: false + ) + #expect(summary == .descendantsOnly(count: 3)) + } + + @Test("Nothing grantable at this level is distinct from nothing granted") + func distinguishesNotGrantable() { + #expect( + ScopeSummary.make( + granted: [], + grantable: [], + descendantCount: 0, + hasGrantOption: false, + isBrowsingRestricted: false + ) == .notGrantable + ) + } +} + +@Suite("Password generator") +struct PasswordGeneratorTests { + @Test("Generates the requested length from an unambiguous alphabet") + func generatesLength() { + let password = PasswordGenerator.generate(length: 24) + #expect(password.count == 24) + #expect(!password.contains(where: { "0O1lI".contains($0) })) + } + + @Test("Successive passwords differ") + func generatesUniqueValues() { + let generated = Set((0 ..< 200).map { _ in PasswordGenerator.generate() }) + #expect(generated.count == 200) + } +} + +@Suite("Privilege categories") +struct PrivilegeCategoryTests { + @Test("Known keys map to localized titles in a stable order") + func mapsKnownKeys() { + #expect(PrivilegeCategory.resolve(PluginPrivilegeCategoryKey.data).sortOrder == 0) + #expect(PrivilegeCategory.resolve(PluginPrivilegeCategoryKey.structure).sortOrder == 1) + #expect(PrivilegeCategory.resolve(PluginPrivilegeCategoryKey.administration).isCollapsedByDefault) + #expect(PrivilegeCategory.resolve(PluginPrivilegeCategoryKey.dynamic).isCollapsedByDefault) + } + + @Test("A missing key becomes Other, an unknown key is shown verbatim") + func handlesUnknownKeys() { + #expect(PrivilegeCategory.resolve(nil) == .other) + #expect(PrivilegeCategory.resolve("").key == "other") + #expect(PrivilegeCategory.resolve("future").title == "future") + } + + @Test("Grouping sorts categories and keeps their privileges") + func groupsDescriptors() { + let grouped = PrivilegeCategory.group([ + PluginPrivilegeDescriptor( + name: "SUPER", + label: "Super", + category: PluginPrivilegeCategoryKey.administration + ), + PluginPrivilegeDescriptor( + name: "SELECT", + label: "Select", + category: PluginPrivilegeCategoryKey.data + ) + ]) + + #expect(grouped.map(\.category.key) == [ + PluginPrivilegeCategoryKey.data, + PluginPrivilegeCategoryKey.administration + ]) + #expect(grouped[0].descriptors.map(\.name) == ["SELECT"]) + } +} diff --git a/TableProTests/Plugins/MySQLGrantEscapingTests.swift b/TableProTests/Plugins/MySQLGrantEscapingTests.swift new file mode 100644 index 000000000..2fe9e326c --- /dev/null +++ b/TableProTests/Plugins/MySQLGrantEscapingTests.swift @@ -0,0 +1,277 @@ +// +// MySQLGrantEscapingTests.swift +// TableProTests +// +// In a MySQL GRANT the database-name position is a LIKE pattern, so `_` and `%` +// must be escaped or a grant intended for one database silently matches others. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("MySQL GRANT pattern escaping") +struct MySQLGrantEscapingTests { + @Test("Wildcard characters are escaped in the database position") + func escapesWildcards() { + #expect(MySQLGrantPatternEscaping.escapeDatabasePattern("prod_forums") == #"prod\_forums"#) + #expect(MySQLGrantPatternEscaping.escapeDatabasePattern("100%off") == #"100\%off"#) + #expect(MySQLGrantPatternEscaping.escapeDatabasePattern(#"back\slash"#) == #"back\\slash"#) + } + + @Test("Names without wildcards are left alone") + func leavesPlainNames() { + #expect(MySQLGrantPatternEscaping.escapeDatabasePattern("analytics") == "analytics") + } + + @Test( + "Escaping round-trips", + arguments: [ + "prod_forums", + "100%off", + #"back\slash"#, + "analytics", + "a_b%c", + #"a\_b"#, + #"\"#, + "_", + "%", + "" + ] + ) + func roundTrips(name: String) { + let escaped = MySQLGrantPatternEscaping.escapeDatabasePattern(name) + #expect(MySQLGrantPatternEscaping.unescapeDatabasePattern(escaped) == name) + } +} + +@Suite("MySQL SHOW GRANTS parsing") +struct MySQLGrantParserTests { + @Test("Server scope") + func parsesServerScope() { + let grant = MySQLGrantParser.parseGrant("GRANT SELECT, INSERT ON *.* TO `u`@`h`") + #expect(grant?.scope == .server) + #expect(grant?.privilegeNames == ["SELECT", "INSERT"]) + } + + @Test("Escaped database name is unescaped back to its literal form") + func parsesEscapedDatabaseScope() { + let grant = MySQLGrantParser.parseGrant(#"GRANT SELECT ON `prod\_forums`.* TO `u`@`h`"#) + #expect(grant?.scope == .database("prod_forums")) + } + + @Test("Table scope") + func parsesTableScope() { + let grant = MySQLGrantParser.parseGrant("GRANT SELECT ON `shop`.`orders` TO `u`@`h`") + #expect(grant?.scope == .table(database: "shop", schema: nil, table: "orders")) + } + + @Test("A table-level database name is a literal identifier, not a LIKE pattern") + func doesNotUnescapeTableLevelDatabase() { + // MySQL only treats _ and % as wildcards in the database position of a global or + // database-level grant. Unescaping here would rewrite a name containing a backslash. + let grant = MySQLGrantParser.parseGrant(#"GRANT SELECT ON `a\_b`.`orders` TO `u`@`h`"#) + #expect(grant?.scope == .table(database: #"a\_b"#, schema: nil, table: "orders")) + } + + @Test("WITH GRANT OPTION is detected") + func parsesGrantOption() { + let withOption = MySQLGrantParser.parseGrant("GRANT SELECT ON `db`.* TO `u`@`h` WITH GRANT OPTION") + let withoutOption = MySQLGrantParser.parseGrant("GRANT SELECT ON `db`.* TO `u`@`h`") + #expect(withOption?.isGrantable == true) + #expect(withoutOption?.isGrantable == false) + } + + @Test("Column-scoped privileges keep their column list and do not split on the inner comma") + func parsesColumnScopedPrivileges() { + let grant = MySQLGrantParser.parseGrant( + "GRANT SELECT (`id`, `name`), INSERT ON `db`.`t` TO `u`@`h`" + ) + #expect(grant?.privilegeNames == ["SELECT", "INSERT"]) + #expect(grant?.isColumnScoped == true) + #expect(grant?.privileges.first?.columns == ["id", "name"]) + #expect(grant?.privileges.last?.columns.isEmpty == true) + } + + @Test("Multi-word and dynamic privileges survive") + func parsesMultiWordPrivileges() { + let multiWord = MySQLGrantParser.parseGrant( + "GRANT CREATE TEMPORARY TABLES, REPLICATION SLAVE ON *.* TO `u`@`h`" + ) + #expect(multiWord?.privilegeNames == ["CREATE TEMPORARY TABLES", "REPLICATION SLAVE"]) + + let dynamic = MySQLGrantParser.parseGrant("GRANT BACKUP_ADMIN ON *.* TO `u`@`h`") + #expect(dynamic?.privilegeNames == ["BACKUP_ADMIN"]) + } + + @Test("ALL PRIVILEGES is kept as a sentinel for the driver to expand") + func parsesAllPrivileges() { + let grant = MySQLGrantParser.parseGrant("GRANT ALL PRIVILEGES ON `db`.* TO `u`@`h`") + #expect(grant?.privilegeNames == [MySQLGrantParser.allPrivileges]) + } + + @Test("Role grants are not privilege grants") + func separatesRoleGrants() { + let line = "GRANT `dev`@`%` TO `alice`@`localhost`" + #expect(MySQLGrantParser.parseGrant(line) == nil) + #expect(MySQLGrantParser.parseRoleGrant(line) == ["dev"]) + } +} + +@Suite("Grant SQL builder") +struct PluginGrantSQLBuilderTests { + private func mysqlQuote(_ value: String) -> String { + "`" + value.replacingOccurrences(of: "`", with: "``") + "`" + } + + private func mysqlTarget(_ scope: PluginPrivilegeScope) -> String? { + func database(_ name: String) -> String { + mysqlQuote(MySQLGrantPatternEscaping.escapeDatabasePattern(name)) + } + switch scope { + case .server: + return "*.*" + case let .database(name): + return "\(database(name)).*" + case let .schema(db, _): + return "\(database(db)).*" + case let .table(db, _, table), let .column(db, _, table, _): + return "\(database(db)).\(mysqlQuote(table))" + } + } + + private var builder: PluginGrantSQLBuilder { + PluginGrantSQLBuilder( + grantee: "`u`@`h`", + quoteIdentifier: mysqlQuote, + target: mysqlTarget + ) + } + + @Test("A table grant and its column grants become one statement with a wildcard-escaped database") + func buildsSingleStatementPerObject() { + let table = PluginPrivilegeScope.table(database: "prod_forums", schema: nil, table: "orders") + let statements = builder.grantStatements([ + PluginGrantInfo(privilege: "SELECT", scope: table), + PluginGrantInfo( + privilege: "UPDATE", + scope: .column(database: "prod_forums", schema: nil, table: "orders", column: "total") + ), + PluginGrantInfo( + privilege: "UPDATE", + scope: .column(database: "prod_forums", schema: nil, table: "orders", column: "status") + ) + ]) + + #expect(statements == [ + #"GRANT SELECT, UPDATE (`total`, `status`) ON `prod\_forums`.`orders` TO `u`@`h`"# + ]) + } + + @Test("WITH GRANT OPTION is emitted only when the grant carries it") + func emitsGrantOption() { + let grantable = builder.grantStatements([ + PluginGrantInfo(privilege: "SELECT", scope: .database("app"), isGrantable: true) + ]) + let plain = builder.grantStatements([ + PluginGrantInfo(privilege: "SELECT", scope: .database("app")) + ]) + + #expect(grantable == ["GRANT SELECT ON `app`.* TO `u`@`h` WITH GRANT OPTION"]) + #expect(plain == ["GRANT SELECT ON `app`.* TO `u`@`h`"]) + } + + @Test("Revoke uses FROM and never carries a grant option") + func buildsRevoke() { + let statements = builder.revokeStatements([ + PluginGrantInfo(privilege: "SELECT", scope: .database("app"), isGrantable: true) + ]) + #expect(statements == ["REVOKE SELECT ON `app`.* FROM `u`@`h`"]) + } + + @Test("A privilege name that is not a keyword is never interpolated into SQL") + func dropsHostilePrivilegeNames() { + let statements = builder.grantStatements([ + PluginGrantInfo(privilege: "SELECT; DROP DATABASE x; --", scope: .database("app")) + ]) + #expect(statements.isEmpty) + } + + @Test("A scope with no target in this dialect produces no statement") + func dropsUntargetableScopes() { + let noTarget = PluginGrantSQLBuilder( + grantee: "\"analytics\"", + quoteIdentifier: { "\"\($0)\"" }, + target: { scope in + if case .server = scope { return nil } + return "DATABASE \"x\"" + } + ) + #expect(noTarget.grantStatements([ + PluginGrantInfo(privilege: "SELECT", scope: .server) + ]).isEmpty) + } +} + +@Suite("Grant grouping") +struct PluginGrantGroupingTests { + private let table = PluginPrivilegeScope.table(database: "app", schema: "public", table: "orders") + + private func column(_ name: String) -> PluginPrivilegeScope { + .column(database: "app", schema: "public", table: "orders", column: name) + } + + @Test("Column grants fold onto their parent table so one statement covers the object") + func foldsColumnsOntoTable() { + let groups = PluginGrantGrouping.group([ + PluginGrantInfo(privilege: "SELECT", scope: table), + PluginGrantInfo(privilege: "UPDATE", scope: column("total")), + PluginGrantInfo(privilege: "UPDATE", scope: column("status")), + PluginGrantInfo(privilege: "SELECT", scope: column("total")) + ]) + + #expect(groups.count == 1) + #expect(groups[0].scope == table) + #expect(groups[0].privileges == ["SELECT"]) + #expect(groups[0].columnPrivileges == [ + PluginColumnPrivilege(privilege: "SELECT", columns: ["total"]), + PluginColumnPrivilege(privilege: "UPDATE", columns: ["total", "status"]) + ]) + } + + @Test("Distinct scopes stay in distinct groups") + func keepsScopesSeparate() { + let groups = PluginGrantGrouping.group([ + PluginGrantInfo(privilege: "CONNECT", scope: .database("app")), + PluginGrantInfo(privilege: "SELECT", scope: table) + ]) + + #expect(groups.count == 2) + #expect(groups.map(\.scope) == [.database("app"), table]) + } + + @Test("Grant option on any grant marks the whole group grantable") + func propagatesGrantOption() { + let groups = PluginGrantGrouping.group([ + PluginGrantInfo(privilege: "SELECT", scope: table, isGrantable: true) + ]) + #expect(groups[0].isGrantable) + } +} + +@Suite("Privilege name sanitizer") +struct PluginPrivilegeNameTests { + @Test("Rejects anything that is not a privilege keyword") + func rejectsInjection() { + #expect(PluginPrivilegeName.sanitized("SELECT; DROP DATABASE x; --") == nil) + #expect(PluginPrivilegeName.sanitized("SEL`ECT") == nil) + #expect(PluginPrivilegeName.sanitized("") == nil) + } + + @Test("Accepts real privilege names") + func acceptsPrivileges() { + #expect(PluginPrivilegeName.sanitized("select") == "SELECT") + #expect(PluginPrivilegeName.sanitized("BACKUP_ADMIN") == "BACKUP_ADMIN") + #expect(PluginPrivilegeName.sanitized("CREATE TEMPORARY TABLES") == "CREATE TEMPORARY TABLES") + } +} diff --git a/docs/docs.json b/docs/docs.json index ca12e572c..ce1dab436 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -109,7 +109,8 @@ "features/er-diagram", "features/explain-visualization", "features/json-viewer", - "features/server-dashboard" + "features/server-dashboard", + "features/users-roles" ] }, { diff --git a/docs/features/users-roles.mdx b/docs/features/users-roles.mdx new file mode 100644 index 000000000..6a1ee8072 --- /dev/null +++ b/docs/features/users-roles.mdx @@ -0,0 +1,68 @@ +--- +title: "Users & Roles" +description: "Manage database users, roles, and privileges without writing GRANT statements by hand." +--- + +TablePro can manage the users and roles on a server and the privileges they hold. Changes are staged, so nothing reaches the server until you review the SQL and apply it. + +Available on **MySQL**, **MariaDB**, and **PostgreSQL** connections. Open it from **View > Users & Roles**. The command is disabled on connections whose driver does not support it. + +## The layout + +The tab has three panes: + +- **Users and roles** on the left. Filter, sort, and select an account. **+** and **-** below the list create and drop accounts. +- **Objects** in the middle: the server, its databases, and, as you expand them, schemas, tables, and columns. The **Privileges** column tells you what the selected account has on each object without having to click it. +- **Privileges** on the right: the privileges that can actually be granted on the object you selected, grouped into Data, Structure, Administration, and Dynamic. + +Select several objects of the same kind to grant a privilege on all of them at once. + +## Effective privileges + +A checkbox tells you what is granted **directly** on that object. The **Effective** column tells you where access actually comes from: + +- **Inherited from `role`** means the account holds the privilege through a role it is a member of. PostgreSQL roles are followed transitively. If the account has `NOINHERIT`, the privilege only applies after `SET ROLE`, and TablePro says so. +- **Granted on `object`** means a privilege on a parent object already covers this one. This is engine-specific: on MySQL a database-level grant covers the tables inside it, while on PostgreSQL it does not. TablePro asks the driver rather than guessing. + +This is the difference between "this box is unchecked" and "this user cannot do this". They are not the same thing, and the column exists to keep them apart. + +## Granting and revoking + +Toggling a checkbox stages a change. The account, the object, and the privilege each show that they have unsaved edits, so a change is findable even in a server with hundreds of tables. + +**Review & Apply** shows the exact statements. Statements that remove access are listed too. You can copy the SQL, open it in a query editor, or run it. + +Changes are always a diff. Unchecking a box you just checked cancels out instead of generating a redundant `REVOKE`, and a privilege that was grantable to others stays grantable. + +Changes run through the same safeguards as any other write: a read-only connection blocks them, and Safe Mode asks for confirmation or authentication as configured. + + +If a change would remove access for the account your connection is using, TablePro warns you in the review sheet before the SQL runs. It does not block the change, since revoking your own admin privileges can be deliberate. + + +## Refreshing while you have unsaved changes + +Press ⌘R at any time. TablePro re-reads the server and keeps your staged changes on top of it. If another DBA already made a change you had staged, that change quietly disappears from your diff because there is nothing left to do. + +## Dropping an account + +Select it and press ⌫, or use **-**, or the context menu. The drop is staged and struck through, and ⌘Z undoes it. Nothing runs until you apply. + +On PostgreSQL, a role that owns objects cannot simply be dropped. TablePro detects this and asks whether to reassign the owned objects to another role or drop them, generating `REASSIGN OWNED` or `DROP OWNED` alongside the `DROP ROLE`. + + +`REASSIGN OWNED` and `DROP OWNED` only affect the database you are connected to, plus shared objects. A role that owns objects in several databases needs the same step run in each. This is a PostgreSQL behaviour, not a TablePro limitation. + + +## What PostgreSQL cannot show you + +PostgreSQL stores schema, table, and column privileges per database and cannot read them for a database it is not connected to. Those databases still appear, still show their cluster-wide database-level grants, and are marked as not browsable. They are never shown as though the account had no privileges there. + +MySQL has no such restriction. + +## What is not covered + +- Editing `WITH GRANT OPTION`. It is displayed, and never silently removed, but it is not editable. +- `ALTER DEFAULT PRIVILEGES` and `GRANT ... ON ALL TABLES IN SCHEMA`. +- MySQL 8 roles. MySQL accounts are managed as users. +- Renaming an account.