From 5a931ec2ec3565895669072f56a0d7bf0fa78023 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 16:44:27 +0700 Subject: [PATCH 1/6] feat(connections): AWS IAM authentication for PostgreSQL and MySQL (#1291) --- CHANGELOG.md | 1 + Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 53 ++- .../PostgreSQLPlugin.swift | 50 +++ TablePro/Core/Database/AWS/AWSAuthError.swift | 32 ++ .../Database/AWS/AWSCredentialResolver.swift | 89 +++++ .../Core/Database/AWS/AWSCredentials.swift | 12 + TablePro/Core/Database/AWS/AWSSSO.swift | 305 ++++++++++++++++++ TablePro/Core/Database/AWS/AWSSigV4.swift | 55 ++++ .../Database/AWS/RDSAuthTokenGenerator.swift | 85 +++++ TablePro/Core/Database/AWS/RDSEndpoint.swift | 20 ++ TablePro/Core/Database/DatabaseDriver.swift | 36 ++- .../Database/DatabaseManager+Health.swift | 2 +- .../Database/DatabaseManager+Sessions.swift | 2 +- .../Connection/DatabaseConnection.swift | 5 + .../ViewModels/AuthPaneViewModel.swift | 9 +- TableProTests/AWS/AWSIAMAuthTests.swift | 194 +++++++++++ docs/databases/mysql.mdx | 17 + docs/databases/postgresql.mdx | 18 +- 18 files changed, 974 insertions(+), 11 deletions(-) create mode 100644 TablePro/Core/Database/AWS/AWSAuthError.swift create mode 100644 TablePro/Core/Database/AWS/AWSCredentialResolver.swift create mode 100644 TablePro/Core/Database/AWS/AWSCredentials.swift create mode 100644 TablePro/Core/Database/AWS/AWSSSO.swift create mode 100644 TablePro/Core/Database/AWS/AWSSigV4.swift create mode 100644 TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift create mode 100644 TablePro/Core/Database/AWS/RDSEndpoint.swift create mode 100644 TableProTests/AWS/AWSIAMAuthTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0a5d639..7a0cf8c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304) +- AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291) ## [0.44.0] - 2026-05-23 diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 4a4cd9e8e..b85817d53 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -22,7 +22,58 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "MySQL" static let iconName = "mysql-icon" static let defaultPort = 3306 - static let additionalConnectionFields: [ConnectionField] = [] + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "awsAuth", + label: String(localized: "Authentication"), + defaultValue: "off", + fieldType: .dropdown(options: [ + .init(value: "off", label: String(localized: "Password")), + .init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")), + .init(value: "profile", label: String(localized: "AWS IAM (Profile)")), + .init(value: "sso", label: String(localized: "AWS IAM (SSO)")) + ]), + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "awsRegion", + label: String(localized: "AWS Region"), + placeholder: "us-east-1", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"]) + ), + ConnectionField( + id: "awsAccessKeyId", + label: String(localized: "Access Key ID"), + placeholder: "AKIA...", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSecretAccessKey", + label: String(localized: "Secret Access Key"), + placeholder: "wJalr...", + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSessionToken", + label: String(localized: "Session Token"), + placeholder: String(localized: "Optional, for temporary credentials"), + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsProfileName", + label: String(localized: "Profile Name"), + placeholder: "default", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"]) + ), + ] static let additionalDatabaseTypeIds: [String] = ["MariaDB"] // MARK: - UI/Capability Metadata diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 89ba53455..d4476958b 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -30,6 +30,56 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { section: .authentication, hidesPassword: true ), + ConnectionField( + id: "awsAuth", + label: String(localized: "Authentication"), + defaultValue: "off", + fieldType: .dropdown(options: [ + .init(value: "off", label: String(localized: "Password")), + .init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")), + .init(value: "profile", label: String(localized: "AWS IAM (Profile)")), + .init(value: "sso", label: String(localized: "AWS IAM (SSO)")) + ]), + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "awsRegion", + label: String(localized: "AWS Region"), + placeholder: "us-east-1", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"]) + ), + ConnectionField( + id: "awsAccessKeyId", + label: String(localized: "Access Key ID"), + placeholder: "AKIA...", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSecretAccessKey", + label: String(localized: "Secret Access Key"), + placeholder: "wJalr...", + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSessionToken", + label: String(localized: "Session Token"), + placeholder: String(localized: "Optional, for temporary credentials"), + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsProfileName", + label: String(localized: "Profile Name"), + placeholder: "default", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"]) + ), ConnectionField( id: "connectionOptions", label: String(localized: "Connection Options"), diff --git a/TablePro/Core/Database/AWS/AWSAuthError.swift b/TablePro/Core/Database/AWS/AWSAuthError.swift new file mode 100644 index 000000000..aac89b9ab --- /dev/null +++ b/TablePro/Core/Database/AWS/AWSAuthError.swift @@ -0,0 +1,32 @@ +// +// AWSAuthError.swift +// TablePro +// + +import Foundation + +enum AWSAuthError: Error, LocalizedError, Equatable { + case missingAccessKey + case credentialsFileUnreadable + case profileIncomplete(String) + case regionUnknown(host: String) + + var errorDescription: String? { + switch self { + case .missingAccessKey: + return String(localized: "Access Key ID and Secret Access Key are required for AWS IAM authentication.") + case .credentialsFileUnreadable: + return String(localized: "Cannot read ~/.aws/credentials.") + case .profileIncomplete(let profile): + return String( + format: String(localized: "Profile \"%@\" was not found or is missing keys in ~/.aws/credentials."), + profile + ) + case .regionUnknown(let host): + return String( + format: String(localized: "Could not determine an AWS region for \"%@\". Set the AWS Region field."), + host + ) + } + } +} diff --git a/TablePro/Core/Database/AWS/AWSCredentialResolver.swift b/TablePro/Core/Database/AWS/AWSCredentialResolver.swift new file mode 100644 index 000000000..d2422d6bb --- /dev/null +++ b/TablePro/Core/Database/AWS/AWSCredentialResolver.swift @@ -0,0 +1,89 @@ +// +// AWSCredentialResolver.swift +// TablePro +// + +import Foundation + +enum AWSCredentialResolver { + static func resolve(source: String, fields: [String: String]) async throws -> AWSCredentials { + switch source { + case "profile": + return try resolveProfile(fields: fields) + case "sso": + return try await resolveSSO(fields: fields) + default: + return try resolveAccessKey(fields: fields) + } + } + + private static func resolveAccessKey(fields: [String: String]) throws -> AWSCredentials { + let accessKeyId = fields["awsAccessKeyId"] ?? "" + let secretAccessKey = fields["awsSecretAccessKey"] ?? "" + let sessionToken = fields["awsSessionToken"] + + guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { + throw AWSAuthError.missingAccessKey + } + + return AWSCredentials( + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken + ) + } + + private static func resolveProfile(fields: [String: String]) throws -> AWSCredentials { + let profileName = fields["awsProfileName"].flatMap { $0.isEmpty ? nil : $0 } ?? "default" + let credentialsPath = NSString("~/.aws/credentials").expandingTildeInPath + + guard let content = try? String(contentsOfFile: credentialsPath, encoding: .utf8) else { + throw AWSAuthError.credentialsFileUnreadable + } + + let sections = AWSSSO.parseIniSections(content) + guard let profile = sections[profileName] else { + throw AWSAuthError.profileIncomplete(profileName) + } + + let accessKeyId = profile["aws_access_key_id"] ?? "" + let secretAccessKey = profile["aws_secret_access_key"] ?? "" + guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else { + throw AWSAuthError.profileIncomplete(profileName) + } + + return AWSCredentials( + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + sessionToken: profile["aws_session_token"] + ) + } + + private static func resolveSSO(fields: [String: String]) async throws -> AWSCredentials { + let profileName = fields["awsProfileName"].flatMap { $0.isEmpty ? nil : $0 } ?? "default" + let configPath = NSString("~/.aws/config").expandingTildeInPath + let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath + + guard let configContent = try? String(contentsOfFile: configPath, encoding: .utf8) else { + throw AWSSSOError.configReadFailed + } + + let settings = try AWSSSO.parseProfileSettings(configContent: configContent, profileName: profileName) + let accessToken = try AWSSSO.readAccessToken( + cacheDirectory: cacheDir, + settings: settings, + profileName: profileName + ) + let credentials = try await AWSSSO.fetchRoleCredentials( + accessToken: accessToken, + settings: settings, + profileName: profileName, + session: URLSession.shared + ) + return AWSCredentials( + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken + ) + } +} diff --git a/TablePro/Core/Database/AWS/AWSCredentials.swift b/TablePro/Core/Database/AWS/AWSCredentials.swift new file mode 100644 index 000000000..ea8a1779e --- /dev/null +++ b/TablePro/Core/Database/AWS/AWSCredentials.swift @@ -0,0 +1,12 @@ +// +// AWSCredentials.swift +// TablePro +// + +import Foundation + +struct AWSCredentials: Sendable { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String? +} diff --git a/TablePro/Core/Database/AWS/AWSSSO.swift b/TablePro/Core/Database/AWS/AWSSSO.swift new file mode 100644 index 000000000..d33a86828 --- /dev/null +++ b/TablePro/Core/Database/AWS/AWSSSO.swift @@ -0,0 +1,305 @@ +// +// AWSSSO.swift +// TablePro +// +// AWS SSO credential resolution: reads the OIDC access token from +// ~/.aws/sso/cache/ and exchanges it for STS credentials via the SSO portal +// GetRoleCredentials endpoint. Matches the flow used by AWS SDKs. +// + +import CommonCrypto +import Foundation + +struct AWSSSOProfileSettings: Equatable, Sendable { + let accountId: String + let roleName: String + let startUrl: String + let region: String + let ssoSession: String? +} + +struct AWSSSORoleCredentials: Equatable, Sendable { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String +} + +enum AWSSSOError: Error, LocalizedError, Equatable { + case configReadFailed + case profileNotFound(String) + case profileMissingFields(profile: String) + case sessionNotFound(profile: String, session: String) + case sessionMissingFields(session: String) + case profileMissingUrlOrRegion(String) + case tokenCacheNotFound(profile: String) + case tokenCacheMalformed(profile: String) + case tokenExpired(profile: String) + case urlBuildFailed(profile: String) + case networkFailure(profile: String, underlying: String) + case invalidResponse(profile: String) + case sessionUnauthorized(profile: String) + case roleNotAccessible(role: String, account: String) + case portalError(profile: String, status: Int) + case responseDecodeFailed(profile: String) + case credentialsAlreadyExpired(profile: String) + + var errorDescription: String? { + switch self { + case .configReadFailed: + return String(localized: "Cannot read ~/.aws/config.") + case .profileNotFound(let profile): + return String(format: String(localized: "Profile \"%@\" not found in ~/.aws/config."), profile) + case .profileMissingFields(let profile): + return String( + format: String(localized: "Profile \"%@\" in ~/.aws/config is missing sso_account_id or sso_role_name."), + profile + ) + case .sessionNotFound(let profile, let session): + return String( + format: String(localized: "SSO session \"%@\" referenced by profile \"%@\" was not found in ~/.aws/config."), + session, profile + ) + case .sessionMissingFields(let session): + return String( + format: String(localized: "SSO session \"%@\" in ~/.aws/config is missing sso_start_url or sso_region."), + session + ) + case .profileMissingUrlOrRegion(let profile): + return String( + format: String(localized: "Profile \"%@\" in ~/.aws/config is missing sso_start_url or sso_region."), + profile + ) + case .tokenCacheNotFound(let profile): + return String( + format: String(localized: "SSO token cache not found for profile \"%@\". Run 'aws sso login --profile %@' first."), + profile, profile + ) + case .tokenCacheMalformed(let profile): + return String( + format: String(localized: "SSO token cache for profile \"%@\" is malformed. Run 'aws sso login --profile %@' to refresh."), + profile, profile + ) + case .tokenExpired(let profile), .sessionUnauthorized(let profile): + return String( + format: String(localized: "SSO session for profile \"%@\" has expired. Run 'aws sso login --profile %@' to refresh."), + profile, profile + ) + case .urlBuildFailed(let profile): + return String(format: String(localized: "Failed to build the SSO portal URL for profile \"%@\"."), profile) + case .networkFailure(let profile, let underlying): + return String( + format: String(localized: "Failed to reach the SSO portal for profile \"%@\": %@"), + profile, underlying + ) + case .invalidResponse(let profile): + return String(format: String(localized: "Unexpected response from the SSO portal for profile \"%@\"."), profile) + case .roleNotAccessible(let role, let account): + return String( + format: String(localized: "Role \"%@\" in account \"%@\" is not accessible via SSO. Check role permissions in IAM Identity Center."), + role, account + ) + case .portalError(let profile, let status): + return String( + format: String(localized: "SSO portal returned HTTP %lld for profile \"%@\"."), + Int64(status), profile + ) + case .responseDecodeFailed(let profile): + return String(format: String(localized: "Failed to decode the SSO portal response for profile \"%@\"."), profile) + case .credentialsAlreadyExpired(let profile): + return String( + format: String(localized: "SSO role credentials for profile \"%@\" were already expired. Run 'aws sso login --profile %@' to refresh."), + profile, profile + ) + } + } +} + +enum AWSSSO { + static func parseIniSections(_ content: String) -> [String: [String: String]] { + var sections: [String: [String: String]] = [:] + var current = "" + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") || trimmed.hasPrefix(";") { continue } + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + current = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + if sections[current] == nil { + sections[current] = [:] + } + continue + } + + guard !current.isEmpty else { continue } + + let parts = trimmed.split(separator: "=", maxSplits: 1).map { + $0.trimmingCharacters(in: .whitespaces) + } + guard parts.count == 2 else { continue } + + sections[current, default: [:]][parts[0]] = parts[1] + } + + return sections + } + + static func parseProfileSettings(configContent: String, profileName: String) throws -> AWSSSOProfileSettings { + let sections = parseIniSections(configContent) + let profileSection = profileName == "default" ? "default" : "profile \(profileName)" + + guard let profile = sections[profileSection] else { + throw AWSSSOError.profileNotFound(profileName) + } + + guard let accountId = profile["sso_account_id"], let roleName = profile["sso_role_name"] else { + throw AWSSSOError.profileMissingFields(profile: profileName) + } + + let ssoSession = profile["sso_session"] + let resolvedStartUrl: String + let resolvedRegion: String + + if let sessionName = ssoSession { + guard let session = sections["sso-session \(sessionName)"] else { + throw AWSSSOError.sessionNotFound(profile: profileName, session: sessionName) + } + guard let startUrl = session["sso_start_url"], let region = session["sso_region"] else { + throw AWSSSOError.sessionMissingFields(session: sessionName) + } + resolvedStartUrl = startUrl + resolvedRegion = region + } else { + guard let startUrl = profile["sso_start_url"], let region = profile["sso_region"] else { + throw AWSSSOError.profileMissingUrlOrRegion(profileName) + } + resolvedStartUrl = startUrl + resolvedRegion = region + } + + return AWSSSOProfileSettings( + accountId: accountId, + roleName: roleName, + startUrl: resolvedStartUrl, + region: resolvedRegion, + ssoSession: ssoSession + ) + } + + static func readAccessToken( + cacheDirectory: String, + settings: AWSSSOProfileSettings, + profileName: String, + now: Date = Date() + ) throws -> String { + let cacheKey = settings.ssoSession ?? settings.startUrl + let cacheFileName = sha1Hex(Data(cacheKey.utf8)) + ".json" + let cacheFilePath = (cacheDirectory as NSString).appendingPathComponent(cacheFileName) + + guard let data = FileManager.default.contents(atPath: cacheFilePath) else { + throw AWSSSOError.tokenCacheNotFound(profile: profileName) + } + + struct TokenCache: Decodable { + let accessToken: String + let expiresAt: String + } + + let token: TokenCache + do { + token = try JSONDecoder().decode(TokenCache.self, from: data) + } catch { + throw AWSSSOError.tokenCacheMalformed(profile: profileName) + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiresAt = formatter.date(from: token.expiresAt) ?? ISO8601DateFormatter().date(from: token.expiresAt) + if let expiresAt, expiresAt <= now { + throw AWSSSOError.tokenExpired(profile: profileName) + } + + return token.accessToken + } + + static func fetchRoleCredentials( + accessToken: String, + settings: AWSSSOProfileSettings, + profileName: String, + session: URLSession, + now: Date = Date() + ) async throws -> AWSSSORoleCredentials { + var components = URLComponents(string: "https://portal.sso.\(settings.region).amazonaws.com/federation/credentials") + components?.queryItems = [ + URLQueryItem(name: "account_id", value: settings.accountId), + URLQueryItem(name: "role_name", value: settings.roleName) + ] + guard let url = components?.url else { + throw AWSSSOError.urlBuildFailed(profile: profileName) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(accessToken, forHTTPHeaderField: "x-amz-sso_bearer_token") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AWSSSOError.networkFailure(profile: profileName, underlying: error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw AWSSSOError.invalidResponse(profile: profileName) + } + + switch http.statusCode { + case 200: + break + case 401: + throw AWSSSOError.sessionUnauthorized(profile: profileName) + case 403: + throw AWSSSOError.roleNotAccessible(role: settings.roleName, account: settings.accountId) + default: + throw AWSSSOError.portalError(profile: profileName, status: http.statusCode) + } + + struct RoleCredentialsEnvelope: Decodable { + struct RoleCredentials: Decodable { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String + let expiration: Int64 + } + let roleCredentials: RoleCredentials + } + + let envelope: RoleCredentialsEnvelope + do { + envelope = try JSONDecoder().decode(RoleCredentialsEnvelope.self, from: data) + } catch { + throw AWSSSOError.responseDecodeFailed(profile: profileName) + } + + let expiry = Date(timeIntervalSince1970: TimeInterval(envelope.roleCredentials.expiration) / 1_000) + if expiry <= now { + throw AWSSSOError.credentialsAlreadyExpired(profile: profileName) + } + + return AWSSSORoleCredentials( + accessKeyId: envelope.roleCredentials.accessKeyId, + secretAccessKey: envelope.roleCredentials.secretAccessKey, + sessionToken: envelope.roleCredentials.sessionToken + ) + } + + static func sha1Hex(_ data: Data) -> String { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { ptr in + _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/TablePro/Core/Database/AWS/AWSSigV4.swift b/TablePro/Core/Database/AWS/AWSSigV4.swift new file mode 100644 index 000000000..63d38b6fd --- /dev/null +++ b/TablePro/Core/Database/AWS/AWSSigV4.swift @@ -0,0 +1,55 @@ +// +// AWSSigV4.swift +// TablePro +// +// AWS Signature Version 4 primitives over CommonCrypto. Used to presign the +// RDS IAM connect URL. Mirrors the signing primitives in the DynamoDB driver, +// which lives in a separate plugin binary the host cannot link against. +// + +import CommonCrypto +import Foundation + +enum AWSSigV4 { + static func hmac(key: Data, data: Data) -> Data { + var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + key.withUnsafeBytes { keyPtr in + data.withUnsafeBytes { dataPtr in + CCHmac( + CCHmacAlgorithm(kCCHmacAlgSHA256), + keyPtr.baseAddress, key.count, + dataPtr.baseAddress, data.count, + &result + ) + } + } + return Data(result) + } + + static func hmacHex(key: Data, data: Data) -> String { + hmac(key: key, data: data).map { String(format: "%02x", $0) }.joined() + } + + static func sha256Hex(_ data: Data) -> String { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { ptr in + _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() + } + + static func deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String) -> Data { + let kDate = hmac(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8)) + let kRegion = hmac(key: kDate, data: Data(region.utf8)) + let kService = hmac(key: kRegion, data: Data(service.utf8)) + return hmac(key: kService, data: Data("aws4_request".utf8)) + } + + private static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + + static func uriEncode(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value + } +} diff --git a/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift b/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift new file mode 100644 index 000000000..9a87c6085 --- /dev/null +++ b/TablePro/Core/Database/AWS/RDSAuthTokenGenerator.swift @@ -0,0 +1,85 @@ +// +// RDSAuthTokenGenerator.swift +// TablePro +// +// Builds an RDS IAM authentication token: a SigV4 presigned GET URL for the +// rds-db connect action, with the scheme stripped. The token is used as the +// database password and is valid for 15 minutes. +// + +import Foundation + +enum RDSAuthTokenGenerator { + private static let service = "rds-db" + private static let expirySeconds = 900 + + static func generateToken( + host: String, + port: Int, + region: String, + username: String, + credentials: AWSCredentials, + now: Date = Date() + ) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let amzDate = formatter.string(from: now) + formatter.dateFormat = "yyyyMMdd" + let dateStamp = formatter.string(from: now) + + let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let credential = "\(credentials.accessKeyId)/\(credentialScope)" + + var params: [(String, String)] = [ + ("Action", "connect"), + ("DBUser", username), + ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), + ("X-Amz-Credential", credential), + ("X-Amz-Date", amzDate), + ("X-Amz-Expires", String(expirySeconds)), + ("X-Amz-SignedHeaders", "host") + ] + if let sessionToken = credentials.sessionToken, !sessionToken.isEmpty { + params.append(("X-Amz-Security-Token", sessionToken)) + } + + let canonicalQuery = params + .map { (AWSSigV4.uriEncode($0.0), AWSSigV4.uriEncode($0.1)) } + .sorted { $0.0 < $1.0 } + .map { "\($0.0)=\($0.1)" } + .joined(separator: "&") + + let canonicalHeaders = "host:\(host):\(port)\n" + let signedHeaders = "host" + let payloadHash = AWSSigV4.sha256Hex(Data()) + + let canonicalRequest = [ + "GET", + "/", + canonicalQuery, + canonicalHeaders, + signedHeaders, + payloadHash + ].joined(separator: "\n") + + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + AWSSigV4.sha256Hex(Data(canonicalRequest.utf8)) + ].joined(separator: "\n") + + let signingKey = AWSSigV4.deriveSigningKey( + secretKey: credentials.secretAccessKey, + dateStamp: dateStamp, + region: region, + service: service + ) + let signature = AWSSigV4.hmacHex(key: signingKey, data: Data(stringToSign.utf8)) + + let url = "https://\(host):\(port)/?\(canonicalQuery)&X-Amz-Signature=\(signature)" + return String(url.dropFirst("https://".count)) + } +} diff --git a/TablePro/Core/Database/AWS/RDSEndpoint.swift b/TablePro/Core/Database/AWS/RDSEndpoint.swift new file mode 100644 index 000000000..7760ca2ad --- /dev/null +++ b/TablePro/Core/Database/AWS/RDSEndpoint.swift @@ -0,0 +1,20 @@ +// +// RDSEndpoint.swift +// TablePro +// + +import Foundation + +enum RDSEndpoint { + static func region(forHost host: String) -> String? { + let parts = host.split(separator: ".").map(String.init) + guard let rdsIndex = parts.firstIndex(of: "rds"), + rdsIndex > 0, + parts.count > rdsIndex + 1, + parts[rdsIndex + 1] == "amazonaws" else { + return nil + } + let region = parts[rdsIndex - 1] + return region.isEmpty ? nil : region + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 7004a3410..97592229b 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -406,13 +406,13 @@ enum DatabaseDriverFactory { logger.info("Plugin '\(pluginId)' not loaded yet — waiting for background load") await PluginManager.shared.waitForInitialLoad() } - return try createDriverFromPlugin(for: connection, passwordOverride: passwordOverride) + return try await createDriverFromPlugin(for: connection, passwordOverride: passwordOverride) } private static func createDriverFromPlugin( for connection: DatabaseConnection, passwordOverride: String? = nil - ) throws -> DatabaseDriver { + ) async throws -> DatabaseDriver { let pluginId = connection.type.pluginTypeId guard let plugin = PluginManager.shared.driverPlugin(for: connection.type) else { if connection.type.isDownloadablePlugin { @@ -422,23 +422,49 @@ enum DatabaseDriverFactory { "\(pluginId) driver plugin not loaded. The plugin may be disabled or missing from the PlugIns directory." ) } + var ssl = connection.sslConfig + if connection.usesAWSIAM, ssl.mode == .disabled || ssl.mode == .preferred { + ssl.mode = .required + } let config = DriverConnectionConfig( host: connection.host, port: connection.port, username: connection.username, - password: resolvePassword(for: connection, override: passwordOverride), + password: try await resolvePassword(for: connection, override: passwordOverride), database: connection.database, - ssl: connection.sslConfig, + ssl: ssl, additionalFields: buildAdditionalFields(for: connection, plugin: plugin) ) let pluginDriver = plugin.createDriver(config: config) return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) } + private static func resolveIAMPassword(for connection: DatabaseConnection) async throws -> String { + let source = connection.additionalFields["awsAuth"] ?? "accessKey" + let explicitRegion = connection.additionalFields["awsRegion"].flatMap { $0.isEmpty ? nil : $0 } + guard let region = explicitRegion ?? RDSEndpoint.region(forHost: connection.host) else { + throw AWSAuthError.regionUnknown(host: connection.host) + } + let credentials = try await AWSCredentialResolver.resolve( + source: source, + fields: connection.additionalFields + ) + return RDSAuthTokenGenerator.generateToken( + host: connection.host, + port: connection.port, + region: region, + username: connection.username, + credentials: credentials + ) + } + private static func resolvePassword( for connection: DatabaseConnection, override: String? = nil - ) -> String { + ) async throws -> String { + if connection.usesAWSIAM { + return try await resolveIAMPassword(for: connection) + } if let override { return override } if connection.usePgpass { let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index d469f7d02..5635e706a 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -271,7 +271,7 @@ extension DatabaseManager { session.driver = driver session.status = .connected session.effectiveConnection = effectiveConnection - if let passwordOverride { + if let passwordOverride, !session.connection.usesAWSIAM { session.cachedPassword = passwordOverride } } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 2b05f879e..c9300c6a5 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -126,7 +126,7 @@ extension DatabaseManager { session.driver = driver session.status = driver.status session.effectiveConnection = effectiveConnection - if let passwordOverride { + if let passwordOverride, !connection.usesAWSIAM { session.cachedPassword = passwordOverride } setSession(session, for: connection.id) diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 45ee4a1c0..4aa326918 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -383,6 +383,11 @@ struct DatabaseConnection: Identifiable, Hashable { set { additionalFields["promptForPassword"] = newValue ? "true" : "" } } + var usesAWSIAM: Bool { + let value = additionalFields["awsAuth"] ?? "off" + return value != "off" && !value.isEmpty + } + var preConnectScript: String? { get { additionalFields["preConnectScript"]?.nilIfEmpty } set { additionalFields["preConnectScript"] = newValue ?? "" } diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index 14120a3d1..1d6eb92b3 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -43,10 +43,15 @@ final class AuthPaneViewModel { var hidesPassword: Bool { authFields.contains { field in guard field.hidesPassword else { return false } - if case .toggle = field.fieldType { + switch field.fieldType { + case .toggle: return additionalFieldValues[field.id] == "true" + case .dropdown: + let value = additionalFieldValues[field.id] ?? field.defaultValue + return value != field.defaultValue + default: + return true } - return true } } diff --git a/TableProTests/AWS/AWSIAMAuthTests.swift b/TableProTests/AWS/AWSIAMAuthTests.swift new file mode 100644 index 000000000..e5e0609ec --- /dev/null +++ b/TableProTests/AWS/AWSIAMAuthTests.swift @@ -0,0 +1,194 @@ +// +// AWSIAMAuthTests.swift +// TableProTests +// +// Covers the pure, deterministic parts of RDS IAM authentication: the SigV4 +// primitives against published test vectors, the presigned token structure, +// region derivation from RDS hostnames, the access-key credential resolver, +// and the AWS config INI parser. Profile/SSO resolution and the live SSO +// network exchange require the filesystem/AWS and are not unit-tested. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("AWS SigV4 primitives") +struct AWSSigV4Tests { + @Test("SHA-256 matches NIST vectors") + func sha256Vectors() { + #expect(AWSSigV4.sha256Hex(Data()) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + #expect(AWSSigV4.sha256Hex(Data("abc".utf8)) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + } + + @Test("HMAC-SHA256 matches RFC 4231 test case 1") + func hmacVector() { + let key = Data(repeating: 0x0b, count: 20) + let data = Data("Hi There".utf8) + #expect(AWSSigV4.hmacHex(key: key, data: data) == "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7") + } + + @Test("URI encoding percent-encodes reserved characters") + func uriEncoding() { + #expect(AWSSigV4.uriEncode("a/b") == "a%2Fb") + #expect(AWSSigV4.uriEncode("us-east-1/rds-db") == "us-east-1%2Frds-db") + #expect(AWSSigV4.uriEncode("safe-._~AZ09") == "safe-._~AZ09") + } +} + +@Suite("RDS auth token") +struct RDSAuthTokenGeneratorTests { + private let credentials = AWSCredentials( + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + sessionToken: nil + ) + private let fixedDate = Date(timeIntervalSince1970: 1_440_938_160) + + private func makeToken(sessionToken: String? = nil) -> String { + RDSAuthTokenGenerator.generateToken( + host: "mydb.us-east-1.rds.amazonaws.com", + port: 5432, + region: "us-east-1", + username: "iam_user", + credentials: AWSCredentials( + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: sessionToken + ), + now: fixedDate + ) + } + + @Test("Token has the documented shape and no scheme") + func tokenShape() { + let token = makeToken() + #expect(!token.hasPrefix("https://")) + #expect(token.hasPrefix("mydb.us-east-1.rds.amazonaws.com:5432/?")) + #expect(token.contains("Action=connect")) + #expect(token.contains("DBUser=iam_user")) + #expect(token.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256")) + #expect(token.contains("X-Amz-Expires=900")) + #expect(token.contains("X-Amz-Credential=AKIDEXAMPLE%2F")) + #expect(token.contains("%2Frds-db%2Faws4_request")) + #expect(token.contains("X-Amz-Signature=")) + } + + @Test("Same inputs produce the same token") + func deterministic() { + let first = makeToken() + let second = makeToken() + #expect(first == second) + } + + @Test("Session token is included only for temporary credentials") + func sessionToken() { + #expect(!makeToken().contains("X-Amz-Security-Token")) + #expect(makeToken(sessionToken: "FQoGZXIvYXdzEXAMPLE").contains("X-Amz-Security-Token=FQoGZXIvYXdzEXAMPLE")) + } +} + +@Suite("RDS endpoint region") +struct RDSEndpointTests { + @Test("Derives region from cluster hostname") + func clusterHostname() { + #expect(RDSEndpoint.region(forHost: "mydb.cluster-abc123.us-east-1.rds.amazonaws.com") == "us-east-1") + } + + @Test("Derives region from instance hostname") + func instanceHostname() { + #expect(RDSEndpoint.region(forHost: "mydb.abc123.us-west-2.rds.amazonaws.com") == "us-west-2") + } + + @Test("Derives region from China partition hostname") + func chinaHostname() { + #expect(RDSEndpoint.region(forHost: "mydb.abc.cn-north-1.rds.amazonaws.com.cn") == "cn-north-1") + } + + @Test("Returns nil for non-RDS hosts") + func nonRDSHost() { + #expect(RDSEndpoint.region(forHost: "localhost") == nil) + #expect(RDSEndpoint.region(forHost: "db.example.com") == nil) + } +} + +@Suite("AWS credential resolver") +struct AWSCredentialResolverTests { + @Test("Resolves static access-key credentials") + func staticCredentials() async throws { + let credentials = try await AWSCredentialResolver.resolve( + source: "accessKey", + fields: ["awsAccessKeyId": "AKID", "awsSecretAccessKey": "SECRET", "awsSessionToken": "TOKEN"] + ) + #expect(credentials.accessKeyId == "AKID") + #expect(credentials.secretAccessKey == "SECRET") + #expect(credentials.sessionToken == "TOKEN") + } + + @Test("Treats an empty session token as absent") + func emptySessionToken() async throws { + let credentials = try await AWSCredentialResolver.resolve( + source: "accessKey", + fields: ["awsAccessKeyId": "AKID", "awsSecretAccessKey": "SECRET", "awsSessionToken": ""] + ) + #expect(credentials.sessionToken == nil) + } + + @Test("Throws when access keys are missing") + func missingKeys() async { + await #expect(throws: AWSAuthError.missingAccessKey) { + _ = try await AWSCredentialResolver.resolve(source: "accessKey", fields: [:]) + } + } +} + +@Suite("AWS config INI parsing") +struct AWSSSOParsingTests { + private let config = """ + [default] + region = us-east-1 + + [profile dev] + sso_session = my-sso + sso_account_id = 111122223333 + sso_role_name = Developer + + [sso-session my-sso] + sso_start_url = https://example.awsapps.com/start + sso_region = us-east-1 + """ + + @Test("Resolves a profile that references an sso-session") + func profileWithSession() throws { + let settings = try AWSSSO.parseProfileSettings(configContent: config, profileName: "dev") + #expect(settings.accountId == "111122223333") + #expect(settings.roleName == "Developer") + #expect(settings.startUrl == "https://example.awsapps.com/start") + #expect(settings.region == "us-east-1") + #expect(settings.ssoSession == "my-sso") + } + + @Test("Throws for a profile that is not present") + func profileNotFound() { + #expect(throws: AWSSSOError.profileNotFound("missing")) { + _ = try AWSSSO.parseProfileSettings(configContent: config, profileName: "missing") + } + } + + @Test("Throws when the sso-session is missing required fields") + func sessionMissingFields() { + let broken = """ + [profile dev] + sso_session = my-sso + sso_account_id = 111122223333 + sso_role_name = Developer + + [sso-session my-sso] + sso_start_url = https://example.awsapps.com/start + """ + #expect(throws: AWSSSOError.sessionMissingFields(session: "my-sso")) { + _ = try AWSSSO.parseProfileSettings(configContent: broken, profileName: "dev") + } + } +} diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 80303d242..8fdbdd6db 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -50,8 +50,25 @@ Open URLs like `mysql://user:pass@host/db` from your browser to connect directly **Local**: host `localhost:3306`, user `root` **Docker**: host `localhost:3306` (or mapped port), password from `MYSQL_ROOT_PASSWORD` env **MAMP Pro**: host `localhost:8889`, user/pass `root:root` +**AWS RDS**: Use the endpoint hostname with a password or AWS IAM (see below) **Remote**: Use [SSH tunneling](/databases/ssh-tunneling) for production servers +## AWS IAM Authentication + +Connect to RDS or Aurora with IAM database authentication instead of a static password. Set **Authentication** in the connection form to one of the AWS IAM options: + +- **AWS IAM (Access Key)**: enter an access key ID, secret access key, and optional session token. +- **AWS IAM (Profile)**: use a named profile from `~/.aws/credentials`. +- **AWS IAM (SSO)**: use a profile backed by IAM Identity Center. Run `aws sso login --profile ` first. + +Set **Username** to a database user created with the AWS authentication plugin. The **AWS Region** is detected from the RDS hostname and can be overridden. + +TablePro generates a fresh 15-minute login token on every connect and reconnect, so you never paste an expiring token. SSL is required for IAM and is turned on automatically. + + +The database user must be created for IAM auth (`IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'`). Connecting fails if the user only has a password. + + ## MySQL vs MariaDB Same driver for both. MySQL 8.0 defaults to `caching_sha2_password` auth (vs MariaDB's `mysql_native_password`). Both support JSON, window functions, and CTEs. diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 10ce91f7c..681fbaf29 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -49,10 +49,26 @@ Open URLs like `postgresql://user:pass@host/db` directly to connect. See [Connec **Local**: host `localhost:5432`, user `postgres`, "trust" auth (no password) with Homebrew **Docker**: host `localhost:5432`, password from `POSTGRES_PASSWORD` env -**AWS RDS**: Use endpoint hostname, standard credentials +**AWS RDS**: Use the endpoint hostname with a password or AWS IAM (see below) **Heroku**: Parse `DATABASE_URL` for credentials **Remote**: Use [SSH tunneling](/databases/ssh-tunneling) for secure access +## AWS IAM Authentication + +Connect to RDS or Aurora with IAM database authentication instead of a static password. Set **Authentication** in the connection form to one of the AWS IAM options: + +- **AWS IAM (Access Key)**: enter an access key ID, secret access key, and optional session token. +- **AWS IAM (Profile)**: use a named profile from `~/.aws/credentials`. +- **AWS IAM (SSO)**: use a profile backed by IAM Identity Center. Run `aws sso login --profile ` first. + +Set **Username** to a database role granted `rds_iam`. The **AWS Region** is detected from the RDS hostname and can be overridden. + +TablePro generates a fresh 15-minute login token on every connect and reconnect, so you never paste an expiring token. SSL is required for IAM and is turned on automatically. + + +The database role must be set up for IAM auth (`GRANT rds_iam TO "user"`). Connecting fails if the role only has a password. + + ## Features Sidebar displays all accessible schemas and tables. Switch databases/schemas with **Cmd+K**. Table info shows structure (columns, indexes, constraints) and DDL. Full PostgreSQL syntax support: From 192a83ca1b5969878c9b21e177ab5a3d5829ffe8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 17:45:55 +0700 Subject: [PATCH 2/6] fix(connections): register AWS IAM auth fields in plugin metadata so the connection form shows them (#1291) --- .../Core/Plugins/PluginMetadataRegistry.swift | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 224e7e52a..cb3ad4024 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -418,6 +418,59 @@ final class PluginMetadataRegistry: @unchecked Sendable { section: .advanced ) + let awsIAMFields: [ConnectionField] = [ + ConnectionField( + id: "awsAuth", + label: String(localized: "Authentication"), + defaultValue: "off", + fieldType: .dropdown(options: [ + .init(value: "off", label: String(localized: "Password")), + .init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")), + .init(value: "profile", label: String(localized: "AWS IAM (Profile)")), + .init(value: "sso", label: String(localized: "AWS IAM (SSO)")) + ]), + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "awsRegion", + label: String(localized: "AWS Region"), + placeholder: "us-east-1", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"]) + ), + ConnectionField( + id: "awsAccessKeyId", + label: String(localized: "Access Key ID"), + placeholder: "AKIA...", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSecretAccessKey", + label: String(localized: "Secret Access Key"), + placeholder: "wJalr...", + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsSessionToken", + label: String(localized: "Session Token"), + placeholder: String(localized: "Optional, for temporary credentials"), + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"]) + ), + ConnectionField( + id: "awsProfileName", + label: String(localized: "Profile Name"), + placeholder: "default", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"]) + ) + ] + let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ ("MySQL", PluginMetadataSnapshot( displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306, @@ -462,6 +515,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { columnTypesByCategory: mysqlColumnTypes ), connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: awsIAMFields, category: .relational, tagline: String(localized: "Most popular open-source SQL database") ) @@ -509,6 +563,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { columnTypesByCategory: mysqlColumnTypes ), connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: awsIAMFields, category: .relational, tagline: String(localized: "Open-source fork of MySQL") ) @@ -557,7 +612,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { columnTypesByCategory: postgresqlColumnTypes ), connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField], + additionalConnectionFields: [pgpassField, connectionOptionsField] + awsIAMFields, category: .relational, tagline: String(localized: "Advanced object-relational SQL") ) From 05efe0dcc479f7c10aadb1ec6ac649b3ad61c35c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 23:41:11 +0700 Subject: [PATCH 3/6] fix(connections): resolve AWS IAM credentials from Keychain-backed fields on connect (#1291) --- TablePro/Core/Database/DatabaseDriver.swift | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 97592229b..458778b8c 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -426,29 +426,30 @@ enum DatabaseDriverFactory { if connection.usesAWSIAM, ssl.mode == .disabled || ssl.mode == .preferred { ssl.mode = .required } + let additionalFields = buildAdditionalFields(for: connection, plugin: plugin) let config = DriverConnectionConfig( host: connection.host, port: connection.port, username: connection.username, - password: try await resolvePassword(for: connection, override: passwordOverride), + password: try await resolvePassword(for: connection, fields: additionalFields, override: passwordOverride), database: connection.database, ssl: ssl, - additionalFields: buildAdditionalFields(for: connection, plugin: plugin) + additionalFields: additionalFields ) let pluginDriver = plugin.createDriver(config: config) return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) } - private static func resolveIAMPassword(for connection: DatabaseConnection) async throws -> String { - let source = connection.additionalFields["awsAuth"] ?? "accessKey" - let explicitRegion = connection.additionalFields["awsRegion"].flatMap { $0.isEmpty ? nil : $0 } + private static func resolveIAMPassword( + for connection: DatabaseConnection, + fields: [String: String] + ) async throws -> String { + let source = fields["awsAuth"] ?? "accessKey" + let explicitRegion = fields["awsRegion"].flatMap { $0.isEmpty ? nil : $0 } guard let region = explicitRegion ?? RDSEndpoint.region(forHost: connection.host) else { throw AWSAuthError.regionUnknown(host: connection.host) } - let credentials = try await AWSCredentialResolver.resolve( - source: source, - fields: connection.additionalFields - ) + let credentials = try await AWSCredentialResolver.resolve(source: source, fields: fields) return RDSAuthTokenGenerator.generateToken( host: connection.host, port: connection.port, @@ -460,10 +461,11 @@ enum DatabaseDriverFactory { private static func resolvePassword( for connection: DatabaseConnection, + fields: [String: String], override: String? = nil ) async throws -> String { if connection.usesAWSIAM { - return try await resolveIAMPassword(for: connection) + return try await resolveIAMPassword(for: connection, fields: fields) } if let override { return override } if connection.usePgpass { From 9c464cae9b1f5bc6f6fc3013529374f03740ea33 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 00:12:00 +0700 Subject: [PATCH 4/6] fix(plugin-mysql): enable cleartext auth plugin for AWS IAM, add macOS libmariadb build (#1291) --- .../MariaDBPluginConnection.swift | 10 +- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 5 +- TablePro.xcodeproj/project.pbxproj | 2 + scripts/build-mariadb.sh | 116 ++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100755 scripts/build-mariadb.sh diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 132c6b8d8..e57138a6b 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -144,6 +144,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { private let password: String? private let database: String private let sslConfig: SSLConfiguration + private let enableCleartextPlugin: Bool private let stateLock = NSLock() private var _isConnected: Bool = false @@ -176,7 +177,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { user: String, password: String?, database: String, - sslConfig: SSLConfiguration + sslConfig: SSLConfiguration, + enableCleartextPlugin: Bool = false ) { self.host = host self.port = UInt32(port) @@ -184,6 +186,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { self.password = password self.database = database self.sslConfig = sslConfig + self.enableCleartextPlugin = enableCleartextPlugin } deinit { @@ -291,6 +294,11 @@ final class MariaDBPluginConnection: @unchecked Sendable { mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") + if enableCleartextPlugin { + var enableCleartext: my_bool = 1 + mysql_options(mysql, MYSQL_ENABLE_CLEARTEXT_PLUGIN, &enableCleartext) + } + let dbToUse = database.isEmpty ? nil : database let passToUse = password diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index df120395e..9878583cc 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -68,6 +68,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect() async throws { let sslConfig = config.ssl + let awsAuth = config.additionalFields["awsAuth"] ?? "off" + let usesAWSIAM = awsAuth != "off" && !awsAuth.isEmpty let conn = MariaDBPluginConnection( host: config.host, @@ -75,7 +77,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { user: config.username, password: config.password, database: _activeDatabase, - sslConfig: sslConfig + sslConfig: sslConfig, + enableCleartextPlugin: usesAWSIAM ) try await conn.connect() diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 468e33adc..43ee7bfa2 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -2884,6 +2884,7 @@ "-lssl.3", "-lcrypto.3", "-liconv", + "-lz", ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MySQLDriver; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2923,6 +2924,7 @@ "-lssl.3", "-lcrypto.3", "-liconv", + "-lz", ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MySQLDriver; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/scripts/build-mariadb.sh b/scripts/build-mariadb.sh new file mode 100755 index 000000000..87e718519 --- /dev/null +++ b/scripts/build-mariadb.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -eo pipefail + +# Build static MariaDB Connector/C for macOS (arm64 + x86_64 + universal). +# +# Includes the mysql_clear_password client plugin (STATIC), required for AWS RDS +# IAM authentication. The previous Libs/libmariadb*.a were built without it, so +# IAM connections failed with "Plugin mysql_clear_password could not be loaded". +# +# Output (overwrites, since Libs/*.a are not in git): +# Libs/libmariadb_arm64.a Libs/libmariadb_x86_64.a +# Libs/libmariadb_universal.a Libs/libmariadb.a (= universal) +# +# Requires: cmake, OpenSSL 3 (defaults to Homebrew openssl@3; override OPENSSL_ROOT). +# After running: regenerate Libs/checksums.sha256 and re-upload to the libs-v1 +# release (see CLAUDE.md "Updating Static Libraries"). +# +# Usage: ./scripts/build-mariadb.sh + +MARIADB_VERSION="3.4.4" +MIN_MACOS="14.0" +OPENSSL_ROOT="${OPENSSL_ROOT:-$(brew --prefix openssl@3 2>/dev/null || true)}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +if [ -z "$OPENSSL_ROOT" ] || [ ! -d "$OPENSSL_ROOT" ]; then + echo "ERROR: OpenSSL 3 not found. Install with 'brew install openssl@3' or set OPENSSL_ROOT." >&2 + exit 1 +fi + +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + echo "FAILED: $*"; tail -50 "$logfile"; rm -f "$logfile"; return 1 + fi + rm -f "$logfile" +} + +cleanup() { rm -rf "$BUILD_DIR"; } +trap cleanup EXIT + +echo "Building MariaDB Connector/C $MARIADB_VERSION for macOS (OpenSSL: $OPENSSL_ROOT)" + +echo "=> Downloading source..." +curl -fSL "https://github.com/mariadb-corporation/mariadb-connector-c/archive/refs/tags/v$MARIADB_VERSION.tar.gz" \ + -o "$BUILD_DIR/mariadb.tar.gz" +tar xzf "$BUILD_DIR/mariadb.tar.gz" -C "$BUILD_DIR" +MARIADB_SRC="$BUILD_DIR/mariadb-connector-c-$MARIADB_VERSION" + +build_slice() { + local ARCH=$1 + local SRC_COPY="$BUILD_DIR/mariadb-$ARCH" + cp -R "$MARIADB_SRC" "$SRC_COPY" + local BUILD="$SRC_COPY/cmake-build" + mkdir -p "$BUILD"; cd "$BUILD" + + echo "=> Building $ARCH..." + run_quiet cmake .. \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DCMAKE_C_FLAGS="-w -Wno-error -Wno-inline-asm -Wno-deprecated-non-prototype -Wno-macro-redefined" \ + -DBUILD_SHARED_LIBS=OFF \ + -DWITH_EXTERNAL_ZLIB=ON \ + -DWITH_SSL=OPENSSL \ + -DOPENSSL_ROOT_DIR="$OPENSSL_ROOT" \ + -DOPENSSL_SSL_LIBRARY="$OPENSSL_ROOT/lib/libssl.a" \ + -DOPENSSL_CRYPTO_LIBRARY="$OPENSSL_ROOT/lib/libcrypto.a" \ + -DOPENSSL_INCLUDE_DIR="$OPENSSL_ROOT/include" \ + -DWITH_UNIT_TESTS=OFF \ + -DWITH_CURL=OFF \ + -DCLIENT_PLUGIN_AUTH_GSSAPI_CLIENT=OFF \ + -DCLIENT_PLUGIN_DIALOG=STATIC \ + -DCLIENT_PLUGIN_MYSQL_CLEAR_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_CACHING_SHA2_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_SHA256_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_MYSQL_NATIVE_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_MYSQL_OLD_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_PVIO_NPIPE=OFF \ + -DCLIENT_PLUGIN_PVIO_SHMEM=OFF + + run_quiet cmake --build . --target mariadbclient -j"$NCPU" + cp libmariadb/libmariadbclient.a "$BUILD_DIR/libmariadb_$ARCH.a" + echo " built libmariadb_$ARCH.a" +} + +build_slice arm64 +build_slice x86_64 + +echo "=> Creating universal + installing into Libs/" +cp "$BUILD_DIR/libmariadb_arm64.a" "$LIBS_DIR/libmariadb_arm64.a" +cp "$BUILD_DIR/libmariadb_x86_64.a" "$LIBS_DIR/libmariadb_x86_64.a" +lipo -create "$BUILD_DIR/libmariadb_arm64.a" "$BUILD_DIR/libmariadb_x86_64.a" \ + -output "$LIBS_DIR/libmariadb_universal.a" +cp "$LIBS_DIR/libmariadb_universal.a" "$LIBS_DIR/libmariadb.a" + +echo "=> Verifying mysql_clear_password is now built in:" +if [ "$(nm "$LIBS_DIR/libmariadb_arm64.a" 2>/dev/null | grep -c "clear_password_client_plugin")" -gt 0 ]; then + echo " OK: mysql_clear_password_client_plugin present" +else + echo " WARNING: clear_password plugin symbol not found; check the build" >&2 +fi +lipo -info "$LIBS_DIR/libmariadb_universal.a" + +echo "" +echo "Done. Libs/libmariadb*.a rebuilt with the cleartext plugin." +echo "Next: rebuild the app and test MySQL IAM. When confirmed working, publish the libs:" +echo " shasum -a 256 Libs/*.a > Libs/checksums.sha256" +echo " tar czf /tmp/tablepro-libs-v1.tar.gz -C Libs . && gh release upload libs-v1 /tmp/tablepro-libs-v1.tar.gz --clobber --repo TableProApp/TablePro" +echo " git add Libs/checksums.sha256 && git commit -m 'build: rebuild libmariadb with cleartext auth plugin'" From ae68e00fd38a209ff2378ad1eee1c9a919d64581 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 00:38:22 +0700 Subject: [PATCH 5/6] build: rebuild libmariadb with cleartext auth plugin for AWS IAM (#1291) --- Libs/checksums.sha256 | 54 +++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/Libs/checksums.sha256 b/Libs/checksums.sha256 index 0b743b9da..e9cacff6e 100644 --- a/Libs/checksums.sha256 +++ b/Libs/checksums.sha256 @@ -1,60 +1,60 @@ -6c16abade13041111a9551245043b195aa27ce79ce0ab32113962e762f63485f Libs/libbson_arm64.a -9b7d7abb13b36a4856fd8b4a6cc2a2b312c1bb1815ae9891cc30fe605badeb3c Libs/libbson_universal.a -adb2ade8531660c846df3dab6cafc0a6ba15bf4a9d0ae0a1bc8edaca98bf1ffa Libs/libbson_x86_64.a -9b7d7abb13b36a4856fd8b4a6cc2a2b312c1bb1815ae9891cc30fe605badeb3c Libs/libbson.a +b7716e3f295a54feee85c8771332505be2f9a4a430a088d476d60e358d737c9e Libs/libbson.a +36e3a521b8da03bafd0f943c4f3b21c8c573bf9d640c6c9e764c0c3632672849 Libs/libbson_arm64.a +b7716e3f295a54feee85c8771332505be2f9a4a430a088d476d60e358d737c9e Libs/libbson_universal.a +1e502e7fb4edc79639140e18d433a1ed1be2931162daecee71a74d09e9f4c550 Libs/libbson_x86_64.a +9bfd7d7cb4a7ee9823b4c5141e942a8534de63395983388722dc7c98e5d7731e Libs/libcassandra.a 8d7e31145470a339f4f57930831936db30412393a339598deece6f650214865a Libs/libcassandra_arm64.a 9bfd7d7cb4a7ee9823b4c5141e942a8534de63395983388722dc7c98e5d7731e Libs/libcassandra_universal.a 7f1d058c77b66273db2b3867103c19f62ed0518fb38611b178ce04029213d5d8 Libs/libcassandra_x86_64.a -9bfd7d7cb4a7ee9823b4c5141e942a8534de63395983388722dc7c98e5d7731e Libs/libcassandra.a +732adf315bc49f77e2511a9293e49a65e18eb54a3e6d01d8a24eee2d671d2a8a Libs/libcrypto.a a891a67c2619e2ac1dce64dafc6a24bfde9cabe15312dac6b70a19385664ea84 Libs/libcrypto_arm64.a 732adf315bc49f77e2511a9293e49a65e18eb54a3e6d01d8a24eee2d671d2a8a Libs/libcrypto_universal.a 965ccd38fea5cd97bc878dbf58567e4eed2b2337120f8d46a2da62c094b3c821 Libs/libcrypto_x86_64.a -732adf315bc49f77e2511a9293e49a65e18eb54a3e6d01d8a24eee2d671d2a8a Libs/libcrypto.a -69953f30dbc41fb2d12af2471ccc3eea90c465ab775a18cb3eae502c2fa0dd68 Libs/libduckdb_arm64.a -2af0158001439fc4c4f06a5fabb0a5ca4a66b468c0b2c6e808488a399682dc7a Libs/libduckdb_universal.a -66ed8e2e6ac645c09d698028c772649e3276c21757beb4f2eb6cb6589e426eb3 Libs/libduckdb_x86_64.a -2af0158001439fc4c4f06a5fabb0a5ca4a66b468c0b2c6e808488a399682dc7a Libs/libduckdb.a +d95520ba0e250f7c5847cc9dab4bf8a2656fcefd64b35c859f8fae0d37f2f69f Libs/libduckdb.a +1756e47a21076dbfd3bcfb937964dd0af231017f3adc549fdbc114464b304179 Libs/libduckdb_arm64.a +d95520ba0e250f7c5847cc9dab4bf8a2656fcefd64b35c859f8fae0d37f2f69f Libs/libduckdb_universal.a +aa5dfb4014c4b227d842ca20c2572434784cdad2de324afdc28fa8af83965ecd Libs/libduckdb_x86_64.a +c855b0bf6fb8a2f52175a8e212c88a99ddf02890a1f88239613728c145607915 Libs/libhiredis.a 7e63017fa22c2eb7744eccad13857361a5088aa7b2772ab02cd026c8c7b78341 Libs/libhiredis_arm64.a +fb7a32c2c724cb4f3f880030cb19afbbc7db52121ad8e35e00a2e818da9562cf Libs/libhiredis_ssl.a f1cfc36a7ab47361e9705fe32b1c919b318f606989478e91a808707d93db55a5 Libs/libhiredis_ssl_arm64.a fb7a32c2c724cb4f3f880030cb19afbbc7db52121ad8e35e00a2e818da9562cf Libs/libhiredis_ssl_universal.a 7eb76bcb7ad4c10da0a0a5d43de182619f74f11c1ae9096823adc5c85280e34b Libs/libhiredis_ssl_x86_64.a -fb7a32c2c724cb4f3f880030cb19afbbc7db52121ad8e35e00a2e818da9562cf Libs/libhiredis_ssl.a c855b0bf6fb8a2f52175a8e212c88a99ddf02890a1f88239613728c145607915 Libs/libhiredis_universal.a 5e89a8a3b48590f2c68bdcfc0cfde134145e3156d48264c1fd751dc9ef3be505 Libs/libhiredis_x86_64.a -c855b0bf6fb8a2f52175a8e212c88a99ddf02890a1f88239613728c145607915 Libs/libhiredis.a -b777f7a42766fb08c8e67b2310c67d2d463d77d3554c6092221c3352778622b2 Libs/libmariadb_arm64.a -5326ed729b287ae5dbbcf073aaa70dce29a73c7431e446d5958271af19dac8d8 Libs/libmariadb_universal.a -4f7bbb3d73be178d4211c3bd5b2726b4a12db8b808eaa5212bf8e9eb3c570814 Libs/libmariadb_x86_64.a -5326ed729b287ae5dbbcf073aaa70dce29a73c7431e446d5958271af19dac8d8 Libs/libmariadb.a -06268890fb365085d7f093b6941c507fb8f7fa2754fb22c62331ba8e8ae2068a Libs/libmongoc_arm64.a -b063818886170377f6cd1de714157032e3948e8a9616d3488a503423d8045053 Libs/libmongoc_universal.a -51b08ff457246e3032f1a13306f0e540658e91b1c560a7251ce5087a2ff17be0 Libs/libmongoc_x86_64.a -b063818886170377f6cd1de714157032e3948e8a9616d3488a503423d8045053 Libs/libmongoc.a +bdec9e92f4e94ccb20fcfc4c121aab42de510cd214e23c0e334057de11889b9a Libs/libmariadb.a +1fa33b78f52d4815761aeeec20add208719e4f01e0585e417a4d0d6dc98d3e2d Libs/libmariadb_arm64.a +bdec9e92f4e94ccb20fcfc4c121aab42de510cd214e23c0e334057de11889b9a Libs/libmariadb_universal.a +6ca5ea9b190b515108ba577b1db89458c1ac2f3263f2a4e4edcc94d5a66c5984 Libs/libmariadb_x86_64.a +0d7ddc82dc7327a4b5187ffbc68a1419b5e5ff7b2be7b927e16793eef4d34303 Libs/libmongoc.a +9f4c87916ef65eae43b19d7568dc4fd4dffd884dc0cae15913b90965293339a7 Libs/libmongoc_arm64.a +0d7ddc82dc7327a4b5187ffbc68a1419b5e5ff7b2be7b927e16793eef4d34303 Libs/libmongoc_universal.a +635705c7dc8d689efdee5ec1bd8a8cbd0d09ae20db0869480271a293d492de50 Libs/libmongoc_x86_64.a +3ca491a723b9d9dfc13b815659b44a82253b540dd6b115f03ac68c5154ec26db Libs/libpgcommon.a 5dbf2cb5ef37d8adbf607db82461b36a3fd7037c11d891383e6e918378a33d78 Libs/libpgcommon_arm64.a 3ca491a723b9d9dfc13b815659b44a82253b540dd6b115f03ac68c5154ec26db Libs/libpgcommon_universal.a 4bfad7376aefa866d1ed0b7e54966ec6c9d70dcfed928e1311c20321bf08881c Libs/libpgcommon_x86_64.a -3ca491a723b9d9dfc13b815659b44a82253b540dd6b115f03ac68c5154ec26db Libs/libpgcommon.a +efba529b1ad767de988a58ca2c3fdcc26c38ce79df044a988f41fddbf9fde118 Libs/libpgport.a 813b962c5ae1c317bf6facfe68bd1301fa766768e074f3063fc2e8243213fe13 Libs/libpgport_arm64.a efba529b1ad767de988a58ca2c3fdcc26c38ce79df044a988f41fddbf9fde118 Libs/libpgport_universal.a bf71cc776245c0ce44bfd7b0286664d5c9771992fd70ec32a0c27fc669e4422f Libs/libpgport_x86_64.a -efba529b1ad767de988a58ca2c3fdcc26c38ce79df044a988f41fddbf9fde118 Libs/libpgport.a +b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq.a 70cb70b88130c1c88ccf108e31e17d45dbbc2d10267db7ff33d63305a6a05baf Libs/libpq_arm64.a b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq_universal.a 1ce2b45af228915fad05e07f54e96621af7143e199e002e5100777261a7f4a13 Libs/libpq_x86_64.a -b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq.a +445b51e6fdaa0a0eceb8090e6d552a551ec15d91e4370a4cc356c8f561e8b469 Libs/libssh2.a 166e0e23ce60fd2edcae38b6005de106394f7e2bc922a4944317d6aa576f284c Libs/libssh2_arm64.a 445b51e6fdaa0a0eceb8090e6d552a551ec15d91e4370a4cc356c8f561e8b469 Libs/libssh2_universal.a 76681299c4305273cea62e59cfa366ceb5cc320831b87fd6a06143d342f8b7db Libs/libssh2_x86_64.a -445b51e6fdaa0a0eceb8090e6d552a551ec15d91e4370a4cc356c8f561e8b469 Libs/libssh2.a +3ca208dedf57dbae4f5cb0a22bfbedeba80dc6740d626484d9d815811d64a2aa Libs/libssl.a b3861975896ebf35255d8c3efccdc59ad39874c9b70fdd710ebd15f0a58c4e10 Libs/libssl_arm64.a 3ca208dedf57dbae4f5cb0a22bfbedeba80dc6740d626484d9d815811d64a2aa Libs/libssl_universal.a 34de647ccd0951095f987591562a5236348bac2d4b3e217877559a7b170cf4e4 Libs/libssl_x86_64.a -3ca208dedf57dbae4f5cb0a22bfbedeba80dc6740d626484d9d815811d64a2aa Libs/libssl.a +071e9853ec4bb1f6a19ed99eb91cfe823e83bad178e1e1997deee414cd0e4dfc Libs/libsybdb.a 38a16ca8a041c1be3ca6d4884f7c5e196d14f60bee80004c8f54a41899c17e0f Libs/libsybdb_arm64.a 071e9853ec4bb1f6a19ed99eb91cfe823e83bad178e1e1997deee414cd0e4dfc Libs/libsybdb_universal.a e437cf1fab3eaf675bdb5aab4443a891763e5325033ddfe369775bd64a22b57b Libs/libsybdb_x86_64.a -071e9853ec4bb1f6a19ed99eb91cfe823e83bad178e1e1997deee414cd0e4dfc Libs/libsybdb.a +8f8135b8214cfef035b49486a863f891979efc04d97d75e2bc14cb4e28aed233 Libs/libuv.a beff08628396ffb7c2e23b9f1db08ce92be215fbfd50c6e62088e216d73a0897 Libs/libuv_arm64.a 8f8135b8214cfef035b49486a863f891979efc04d97d75e2bc14cb4e28aed233 Libs/libuv_universal.a 2592a74df696709dcc631e9ad48894763157e9c5a34f0cb6a23a4036bce0c472 Libs/libuv_x86_64.a -8f8135b8214cfef035b49486a863f891979efc04d97d75e2bc14cb4e28aed233 Libs/libuv.a From 862b637b962e75062f6c40abd1c01b719930e551 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 00:47:36 +0700 Subject: [PATCH 6/6] refactor(plugin-mysql): signal cleartext via generic flag, add registry IAM-field test (#1291) --- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 4 +-- TablePro/Core/Database/DatabaseDriver.swift | 9 +++-- TableProTests/AWS/AWSIAMAuthTests.swift | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 9878583cc..bda30323c 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -68,8 +68,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect() async throws { let sslConfig = config.ssl - let awsAuth = config.additionalFields["awsAuth"] ?? "off" - let usesAWSIAM = awsAuth != "off" && !awsAuth.isEmpty let conn = MariaDBPluginConnection( host: config.host, @@ -78,7 +76,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { password: config.password, database: _activeDatabase, sslConfig: sslConfig, - enableCleartextPlugin: usesAWSIAM + enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true" ) try await conn.connect() diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 458778b8c..c13b62485 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -423,10 +423,13 @@ enum DatabaseDriverFactory { ) } var ssl = connection.sslConfig - if connection.usesAWSIAM, ssl.mode == .disabled || ssl.mode == .preferred { - ssl.mode = .required + var additionalFields = buildAdditionalFields(for: connection, plugin: plugin) + if connection.usesAWSIAM { + if ssl.mode == .disabled || ssl.mode == .preferred { + ssl.mode = .required + } + additionalFields["enableCleartextPlugin"] = "true" } - let additionalFields = buildAdditionalFields(for: connection, plugin: plugin) let config = DriverConnectionConfig( host: connection.host, port: connection.port, diff --git a/TableProTests/AWS/AWSIAMAuthTests.swift b/TableProTests/AWS/AWSIAMAuthTests.swift index e5e0609ec..70a904cc8 100644 --- a/TableProTests/AWS/AWSIAMAuthTests.swift +++ b/TableProTests/AWS/AWSIAMAuthTests.swift @@ -192,3 +192,37 @@ struct AWSSSOParsingTests { } } } + +@Suite("AWS IAM connection fields in the plugin metadata registry") +@MainActor +struct RegistryAWSIAMFieldsTests { + private func fieldIds(forTypeId typeId: String) -> [String] { + PluginMetadataRegistry.shared.snapshot(forTypeId: typeId)? + .connection.additionalConnectionFields.map(\.id) ?? [] + } + + @Test("MySQL, MariaDB, and PostgreSQL expose the AWS IAM auth fields") + func iamFieldsPresent() { + for typeId in ["MySQL", "MariaDB", "PostgreSQL"] { + let ids = fieldIds(forTypeId: typeId) + #expect(ids.contains("awsAuth"), "\(typeId) is missing the awsAuth field") + #expect(ids.contains("awsRegion"), "\(typeId) is missing awsRegion") + #expect(ids.contains("awsAccessKeyId"), "\(typeId) is missing awsAccessKeyId") + #expect(ids.contains("awsSecretAccessKey"), "\(typeId) is missing awsSecretAccessKey") + #expect(ids.contains("awsProfileName"), "\(typeId) is missing awsProfileName") + } + } + + @Test("The secret access key field is Keychain-backed (secure)") + func secretFieldIsSecure() { + let field = PluginMetadataRegistry.shared.snapshot(forTypeId: "MySQL")? + .connection.additionalConnectionFields.first { $0.id == "awsSecretAccessKey" } + #expect(field?.isSecure == true) + } + + @Test("Redshift and CockroachDB do not offer AWS IAM auth") + func excludedTypesHaveNoIAM() { + #expect(!fieldIds(forTypeId: "Redshift").contains("awsAuth")) + #expect(!fieldIds(forTypeId: "CockroachDB").contains("awsAuth")) + } +}