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 @@ -526,7 +526,9 @@ extension QueryExecutionCoordinator {
: []
} else {
produced = output.resultSets.enumerated().map { offset, result in
numberedResultSet(result, anchor: entry.batch.anchor, index: firstNumber + resultSets.count + offset)
numberedResultSet(
result, anchor: entry.batch.anchor, index: firstNumber + resultSets.count + offset, tabId: tabId
)
}
}
produced.last?.serverOutput = output.serverOutput
Expand All @@ -550,14 +552,16 @@ extension QueryExecutionCoordinator {
)
}

private func numberedResultSet(_ result: QueryResult, anchor: StatementAnchor?, index: Int) -> ResultSet {
private func numberedResultSet(_ result: QueryResult, anchor: StatementAnchor?, index: Int, tabId: UUID) -> ResultSet {
let columns = result.columns.map { String($0) }
let resultSet = ResultSet(
label: ResultSet.label(tableName: nil, anchor: nil, index: index),
tableRows: TableRows.from(
queryRows: result.rows,
columns: result.columns.map { String($0) },
columns: columns,
columnTypes: result.columnTypes,
absentCells: result.absentCells
absentCells: result.absentCells,
masks: parent.columnMasks(forTabId: tabId, table: nil, columns: columns, columnTypes: result.columnTypes)
)
)
resultSet.statementAnchor = anchor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ extension QueryExecutionCoordinator {
tableName: tableName
)
let generatedColumns = resolved.generatedColumns
// A Reveal covers the table it was asked on, not the next one this tab opens.
if parent.tabManager.tabs[idx].revealsMaskedValues, parent.tabManager.tabs[idx].tableContext.tableName != tableName {
parent.tabManager.mutate(at: idx) { $0.revealsMaskedValues = false }
}
let masks = parent.columnMasks(forTabId: tabId, table: tableName, columns: columns, columnTypes: columnTypes)

let newTableRows = TableRows.from(
queryRows: rows,
Expand All @@ -252,7 +257,8 @@ extension QueryExecutionCoordinator {
foreignKeysFetched: resolved.foreignKeysFetched,
rowLocators: rowLocators,
absentCells: absentCells,
unloadedCells: unloadedCells
unloadedCells: unloadedCells,
masks: masks
)
let previousTableName = parent.tabManager.tabs[idx].tableContext.tableName
let definitionChanged = parent.tabSessionRegistry.needsDefinition(existingTabId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ extension QueryExecutionCoordinator {
anchor: StatementAnchor? = nil
) -> ResultSet {
let tableName = parent.extractTableName(from: sql)
let columns = result.columns.map { String($0) }
let rows = TableRows.from(
queryRows: result.rows,
columns: result.columns.map { String($0) },
columns: columns,
columnTypes: result.columnTypes,
absentCells: result.absentCells
absentCells: result.absentCells,
masks: parent.columnMasks(forTabId: tabId, table: tableName, columns: columns, columnTypes: result.columnTypes)
)
let resultSet = ResultSet(
label: ResultSet.label(tableName: tableName, anchor: anchor, index: index),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ extension DatabaseAccessBridge {
)
return .statement(outcome.result, executionTimeMs: outcome.executionTimeMs)
case .batches(let batches):
return try await runBatches(
let outcome = try await runBatches(
batches,
of: query,
scope: scope,
Expand All @@ -75,6 +75,12 @@ extension DatabaseAccessBridge {
timeoutSeconds: timeoutSeconds,
cancellation: cancellation
)
let policy = await maskingPolicy(for: scope.connectionId)
return ScriptOutcome(
resultSets: outcome.resultSets.map { $0.masked(by: policy) },
primary: outcome.primary.masked(by: policy),
executionTimeMs: outcome.executionTimeMs
)
case .statements:
throw DatabaseAccessError.invalidArgument(
String(localized: "Update the database driver in Settings > Extensions to run several statements in one call.")
Expand Down
21 changes: 19 additions & 2 deletions apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ internal actor DatabaseAccessBridge {
}
}

/// Every row an automation client, MCP or the in-app assistant reads leaves through this bridge, so this is
/// where those rows take the connection's masking rules.
internal func maskingPolicy(for connectionId: UUID) async -> MaskingPolicy {
let manager = await databaseManager
return await MainActor.run {
if let session = manager.session(for: connectionId) {
return session.connection.maskingPolicy
}
return manager.connectionStorage.loadConnection(id: connectionId)?.maskingPolicy ?? .none
}
}

internal func resolveDriver(_ connectionId: UUID) async throws -> (DatabaseDriver, DatabaseType) {
let manager = await databaseManager
let pending: DatabaseConnection? = await MainActor.run {
Expand Down Expand Up @@ -204,12 +216,15 @@ internal actor DatabaseAccessBridge {
internal let executionTimeMs: Double
}

/// `maskingTable` names the table the rows come from when the caller built the query for one, so a
/// `table.column` rule matches by table; without it a rule matches by column name alone.
internal func runStatement(
scope: DatabaseScope,
query: String,
maxRows: Int,
timeoutSeconds: Int,
cancellation: (any StatementCancellationSignal)?
cancellation: (any StatementCancellationSignal)?,
maskingTable: String? = nil
) async throws -> StatementOutcome {
guard !Self.statementText(query, grammar: .ansi).isEmpty else {
throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty."))
Expand Down Expand Up @@ -293,7 +308,9 @@ internal actor DatabaseAccessBridge {
if let tableEdits {
CatalogChangeService.post(.statementsSucceeded(tableEdits))
}
return StatementOutcome(result: result, executionTimeMs: (CFAbsoluteTimeGetCurrent() - startTime) * 1_000)
let executionTimeMs = (CFAbsoluteTimeGetCurrent() - startTime) * 1_000
let masking = await maskingPolicy(for: connectionId)
return StatementOutcome(result: result.masked(by: masking, table: maskingTable), executionTimeMs: executionTimeMs)
}

internal func runExplainPlan(
Expand Down
2 changes: 2 additions & 0 deletions apple/TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,8 @@ extension DatabaseManager {
adopted.color = source.color
adopted.iconName = source.iconName
adopted.tagIds = source.tagIds
// Masking changes no route, and a rule saved while the window is open must hide values at once.
adopted.maskingRules = source.maskingRules
return adopted
}

Expand Down
3 changes: 2 additions & 1 deletion apple/TablePro/Core/MCP/MCPConnectionBridge+Data.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ extension MCPConnectionBridge {
query: sql,
maxRows: limit,
timeoutSeconds: timeoutSeconds,
cancellation: cancellation
cancellation: cancellation,
maskingTable: request.table
)
if case .object(var fields) = payload {
fields["table"] = .string(request.table)
Expand Down
12 changes: 8 additions & 4 deletions apple/TablePro/Core/MCP/MCPConnectionBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,16 @@ public actor MCPConnectionBridge {
query: String,
maxRows: Int,
timeoutSeconds: Int,
cancellation: MCPCancellationToken?
cancellation: MCPCancellationToken?,
maskingTable: String? = nil
) async throws -> JsonValue {
let outcome = try await runStatement(
scope: scope,
query: query,
maxRows: maxRows,
timeoutSeconds: timeoutSeconds,
cancellation: cancellation
cancellation: cancellation,
maskingTable: maskingTable
)
return Self.encode(
result: outcome.result,
Expand Down Expand Up @@ -151,14 +153,16 @@ public actor MCPConnectionBridge {
query: String,
maxRows: Int,
timeoutSeconds: Int,
cancellation: MCPCancellationToken?
cancellation: MCPCancellationToken?,
maskingTable: String? = nil
) async throws -> (result: QueryResult, executionTimeMs: Double) {
let outcome = try await access.runStatement(
scope: scope,
query: query,
maxRows: maxRows,
timeoutSeconds: timeoutSeconds,
cancellation: cancellation
cancellation: cancellation,
maskingTable: maskingTable
)
return (outcome.result, outcome.executionTimeMs)
}
Expand Down
12 changes: 11 additions & 1 deletion apple/TablePro/Core/Plugins/QueryResultExportDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
columnTypeNames: Self.columnTypeNames(of: tableRows),
rows: tableRows.rows.map { Array($0.values) },
unloadedRows: unloadedRows,
fillUnloaded: fillUnloaded,
fillUnloaded: fillUnloaded.map { Self.masking($0, by: tableRows.masks) },
databaseTypeId: databaseType.rawValue,
quoteIdentifier: quoting.quoteIdentifier,
escapeStringLiteral: quoting.escapeStringLiteral
Expand All @@ -85,6 +85,16 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
)
}

// A value the page left on the server arrives raw, so it takes the same masks as the rows already held.
private static func masking(_ fill: @escaping UnloadedValueFill, by masks: ColumnMasks) -> UnloadedValueFill {
guard !masks.isEmpty else { return fill }
return { rows in
try await fill(rows).mapValues { cells in
Dictionary(uniqueKeysWithValues: cells.map { ($0.key, masks.value($0.value, column: $0.key)) })
}
}
}

private static func columnTypeNames(of tableRows: TableRows) -> [String] {
tableRows.columnTypes.map { $0.rawType ?? "" }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,8 @@ internal final class RoutineDebugController: ObservableObject {
break
}
if case .finished(let result) = final {
resultGrid = result.flatMap { Self.makeResultGrid($0, databaseType: databaseType) }
let policy = DatabaseManager.shared.session(for: connectionId)?.connection.maskingPolicy ?? .none
resultGrid = result.flatMap { Self.makeResultGrid($0.masked(by: policy), databaseType: databaseType) }
}
hasEnded = true
setState(final)
Expand Down
13 changes: 11 additions & 2 deletions apple/TablePro/Core/Services/Export/ExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ final class ExportService: ObservableObject {
do {
result = try await plugin.export(
tables: pluginTables,
dataSource: dataSource,
dataSource: MaskingExportDataSource.wrapping(dataSource, policy: maskingPolicy(for: driver)),
destination: url,
progress: progress
)
Expand All @@ -267,6 +267,13 @@ final class ExportService: ObservableObject {
state.notes = result.notes
}

// MARK: - Masking

// The live session's copy, so a rule saved after the connection opened applies to this export.
private func maskingPolicy(for driver: DatabaseDriver) -> MaskingPolicy {
(DatabaseManager.shared.session(for: driver.connection.id)?.connection ?? driver.connection).maskingPolicy
}

// MARK: - Statement Timeout

func suppressStatementTimeout(on driver: DatabaseDriver) async {
Expand Down Expand Up @@ -433,7 +440,9 @@ final class ExportService: ObservableObject {
do {
result = try await plugin.export(
tables: [exportTable],
dataSource: dataSource,
dataSource: MaskingExportDataSource.wrapping(
dataSource, policy: maskingPolicy(for: driver), streamsTables: false
),
destination: url,
progress: progress
)
Expand Down
49 changes: 49 additions & 0 deletions apple/TablePro/Core/Services/Masking/MaskedValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// MaskedValue.swift
// TablePro
//

import CryptoKit
import Foundation
import TableProPluginKit

enum MaskedValue {
static let bullets = "••••"

// Keyed per launch, so equal values hash alike everywhere in this run but a hash cannot be looked up in a table.
private static let hashKey = SymmetricKey(size: .bits256)

static func mask(_ value: PluginCellValue, style: MaskingStyle) -> PluginCellValue {
if style == .null { return .null }
switch value {
case .null:
return .null
case .text(let text):
return .text(mask(text: text, style: style))
case .bytes(let data):
return .text(style == .hash ? hash(data) : bullets)
}
}

private static func mask(text: String, style: MaskingStyle) -> String {
switch style {
case .full, .null:
return bullets
case .lastFour:
guard text.count > 4 else { return bullets }
return bullets + String(text.suffix(4))
case .email:
guard let at = text.lastIndex(of: "@"), at != text.startIndex else { return bullets }
let domain = text[text.index(after: at)...]
guard !domain.isEmpty else { return bullets }
return String(text[text.startIndex]) + "•••@" + domain
case .hash:
return hash(Data(text.utf8))
}
}

private static func hash(_ data: Data) -> String {
let code = HMAC<SHA256>.authenticationCode(for: data, using: hashKey)
return code.prefix(6).map { String(format: "%02x", $0) }.joined()
}
}
Loading
Loading