Skip to content

Commit 47055c4

Browse files
committed
feat: store plugin secure fields in Keychain, add DynamoDB tests
1 parent 950c403 commit 47055c4

10 files changed

Lines changed: 982 additions & 5 deletions

File tree

Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ final class DynamoDBPlugin: NSObject, TableProPlugin, DriverPlugin {
9090
placeholder: "AKIA...",
9191
section: .authentication
9292
),
93-
// TODO: .secure fields stored in additionalFields (plain JSON), not Keychain — needs migration
9493
ConnectionField(
9594
id: "awsSecretAccessKey",
9695
label: String(localized: "Secret Access Key"),

TablePro/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6565
}
6666
}
6767
PluginManager.shared.loadPlugins()
68+
ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded()
6869

6970
Task { @MainActor in
7071
LicenseManager.shared.startPeriodicValidation()

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,18 @@ enum DatabaseDriverFactory {
378378
fields[key] = value
379379
}
380380

381+
let secureFields = PluginManager.shared.additionalConnectionFields(for: connection.type)
382+
.filter(\.isSecure)
383+
for field in secureFields {
384+
if fields[field.id] == nil || fields[field.id]?.isEmpty == true {
385+
if let secureValue = ConnectionStorage.shared.loadPluginSecureField(
386+
fieldId: field.id, for: connection.id
387+
) {
388+
fields[field.id] = secureValue
389+
}
390+
}
391+
}
392+
381393
switch connection.type {
382394
case .mongodb:
383395
fields["sslCACertPath"] = ssl.caCertificatePath

TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,9 +1118,6 @@ extension PluginMetadataRegistry {
11181118
placeholder: "AKIA...",
11191119
section: .authentication
11201120
),
1121-
// TODO: awsSecretAccessKey and awsSessionToken use .secure fieldType but are stored
1122-
// in additionalFields (plain JSON), not Keychain. Needs Keychain migration for
1123-
// plugin .secure fields via ConnectionStorage.
11241121
ConnectionField(
11251122
id: "awsSecretAccessKey",
11261123
label: String(localized: "Secret Access Key"),

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import os
10+
import TableProPluginKit
1011

1112
/// Service for persisting database connections
1213
final class ConnectionStorage {
@@ -101,6 +102,9 @@ final class ConnectionStorage {
101102
deleteSSHPassword(for: connection.id)
102103
deleteKeyPassphrase(for: connection.id)
103104
deleteTOTPSecret(for: connection.id)
105+
106+
let secureFieldIds = Self.secureFieldIds(for: connection.type)
107+
deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds)
104108
}
105109

106110
/// Duplicate a connection with a new UUID and "(Copy)" suffix
@@ -150,6 +154,13 @@ final class ConnectionStorage {
150154
saveTOTPSecret(totpSecret, for: newId)
151155
}
152156

157+
let secureFieldIds = Self.secureFieldIds(for: connection.type)
158+
for fieldId in secureFieldIds {
159+
if let value = loadPluginSecureField(fieldId: fieldId, for: connection.id) {
160+
savePluginSecureField(value, fieldId: fieldId, for: newId)
161+
}
162+
}
163+
153164
return duplicate
154165
}
155166

@@ -211,6 +222,29 @@ final class ConnectionStorage {
211222
KeychainHelper.shared.delete(key: key)
212223
}
213224

225+
// MARK: - Plugin Secure Field Storage
226+
227+
func savePluginSecureField(_ value: String, fieldId: String, for connectionId: UUID) {
228+
let key = "com.TablePro.plugin.\(fieldId).\(connectionId.uuidString)"
229+
KeychainHelper.shared.saveString(value, forKey: key)
230+
}
231+
232+
func loadPluginSecureField(fieldId: String, for connectionId: UUID) -> String? {
233+
let key = "com.TablePro.plugin.\(fieldId).\(connectionId.uuidString)"
234+
return KeychainHelper.shared.loadString(forKey: key)
235+
}
236+
237+
func deletePluginSecureField(fieldId: String, for connectionId: UUID) {
238+
let key = "com.TablePro.plugin.\(fieldId).\(connectionId.uuidString)"
239+
KeychainHelper.shared.delete(key: key)
240+
}
241+
242+
func deleteAllPluginSecureFields(for connectionId: UUID, fieldIds: [String]) {
243+
for fieldId in fieldIds {
244+
deletePluginSecureField(fieldId: fieldId, for: connectionId)
245+
}
246+
}
247+
214248
// MARK: - TOTP Secret Storage
215249

216250
func saveTOTPSecret(_ secret: String, for connectionId: UUID) {
@@ -227,6 +261,41 @@ final class ConnectionStorage {
227261
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
228262
KeychainHelper.shared.delete(key: key)
229263
}
264+
265+
// MARK: - Plugin Secure Field Migration
266+
267+
private static func secureFieldIds(for databaseType: DatabaseType) -> [String] {
268+
(PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
269+
.connection.additionalConnectionFields ?? [])
270+
.filter(\.isSecure).map(\.id)
271+
}
272+
273+
func migratePluginSecureFieldsIfNeeded() {
274+
let migrationKey = "com.TablePro.pluginSecureFieldsMigrated"
275+
guard !UserDefaults.standard.bool(forKey: migrationKey) else { return }
276+
defer { UserDefaults.standard.set(true, forKey: migrationKey) }
277+
278+
var connections = loadConnections()
279+
var changed = false
280+
281+
for index in connections.indices {
282+
let secureFields = (PluginMetadataRegistry.shared
283+
.snapshot(forTypeId: connections[index].type.pluginTypeId)?
284+
.connection.additionalConnectionFields ?? [])
285+
.filter(\.isSecure)
286+
for field in secureFields {
287+
if let value = connections[index].additionalFields[field.id], !value.isEmpty {
288+
savePluginSecureField(value, fieldId: field.id, for: connections[index].id)
289+
connections[index].additionalFields.removeValue(forKey: field.id)
290+
changed = true
291+
}
292+
}
293+
}
294+
295+
if changed {
296+
saveConnections(connections)
297+
}
298+
}
230299
}
231300

232301
// MARK: - Stored Connection (Codable wrapper)

TablePro/Views/Connection/ConnectionFormView.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,13 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
10701070
}
10711071
}
10721072

1073+
for field in PluginManager.shared.additionalConnectionFields(for: existing.type)
1074+
where field.isSecure {
1075+
if let secureValue = storage.loadPluginSecureField(fieldId: field.id, for: existing.id) {
1076+
additionalFieldValues[field.id] = secureValue
1077+
}
1078+
}
1079+
10731080
// Load startup commands
10741081
startupCommands = existing.startupCommands ?? ""
10751082
preConnectScript = existing.preConnectScript ?? ""
@@ -1124,6 +1131,8 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
11241131
trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: type)
11251132
? "root" : trimmedUsername
11261133

1134+
let finalId = connectionId ?? UUID()
1135+
11271136
var finalAdditionalFields = additionalFieldValues
11281137
let trimmedScript = preConnectScript.trimmingCharacters(in: .whitespacesAndNewlines)
11291138
if !trimmedScript.isEmpty {
@@ -1132,8 +1141,18 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
11321141
finalAdditionalFields.removeValue(forKey: "preConnectScript")
11331142
}
11341143

1144+
let secureFields = PluginManager.shared.additionalConnectionFields(for: type).filter(\.isSecure)
1145+
for field in secureFields {
1146+
if let value = finalAdditionalFields[field.id], !value.isEmpty {
1147+
storage.savePluginSecureField(value, fieldId: field.id, for: finalId)
1148+
} else {
1149+
storage.deletePluginSecureField(fieldId: field.id, for: finalId)
1150+
}
1151+
finalAdditionalFields.removeValue(forKey: field.id)
1152+
}
1153+
11351154
let connectionToSave = DatabaseConnection(
1136-
id: connectionId ?? UUID(),
1155+
id: finalId,
11371156
name: name,
11381157
host: finalHost,
11391158
port: finalPort,
@@ -1345,6 +1364,15 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
13451364
}
13461365
}
13471366

1367+
for field in PluginManager.shared.additionalConnectionFields(for: type)
1368+
where field.isSecure {
1369+
if let value = additionalFieldValues[field.id], !value.isEmpty {
1370+
ConnectionStorage.shared.savePluginSecureField(
1371+
value, fieldId: field.id, for: testConn.id
1372+
)
1373+
}
1374+
}
1375+
13481376
let sshPasswordForTest = sshProfileId == nil ? sshPassword : nil
13491377
let success = try await DatabaseManager.shared.testConnection(
13501378
testConn, sshPassword: sshPasswordForTest)
@@ -1398,6 +1426,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
13981426
ConnectionStorage.shared.deleteSSHPassword(for: testId)
13991427
ConnectionStorage.shared.deleteKeyPassphrase(for: testId)
14001428
ConnectionStorage.shared.deleteTOTPSecret(for: testId)
1429+
let secureFieldIds = PluginManager.shared.additionalConnectionFields(for: type)
1430+
.filter(\.isSecure).map(\.id)
1431+
ConnectionStorage.shared.deleteAllPluginSecureFields(for: testId, fieldIds: secureFieldIds)
14011432
}
14021433

14031434
private func browseForPrivateKey() {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../Plugins/DynamoDBDriverPlugin/DynamoDBQueryBuilder.swift
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift

0 commit comments

Comments
 (0)