Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import.
- Oracle connections negotiate Native Network Encryption when the server asks for it, so servers with `SQLNET.ENCRYPTION_SERVER` or `SQLNET.CRYPTO_CHECKSUM_SERVER` set to REQUIRED now connect (AES with a SHA crypto-checksum), matching what SQL Developer and DBeaver do. (#483)
- Oracle connections follow listener redirects, so RAC SCAN listeners, shared server, and load-balanced setups now connect instead of failing during the handshake. (#483)
- AWS connections can assume an IAM role: profiles with `role_arn` plus `source_profile` (or `credential_source = Environment`) are resolved through STS AssumeRole, including chained source profiles, `external_id`, and `duration_seconds`. (#1567)
- Redis connects to Amazon ElastiCache with IAM auth (access key, profile, or SSO). TablePro generates the IAM token and uses it as the password; set the AWS region and cache name and enable TLS. (#1567)
- AWS SSO connections can sign in from TablePro: when the SSO session has expired, a prompt opens the AWS sign-in page in your browser and refreshes the cached token. (#1567)
- Cassandra connects to Amazon Keyspaces with AWS IAM (SigV4) auth, using access keys, a profile, or SSO. Set Authentication to an AWS IAM mode and the region, and enable TLS. (#1567)

### Changed

Expand All @@ -23,9 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cmd+N now opens a new connection; Manage Connections keeps its File menu item.
- First Page and Last Page now default to Cmd+Option+Up and Cmd+Option+Down.
- Shortcuts can be bound to function keys (F1 through F12), with or without a modifier.
- AWS connections show a dropdown of the profiles found in `~/.aws/config` and `~/.aws/credentials`, and still accept a typed profile name. (#1567)

### Fixed

- DynamoDB AWS Profile auth now reads `~/.aws/config` as well as `~/.aws/credentials` and supports `credential_process`, matching the AWS CLI. Profiles defined only in `~/.aws/config`, including SSO and credential-process profiles, no longer fail with "profile not found". (#1567)
- Query result columns now follow the order in the SELECT. Adding or removing a column no longer leaves new columns stuck at the end of the grid. (#1565)
- JSON file import works again. It failed to load in 0.48.0.
- SQL export quotes empty or malformed values in numeric columns instead of writing them unquoted, which could produce invalid INSERT statements.
Expand Down
8 changes: 6 additions & 2 deletions Plugins/CassandraDriverPlugin/CassandraConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ actor CassandraConnectionActor {
sslCaCertPath: String?,
sslClientCertPath: String?,
sslClientKeyPath: String?,
sslClientKeyPassphrase: String?
sslClientKeyPassphrase: String?,
awsCredentials: AWSCredentials? = nil,
awsRegion: String? = nil
) throws {
cluster = cass_cluster_new()
guard let cluster else {
Expand All @@ -54,7 +56,9 @@ actor CassandraConnectionActor {
cass_cluster_set_contact_points(cluster, host)
cass_cluster_set_port(cluster, Int32(port))

if let username, !username.isEmpty, let password {
if let awsCredentials, let awsRegion, !awsRegion.isEmpty {
CassandraSigV4Authenticator.apply(to: cluster, credentials: awsCredentials, region: awsRegion)
} else if let username, !username.isEmpty, let password {
cass_cluster_set_credentials(cluster, username, password)
}

Expand Down
22 changes: 20 additions & 2 deletions Plugins/CassandraDriverPlugin/CassandraPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin {
static let databaseDisplayName = "Cassandra / ScyllaDB"
static let iconName = "cassandra-icon"
static let defaultPort = 9042
static let additionalConnectionFields: [ConnectionField] = []
static let additionalConnectionFields: [ConnectionField] = AWSAuthFields.standard()
static let additionalDatabaseTypeIds: [String] = ["ScyllaDB"]

// MARK: - UI/Capability Metadata
Expand Down Expand Up @@ -155,6 +155,22 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
let clientKeyPath = config.ssl.clientKeyPath.isEmpty ? nil : config.ssl.clientKeyPath
let clientKeyPassphrase = config.additionalFields["sslClientKeyPassphrase"]

var awsCredentials: AWSCredentials?
var awsRegion: String?
let awsAuth = config.additionalFields["awsAuth"] ?? "off"
if awsAuth != "off", !awsAuth.isEmpty {
guard let region = config.additionalFields["awsRegion"].flatMap({ $0.isEmpty ? nil : $0 }) else {
throw AWSAuthError.regionUnknown(host: config.host)
}
guard config.ssl.mode != .disabled else {
throw AWSAuthError.missingConfiguration(
String(localized: "Amazon Keyspaces IAM authentication requires TLS. Enable SSL in the connection's SSL settings.")
)
}
awsRegion = region
awsCredentials = try await AWSCredentialResolver.resolve(source: awsAuth, fields: config.additionalFields)
}

try await connectionActor.connect(
host: config.host,
port: Int(config.port) ?? 9_042,
Expand All @@ -165,7 +181,9 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
sslCaCertPath: resolvedCaPath,
sslClientCertPath: clientCertPath,
sslClientKeyPath: clientKeyPath,
sslClientKeyPassphrase: clientKeyPassphrase
sslClientKeyPassphrase: clientKeyPassphrase,
awsCredentials: awsCredentials,
awsRegion: awsRegion
)

if let keyspace {
Expand Down
66 changes: 66 additions & 0 deletions Plugins/CassandraDriverPlugin/CassandraSigV4Authenticator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import CCassandra
import Foundation
import TableProPluginKit

final class CassandraSigV4Context {
let credentials: AWSCredentials
let region: String

init(credentials: AWSCredentials, region: String) {
self.credentials = credentials
self.region = region
}
}

enum CassandraSigV4Authenticator {
static func apply(to cluster: OpaquePointer, credentials: AWSCredentials, region: String) {
var callbacks = CassAuthenticatorCallbacks(
initial_callback: cassandraSigV4Initial,
challenge_callback: cassandraSigV4Challenge,
success_callback: nil,
cleanup_callback: nil
)
let context = CassandraSigV4Context(credentials: credentials, region: region)
let data = Unmanaged.passRetained(context).toOpaque()
cass_cluster_set_authenticator_callbacks(cluster, &callbacks, cassandraSigV4DataCleanup, data)
}
}

private func cassandraSigV4Initial(_ auth: OpaquePointer?, _ data: UnsafeMutableRawPointer?) {
let bytes = Array(KeyspacesSigV4.initialResponse.utf8)
bytes.withUnsafeBytes { raw in
cass_authenticator_set_response(auth, raw.bindMemory(to: CChar.self).baseAddress, bytes.count)
}
}

private func cassandraSigV4Challenge(
_ auth: OpaquePointer?,
_ data: UnsafeMutableRawPointer?,
_ token: UnsafePointer<CChar>?,
_ tokenSize: Int
) {
guard let data else { return }
let context = Unmanaged<CassandraSigV4Context>.fromOpaque(data).takeUnretainedValue()

let challenge: Data
if let token, tokenSize > 0 {
challenge = Data(bytes: token, count: tokenSize)
} else {
challenge = Data()
}
guard let nonce = KeyspacesSigV4.nonce(fromChallenge: challenge) else { return }

let response = KeyspacesSigV4.authResponse(
nonce: nonce,
credentials: context.credentials,
region: context.region
)
response.withCString { pointer in
cass_authenticator_set_response(auth, pointer, strlen(pointer))
}
}

private func cassandraSigV4DataCleanup(_ data: UnsafeMutableRawPointer?) {
guard let data else { return }
Unmanaged<CassandraSigV4Context>.fromOpaque(data).release()
}
146 changes: 30 additions & 116 deletions Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,6 @@ private enum DynamoDBTypeCodingKey: String, CodingKey {
case bs = "BS"
}

// MARK: - AWS Credentials

internal struct AWSCredentials: Sendable {
let accessKeyId: String
let secretAccessKey: String
let sessionToken: String?
}

// MARK: - DynamoDB Error

internal enum DynamoDBError: Error, LocalizedError {
Expand Down Expand Up @@ -414,11 +406,11 @@ internal final class DynamoDBConnection: @unchecked Sendable {
// MARK: - Internal Request Handling

private func request<T: Decodable>(target: String, body: [String: Any]) async throws -> T {
let (urlSession, credentials): (URLSession, AWSCredentials) = try lock.withLock {
let urlSession: URLSession = try lock.withLock {
guard let s = _session else { throw DynamoDBError.notConnected }
guard let c = _credentials else { throw DynamoDBError.authFailed("No credentials available") }
return (s, c)
return s
}
let credentials = try await validCredentials()

let bodyData = try JSONSerialization.data(withJSONObject: body, options: [.sortedKeys])

Expand Down Expand Up @@ -598,120 +590,42 @@ internal final class DynamoDBConnection: @unchecked Sendable {
// MARK: - Credential Resolution

private func resolveCredentials() async throws -> AWSCredentials {
let authMethod = config.additionalFields["awsAuthMethod"] ?? "credentials"

switch authMethod {
case "credentials":
return try resolveAccessKeyCredentials()
case "profile":
return try resolveProfileCredentials()
case "sso":
return try await resolveSsoCredentials()
default:
return try resolveAccessKeyCredentials()
}
}

private func resolveAccessKeyCredentials() throws -> AWSCredentials {
let accessKeyId = config.additionalFields["awsAccessKeyId"] ?? config.username
let secretAccessKey = config.additionalFields["awsSecretAccessKey"] ?? config.password
let sessionToken = config.additionalFields["awsSessionToken"]

Self.logger.debug("Resolved credentials — credentialSource: accessKey, region: \(self.region)")

guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else {
throw DynamoDBError.authFailed("Access Key ID and Secret Access Key are required")
let source = Self.credentialSource(forAuthMethod: config.additionalFields["awsAuthMethod"])
var fields = config.additionalFields
if (fields["awsAccessKeyId"] ?? "").isEmpty, !config.username.isEmpty {
fields["awsAccessKeyId"] = config.username
}

return AWSCredentials(
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken
)
}

private func resolveProfileCredentials() throws -> AWSCredentials {
let profileName = config.additionalFields["awsProfileName"] ?? "default"
let credentialsPath = NSString("~/.aws/credentials").expandingTildeInPath

guard let content = try? String(contentsOfFile: credentialsPath, encoding: .utf8) else {
throw DynamoDBError.authFailed("Cannot read ~/.aws/credentials")
if (fields["awsSecretAccessKey"] ?? "").isEmpty, !config.password.isEmpty {
fields["awsSecretAccessKey"] = config.password
}

var currentProfile = ""
var accessKeyId = ""
var secretAccessKey = ""
var sessionToken: String?

for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") {
currentProfile = String(trimmed.dropFirst().dropLast())
continue
}
guard currentProfile == profileName else { continue }

let parts = trimmed.split(separator: "=", maxSplits: 1).map {
$0.trimmingCharacters(in: .whitespaces)
}
guard parts.count == 2 else { continue }

switch parts[0] {
case "aws_access_key_id":
accessKeyId = parts[1]
case "aws_secret_access_key":
secretAccessKey = parts[1]
case "aws_session_token":
sessionToken = parts[1]
default:
break
}
}

guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else {
throw DynamoDBError.authFailed("Profile '\(profileName)' not found or incomplete in ~/.aws/credentials")
do {
return try await AWSCredentialResolver.resolve(source: source, fields: fields)
} catch let error as AWSAuthError {
throw DynamoDBError.authFailed(error.localizedDescription)
} catch let error as AWSSSOError {
throw DynamoDBError.authFailed(error.localizedDescription)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve SSO errors for DynamoDB login recovery

When a DynamoDB SSO profile has a missing or expired token cache, AWSCredentialResolver throws AWSSSOError, but this catch converts it into DynamoDBError.authFailed. The test-connection flow only offers the browser sign-in path when AWSSSOLoginService.isSSOExpired(error) receives an AWSSSOError directly, so DynamoDB SSO users hit the generic connection failure instead of the new login prompt. Preserve the underlying SSO error (or teach the detector about this wrapper) for the awsAuthMethod == "sso" path.

Useful? React with 👍 / 👎.

}

return AWSCredentials(
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: sessionToken
)
}

private func resolveSsoCredentials() async throws -> AWSCredentials {
let profileName = config.additionalFields["awsProfileName"] ?? "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 DynamoDBError.authFailed(SsoCredentialError.configReadFailed.userMessage)
static func credentialSource(forAuthMethod authMethod: String?) -> String {
switch authMethod {
case "profile":
return "profile"
case "sso":
return "sso"
default:
return "accessKey"
}
}

do {
let settings = try DynamoDBSso.parseProfileSettings(
configContent: configContent,
profileName: profileName
)
let accessToken = try DynamoDBSso.readAccessToken(
cacheDirectory: cacheDir,
settings: settings,
profileName: profileName
)
let credentials = try await DynamoDBSso.fetchRoleCredentials(
accessToken: accessToken,
settings: settings,
profileName: profileName,
session: URLSession.shared
)
return AWSCredentials(
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken
)
} catch let error as SsoCredentialError {
throw DynamoDBError.authFailed(error.userMessage)
private func validCredentials() async throws -> AWSCredentials {
if let current = lock.withLock({ _credentials }), !current.isExpired() {
return current
}
let refreshed = try await resolveCredentials()
lock.withLock { _credentials = refreshed }
return refreshed
}

// MARK: - Helpers
Expand Down
2 changes: 1 addition & 1 deletion Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ final class DynamoDBPlugin: NSObject, TableProPlugin, DriverPlugin {
placeholder: "default",
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "awsAuthMethod", values: ["profile", "sso"])
),
).withDynamicOptions(.awsProfiles),
ConnectionField(
id: "awsRegion",
label: String(localized: "AWS Region"),
Expand Down
Loading
Loading