Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apple/Configs/PluginKit.xcconfig
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// When to raise each, and how the build stamps them: .claude/rules/plugin-system.md, ABI.
TABLEPRO_PLUGINKIT_VERSION = 39
TABLEPRO_PLUGINKIT_VERSION = 40
TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ public final class ConnectionManager: @unchecked Sendable {
throw ConnectionError.previousSessionStillClosing
}
guard isCurrentAttempt(generation, for: connection.id) else { throw CancellationError() }
let password = try secureStore.retrieve(forKey: Self.passwordKey(for: connection.id))
let stored = try secureStore.retrieve(forKey: Self.passwordKey(for: connection.id))
let password = try await driverFactory.resolvePassword(for: connection, stored: stored, secureStore: secureStore)
guard isCurrentAttempt(generation, for: connection.id) else { throw CancellationError() }

var effectiveHost = connection.host
var effectivePort = connection.port
Expand All @@ -74,7 +76,11 @@ public final class ConnectionManager: @unchecked Sendable {
effectiveConnection.host = effectiveHost
effectiveConnection.port = effectivePort

let driver = try driverFactory.createDriver(for: effectiveConnection, password: password)
let driver = try driverFactory.createDriver(
for: effectiveConnection,
password: password,
preTunnelHost: tunnelId == nil ? nil : connection.host
)
try await driver.connect()

let session = ConnectionSession(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation
import TableProModels

public extension DriverFactory {
func createDriver(
for connection: DatabaseConnection,
password: String?,
preTunnelHost: String?
) throws -> any DatabaseDriver {
try createDriver(for: connection, password: password)
}

func resolvePassword(
for connection: DatabaseConnection,
stored: String?,
secureStore: any SecureStore
) async throws -> String? {
stored
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import TableProModels

public protocol DriverFactory: Sendable {
func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver
// `preTunnelHost` is the saved host when `connection.host` is a tunnel's loopback, else nil.
func createDriver(
for connection: DatabaseConnection,
password: String?,
preTunnelHost: String?
) throws -> any DatabaseDriver
// Runs before the tunnel, on the connection as saved, so a sign-in failure comes before any SSH prompt.
func resolvePassword(
for connection: DatabaseConnection,
stored: String?,
secureStore: any SecureStore
) async throws -> String?
func supportedTypes() -> [DatabaseType]
func refusal(for connection: DatabaseConnection) -> ConnectionError?
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

public enum GoogleCloudSQLLogin {
// What `gcloud sql generate-login-token` and the Cloud SQL connectors ask for. It allows a database
// sign-in and nothing else, so a token that leaks cannot call another Google API.
public static let scopes = ["https://www.googleapis.com/auth/sqlservice.login"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import Foundation
@testable import TableProDatabase
import TableProModels
import TableProTestSupport
import Testing

private let mockType = DatabaseType(rawValue: "mock")

private struct MintFailure: Error {}

private struct MintRequest: Sendable {
let host: String
let port: Int
let stored: String?
}

private struct DriverRequest: Sendable {
let host: String
let port: Int
let password: String?
let preTunnelHost: String?
}

private final class SigningDriverFactory: DriverFactory, @unchecked Sendable {
var mint: @Sendable (String?) async throws -> String? = { stored in stored }
private(set) var mintRequests: [MintRequest] = []
private(set) var driverRequests: [DriverRequest] = []

func resolvePassword(
for connection: DatabaseConnection,
stored: String?,
secureStore: any SecureStore
) async throws -> String? {
mintRequests.append(MintRequest(host: connection.host, port: connection.port, stored: stored))
return try await mint(stored)
}

func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver {
try createDriver(for: connection, password: password, preTunnelHost: nil)
}

func createDriver(
for connection: DatabaseConnection,
password: String?,
preTunnelHost: String?
) throws -> any DatabaseDriver {
driverRequests.append(DriverRequest(
host: connection.host,
port: connection.port,
password: password,
preTunnelHost: preTunnelHost
))
return MockDatabaseDriver()
}

func supportedTypes() -> [DatabaseType] { [mockType] }
}

private final class PasswordRecordingFactory: DriverFactory, @unchecked Sendable {
private(set) var passwords: [String?] = []

func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver {
passwords.append(password)
return MockDatabaseDriver()
}

func supportedTypes() -> [DatabaseType] { [mockType] }
}

private func passwordKey(for connection: DatabaseConnection) -> String {
"com.TablePro.password.\(connection.id.uuidString)"
}

private func rdsConnection(tunneled: Bool) -> DatabaseConnection {
DatabaseConnection(
name: "RDS",
type: mockType,
host: "mydb.abc123.us-east-1.rds.amazonaws.com",
port: 5_432,
username: "iam_user",
additionalFields: ["awsAuth": "accessKey"],
sshEnabled: tunneled,
sshConfiguration: tunneled ? SSHConfiguration(host: "bastion.example.com") : nil
)
}

@Suite(.timeLimit(.minutes(1)))
struct ConnectionManagerSignInTests {
@Test("The minted password reaches the driver in place of the stored one")
func mintedPasswordReachesTheDriver() async throws {
let connection = rdsConnection(tunneled: false)
let factory = SigningDriverFactory()
factory.mint = { _ in "signed-token" }
let manager = ConnectionManager(
driverFactory: factory,
secureStore: MockSecureStore(passwords: [passwordKey(for: connection): "stored-secret"])
)

_ = try await manager.connect(connection)

let mint = try #require(factory.mintRequests.first)
#expect(mint.stored == "stored-secret")
let driver = try #require(factory.driverRequests.first)
#expect(driver.password == "signed-token")
}

@Test("A factory without sign-in still hands the stored password to its driver")
func defaultResolutionPassesTheStoredPassword() async throws {
let connection = rdsConnection(tunneled: false)
let factory = PasswordRecordingFactory()
let manager = ConnectionManager(
driverFactory: factory,
secureStore: MockSecureStore(passwords: [passwordKey(for: connection): "stored-secret"])
)

_ = try await manager.connect(connection)

#expect(factory.passwords == ["stored-secret"])
}

@Test("A mint failure throws before any tunnel opens, so no SSH prompt follows it")
func mintFailureThrowsBeforeTheTunnel() async throws {
let factory = SigningDriverFactory()
factory.mint = { _ in throw MintFailure() }
let ssh = MockSSHProvider()
let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore(), sshProvider: ssh)
let connection = rdsConnection(tunneled: true)

await #expect(throws: MintFailure.self) {
_ = try await manager.connect(connection)
}

#expect(ssh.openedTunnelIds.isEmpty)
#expect(factory.driverRequests.isEmpty)
#expect(manager.session(for: connection.id) == nil)
}

@Test("An attempt invalidated during the mint throws CancellationError and opens no tunnel")
func attemptInvalidatedDuringTheMintStops() async throws {
let gate = AsyncGate()
let factory = SigningDriverFactory()
factory.mint = { _ in
await gate.wait()
return "signed-token"
}
let ssh = MockSSHProvider()
let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore(), sshProvider: ssh)
let connection = rdsConnection(tunneled: true)

let attempt = Task { try await manager.connect(connection) }
await gate.arrived(1)
manager.invalidateAttempt(for: connection.id)
await gate.open()

await #expect(throws: CancellationError.self) { try await attempt.value }
#expect(ssh.openedTunnelIds.isEmpty)
#expect(factory.driverRequests.isEmpty)
#expect(manager.session(for: connection.id) == nil)
}

@Test("Through a tunnel, the mint sees the saved endpoint and the driver gets the saved host as preTunnelHost")
func tunneledConnectPassesTheSavedHost() async throws {
let factory = SigningDriverFactory()
let manager = ConnectionManager(
driverFactory: factory,
secureStore: MockSecureStore(),
sshProvider: MockSSHProvider()
)
let connection = rdsConnection(tunneled: true)

_ = try await manager.connect(connection)

let mint = try #require(factory.mintRequests.first)
#expect(mint.host == connection.host)
#expect(mint.port == connection.port)
let driver = try #require(factory.driverRequests.first)
#expect(driver.host == "127.0.0.1")
#expect(driver.port == 33_306)
#expect(driver.preTunnelHost == connection.host)
}

@Test("Without a tunnel, the driver dials the saved host and gets no preTunnelHost")
func directConnectHasNoPreTunnelHost() async throws {
let factory = SigningDriverFactory()
let manager = ConnectionManager(
driverFactory: factory,
secureStore: MockSecureStore(),
sshProvider: MockSSHProvider()
)
let connection = rdsConnection(tunneled: false)

_ = try await manager.connect(connection)

let driver = try #require(factory.driverRequests.first)
#expect(driver.host == connection.host)
#expect(driver.preTunnelHost == nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Foundation
import TableProPluginKit
import Testing

struct AWSIAMTokenTargetTests {
private let credentials = AWSCredentials(
accessKeyId: "AKIDEXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
sessionToken: nil
)
private let fixedDate = Date(timeIntervalSince1970: 1_440_938_160)
private let cacheHost = "my-cache.abc123.use1.cache.amazonaws.com"
private let cacheFields = ["awsRegion": "us-east-1", "awsReplicationGroupId": "my-cache"]

private func token(_ target: AWSIAMTokenTarget, fields: [String: String] = [:]) throws -> String {
try target.token(username: "iam_user", fields: fields, credentials: credentials, now: fixedDate)
}

private func rds(
host: String = "mydb.abc123.us-east-1.rds.amazonaws.com",
port: Int = 5_432,
preTunnelHost: String? = nil,
preTunnelPort: Int? = nil
) -> AWSIAMTokenTarget {
.rds(
configuredHost: host,
configuredPort: port,
preTunnelHost: preTunnelHost,
preTunnelPort: preTunnelPort,
defaultPort: 5_432
)
}

@Test("Redis with SSL off throws the requires-TLS error")
func elastiCacheWithTLSOffThrows() {
let requiresTLS = AWSAuthError.missingConfiguration(
String(localized: "ElastiCache IAM authentication requires TLS. Enable SSL in the connection's SSL settings.")
)
#expect(throws: requiresTLS) {
_ = try token(.elastiCache(host: cacheHost, tlsDisabled: true), fields: cacheFields)
}
}

@Test("Redis with SSL on signs the cache name")
func elastiCacheWithTLSOnSigns() throws {
let expected = ElastiCacheAuthTokenGenerator.generateToken(
replicationGroupId: "my-cache",
region: "us-east-1",
userId: "iam_user",
credentials: credentials,
now: fixedDate
)
#expect(try token(.elastiCache(host: cacheHost, tlsDisabled: false), fields: cacheFields) == expected)
}

@Test("ElastiCache asks for the region before TLS, and for the cache name after it")
func elastiCacheCheckOrder() {
#expect(throws: AWSAuthError.regionUnknown(host: cacheHost)) {
_ = try token(.elastiCache(host: cacheHost, tlsDisabled: true), fields: ["awsReplicationGroupId": "my-cache"])
}
let missingCacheName = AWSAuthError.missingConfiguration(
String(localized: "Enter the ElastiCache cache name (replication group ID) to use IAM authentication.")
)
#expect(throws: missingCacheName) {
_ = try token(.elastiCache(host: cacheHost, tlsDisabled: false), fields: ["awsRegion": "us-east-1"])
}
}

@Test("A tunneled RDS connection signs the endpoint from before the tunnel")
func rdsTunneledSignsPreTunnelHost() throws {
let tunneled = try token(rds(
host: "127.0.0.1",
port: 62_000,
preTunnelHost: "mydb.abc123.us-east-1.rds.amazonaws.com",
preTunnelPort: 5_432
))
let direct = try token(rds())
#expect(tunneled == direct)
#expect(tunneled.hasPrefix("mydb.abc123.us-east-1.rds.amazonaws.com:5432/?Action=connect&DBUser=iam_user&"))
}

@Test("A host typed with capitals finds its region and signs as the lowercase endpoint")
func rdsMixedCaseHost() throws {
let mixedCase = try token(rds(host: "MyDB.abc123.US-EAST-1.RDS.amazonaws.com"))
let lowercase = try token(rds())
#expect(mixedCase == lowercase)
}

@Test("The AWS Region field wins over the region in the host")
func rdsExplicitRegionWins() throws {
let signed = try token(rds(), fields: ["awsRegion": "eu-west-1"])
#expect(signed.contains("%2Feu-west-1%2Frds-db%2Faws4_request"))
}

@Test("A host with no region and no AWS Region field is rejected")
func rdsRegionUnknown() {
#expect(throws: AWSAuthError.regionUnknown(host: "db.example.com")) {
_ = try token(rds(host: "db.example.com"))
}
}

@Test("A local forward with no RDS Endpoint is rejected")
func rdsLoopbackRejected() {
#expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "127.0.0.1")) {
_ = try token(rds(host: "127.0.0.1", port: 54_321))
}
}

@Test("The RDS Endpoint field signs the endpoint behind a local forward")
func rdsEndpointOverride() throws {
let signed = try token(
rds(host: "127.0.0.1", port: 54_321),
fields: ["awsRDSEndpoint": "mydb.abc123.ap-south-1.rds.amazonaws.com:5433"]
)
#expect(signed.hasPrefix("mydb.abc123.ap-south-1.rds.amazonaws.com:5433/?"))
#expect(signed.contains("%2Fap-south-1%2Frds-db%2Faws4_request"))
}
}
Loading
Loading