Skip to content

Commit 5a931ec

Browse files
committed
feat(connections): AWS IAM authentication for PostgreSQL and MySQL (#1291)
1 parent 18bbd4f commit 5a931ec

18 files changed

Lines changed: 974 additions & 11 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- 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)
13+
- 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)
1314

1415
## [0.44.0] - 2026-05-23
1516

Plugins/MySQLDriverPlugin/MySQLPlugin.swift

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,58 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {
2222
static let databaseDisplayName = "MySQL"
2323
static let iconName = "mysql-icon"
2424
static let defaultPort = 3306
25-
static let additionalConnectionFields: [ConnectionField] = []
25+
static let additionalConnectionFields: [ConnectionField] = [
26+
ConnectionField(
27+
id: "awsAuth",
28+
label: String(localized: "Authentication"),
29+
defaultValue: "off",
30+
fieldType: .dropdown(options: [
31+
.init(value: "off", label: String(localized: "Password")),
32+
.init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")),
33+
.init(value: "profile", label: String(localized: "AWS IAM (Profile)")),
34+
.init(value: "sso", label: String(localized: "AWS IAM (SSO)"))
35+
]),
36+
section: .authentication,
37+
hidesPassword: true
38+
),
39+
ConnectionField(
40+
id: "awsRegion",
41+
label: String(localized: "AWS Region"),
42+
placeholder: "us-east-1",
43+
section: .authentication,
44+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"])
45+
),
46+
ConnectionField(
47+
id: "awsAccessKeyId",
48+
label: String(localized: "Access Key ID"),
49+
placeholder: "AKIA...",
50+
section: .authentication,
51+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"])
52+
),
53+
ConnectionField(
54+
id: "awsSecretAccessKey",
55+
label: String(localized: "Secret Access Key"),
56+
placeholder: "wJalr...",
57+
fieldType: .secure,
58+
section: .authentication,
59+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"])
60+
),
61+
ConnectionField(
62+
id: "awsSessionToken",
63+
label: String(localized: "Session Token"),
64+
placeholder: String(localized: "Optional, for temporary credentials"),
65+
fieldType: .secure,
66+
section: .authentication,
67+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"])
68+
),
69+
ConnectionField(
70+
id: "awsProfileName",
71+
label: String(localized: "Profile Name"),
72+
placeholder: "default",
73+
section: .authentication,
74+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"])
75+
),
76+
]
2677
static let additionalDatabaseTypeIds: [String] = ["MariaDB"]
2778

2879
// MARK: - UI/Capability Metadata

Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,56 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
3030
section: .authentication,
3131
hidesPassword: true
3232
),
33+
ConnectionField(
34+
id: "awsAuth",
35+
label: String(localized: "Authentication"),
36+
defaultValue: "off",
37+
fieldType: .dropdown(options: [
38+
.init(value: "off", label: String(localized: "Password")),
39+
.init(value: "accessKey", label: String(localized: "AWS IAM (Access Key)")),
40+
.init(value: "profile", label: String(localized: "AWS IAM (Profile)")),
41+
.init(value: "sso", label: String(localized: "AWS IAM (SSO)"))
42+
]),
43+
section: .authentication,
44+
hidesPassword: true
45+
),
46+
ConnectionField(
47+
id: "awsRegion",
48+
label: String(localized: "AWS Region"),
49+
placeholder: "us-east-1",
50+
section: .authentication,
51+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey", "profile", "sso"])
52+
),
53+
ConnectionField(
54+
id: "awsAccessKeyId",
55+
label: String(localized: "Access Key ID"),
56+
placeholder: "AKIA...",
57+
section: .authentication,
58+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"])
59+
),
60+
ConnectionField(
61+
id: "awsSecretAccessKey",
62+
label: String(localized: "Secret Access Key"),
63+
placeholder: "wJalr...",
64+
fieldType: .secure,
65+
section: .authentication,
66+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"])
67+
),
68+
ConnectionField(
69+
id: "awsSessionToken",
70+
label: String(localized: "Session Token"),
71+
placeholder: String(localized: "Optional, for temporary credentials"),
72+
fieldType: .secure,
73+
section: .authentication,
74+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["accessKey"])
75+
),
76+
ConnectionField(
77+
id: "awsProfileName",
78+
label: String(localized: "Profile Name"),
79+
placeholder: "default",
80+
section: .authentication,
81+
visibleWhen: FieldVisibilityRule(fieldId: "awsAuth", values: ["profile", "sso"])
82+
),
3383
ConnectionField(
3484
id: "connectionOptions",
3585
label: String(localized: "Connection Options"),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// AWSAuthError.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
enum AWSAuthError: Error, LocalizedError, Equatable {
9+
case missingAccessKey
10+
case credentialsFileUnreadable
11+
case profileIncomplete(String)
12+
case regionUnknown(host: String)
13+
14+
var errorDescription: String? {
15+
switch self {
16+
case .missingAccessKey:
17+
return String(localized: "Access Key ID and Secret Access Key are required for AWS IAM authentication.")
18+
case .credentialsFileUnreadable:
19+
return String(localized: "Cannot read ~/.aws/credentials.")
20+
case .profileIncomplete(let profile):
21+
return String(
22+
format: String(localized: "Profile \"%@\" was not found or is missing keys in ~/.aws/credentials."),
23+
profile
24+
)
25+
case .regionUnknown(let host):
26+
return String(
27+
format: String(localized: "Could not determine an AWS region for \"%@\". Set the AWS Region field."),
28+
host
29+
)
30+
}
31+
}
32+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// AWSCredentialResolver.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
enum AWSCredentialResolver {
9+
static func resolve(source: String, fields: [String: String]) async throws -> AWSCredentials {
10+
switch source {
11+
case "profile":
12+
return try resolveProfile(fields: fields)
13+
case "sso":
14+
return try await resolveSSO(fields: fields)
15+
default:
16+
return try resolveAccessKey(fields: fields)
17+
}
18+
}
19+
20+
private static func resolveAccessKey(fields: [String: String]) throws -> AWSCredentials {
21+
let accessKeyId = fields["awsAccessKeyId"] ?? ""
22+
let secretAccessKey = fields["awsSecretAccessKey"] ?? ""
23+
let sessionToken = fields["awsSessionToken"]
24+
25+
guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else {
26+
throw AWSAuthError.missingAccessKey
27+
}
28+
29+
return AWSCredentials(
30+
accessKeyId: accessKeyId,
31+
secretAccessKey: secretAccessKey,
32+
sessionToken: sessionToken?.isEmpty == true ? nil : sessionToken
33+
)
34+
}
35+
36+
private static func resolveProfile(fields: [String: String]) throws -> AWSCredentials {
37+
let profileName = fields["awsProfileName"].flatMap { $0.isEmpty ? nil : $0 } ?? "default"
38+
let credentialsPath = NSString("~/.aws/credentials").expandingTildeInPath
39+
40+
guard let content = try? String(contentsOfFile: credentialsPath, encoding: .utf8) else {
41+
throw AWSAuthError.credentialsFileUnreadable
42+
}
43+
44+
let sections = AWSSSO.parseIniSections(content)
45+
guard let profile = sections[profileName] else {
46+
throw AWSAuthError.profileIncomplete(profileName)
47+
}
48+
49+
let accessKeyId = profile["aws_access_key_id"] ?? ""
50+
let secretAccessKey = profile["aws_secret_access_key"] ?? ""
51+
guard !accessKeyId.isEmpty, !secretAccessKey.isEmpty else {
52+
throw AWSAuthError.profileIncomplete(profileName)
53+
}
54+
55+
return AWSCredentials(
56+
accessKeyId: accessKeyId,
57+
secretAccessKey: secretAccessKey,
58+
sessionToken: profile["aws_session_token"]
59+
)
60+
}
61+
62+
private static func resolveSSO(fields: [String: String]) async throws -> AWSCredentials {
63+
let profileName = fields["awsProfileName"].flatMap { $0.isEmpty ? nil : $0 } ?? "default"
64+
let configPath = NSString("~/.aws/config").expandingTildeInPath
65+
let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath
66+
67+
guard let configContent = try? String(contentsOfFile: configPath, encoding: .utf8) else {
68+
throw AWSSSOError.configReadFailed
69+
}
70+
71+
let settings = try AWSSSO.parseProfileSettings(configContent: configContent, profileName: profileName)
72+
let accessToken = try AWSSSO.readAccessToken(
73+
cacheDirectory: cacheDir,
74+
settings: settings,
75+
profileName: profileName
76+
)
77+
let credentials = try await AWSSSO.fetchRoleCredentials(
78+
accessToken: accessToken,
79+
settings: settings,
80+
profileName: profileName,
81+
session: URLSession.shared
82+
)
83+
return AWSCredentials(
84+
accessKeyId: credentials.accessKeyId,
85+
secretAccessKey: credentials.secretAccessKey,
86+
sessionToken: credentials.sessionToken
87+
)
88+
}
89+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// AWSCredentials.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct AWSCredentials: Sendable {
9+
let accessKeyId: String
10+
let secretAccessKey: String
11+
let sessionToken: String?
12+
}

0 commit comments

Comments
 (0)