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
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ enum ConnectionCredentialResolver {
/// The profiles file carries its own tag for exactly this: a source can name a shell
/// command, so the file has to be the one TablePro last wrote before one runs.
guard CredentialProfileStorage.shared.storeIsTrusted else {
throw PasswordSourceResolver.ResolutionError.storeNotTrusted
throw PasswordSourceResolver.ResolutionError.profileStoreNotTrusted
}
return try await PasswordSourceResolver.resolve(source, for: ConnectionScriptContext(connection: connection))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ enum PasswordSourceResolver {
private static let commandTimeoutSeconds: UInt64 = 30
private static let maxOutputBytes = 1_048_576

enum ResolutionError: LocalizedError {
enum ResolutionError: LocalizedError, Equatable {
case fileNotFound(path: String)
case fileUnreadable(path: String)
case environmentVariableNotSet(name: String)
Expand All @@ -26,6 +26,7 @@ enum PasswordSourceResolver {
case invalidSecretJson
case jsonKeyNotFound(key: String)
case storeNotTrusted
case profileStoreNotTrusted

var errorDescription: String? {
switch self {
Expand Down Expand Up @@ -64,10 +65,20 @@ enum PasswordSourceResolver {
password source was not run. Open the connection and save it again to \
confirm the change.
""")
case .profileStoreNotTrusted:
return String(localized: """
Your credential profiles file was changed outside TablePro, so this profile's \
password source was not run.
""")
}
}
}

// A test reports what the source returns right now, so it neither reads nor fills the reuse cache.
static func test(_ source: PasswordSource, for context: ConnectionScriptContext) async throws -> String {
try await resolve(source.withoutReuse, for: context)
}

static func resolve(
_ source: PasswordSource,
for context: ConnectionScriptContext? = nil,
Expand Down
5 changes: 5 additions & 0 deletions apple/TablePro/Models/Connection/PasswordSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ enum PasswordSource: Codable, Hashable, Sendable {
value.flatMap { reuseSecondsRange.contains($0) ? $0 : nil }
}

var withoutReuse: PasswordSource {
guard case .command(let shell, _) = self else { return self }
return .command(shell: shell)
}

/// Decodes a password source from a connection container, treating a present-but-malformed
/// entry as absent so one bad connection cannot fail loading of the whole store.
static func resilientlyDecoded<Key>(
Expand Down
15 changes: 15 additions & 0 deletions apple/TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -16692,6 +16692,9 @@
}
}
}
},
"A test here has no connection to take the host, port and database from." : {

},
"A text index cannot be unique." : {
"extractionState" : "manual",
Expand Down Expand Up @@ -156880,6 +156883,9 @@
}
}
}
},
"show a saved password" : {

},
"Secret Access Key" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -160199,6 +160205,9 @@
}
}
}
},
"Set in connections.json." : {

},
"Set Cells to Value" : {
"localizations" : {
Expand Down Expand Up @@ -179014,6 +179023,9 @@
}
}
}
},
"Test Source" : {

},
"Test the current connection settings" : {
"localizations" : {
Expand Down Expand Up @@ -211677,6 +211689,9 @@
}
}
}
},
"Your credential profiles file was changed outside TablePro, so this profile's password source was not run." : {

},
"Your database schema and query data will be sent to the AI provider for analysis. Allow for this connection?" : {
"localizations" : {
Expand Down
47 changes: 30 additions & 17 deletions apple/TablePro/Views/Connection/PasswordPromptToggle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,53 @@
// PasswordPromptToggle.swift
// TablePro
//
// Toggle + conditional SecureField for the "ask for password on every connection" option.
//

import SwiftUI
import TableProPluginKit

struct PasswordPromptToggle: View {
let type: DatabaseType
@Binding var promptForPassword: Bool
@Binding var password: String
@Binding var additionalFieldValues: [String: String]
@ObservedObject var coordinator: ConnectionFormCoordinator

private var auth: AuthPaneViewModel { coordinator.auth }

private var isApiOnly: Bool {
PluginManager.shared.connectionMode(for: type) == .apiOnly
PluginManager.shared.connectionMode(for: coordinator.network.type) == .apiOnly
}

private var passwordTitle: String {
isApiOnly ? String(localized: "API Token") : String(localized: "Password")
}

var body: some View {
if !promptForPassword {
SecureField(
isApiOnly ? String(localized: "API Token") : String(localized: "Password"),
text: $password
)
if !auth.promptForPassword {
if let source = auth.passwordSource {
PasswordSourceSummary(title: passwordTitle, source: source)
PasswordSourceTestView(
source: source,
context: { coordinator.passwordSourceContext() },
refusal: {
coordinator.storage.storeIsTrusted ? nil : PasswordSourceResolver.ResolutionError.storeNotTrusted
}
)
} else {
RevealablePasswordField(
title: passwordTitle,
text: $coordinator.auth.password,
requiresAuthentication: auth.storedPasswordState != .absent
)
}
}
Toggle(
isApiOnly
? String(localized: "Prompt for API token")
: String(localized: "Prompt for password"),
isOn: $promptForPassword
isOn: $coordinator.auth.promptForPassword
)
.onChange(of: promptForPassword) { newValue in
.onChange(of: auth.promptForPassword) { newValue in
if newValue {
password = ""
if additionalFieldValues["usePgpass"] == "true" {
additionalFieldValues["usePgpass"] = ""
coordinator.auth.password = ""
if coordinator.auth.additionalFieldValues["usePgpass"] == "true" {
coordinator.auth.additionalFieldValues["usePgpass"] = ""
}
}
}
Expand Down
78 changes: 78 additions & 0 deletions apple/TablePro/Views/Connection/PasswordSourceKind.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// PasswordSourceKind.swift
// TablePro
//

import Foundation

enum PasswordSourceKind: String, CaseIterable, Identifiable {
case file, env, command, onePassword, vault, awsSecretsManager

var id: String { rawValue }

var displayName: String {
switch self {
case .file: String(localized: "A file")
case .env: String(localized: "An environment variable")
case .command: String(localized: "The output of a command")
case .onePassword: "1Password"
case .vault: "HashiCorp Vault"
case .awsSecretsManager: "AWS Secrets Manager"
}
}

var valueLabel: String {
switch self {
case .file: String(localized: "Path")
case .env: String(localized: "Variable")
case .command: String(localized: "Command")
case .onePassword: String(localized: "Reference")
case .vault: String(localized: "Path")
case .awsSecretsManager: String(localized: "Secret ID")
}
}

var fieldLabel: String? {
switch self {
case .vault: String(localized: "Field")
case .awsSecretsManager: String(localized: "JSON Key")
case .file, .env, .command, .onePassword: nil
}
}

var placeholder: String {
switch self {
case .file: "~/.secrets/db-password"
case .env: "DB_PASSWORD"
case .command: "security find-generic-password -w -s db"
case .onePassword: "op://vault/item/password"
case .vault: "secret/data/db"
case .awsSecretsManager: "prod/db/credentials"
}
}

func source(value: String, field: String, reuseSeconds: Int?) -> PasswordSource {
let trimmedValue = value.trimmingCharacters(in: .whitespaces)
let trimmedField = field.trimmingCharacters(in: .whitespaces)
switch self {
case .file: return .file(path: trimmedValue)
case .env: return .env(variable: trimmedValue)
case .command: return .command(shell: value, reuseSeconds: reuseSeconds)
case .onePassword: return .onePassword(reference: trimmedValue)
case .vault: return .vault(path: trimmedValue, field: trimmedField)
case .awsSecretsManager:
return .awsSecretsManager(secretId: trimmedValue, jsonKey: trimmedField.isEmpty ? nil : trimmedField)
}
}

static func decompose(_ source: PasswordSource) -> (kind: PasswordSourceKind, value: String, field: String) {
switch source {
case .file(let path): (.file, path, "")
case .env(let variable): (.env, variable, "")
case .command(let shell, _): (.command, shell, "")
case .onePassword(let reference): (.onePassword, reference, "")
case .vault(let path, let field): (.vault, path, field)
case .awsSecretsManager(let secretId, let jsonKey): (.awsSecretsManager, secretId, jsonKey ?? "")
}
}
}
127 changes: 127 additions & 0 deletions apple/TablePro/Views/Connection/PasswordSourceTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//
// PasswordSourceTest.swift
// TablePro
//

import Combine
import SwiftUI

@MainActor
final class PasswordSourceTestModel: ObservableObject {
enum Outcome: Equatable {
case returned(String)
case failed(String)
}

@Published private(set) var isRunning = false
@Published private var latest: (source: PasswordSource, environment: [String: String], outcome: Outcome)?

private let resolve: @Sendable (PasswordSource, ConnectionScriptContext) async throws -> String

init(
resolve: @escaping @Sendable (PasswordSource, ConnectionScriptContext) async throws -> String = { source, context in
try await PasswordSourceResolver.test(source, for: context)
}
) {
self.resolve = resolve
}

// A result describes the source and connection details it ran with; after an edit to either it is about something else.
func outcome(for source: PasswordSource, context: @autoclosure () -> ConnectionScriptContext) -> Outcome? {
guard let latest, latest.source == source, latest.environment == context().environment else { return nil }
return latest.outcome
}

func run(_ source: PasswordSource, context: ConnectionScriptContext, refusal: (any Error)?) async {
guard !isRunning else { return }
if let refusal {
latest = (source, context.environment, .failed(refusal.localizedDescription))
return
}
isRunning = true
defer { isRunning = false }
do {
let value = try await resolve(source, context)
latest = (source, context.environment, .returned(value))
} catch {
latest = (source, context.environment, .failed(error.localizedDescription))
}
}
}

struct PasswordSourceTestView: View {
let source: PasswordSource
let context: () -> ConnectionScriptContext
let refusal: () -> (any Error)?

@StateObject private var model = PasswordSourceTestModel()
@StateObject private var reveal = SecretReveal()

var body: some View {
HStack {
Button(String(localized: "Test Source")) {
let context = context()
let refusal = refusal()
Task { await model.run(source, context: context, refusal: refusal) }
}
.disabled(model.isRunning)
.accessibilityIdentifier("password-source-test")
if model.isRunning {
ProgressView()
.controlSize(.small)
}
}
switch model.outcome(for: source, context: context()) {
case .returned(let value):
LabeledContent(String(localized: "Result")) {
if reveal.isRevealed {
Text(value)
.font(.body.monospaced())
.textSelection(.enabled)
.lineLimit(4)
} else {
Text(Self.masked(value))
.accessibilityLabel(String(localized: "Hidden"))
}
}
SecretRevealToggle(reveal: reveal, requiresAuthentication: true)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
.textSelection(.enabled)
case nil:
EmptyView()
}
}

static func masked(_ value: String) -> String {
String(repeating: "\u{2022}", count: min(value.count, 24))
}
}

struct PasswordSourceSummary: View {
let title: String
let source: PasswordSource

private var parts: (kind: PasswordSourceKind, value: String, field: String) {
PasswordSourceKind.decompose(source)
}

var body: some View {
LabeledContent(title, value: parts.kind.displayName)
LabeledContent(parts.kind.valueLabel) {
Text(parts.value)
.font(.body.monospaced())
.textSelection(.enabled)
.lineLimit(3)
.truncationMode(.middle)
}
if let fieldLabel = parts.kind.fieldLabel, !parts.field.isEmpty {
LabeledContent(fieldLabel, value: parts.field)
}
Text("Set in connections.json.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Loading
Loading