diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd0f5146..5668fb009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#1469) - In the CSV editor, choose the escape character (a doubled quote or a backslash) in Set CSV Properties…, so files that escape quotes with a backslash read and save correctly. (#1469) - Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777) +- SQL Server Windows Authentication (Kerberos) now connects to servers whose Kerberos realm differs from your Mac's default realm. TablePro reads the `[domain_realm]` mapping from your Kerberos configuration, the same way the JDBC driver does, so a server that works in DataGrip works here without changing your system `default_realm`. Hosts with no mapping keep using the default realm. (#1947) ### Fixed - SSH tunnels using the None auth method now work on iPhone and iPad, not just the Mac. A connection synced from the Mac with None auth no longer fails to connect. (#1912) - Browsing a Trino table no longer fails with a syntax error. Trino requires `OFFSET` before `LIMIT`, so paging now uses `OFFSET` and `FETCH FIRST`. (#1936) +- The Authentication method selector no longer changes position in the connection form when you switch between methods that show or hide the username and password (for example SQL Server's SQL vs Windows Authentication, or PostgreSQL's password vs AWS IAM). (#1947) ## [0.59.0] - 2026-07-21 diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 4b33148a9..54a8e1ba2 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -12,6 +12,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var loginTimeoutSeconds: Int public var authMethod: MSSQLAuthMethod public var kerberosCachePath: String? + public var kerberosServicePrincipal: String? public static let defaultPort = 1433 public static let defaultSchema = "dbo" @@ -30,12 +31,14 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { applicationName: String = MSSQLConnectionOptions.defaultApplicationName, loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds, authMethod: MSSQLAuthMethod = .sqlServer, - kerberosCachePath: String? = nil + kerberosCachePath: String? = nil, + kerberosServicePrincipal: String? = nil ) { self.host = host self.port = port self.authMethod = authMethod self.kerberosCachePath = kerberosCachePath + self.kerberosServicePrincipal = kerberosServicePrincipal switch authMethod { case .sqlServer: self.user = user diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosRealm.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosRealm.swift new file mode 100644 index 000000000..a921d8f8b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosRealm.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Decides whether a realm resolved from the system Kerberos configuration is worth overriding +/// FreeTDS's default service principal with. +/// +/// macOS Heimdal never consults `default_realm` when it maps a host to a realm. With no matching +/// `[domain_realm]` entry it synthesizes the DNS domain after the first label, upper-cased, so a +/// correct `default_realm` would be replaced by a guess derived from DNS. That guess breaks the +/// setups where the realm and the DNS domain differ (disjoint namespaces, CNAME aliases, hosts +/// whose service principal lives in the forest root), which authenticate today. +/// +/// Only a realm that carries more than the host's own domain came from the Kerberos configuration, +/// and only that one is safe to send. This matches the JDBC driver, which maps a host through +/// `[domain_realm]` and otherwise falls back to `default_realm`. +public enum MSSQLKerberosRealm { + public static func domainGuess(forHost host: String) -> String? { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard let firstDot = trimmed.firstIndex(of: ".") else { return nil } + let domain = trimmed[trimmed.index(after: firstDot)...] + return domain.isEmpty ? nil : domain.uppercased() + } + + public static func isConfigured(_ realm: String, forHost host: String) -> Bool { + let trimmedRealm = realm.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRealm.isEmpty else { return false } + guard let domainGuess = domainGuess(forHost: host) else { return true } + return trimmedRealm.caseInsensitiveCompare(domainGuess) != .orderedSame + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift new file mode 100644 index 000000000..4a8ddd09c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Builds the SQL Server Kerberos service principal name (SPN) with an explicit realm. +/// +/// FreeTDS otherwise constructs an unrealmed `MSSQLSvc/host:port`, which macOS Heimdal resolves +/// against the client's `default_realm` with no cross-realm referral. Windows Authentication then +/// fails (`KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`) whenever the SQL Server's realm differs from the Mac's +/// default realm. Supplying the realm-qualified SPN via `DBSETSERVERPRINCIPAL` makes Heimdal request +/// the service ticket from the correct realm. +public enum MSSQLKerberosSPN { + /// Returns `MSSQLSvc/:@`, or `nil` when no realm is set (letting FreeTDS keep + /// its default, unrealmed SPN). The realm is upper-cased to match Active Directory convention. + public static func build(host: String, port: Int, realm: String?) -> String? { + guard let realm = realm?.trimmingCharacters(in: .whitespacesAndNewlines), !realm.isEmpty else { + return nil + } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return nil } + return "MSSQLSvc/\(trimmedHost):\(port)@\(realm.uppercased())" + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosRealmTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosRealmTests.swift new file mode 100644 index 000000000..7ea66044b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosRealmTests.swift @@ -0,0 +1,43 @@ +import Testing +@testable import TableProMSSQLCore + +@Suite("MSSQL Kerberos realm") +struct MSSQLKerberosRealmTests { + @Test("A realm that only repeats the host's DNS domain is Heimdal's guess, not configuration") + func domainDerivedRealmIsNotConfigured() { + #expect(!MSSQLKerberosRealm.isConfigured("CORP.CONTOSO.LOCAL", forHost: "sql.corp.contoso.local")) + #expect(!MSSQLKerberosRealm.isConfigured("EXAMPLE.COM", forHost: "sql.example.com")) + } + + @Test("A realm that differs from the host's DNS domain came from the Kerberos configuration") + func mappedRealmIsConfigured() { + #expect(MSSQLKerberosRealm.isConfigured("RESOURCE.REALM.COM", forHost: "sql.example.com")) + #expect(MSSQLKerberosRealm.isConfigured("AD.CONTOSO.COM", forHost: "sql.corp.contoso.local")) + } + + @Test("Realm comparison ignores case, so a lower-cased mapping is still treated as the guess") + func comparisonIgnoresCase() { + #expect(!MSSQLKerberosRealm.isConfigured("example.com", forHost: "sql.example.com")) + #expect(!MSSQLKerberosRealm.isConfigured(" Example.Com ", forHost: "sql.example.com")) + } + + @Test("An empty realm is never adopted") + func emptyRealmIsNotConfigured() { + #expect(!MSSQLKerberosRealm.isConfigured("", forHost: "sql.example.com")) + #expect(!MSSQLKerberosRealm.isConfigured(" ", forHost: "sql.example.com")) + } + + @Test("A dot-less host has no domain to guess from, so any realm must be configured") + func shortHostAlwaysConfigured() { + #expect(MSSQLKerberosRealm.isConfigured("AD.CONTOSO.COM", forHost: "sqlhost")) + #expect(MSSQLKerberosRealm.domainGuess(forHost: "sqlhost") == nil) + } + + @Test("The domain guess is every label after the first, upper-cased") + func domainGuessDropsOnlyTheFirstLabel() { + #expect(MSSQLKerberosRealm.domainGuess(forHost: "a.b.c.d.com") == "B.C.D.COM") + #expect(MSSQLKerberosRealm.domainGuess(forHost: "sql.example.com") == "EXAMPLE.COM") + #expect(MSSQLKerberosRealm.domainGuess(forHost: " sql.example.com ") == "EXAMPLE.COM") + #expect(MSSQLKerberosRealm.domainGuess(forHost: "sql.") == nil) + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosSPNTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosSPNTests.swift new file mode 100644 index 000000000..e42053962 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLKerberosSPNTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import TableProMSSQLCore + +@Suite("MSSQL Kerberos SPN") +struct MSSQLKerberosSPNTests { + @Test("Realm-qualified SPN is built and the realm is upper-cased") + func buildsRealmQualifiedSPN() { + #expect( + MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: "resource.realm.com") + == "MSSQLSvc/sql.example.com:1433@RESOURCE.REALM.COM" + ) + } + + @Test("No realm yields no SPN, so FreeTDS keeps its default") + func nilRealmReturnsNil() { + #expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: nil) == nil) + } + + @Test("Empty or whitespace-only realm yields no SPN") + func emptyRealmReturnsNil() { + #expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: "") == nil) + #expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: " ") == nil) + } + + @Test("Whitespace is trimmed from host and realm") + func trimsWhitespace() { + #expect( + MSSQLKerberosSPN.build(host: " sql.example.com ", port: 1433, realm: " resource.realm.com ") + == "MSSQLSvc/sql.example.com:1433@RESOURCE.REALM.COM" + ) + } + + @Test("A non-default port is included in the SPN") + func includesNonDefaultPort() { + #expect( + MSSQLKerberosSPN.build(host: "sql.example.com", port: 14330, realm: "R.COM") + == "MSSQLSvc/sql.example.com:14330@R.COM" + ) + } +} diff --git a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h index 742367e5f..39e145875 100644 --- a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h +++ b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h @@ -55,6 +55,7 @@ typedef struct loginrec LOGINREC; #define DBSETPACKET 11 #define DBSETENCRYPT 12 #define DBSETDBNAME 14 +#define DBSETSERVERPRINCIPAL 103 // Convenience macros (match FreeTDS sybdb.h) #define DBSETLHOST(x, y) dbsetlname((x), (y), DBSETHOST) diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index 6b5794692..bae84dca8 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -189,6 +189,16 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { _ = dbsetlversion(login, UInt8(DBVERSION_74)) _ = dbsetlogintime(Int32(options.loginTimeoutSeconds)) + #if os(macOS) + // Windows Auth cross-realm: FreeTDS otherwise builds its own SPN and only canonicalizes a + // short hostname (via getaddrinfo), never applying [domain_realm] to pick the realm. We + // resolve the canonical host + realm up front and hand FreeTDS the full SPN, so cross-realm + // and short-name/CNAME hosts authenticate like the JDBC driver does. + if options.authMethod == .windows, let spn = options.kerberosServicePrincipal, !spn.isEmpty { + _ = dbsetlname(login, spn, Int32(DBSETSERVERPRINCIPAL)) + } + #endif + freetdsClearError(for: nil) let serverName = "\(options.host):\(options.port)" guard let proc = withKerberosEnvironmentIfNeeded({ dbopen(login, serverName) }) else { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift new file mode 100644 index 000000000..ce65c0262 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift @@ -0,0 +1,91 @@ +import Foundation +import GSS +import TableProMSSQLCore + +/// Resolves the canonical SQL Server Kerberos service (host + realm) for a connection host. +/// +/// macOS Heimdal does not apply the system Kerberos configuration (`[domain_realm]`) when FreeTDS +/// builds its own SPN string, so a cross-realm host fails with `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. +/// We resolve the realm here (like the JDBC driver) and hand FreeTDS an explicit SPN via +/// `DBSETSERVERPRINCIPAL`. +/// +/// Once an explicit SPN is set, FreeTDS stops canonicalizing a short hostname to its FQDN (which it +/// otherwise does with `getaddrinfo` for dot-less names). To avoid regressing those connections we +/// perform the same canonicalization here, so the SPN carries the FQDN the service is registered +/// under, short name or CNAME included. +enum MSSQLKerberosRealmResolver { + /// Canonical host and realm for `host`, or `nil` when the realm is unresolved or Heimdal only + /// guessed it from the host's own DNS domain. The caller then leaves the SPN to FreeTDS, whose + /// unrealmed principal resolves against `default_realm`. + static func canonicalService(forHost host: String) -> (host: String, realm: String)? { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let canonicalHost = canonicalHostname(trimmed) + guard let realm = realm(forHost: canonicalHost), + MSSQLKerberosRealm.isConfigured(realm, forHost: canonicalHost) + else { return nil } + return (host: canonicalHost, realm: realm) + } + + /// Mirrors FreeTDS: canonicalize a dot-less (short) hostname to its FQDN via `getaddrinfo`. + /// A name that already contains a dot is used as-is, matching FreeTDS's own behavior. + private static func canonicalHostname(_ host: String) -> String { + guard !host.contains(".") else { return host } + var hints = addrinfo() + hints.ai_flags = AI_CANONNAME + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + var result: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &result) == 0, let result else { return host } + defer { freeaddrinfo(result) } + if let canonical = result.pointee.ai_canonname { + let name = String(cString: canonical) + if name.contains(".") { return name } + } + return host + } + + /// Realm for a host from the system Kerberos configuration (`[domain_realm]`, `default_realm`), + /// via GSS name canonicalization, the same resolution the KDC clients use. + private static func realm(forHost host: String) -> String? { + var minor: OM_uint32 = 0 + guard let cString = strdup("MSSQLSvc@\(host)") else { return nil } + var nameBuffer = gss_buffer_desc(length: strlen(cString), value: cString) + var importedName: gss_name_t? + let importStatus = gss_import_name( + &minor, &nameBuffer, &__gss_c_nt_hostbased_service_oid_desc, &importedName + ) + free(cString) + guard importStatus == GSS_S_COMPLETE, let importedName else { return nil } + defer { + var releaseMinor: OM_uint32 = 0 + var releasable: gss_name_t? = importedName + _ = gss_release_name(&releaseMinor, &releasable) + } + + var canonicalName: gss_name_t? + let canonStatus = gss_canonicalize_name( + &minor, importedName, &__gss_krb5_mechanism_oid_desc, &canonicalName + ) + guard canonStatus == GSS_S_COMPLETE, let canonicalName else { return nil } + defer { + var releaseMinor: OM_uint32 = 0 + var releasable: gss_name_t? = canonicalName + _ = gss_release_name(&releaseMinor, &releasable) + } + + var displayBuffer = gss_buffer_desc() + var nameType: gss_OID? + let displayStatus = gss_display_name(&minor, canonicalName, &displayBuffer, &nameType) + defer { + var releaseMinor: OM_uint32 = 0 + _ = gss_release_buffer(&releaseMinor, &displayBuffer) + } + guard displayStatus == GSS_S_COMPLETE, let value = displayBuffer.value else { return nil } + + let display = String(data: Data(bytes: value, count: displayBuffer.length), encoding: .utf8) ?? "" + guard let atIndex = display.lastIndex(of: "@") else { return nil } + let realm = String(display[display.index(after: atIndex)...]) + return realm.isEmpty ? nil : realm + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 1df22f7b5..0df635f5b 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -210,6 +210,14 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "MSSQLPluginDriver") + private static let kerberosResolveQueue = DispatchQueue( + label: "com.TablePro.mssql.kerberos-resolve", + qos: .userInitiated + ) + private static let kerberosResolveTimeoutSeconds = 5 + + private struct KerberosRealmResolutionTimeout: Error {} + var currentSchema: String? { _currentSchema } var serverVersion: String? { _serverVersion } var supportsSchemas: Bool { true } @@ -268,6 +276,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let conn: FreeTDSConnection do { let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod) + let kerberosServicePrincipal = try await resolveKerberosServicePrincipal(authMethod: authMethod) let options = MSSQLConnectionOptions( host: config.host, port: config.port, @@ -277,7 +286,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { schema: _currentSchema, encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode), authMethod: authMethod, - kerberosCachePath: kerberosCachePath + kerberosCachePath: kerberosCachePath, + kerberosServicePrincipal: kerberosServicePrincipal ) conn = FreeTDSConnection(options: options) try await conn.connect() @@ -314,6 +324,26 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + private func resolveKerberosServicePrincipal(authMethod: MSSQLAuthMethod) async throws -> String? { + guard authMethod == .windows else { return nil } + let host = config.host + let port = config.port + do { + return try await runCancellableBlocking( + on: Self.kerberosResolveQueue, + deadline: .seconds(Self.kerberosResolveTimeoutSeconds), + timeoutError: { KerberosRealmResolutionTimeout() }, + work: { + MSSQLKerberosRealmResolver.canonicalService(forHost: host) + .flatMap { MSSQLKerberosSPN.build(host: $0.host, port: port, realm: $0.realm) } + } + ) + } catch is KerberosRealmResolutionTimeout { + Self.logger.warning("Kerberos realm resolution timed out; using the default service principal") + return nil + } + } + private func acquireKerberosTicketIfNeeded(authMethod: MSSQLAuthMethod) async throws -> String? { guard authMethod == .windows else { return nil } let principal = (config.additionalFields[MSSQLKerberosField.principal] ?? "") diff --git a/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift b/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift new file mode 100644 index 000000000..294985b16 --- /dev/null +++ b/TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift @@ -0,0 +1,35 @@ +// +// ConnectionField+AuthFieldOrder.swift +// TablePro +// + +import TableProPluginKit + +extension Collection where Element == ConnectionField { + /// Fields that decide whether the built-in Username and Password appear: either they carry the + /// flag themselves (an auth-method dropdown, a password-file toggle), or they gate a dependent + /// field that carries it (SQL Server's Kerberos principal, Snowflake's OAuth token). + var credentialControllerIds: Set { + Set( + filter { $0.hidesUsername || $0.hidesPassword } + .map { $0.visibleWhen?.fieldId ?? $0.id } + ) + } + + /// Splits the fields so the credential controllers render above the built-in Username and + /// Password. A controller placed below them shifts position every time its own selection shows + /// or hides those credentials. + func splitCredentialControllers() -> (controllers: [ConnectionField], rest: [ConnectionField]) { + let controllerIds = credentialControllerIds + var controllers: [ConnectionField] = [] + var rest: [ConnectionField] = [] + for field in self { + if controllerIds.contains(field.id) { + controllers.append(field) + } else { + rest.append(field) + } + } + return (controllers, rest) + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 9bf9a86f5..5cd5b8720 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -223,7 +223,11 @@ struct GeneralPaneView: View { @ViewBuilder private var authenticationSection: some View { if connectionMode != .fileBased { + let authFields = coordinator.auth.authFields.splitCredentialControllers() Section(String(localized: "Authentication")) { + ForEach(authFields.controllers, id: \.id) { field in + authFieldRow(field) + } if connectionMode == .network && !coordinator.auth.hidesUsername { TextField( String(localized: "Username"), @@ -238,21 +242,8 @@ struct GeneralPaneView: View { additionalFieldValues: $coordinator.auth.additionalFieldValues ) } - ForEach(coordinator.auth.authFields, id: \.id) { field in - if coordinator.auth.isFieldVisible(field) { - if FilePathConnectionFieldRow.isFilePathField(field) { - FilePathConnectionFieldRow( - field: field, - value: authFieldBinding(for: field), - onBrowse: { browseForAuthFile(field: field) } - ) - } else { - ConnectionFieldRow( - field: field, - value: authFieldBinding(for: field) - ) - } - } + ForEach(authFields.rest, id: \.id) { field in + authFieldRow(field) } kerberosCaption if coordinator.auth.usePgpass { @@ -262,6 +253,24 @@ struct GeneralPaneView: View { } } + @ViewBuilder + private func authFieldRow(_ field: ConnectionField) -> some View { + if coordinator.auth.isFieldVisible(field) { + if FilePathConnectionFieldRow.isFilePathField(field) { + FilePathConnectionFieldRow( + field: field, + value: authFieldBinding(for: field), + onBrowse: { browseForAuthFile(field: field) } + ) + } else { + ConnectionFieldRow( + field: field, + value: authFieldBinding(for: field) + ) + } + } + } + @ViewBuilder private var pgpassStatusView: some View { switch coordinator.auth.pgpassStatus { diff --git a/TableProTests/Core/Plugins/AuthFieldOrderTests.swift b/TableProTests/Core/Plugins/AuthFieldOrderTests.swift new file mode 100644 index 000000000..360d535f6 --- /dev/null +++ b/TableProTests/Core/Plugins/AuthFieldOrderTests.swift @@ -0,0 +1,117 @@ +// +// AuthFieldOrderTests.swift +// TableProTests +// +// The connection form renders credential controllers above the built-in Username and +// Password so the selector does not shift position when its own selection hides them. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Auth field ordering") +struct AuthFieldOrderTests { + private func selector(_ id: String, hidesPassword: Bool = false) -> ConnectionField { + ConnectionField( + id: id, + label: "Authentication", + defaultValue: "off", + fieldType: .dropdown(options: [.init(value: "off", label: "Off"), .init(value: "on", label: "On")]), + section: .authentication, + hidesPassword: hidesPassword + ) + } + + private func dependent( + _ id: String, + of controllerId: String, + hidesPassword: Bool = false, + hidesUsername: Bool = false + ) -> ConnectionField { + ConnectionField( + id: id, + label: id, + section: .authentication, + hidesPassword: hidesPassword, + visibleWhen: FieldVisibilityRule(fieldId: controllerId, values: ["on"]) + ).withHidesUsername(hidesUsername) + } + + @Test("A selector whose dependent field hides the password is pulled above the credentials") + func controllerOfDependentIsSplitOut() { + let fields = [ + selector("mssqlAuthMethod"), + dependent("kerberosPrincipal", of: "mssqlAuthMethod", hidesUsername: true), + dependent("kerberosPassword", of: "mssqlAuthMethod", hidesPassword: true), + ConnectionField(id: "mssqlSchema", label: "Schema", section: .authentication) + ] + + let split = fields.splitCredentialControllers() + + #expect(split.controllers.map(\.id) == ["mssqlAuthMethod"]) + #expect(split.rest.map(\.id) == ["kerberosPrincipal", "kerberosPassword", "mssqlSchema"]) + } + + @Test("A selector that hides the password itself is pulled above the credentials too") + func selfHidingControllerIsSplitOut() { + let fields = [ + selector("esAuthMethod", hidesPassword: true), + ConnectionField(id: "esApiKey", label: "API Key", section: .authentication) + ] + + let split = fields.splitCredentialControllers() + + #expect(split.controllers.map(\.id) == ["esAuthMethod"]) + #expect(split.rest.map(\.id) == ["esApiKey"]) + } + + @Test("Fields that touch neither credential keep their original order below them") + func nonControllersKeepOrder() { + let fields = [ + ConnectionField(id: "warehouse", label: "Warehouse", section: .authentication), + ConnectionField(id: "role", label: "Role", section: .authentication) + ] + + let split = fields.splitCredentialControllers() + + #expect(split.controllers.isEmpty) + #expect(split.rest.map(\.id) == ["warehouse", "role"]) + } + + @Test("Several controllers keep their relative order") + func multipleControllersKeepRelativeOrder() { + let fields = [ + ConnectionField( + id: "usePgpass", + label: "Use ~/.pgpass", + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ), + selector("awsAuth", hidesPassword: true), + dependent("awsRegion", of: "awsAuth") + ] + + let split = fields.splitCredentialControllers() + + #expect(split.controllers.map(\.id) == ["usePgpass", "awsAuth"]) + #expect(split.rest.map(\.id) == ["awsRegion"]) + } + + @Test("A controller declared after its dependent is still pulled above the credentials") + func controllerDeclaredAfterDependentIsFound() { + let fields = [ + dependent("token", of: "authLevel", hidesPassword: true), + selector("authLevel") + ] + + let split = fields.splitCredentialControllers() + + #expect(split.controllers.map(\.id) == ["authLevel"]) + #expect(split.rest.map(\.id) == ["token"]) + } +} diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index 9972aab48..45e6ade1e 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -59,6 +59,19 @@ Requirements: - **Realm resolution.** Your Mac must be able to find the domain's KDC, either through DNS SRV records or an `/etc/krb5.conf` entry. - **A registered SPN.** The SQL Server service account must have an SPN such as `MSSQLSvc/host.domain.com:1433`. Ask your administrator if connections fail with an SSPI or service-principal error. +### Servers in another realm + +If the SQL Server lives in a different Kerberos realm than your Mac's `default_realm`, map its DNS domain to that realm in `/etc/krb5.conf`: + +```ini +[domain_realm] + .sql.example.com = RESOURCE.REALM.COM +``` + +TablePro reads that mapping and asks the KDC for a ticket in `RESOURCE.REALM.COM`, so a cross-realm trust works without changing `default_realm`. This is the same mapping the JDBC driver uses, so a server that already works in DataGrip works here. + +With no `[domain_realm]` entry for the host, TablePro falls back to `default_realm`, which is what a single-realm domain needs. + TablePro does not run `kinit` or edit `/etc/krb5.conf` for you; those are set up once per machine. ## Connection URL @@ -107,6 +120,6 @@ SQL Server connections can run through the Cloud SQL Auth Proxy; TablePro starts **Login failed**: verify credentials, then check the server allows SQL Server Authentication: `SELECT SERVERPROPERTY('IsIntegratedSecurityOnly')` returns `1` when only Windows Authentication is allowed. Enable mixed mode in SSMS under Server Properties > Security, then restart the service. If the login only has access to one database (an Azure SQL contained user), set the **Database** field; TablePro sends it during login. Without it the server authenticates against `master` and rejects the login. -**Windows Authentication fails**: run `klist` to confirm you have a ticket, and `kinit user@REALM.COM` if you do not. Connect by hostname, not IP address. An SSPI or "server not found in Kerberos database" error means the server's SPN is not registered; ask your administrator to register `MSSQLSvc/host.domain.com:1433`. A clock-skew error means this Mac's clock is too far from the domain controller; turn on **Set time automatically** in System Settings. +**Windows Authentication fails**: run `klist` to confirm you have a ticket, and `kinit user@REALM.COM` if you do not. Connect by hostname, not IP address. An SSPI or "server not found in Kerberos database" error means the server's SPN is not registered, or it is registered in a realm your Mac does not map the host to; ask your administrator to register `MSSQLSvc/host.domain.com:1433`, and if the server is in another realm add a `[domain_realm]` entry as shown in [Servers in another realm](#servers-in-another-realm). A clock-skew error means this Mac's clock is too far from the domain controller; turn on **Set time automatically** in System Settings. **Limitations**: no Microsoft Entra ID (Azure AD) authentication, Windows Authentication (Kerberos) is macOS only, named instances unsupported (use host and port), Verify CA and Verify Identity behave like Required.