From abab74c8e706f45d75f224eb2e434e5507dfc663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:24:13 +0700 Subject: [PATCH 1/4] feat(masking): mask column values in the grid, exports, AI and MCP --- .../QueryExecutionCoordinator+Batches.swift | 12 +- .../QueryExecutionCoordinator+Helpers.swift | 8 +- ...yExecutionCoordinator+MultiStatement.swift | 6 +- .../Access/DatabaseAccessBridge+Scripts.swift | 8 +- .../Access/DatabaseAccessBridge.swift | 21 ++- .../Database/DatabaseManager+Sessions.swift | 2 + .../Core/MCP/MCPConnectionBridge+Data.swift | 3 +- .../Core/MCP/MCPConnectionBridge.swift | 12 +- .../Plugins/QueryResultExportDataSource.swift | 12 +- .../RoutineDebugController.swift | 3 +- .../Core/Services/Export/ExportService.swift | 13 +- .../Core/Services/Masking/MaskedValue.swift | 49 +++++ .../Masking/MaskingExportDataSource.swift | 167 ++++++++++++++++++ .../Core/Services/Masking/MaskingPolicy.swift | 131 ++++++++++++++ .../Core/Services/Masking/MaskingRule.swift | 83 +++++++++ .../Masking/QueryResult+Masking.swift | 51 ++++++ .../Services/Query/ForeignKeyRowFetcher.swift | 4 +- .../DatabaseConnection+Masking.swift | 40 +++++ apple/TablePro/Models/Query/QueryTab.swift | 2 + .../TablePro/Models/Query/ResultOrigin.swift | 3 + apple/TablePro/Models/Query/TableRows.swift | 36 +++- .../TablePro/Resources/Localizable.xcstrings | 36 ++++ .../ViewModels/ServerDashboardViewModel.swift | 7 +- .../ConnectionFormCoordinator.swift | 8 +- .../Panes/OptionsPaneView.swift | 77 ++++++++ .../Support/ConnectionFormEdits.swift | 3 + .../MaskingRulesPaneViewModel.swift | 35 ++++ .../DataGridViewController+Header.swift | 48 +++-- .../Views/Main/Child/DataTabGridAdapter.swift | 10 ++ .../MainContentCoordinator+Masking.swift | 67 +++++++ ...MainContentCoordinator+ResultEditing.swift | 5 +- .../Views/Main/MainContentCoordinator.swift | 2 + .../ConnectionBundleExporterTests.swift | 15 ++ .../Services/Masking/MaskingBridgeTests.swift | 109 ++++++++++++ .../Services/Masking/MaskingExportTests.swift | 130 ++++++++++++++ .../Services/Masking/MaskingGridTests.swift | 167 ++++++++++++++++++ .../Services/Masking/MaskingPolicyTests.swift | 143 +++++++++++++++ .../Helpers/ScriptAnsweringDriver.swift | 9 +- .../ViewModels/ConnectionFormEditsTests.swift | 28 ++- 39 files changed, 1519 insertions(+), 46 deletions(-) create mode 100644 apple/TablePro/Core/Services/Masking/MaskedValue.swift create mode 100644 apple/TablePro/Core/Services/Masking/MaskingExportDataSource.swift create mode 100644 apple/TablePro/Core/Services/Masking/MaskingPolicy.swift create mode 100644 apple/TablePro/Core/Services/Masking/MaskingRule.swift create mode 100644 apple/TablePro/Core/Services/Masking/QueryResult+Masking.swift create mode 100644 apple/TablePro/Models/Connection/DatabaseConnection+Masking.swift create mode 100644 apple/TablePro/Views/ConnectionForm/ViewModels/MaskingRulesPaneViewModel.swift create mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Masking.swift create mode 100644 apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift create mode 100644 apple/TableProTests/Core/Services/Masking/MaskingExportTests.swift create mode 100644 apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift create mode 100644 apple/TableProTests/Core/Services/Masking/MaskingPolicyTests.swift diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift index 923231fcc9..83dbe3eaf0 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift @@ -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 @@ -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 diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 7ed1294821..bafba9641c 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -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, @@ -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) diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index 8a47a37c8f..98d014e3f0 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -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), diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift index 383ee0663f..727d37b426 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift @@ -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, @@ -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.") diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 1420e142da..dc94f2b897 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -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 { @@ -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.")) @@ -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( diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index 7383aeeec9..f65071206d 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -907,6 +907,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 } diff --git a/apple/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/apple/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 4f75131f16..f94d9fc86d 100644 --- a/apple/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/apple/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -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) diff --git a/apple/TablePro/Core/MCP/MCPConnectionBridge.swift b/apple/TablePro/Core/MCP/MCPConnectionBridge.swift index 39e4fe410b..dd54dc492c 100644 --- a/apple/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/apple/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -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, @@ -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) } diff --git a/apple/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/apple/TablePro/Core/Plugins/QueryResultExportDataSource.swift index a760427d17..8ae6fafd0c 100644 --- a/apple/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/apple/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -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 @@ -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 ?? "" } } diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift index 9393c0b321..41f98644ee 100644 --- a/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift @@ -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) diff --git a/apple/TablePro/Core/Services/Export/ExportService.swift b/apple/TablePro/Core/Services/Export/ExportService.swift index 408f425972..fbcf13983b 100644 --- a/apple/TablePro/Core/Services/Export/ExportService.swift +++ b/apple/TablePro/Core/Services/Export/ExportService.swift @@ -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 ) @@ -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 { @@ -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 ) diff --git a/apple/TablePro/Core/Services/Masking/MaskedValue.swift b/apple/TablePro/Core/Services/Masking/MaskedValue.swift new file mode 100644 index 0000000000..c66f293c2a --- /dev/null +++ b/apple/TablePro/Core/Services/Masking/MaskedValue.swift @@ -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.authenticationCode(for: data, using: hashKey) + return code.prefix(6).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apple/TablePro/Core/Services/Masking/MaskingExportDataSource.swift b/apple/TablePro/Core/Services/Masking/MaskingExportDataSource.swift new file mode 100644 index 0000000000..ee711d05e0 --- /dev/null +++ b/apple/TablePro/Core/Services/Masking/MaskingExportDataSource.swift @@ -0,0 +1,167 @@ +// +// MaskingExportDataSource.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Stands between an export format and a data source that reads the database, so every row a format +/// receives, by stream or by its own query, has the connection's masking rules applied. +final class MaskingExportDataSource: PluginExportDataSource, @unchecked Sendable { + private let base: any PluginExportDataSource + private let policy: MaskingPolicy + private let streamsTables: Bool + + /// `streamsTables` is false when the export table is a query result named after its output file, so a + /// `table.column` rule matches by column name instead of by that name. + static func wrapping( + _ base: any PluginExportDataSource, + policy: MaskingPolicy, + streamsTables: Bool = true + ) -> any PluginExportDataSource { + policy.isEmpty ? base : MaskingExportDataSource(base: base, policy: policy, streamsTables: streamsTables) + } + + init(base: any PluginExportDataSource, policy: MaskingPolicy, streamsTables: Bool = true) { + self.base = base + self.policy = policy + self.streamsTables = streamsTables + } + + var databaseTypeId: String { base.databaseTypeId } + var tableDDLIncludesForeignKeys: Bool { base.tableDDLIncludesForeignKeys } + var lexicalFeatures: SQLLexicalFeatures { base.lexicalFeatures } + var supportsCascadeDrop: Bool { base.supportsCascadeDrop } + + func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { + Self.masking(base.streamRows(table: table, databaseName: databaseName), table: sourceTable(table), policy: policy) + } + + func streamRows(for object: PluginExportTable) -> AsyncThrowingStream { + Self.masking(base.streamRows(for: object), table: sourceTable(object.name), policy: policy) + } + + private func sourceTable(_ name: String) -> String? { + streamsTables ? name : nil + } + + func execute(query: String) async throws -> PluginQueryResult { + try await base.execute(query: query).masked(by: policy) + } + + static func masking( + _ stream: AsyncThrowingStream, + table: String?, + policy: MaskingPolicy + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + var masks: ColumnMasks? + do { + for try await element in stream { + switch element { + case .header(let header): + masks = policy.columnMasks(table: table, columns: header.columns, typeNames: header.columnTypeNames) + continuation.yield(element) + case .rows(let rows): + // Without a header there are no column names to match, so no row goes out unmatched. + guard let masks else { + throw PluginExportError.exportFailed("Rows arrived before their columns") + } + continuation.yield(.rows(masks.rows(rows))) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func fetchTableDDL(table: String, databaseName: String) async throws -> String { + try await base.fetchTableDDL(table: table, databaseName: databaseName) + } + + func quoteIdentifier(_ identifier: String) -> String { + base.quoteIdentifier(identifier) + } + + func escapeStringLiteral(_ value: String) -> String { + base.escapeStringLiteral(value) + } + + func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { + try await base.fetchApproximateRowCount(table: table, databaseName: databaseName) + } + + func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] { + try await base.fetchDependentSequences(table: table, databaseName: databaseName) + } + + func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] { + try await base.fetchDependentTypes(table: table, databaseName: databaseName) + } + + func fetchColumns(table: String, databaseName: String) async throws -> [PluginColumnInfo] { + try await base.fetchColumns(table: table, databaseName: databaseName) + } + + func fetchAllColumns(databaseName: String) async throws -> [String: [PluginColumnInfo]] { + try await base.fetchAllColumns(databaseName: databaseName) + } + + func fetchForeignKeys(table: String, databaseName: String) async throws -> [PluginForeignKeyInfo] { + try await base.fetchForeignKeys(table: table, databaseName: databaseName) + } + + func fetchAllForeignKeys(databaseName: String) async throws -> [String: [PluginForeignKeyInfo]] { + try await base.fetchAllForeignKeys(databaseName: databaseName) + } + + func fetchIndexDDL(table: String, databaseName: String) async throws -> [String] { + try await base.fetchIndexDDL(table: table, databaseName: databaseName) + } + + func fetchCommentDDL(table: String, databaseName: String) async throws -> [String] { + try await base.fetchCommentDDL(table: table, databaseName: databaseName) + } + + func fetchObjectDDL(_ object: PluginExportTable) async throws -> String { + try await base.fetchObjectDDL(object) + } + + func scriptText(for ddl: String) -> String { + base.scriptText(for: ddl) + } + + func fetchGrantStatements(principal: String, host: String?) async throws -> [String] { + try await base.fetchGrantStatements(principal: principal, host: host) + } + + func dropStatement(for object: PluginExportTable) -> String? { + base.dropStatement(for: object) + } + + func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { + try await base.openSessionScope(for: purpose) + } + + func closeSessionScope(_ scope: PluginSessionScope) async throws { + try await base.closeSessionScope(scope) + } + + func fetchCreationScope(_ object: PluginExportTable) async throws -> PluginStatementScope? { + try await base.fetchCreationScope(object) + } + + func fetchIndexes(table: String, databaseName: String) async throws -> [PluginIndexInfo] { + try await base.fetchIndexes(table: table, databaseName: databaseName) + } + + func fetchObjectComment(_ object: PluginExportTable) async throws -> String? { + try await base.fetchObjectComment(object) + } +} diff --git a/apple/TablePro/Core/Services/Masking/MaskingPolicy.swift b/apple/TablePro/Core/Services/Masking/MaskingPolicy.swift new file mode 100644 index 0000000000..629a6432bc --- /dev/null +++ b/apple/TablePro/Core/Services/Masking/MaskingPolicy.swift @@ -0,0 +1,131 @@ +// +// MaskingPolicy.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The rules one connection masks its values by, and the only place they are matched against columns. +struct MaskingPolicy: Equatable, Sendable { + let rules: [MaskingRule] + + static let none = MaskingPolicy(rules: []) + + init(rules: [MaskingRule]) { + self.rules = rules.filter { !$0.trimmedPattern.isEmpty } + } + + var isEmpty: Bool { rules.isEmpty } + + /// The first rule that matches wins. Without a table, as for most query results, a `table.column` + /// pattern matches by its column part alone, so an unknown source masks more rather than less. + func style(table: String?, column: String, typeName: String?) -> MaskingStyle? { + for rule in rules where Self.matches(rule, table: table, column: column, typeName: typeName) { + return rule.style + } + return nil + } + + func columnMasks(table: String?, columns: [String], typeNames: [String?]) -> ColumnMasks { + guard !isEmpty else { return .none } + var styles: [Int: MaskingStyle] = [:] + for (index, column) in columns.enumerated() { + let typeName = typeNames.indices.contains(index) ? typeNames[index] : nil + if let style = style(table: table, column: column, typeName: typeName) { + styles[index] = style + } + } + return ColumnMasks(styles: styles) + } + + func columnMasks(table: String?, columns: [String], columnTypes: [ColumnType]) -> ColumnMasks { + columnMasks(table: table, columns: columns, typeNames: columnTypes.map(\.rawType)) + } + + private static func matches(_ rule: MaskingRule, table: String?, column: String, typeName: String?) -> Bool { + let pattern = rule.trimmedPattern + switch rule.target { + case .type: + guard let typeName else { return false } + return MaskingGlob.matches(pattern, typeName) + case .column: + guard let dot = pattern.lastIndex(of: ".") else { + return MaskingGlob.matches(pattern, column) + } + let tablePattern = String(pattern[.. Bool { + let pattern = Array(pattern.lowercased()) + let text = Array(text.lowercased()) + var patternIndex = 0 + var textIndex = 0 + var starIndex: Int? + var resumeIndex = 0 + while textIndex < text.count { + if patternIndex < pattern.count, pattern[patternIndex] == "?" || pattern[patternIndex] == text[textIndex] { + patternIndex += 1 + textIndex += 1 + } else if patternIndex < pattern.count, pattern[patternIndex] == "*" { + starIndex = patternIndex + resumeIndex = textIndex + patternIndex += 1 + } else if let star = starIndex { + patternIndex = star + 1 + resumeIndex += 1 + textIndex = resumeIndex + } else { + return false + } + } + while patternIndex < pattern.count, pattern[patternIndex] == "*" { + patternIndex += 1 + } + return patternIndex == pattern.count + } +} + +/// Which columns of one result are masked, and how. Every surface masks rows through this. +struct ColumnMasks: Equatable, Sendable { + let styles: [Int: MaskingStyle] + + static let none = ColumnMasks(styles: [:]) + + var isEmpty: Bool { styles.isEmpty } + + var maskedColumns: Set { Set(styles.keys) } + + func isMasked(_ column: Int) -> Bool { + styles[column] != nil + } + + func value(_ value: PluginCellValue, column: Int) -> PluginCellValue { + guard let style = styles[column] else { return value } + return MaskedValue.mask(value, style: style) + } + + func row(_ values: [PluginCellValue]) -> [PluginCellValue] { + guard !isEmpty else { return values } + var masked = values + for (column, style) in styles where column < masked.count { + masked[column] = MaskedValue.mask(masked[column], style: style) + } + return masked + } + + func rows(_ rows: [[PluginCellValue]]) -> [[PluginCellValue]] { + guard !isEmpty else { return rows } + return rows.map(row) + } +} diff --git a/apple/TablePro/Core/Services/Masking/MaskingRule.swift b/apple/TablePro/Core/Services/Masking/MaskingRule.swift new file mode 100644 index 0000000000..53008bbf4a --- /dev/null +++ b/apple/TablePro/Core/Services/Masking/MaskingRule.swift @@ -0,0 +1,83 @@ +// +// MaskingRule.swift +// TablePro +// + +import Foundation + +enum MaskingStyle: String, Codable, CaseIterable, Identifiable, Sendable { + case full + case lastFour + case email + case hash + case null + + var id: String { rawValue } + + var displayName: String { + switch self { + case .full: return String(localized: "Full") + case .lastFour: return String(localized: "Last 4 Characters") + case .email: return String(localized: "Email") + case .hash: return String(localized: "Stable Hash") + case .null: return "NULL" + } + } +} + +enum MaskingTarget: String, Codable, CaseIterable, Identifiable, Sendable { + case column + case type + + var id: String { rawValue } + + var displayName: String { + switch self { + case .column: return String(localized: "Column") + case .type: return String(localized: "Type") + } + } +} + +struct MaskingRule: Codable, Hashable, Identifiable, Sendable { + var id: UUID + var pattern: String + var target: MaskingTarget + var style: MaskingStyle + + init(id: UUID = UUID(), pattern: String, target: MaskingTarget = .column, style: MaskingStyle = .full) { + self.id = id + self.pattern = pattern + self.target = target + self.style = style + } + + private enum CodingKeys: String, CodingKey { + case id, pattern, target, style + } + + /// What a rule this release cannot read in full becomes, so a style, target or field a newer release added + /// never shows a value it meant to hide. + static let maskEverything = MaskingRule(pattern: "*") + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + let pattern = try container.decodeIfPresent(String.self, forKey: .pattern) + let target = try container.decodeIfPresent(String.self, forKey: .target).map { MaskingTarget(rawValue: $0) } + let style = try container.decodeIfPresent(String.self, forKey: .style).map { MaskingStyle(rawValue: $0) } + guard let pattern, target != .some(nil) else { + self.pattern = Self.maskEverything.pattern + self.target = .column + self.style = .full + return + } + self.pattern = pattern + self.target = target.flatMap { $0 } ?? .column + self.style = style.flatMap { $0 } ?? .full + } + + var trimmedPattern: String { + pattern.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/apple/TablePro/Core/Services/Masking/QueryResult+Masking.swift b/apple/TablePro/Core/Services/Masking/QueryResult+Masking.swift new file mode 100644 index 0000000000..b65b52f9b2 --- /dev/null +++ b/apple/TablePro/Core/Services/Masking/QueryResult+Masking.swift @@ -0,0 +1,51 @@ +// +// QueryResult+Masking.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension QueryResult { + func masked(by policy: MaskingPolicy, table: String? = nil) -> QueryResult { + let masks = policy.columnMasks(table: table, columns: columns, columnTypes: columnTypes) + guard !masks.isEmpty else { return self } + var masked = QueryResult( + columns: columns, + columnTypes: columnTypes, + rows: masks.rows(rows), + rowsAffected: rowsAffected, + executionTime: executionTime, + error: error + ) + masked.timing = timing + masked.isTruncated = isTruncated + masked.statusMessage = statusMessage + masked.columnMeta = columnMeta + masked.rowLocators = rowLocators + masked.serverOutput = serverOutput + masked.absentCells = absentCells + masked.unloadedCells = unloadedCells + return masked + } +} + +extension PluginQueryResult { + func masked(by policy: MaskingPolicy, table: String? = nil) -> PluginQueryResult { + let masks = policy.columnMasks(table: table, columns: columns, typeNames: columnTypeNames) + guard !masks.isEmpty else { return self } + var masked = PluginQueryResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: masks.rows(rows), + rowsAffected: rowsAffected, + timing: timing, + isTruncated: isTruncated, + statusMessage: statusMessage, + columnMeta: columnMeta + ) + masked.rowLocators = rowLocators + masked.absentCells = absentCells + return masked + } +} diff --git a/apple/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift b/apple/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift index d50ab3f9b1..000eb266a3 100644 --- a/apple/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift +++ b/apple/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift @@ -68,7 +68,9 @@ enum ForeignKeyRowFetcher { ) return try await driver.execute(query: query) } - guard let firstRow = result.rows.first else { return nil } + let policy = DatabaseManager.shared.session(for: origin.connectionId)?.connection.maskingPolicy ?? .none + let masked = result.masked(by: policy, table: reference.referencedTable) + guard let firstRow = masked.rows.first else { return nil } let foreignKeys = includeForeignKeys ? await referencedTableForeignKeys(target: target, reference: reference) diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+Masking.swift b/apple/TablePro/Models/Connection/DatabaseConnection+Masking.swift new file mode 100644 index 0000000000..1170c04b6d --- /dev/null +++ b/apple/TablePro/Models/Connection/DatabaseConnection+Masking.swift @@ -0,0 +1,40 @@ +// +// DatabaseConnection+Masking.swift +// TablePro +// + +import Foundation +import os + +extension DatabaseConnection { + // Kept in the additional fields, which iCloud sync and a published Team Catalog entry already carry. + static let maskingRulesKey = "maskingRules" + + private static let maskingLogger = Logger(subsystem: "com.TablePro", category: "Masking") + + var maskingRules: [MaskingRule] { + get { + guard let json = additionalFields[Self.maskingRulesKey], !json.isEmpty else { return [] } + do { + return try JSONDecoder().decode([MaskingRule].self, from: Data(json.utf8)) + } catch { + Self.maskingLogger.error("Masking rules could not be read: \(error.publicLogShape, privacy: .public)") + return [.maskEverything] + } + } + set { + let kept = newValue.filter { !$0.trimmedPattern.isEmpty } + guard !kept.isEmpty, + let data = try? JSONEncoder().encode(kept), + let json = String(data: data, encoding: .utf8) else { + additionalFields.removeValue(forKey: Self.maskingRulesKey) + return + } + additionalFields[Self.maskingRulesKey] = json + } + } + + var maskingPolicy: MaskingPolicy { + MaskingPolicy(rules: maskingRules) + } +} diff --git a/apple/TablePro/Models/Query/QueryTab.swift b/apple/TablePro/Models/Query/QueryTab.swift index 072966f0b8..c4fe61aa97 100644 --- a/apple/TablePro/Models/Query/QueryTab.swift +++ b/apple/TablePro/Models/Query/QueryTab.swift @@ -64,6 +64,8 @@ struct QueryTab: Identifiable, Equatable { // On the tab, not the grid: JSON mode, the inspector and the Edit menu's row commands read the order unmounted (#2251). var valueFilter: GridValueFilterState var sessionHighlightRules: [HighlightRule] = [] + // Set only by Reveal, after the user authenticated; never saved, so a reopened tab masks again. + var revealsMaskedValues = false var pagination: PaginationState var chartConfiguration: ResultChartConfiguration var mapConfiguration: ResultMapConfiguration diff --git a/apple/TablePro/Models/Query/ResultOrigin.swift b/apple/TablePro/Models/Query/ResultOrigin.swift index cdf986a821..961f6734b3 100644 --- a/apple/TablePro/Models/Query/ResultOrigin.swift +++ b/apple/TablePro/Models/Query/ResultOrigin.swift @@ -57,6 +57,7 @@ enum ResultEditRefusal: Equatable { case view case databaseMoved case keysUnresolved + case masked var message: String { switch self { @@ -68,6 +69,8 @@ enum ResultEditRefusal: Equatable { return String(localized: "These rows came from another database than the one this tab is now using.") case .keysUnresolved: return String(localized: "TablePro has not looked up this table's key columns, so it cannot tell which row an edit would change.") + case .masked: + return String(localized: "These rows show masked values, which cannot be written back. Reveal them to edit.") } } } diff --git a/apple/TablePro/Models/Query/TableRows.swift b/apple/TablePro/Models/Query/TableRows.swift index b09235c46c..5ffb2f607e 100644 --- a/apple/TablePro/Models/Query/TableRows.swift +++ b/apple/TablePro/Models/Query/TableRows.swift @@ -34,6 +34,13 @@ struct TableRows: Sendable { /// filter or a removal cannot pair a row with another row's document. Only a driver that edits /// whole documents fills it, and a row with none cannot be edited that way. private(set) var rowLocators: [RowID: String] = [:] + /// The masking rules matched to these columns. Every fetched value is written through it, and a + /// masked column never takes an edit, so no reader can see the stored value. + private(set) var masks: ColumnMasks = .none + + var maskedColumnNames: Set { + Set(masks.maskedColumns.compactMap { columns.indices.contains($0) ? columns[$0] : nil }) + } init( rows: ContiguousArray = [], @@ -145,6 +152,22 @@ struct TableRows: Sendable { rows.contains { $0.hasUnloadedValues } } + /// Masks the held values of every column `incoming` adds. A column already masked keeps its mask, since the + /// value under it is gone until the next fetch. + @discardableResult + mutating func addMasks(_ incoming: ColumnMasks) -> Delta { + let added = incoming.styles.filter { column, _ in column < columns.count && !masks.isMasked(column) } + guard !added.isEmpty else { return .none } + let addedMasks = ColumnMasks(styles: added) + for index in rows.indices { + for column in added.keys where column < rows[index].values.count { + rows[index].values[column] = addedMasks.value(rows[index].values[column], column: column) + } + } + masks = ColumnMasks(styles: masks.styles.merging(added) { current, _ in current }) + return .fullReplace + } + // A cell an edit or an earlier load already filled keeps what it holds. @discardableResult mutating func fillUnloaded(_ values: [RowID: [Int: PluginCellValue]]) -> Delta { @@ -153,7 +176,7 @@ struct TableRows: Sendable { guard let row = indexByID[id] else { continue } for (column, value) in columns where rows[row].unloadedColumns[column] != nil { guard column < rows[row].values.count else { continue } - rows[row].values[column] = value + rows[row].values[column] = masks.value(value, column: column) rows[row].unloadedColumns.removeValue(forKey: column) changed.insert(CellPosition(row: row, column: column)) } @@ -208,6 +231,7 @@ struct TableRows: Sendable { } private mutating func write(_ value: PluginCellValue, isAbsent: Bool, row: Int, column: Int) -> Bool { + guard !masks.isMasked(column) else { return false } let wasAbsent = rows[row].isAbsent(column) let wasUnloaded = rows[row].unloadedColumns.removeValue(forKey: column) != nil guard rows[row].values[column] != value || wasAbsent != isAbsent || wasUnloaded else { return false } @@ -264,7 +288,7 @@ struct TableRows: Sendable { rows.reserveCapacity(rows.count + pageRows.count) indexByID.reserveCapacity(indexByID.count + pageRows.count) for (idx, values) in pageRows.enumerated() { - let normalized = Self.normalize(values: values, toCount: columns.count) + let normalized = Self.normalize(values: masks.row(values), toCount: columns.count) let row = Row( id: .existing(offset + idx), values: normalized, @@ -319,7 +343,7 @@ struct TableRows: Sendable { var rebuiltIndex = [RowID: Int]() rebuiltIndex.reserveCapacity(replacementRows.count) for (idx, values) in replacementRows.enumerated() { - let normalized = Self.normalize(values: values, toCount: columns.count) + let normalized = Self.normalize(values: masks.row(values), toCount: columns.count) let row = Row( id: .existing(offset + idx), values: normalized, @@ -417,12 +441,13 @@ struct TableRows: Sendable { foreignKeysFetched: Bool = false, rowLocators: [String?]? = nil, absentCells: [Int: Set] = [:], - unloadedCells: [Int: [Int: Int]] = [:] + unloadedCells: [Int: [Int: Int]] = [:], + masks: ColumnMasks = .none ) -> TableRows { var rows = ContiguousArray() rows.reserveCapacity(queryRows.count) for (index, values) in queryRows.enumerated() { - let normalized = normalize(values: values, toCount: columns.count) + let normalized = normalize(values: masks.row(values), toCount: columns.count) rows.append(Row( id: .existing(index), values: normalized, @@ -447,6 +472,7 @@ struct TableRows: Sendable { foreignKeysFetched: foreignKeysFetched ) tableRows.rowLocators = locators(rowLocators, rowCount: queryRows.count, offset: 0) + tableRows.masks = masks return tableRows } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 4b999ea5d9..49b0f18c9d 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -84872,6 +84872,9 @@ } } } + }, + "Full" : { + }, "FULL (rewrites the whole object, blocks access)" : { "extractionState" : "manual", @@ -88548,6 +88551,9 @@ } } } + }, + "Hides matching values in the grid, copies, exports, the AI assistant and MCP. A pattern is table.column or column, with * as a wildcard, such as *.email. Filters and sorts still run on the stored values, so masking does not replace database permissions." : { + }, "High" : { "localizations" : { @@ -99424,6 +99430,9 @@ } } } + }, + "Last 4 Characters" : { + }, "Last 4 Weeks" : { "localizations" : { @@ -106385,6 +106394,21 @@ }, "Marked for deletion. Undo or Undo Delete brings it back." : { + }, + "Mask" : { + + }, + "masked" : { + + }, + "Masked" : { + + }, + "Masked by a masking rule" : { + + }, + "Masking" : { + }, "Match" : { "localizations" : { @@ -150668,6 +150692,9 @@ }, "Reuse Result For" : { + }, + "Reveal Masked Values…" : { + }, "Reveal token" : { "localizations" : { @@ -164474,6 +164501,9 @@ } } } + }, + "show masked values" : { + }, "SHOW needs TOPICS, BROKERS, GROUPS or CLUSTER." : { "extractionState" : "manual", @@ -171692,6 +171722,9 @@ } } } + }, + "Stable Hash" : { + }, "Stack" : { "extractionState" : "manual", @@ -193819,6 +193852,9 @@ } } } + }, + "These rows show masked values, which cannot be written back. Reveal them to edit." : { + }, "These two engines cannot share a script." : { "localizations" : { diff --git a/apple/TablePro/ViewModels/ServerDashboardViewModel.swift b/apple/TablePro/ViewModels/ServerDashboardViewModel.swift index 417728c431..32d2b89271 100644 --- a/apple/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/apple/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -185,10 +185,13 @@ final class ServerDashboardViewModel: ObservableObject { private static func tileExecutor(connectionId: UUID, services: AppServices) -> DashboardTileBoard.Execute { { [connectionId, services] query in let driver = try liveDriver(connectionId: connectionId, services: services) + let policy = await MainActor.run { + services.databaseManager.session(for: connectionId)?.connection.maskingPolicy ?? .none + } if let bounded = try await driver.executeBoundedQuery(query: query, rowCap: DashboardTileTable.rowCap) { - return bounded + return bounded.masked(by: policy) } - return try await driver.executeRead(query: query) + return try await driver.executeRead(query: query).masked(by: policy) } } diff --git a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 0972a6182e..4658ab5212 100644 --- a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -42,6 +42,7 @@ final class ConnectionFormCoordinator: ObservableObject { @Published var customization: CustomizationPaneViewModel @Published var advanced: AdvancedPaneViewModel @Published var aiRules: AIRulesPaneViewModel + @Published var maskingRules: MaskingRulesPaneViewModel @Published var selectedTab: ConnectionFormTab = .general @Published var hasLoadedData: Bool = false @@ -129,6 +130,7 @@ final class ConnectionFormCoordinator: ObservableObject { self.customization = CustomizationPaneViewModel() self.advanced = AdvancedPaneViewModel() self.aiRules = AIRulesPaneViewModel() + self.maskingRules = MaskingRulesPaneViewModel() let ref = WeakCoordinatorRef(self) network.coordinator = ref @@ -143,6 +145,7 @@ final class ConnectionFormCoordinator: ObservableObject { customization.coordinator = ref advanced.coordinator = ref aiRules.coordinator = ref + maskingRules.coordinator = ref childChangeForwarding = forwardChildChanges() } @@ -167,6 +170,7 @@ final class ConnectionFormCoordinator: ObservableObject { Self.changes(of: $customization), Self.changes(of: $advanced), Self.changes(of: $aiRules), + Self.changes(of: $maskingRules), ]) .sink { [weak self] in self?.objectWillChange.send() } } @@ -231,6 +235,7 @@ final class ConnectionFormCoordinator: ObservableObject { customization.load(from: existing) advanced.load(from: existing) aiRules.load(from: existing) + maskingRules.load(from: existing) normalizeTransport() } hasLoadedData = true @@ -371,7 +376,8 @@ final class ConnectionFormCoordinator: ObservableObject { ? nil : advanced.startupCommands, localOnly: advanced.localOnly, additionalFields: fields, - ownedAdditionalFieldIDs: ownedAdditionalFieldIDs() + ownedAdditionalFieldIDs: ownedAdditionalFieldIDs(), + maskingRules: maskingRules.savedRules ) } diff --git a/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index 61336bfd6d..a148c976f0 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -32,6 +32,7 @@ struct OptionsPaneView: View { preConnectSection postDisconnectSection safetySection + MaskingRulesSection(model: coordinator.maskingRules) if aiIsEnabled { aiRulesSection } @@ -434,3 +435,79 @@ struct OptionsPaneView: View { } } } + +/// Columns whose values TablePro hides, for this connection, in the grid, exports, the assistant and MCP. +private struct MaskingRulesSection: View { + @ObservedObject var model: MaskingRulesPaneViewModel + + var body: some View { + Section { + ForEach(model.rules) { rule in + MaskingRuleRow(rule: rule, onChange: model.update, onRemove: { model.removeRule(id: rule.id) }) + } + Button(String(localized: "Add Rule")) { + model.addRule() + } + .accessibilityIdentifier("connection-form-add-masking-rule") + } header: { + Text(String(localized: "Masking")) + } footer: { + Text(String(localized: """ + Hides matching values in the grid, copies, exports, the AI assistant and MCP. \ + A pattern is table.column or column, with * as a wildcard, such as *.email. \ + Filters and sorts still run on the stored values, so masking does not replace database permissions. + """)) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct MaskingRuleRow: View { + let rule: MaskingRule + let onChange: (MaskingRule) -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 8) { + Picker(String(localized: "Match"), selection: binding(\.target)) { + ForEach(MaskingTarget.allCases) { target in + Text(target.displayName).tag(target) + } + } + .labelsHidden() + .fixedSize() + TextField( + String(localized: "Pattern"), + text: binding(\.pattern), + prompt: Text(verbatim: rule.target == .column ? "*.email" : "uuid") + ) + .labelsHidden() + .accessibilityIdentifier("connection-form-masking-pattern") + Picker(String(localized: "Mask"), selection: binding(\.style)) { + ForEach(MaskingStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + .labelsHidden() + .fixedSize() + Button(action: onRemove) { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help(String(localized: "Remove Rule")) + .accessibilityLabel(String(localized: "Remove Rule")) + } + } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { rule[keyPath: keyPath] }, + set: { newValue in + var updated = rule + updated[keyPath: keyPath] = newValue + onChange(updated) + } + ) + } +} diff --git a/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift b/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift index 6a31fc0e64..21fcb4ee5f 100644 --- a/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift +++ b/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift @@ -40,6 +40,7 @@ struct ConnectionFormEdits: Equatable { var localOnly: Bool var additionalFields: [String: String] var ownedAdditionalFieldIDs: Set + var maskingRules: [MaskingRule] = [] static let appManagedAdditionalFieldIDs: Set = [ "preConnectScript", @@ -50,6 +51,7 @@ struct ConnectionFormEdits: Equatable { DatabaseConnection.queryTimeoutSecondsKey, DatabaseConnection.binaryInlineLimitKilobytesKey, DatabaseConnection.completionIdentifierCaseKey, + DatabaseConnection.maskingRulesKey, DatabaseConnection.sshForwardUnixSocketPathKey, MySQLLocalSocket.fieldKey ] @@ -87,6 +89,7 @@ struct ConnectionFormEdits: Equatable { result.queryTimeoutSeconds = queryTimeoutSeconds result.binaryInlineLimitKilobytes = binaryInlineLimitKilobytes result.completionIdentifierCase = completionIdentifierCase + result.maskingRules = maskingRules return result } diff --git a/apple/TablePro/Views/ConnectionForm/ViewModels/MaskingRulesPaneViewModel.swift b/apple/TablePro/Views/ConnectionForm/ViewModels/MaskingRulesPaneViewModel.swift new file mode 100644 index 0000000000..f09d004a4d --- /dev/null +++ b/apple/TablePro/Views/ConnectionForm/ViewModels/MaskingRulesPaneViewModel.swift @@ -0,0 +1,35 @@ +// +// MaskingRulesPaneViewModel.swift +// TablePro +// + +import Combine +import Foundation + +@MainActor +final class MaskingRulesPaneViewModel: ObservableObject { + @Published var rules: [MaskingRule] = [] + + @Published var coordinator: WeakCoordinatorRef? + + func load(from connection: DatabaseConnection) { + rules = connection.maskingRules + } + + func addRule() { + rules.append(MaskingRule(pattern: "")) + } + + func removeRule(id: UUID) { + rules.removeAll { $0.id == id } + } + + func update(_ rule: MaskingRule) { + guard let index = rules.firstIndex(where: { $0.id == rule.id }) else { return } + rules[index] = rule + } + + var savedRules: [MaskingRule] { + rules.filter { !$0.trimmedPattern.isEmpty } + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift index ea37295a25..f97f1afd0d 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift @@ -127,6 +127,7 @@ extension DataGridViewController { func refreshHeaders() { let columns = model.columns let filter = model.store.valueFilter + let masks = model.rows.masks let sortEntries = presentedSortEntries() let showsComments = AppSettingsManager.shared.general.showObjectComments var sortIndex: [DataGridColumnID: (direction: SortDirection, priority: Int)] = [:] @@ -137,14 +138,17 @@ extension DataGridViewController { for tableColumn in pane.tableView.tableColumns { guard let id = pane.columnID(of: tableColumn), let column = columns.column(id: id) else { continue } let sort = sortIndex[id] + let isMasked = masks.isMasked(column.dataIndex) applyHeaderTitle( to: tableColumn, column: column, - isFiltered: model.configuration.supportsValueFilter && filter.isActive(column: column.dataIndex) + isFiltered: model.configuration.supportsValueFilter && filter.isActive(column: column.dataIndex), + isMasked: isMasked ) let tooltip = headerToolTip( for: column, showsComment: showsComments, + isMasked: isMasked, sort: sort, sortKeyCount: sortEntries.count ) @@ -152,7 +156,7 @@ extension DataGridViewController { tableColumn.headerToolTip = tooltip } let cell = tableColumn.headerCell - let label = headerAccessibilityLabel(for: column, showsComment: showsComments) + let label = headerAccessibilityLabel(for: column, showsComment: showsComments, isMasked: isMasked) if cell.accessibilityLabel() != label { cell.setAccessibilityLabel(label) } @@ -163,9 +167,9 @@ extension DataGridViewController { } } - private func applyHeaderTitle(to tableColumn: NSTableColumn, column: DataGridColumn, isFiltered: Bool) { + private func applyHeaderTitle(to tableColumn: NSTableColumn, column: DataGridColumn, isFiltered: Bool, isMasked: Bool) { let cell = tableColumn.headerCell - guard isFiltered else { + guard isFiltered || isMasked else { if cell.stringValue != column.name { tableColumn.title = column.name } @@ -179,10 +183,21 @@ extension DataGridViewController { .font: font, .paragraphStyle: paragraph, ] - let title = NSMutableAttributedString(string: column.name + " ", attributes: attributes) - if let funnel = Self.filterFunnel(for: font, color: model.themeScope.resolved[.gridHeaderText]) { - let attachment = NSAttributedString(attachment: funnel) - let marked = NSMutableAttributedString(attributedString: attachment) + let title = NSMutableAttributedString(string: column.name, attributes: attributes) + let color = model.themeScope.resolved[.gridHeaderText] + var symbols: [(name: String, description: String)] = [] + if isMasked { + symbols.append(("eye.slash", String(localized: "Masked"))) + } + if isFiltered { + symbols.append(("line.3.horizontal.decrease.circle.fill", String(localized: "Filtered"))) + } + for symbol in symbols { + guard let image = Self.headerSymbol(symbol.name, description: symbol.description, font: font, color: color) else { + continue + } + title.append(NSAttributedString(string: " ", attributes: attributes)) + let marked = NSMutableAttributedString(attributedString: NSAttributedString(attachment: image)) marked.addAttributes(attributes, range: NSRange(location: 0, length: marked.length)) title.append(marked) } @@ -190,12 +205,12 @@ extension DataGridViewController { } // A symbol image keeps the color it was built with, so it takes the header text color here. - private static func filterFunnel(for font: NSFont, color: NSColor) -> NSTextAttachment? { + private static func headerSymbol(_ name: String, description: String, font: NSFont, color: NSColor) -> NSTextAttachment? { let configuration = NSImage.SymbolConfiguration(pointSize: font.pointSize, weight: .regular) .applying(.init(hierarchicalColor: color)) guard let image = NSImage( - systemSymbolName: "line.3.horizontal.decrease.circle.fill", - accessibilityDescription: String(localized: "Filtered") + systemSymbolName: name, + accessibilityDescription: description )?.withSymbolConfiguration(configuration) else { return nil } let attachment = NSTextAttachment() attachment.image = image @@ -207,6 +222,7 @@ extension DataGridViewController { private func headerToolTip( for column: DataGridColumn, showsComment: Bool, + isMasked: Bool, sort: (direction: SortDirection, priority: Int)?, sortKeyCount: Int ) -> String { @@ -219,6 +235,9 @@ extension DataGridViewController { if showsComment, let comment = Self.displayableComment(column.comment) { tooltip += "\n" + comment } + if isMasked { + tooltip += "\n" + String(localized: "Masked by a masking rule") + } if let sort, sortKeyCount > 1 { tooltip += "\n" + String( format: String(localized: "Sort key %d of %d, %@"), @@ -230,8 +249,11 @@ extension DataGridViewController { return tooltip } - private func headerAccessibilityLabel(for column: DataGridColumn, showsComment: Bool) -> String { - let label = String(format: String(localized: "Column: %@"), column.name) + private func headerAccessibilityLabel(for column: DataGridColumn, showsComment: Bool, isMasked: Bool) -> String { + var label = String(format: String(localized: "Column: %@"), column.name) + if isMasked { + label += ", " + String(localized: "masked") + } guard showsComment, let comment = Self.displayableComment(column.comment) else { return label } return label + ", " + comment } diff --git a/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift b/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift index fc724a2854..e6da4abdb8 100644 --- a/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift +++ b/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift @@ -198,6 +198,16 @@ final class DataTabGridAdapter: DataGridDataSource, DataGridActions { // MARK: - Menus + func gridColumnMenuItems(for column: DataGridColumn) -> [NSMenuItem] { + guard gridRows.masks.isMasked(column.dataIndex) else { return [] } + let tabId = tabId + return [ClosureMenuTarget.item(title: String(localized: "Reveal Masked Values…")) { [weak coordinator] in + Task { @MainActor [weak coordinator] in + await coordinator?.revealMaskedValues(tabId: tabId) + } + }] + } + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { guard let coordinator, ownsWindowState, coordinator.canInsertDocument else { return [] } var items: [NSMenuItem] = [] diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Masking.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Masking.swift new file mode 100644 index 0000000000..902472bf92 --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Masking.swift @@ -0,0 +1,67 @@ +// +// MainContentCoordinator+Masking.swift +// TablePro +// + +import Combine +import Foundation + +extension MainContentCoordinator { + var maskingPolicy: MaskingPolicy { + (services.databaseManager.session(for: connectionId)?.connection ?? connection).maskingPolicy + } + + func columnMasks(forTabId tabId: UUID, table: String?, columns: [String], columnTypes: [ColumnType]) -> ColumnMasks { + if tabManager.tabs.first(where: { $0.id == tabId })?.revealsMaskedValues == true { return .none } + return maskingPolicy.columnMasks(table: table, columns: columns, columnTypes: columnTypes) + } + + func observeMaskingRuleChanges() -> AnyCancellable { + services.appEvents.connectionUpdated + .filter { [connectionId] changed in changed == nil || changed == connectionId } + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.maskLoadedResults() } + } + + /// Masks the rows already on screen when a rule is saved, rather than waiting for the next run. Read from storage, + /// since the live session may adopt the saved record after this runs. + func maskLoadedResults() { + guard let stored = services.databaseManager.connectionStorage.loadConnection(id: connectionId) else { return } + let policy = stored.maskingPolicy + guard !policy.isEmpty else { return } + for tab in tabManager.tabs where !tab.revealsMaskedValues { + let table = tab.tabType == .table ? tab.tableContext.tableName : nil + mutateActiveTableRows(for: tab.id) { rows in + rows.addMasks(policy.columnMasks(table: table, columns: rows.columns, columnTypes: rows.columnTypes)) + } + for resultSet in tab.display.resultSets { + let rows = resultSet.tableRows + resultSet.tableRows.addMasks(policy.columnMasks(table: nil, columns: rows.columns, columnTypes: rows.columnTypes)) + } + } + } + + // LAContext shows this as "TablePro is trying to .", so it stays a lowercase phrase with no period. + static var revealMaskedValuesReason: String { + String(localized: "show masked values") + } + + func revealMaskedValues( + tabId: UUID, + authenticator: any OperationAuthenticating = BiometricOperationAuthenticating() + ) async { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), !tab.revealsMaskedValues else { return } + guard await authenticator.authenticate(reason: Self.revealMaskedValuesReason) else { return } + tabManager.mutate(tabId: tabId) { $0.revealsMaskedValues = true } + guard tabManager.selectedTabId == tabId else { return } + switch tab.tabType { + case .table: + runQuery(viewport: .keepPlace) + default: + // Running a query tab again could repeat a write, so only a read reruns; anything else shows on its next run. + let classification = QueryClassifier.classify(tab.content.query, databaseType: connection.type) + guard classification.tier == .safe else { return } + runQuery(viewport: .keepPlace) + } + } +} diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift index 3062fb2c1a..39f34ba7da 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift @@ -15,7 +15,10 @@ extension MainContentCoordinator { /// This is fail-closed on purpose: a result that never captured where its rows came from /// refuses the edit instead of borrowing whichever table the tab ran last (#2243). var activeResultEditRefusal: ResultEditRefusal? { - guard let tab = tabManager.selectedTab, tab.tabType == .query else { return nil } + guard let tab = tabManager.selectedTab else { return nil } + // A masked value can neither be written back nor find its row, and a copied row would insert the mask. + if !tabSessionRegistry.tableRows(for: tab.id).masks.isEmpty { return .masked } + guard tab.tabType == .query else { return nil } return ResultEditability.refusal( for: tab.display.activeResultSet?.origin, currentDatabaseName: scope(for: tab)?.database ?? browseDatabaseName diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index f00eefa556..177a85a3b9 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -332,6 +332,7 @@ final class MainContentCoordinator: ObservableObject { private var schemaSwitchCancellable: AnyCancellable? private var clearedEditsCancellable: AnyCancellable? private var gridRepaintCancellable: AnyCancellable? + var maskingRulesCancellable: AnyCancellable? private var browseContainerCancellable: AnyCancellable? @Published var fileConflictRequest: FileConflictRequest? @@ -664,6 +665,7 @@ final class MainContentCoordinator: ObservableObject { self.rowEditingCoordinator = RowEditingCoordinator(parent: self) clearedEditsCancellable = resumeWhenEditsClear() gridRepaintCancellable = observeChangeManagerForGridRepaint() + maskingRulesCancellable = observeMaskingRuleChanges() Self.lifecycleLogger.info( "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" diff --git a/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift b/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift index 8229063982..8b2e0be54a 100644 --- a/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift +++ b/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift @@ -180,6 +180,21 @@ struct ConnectionBundleExporterTests { #expect(try ConnectionBundleCodec.decode(plain).connections.count == 1) } + @Test("A connection published to the Team Catalog carries its masking rules") + func connectionsOnlyBundleCarriesMaskingRules() throws { + let library = try ImportLibraryFixture() + defer { library.cleanUp() } + var exported = makeConnection(name: "Orders") + exported.maskingRules = [MaskingRule(pattern: "*.email", style: .email)] + + let bundle = try library.exporter.connectionsOnlyBundle(for: [exported]) + + let fields = try #require(bundle.connections.first?.settings.additionalFields) + var published = DatabaseConnection(name: "Published") + published.additionalFields = fields + #expect(published.maskingRules == exported.maskingRules) + } + @Test("The Team Catalog bundle never carries saved queries or credentials") func connectionsOnlyBundleCarriesNoQueries() async throws { let library = try ImportLibraryFixture() diff --git a/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift b/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift new file mode 100644 index 0000000000..a38ab604ba --- /dev/null +++ b/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift @@ -0,0 +1,109 @@ +// +// MaskingBridgeTests.swift +// TableProTests +// +// MCP tools, the AI chat tools, AppleScript and Shortcuts all read rows through DatabaseAccessBridge. +// + +import Foundation +@testable import TablePro +import TableProMCP +import TableProPluginKit +import Testing + +@Suite(.serialized) +@MainActor +struct MaskingBridgeTests { + private static let customers = ScriptAnsweringDriver.resultSet( + columns: ["id", "email"], + rows: [["1", "jane@example.com"], ["2", "john@example.com"]] + ) + + private func install(type: DatabaseType = .mysql, sendsBatchesWhole: Bool = true) -> (ScriptAnsweringDriver, DatabaseScope) { + var connection = TestFixtures.makeConnection(database: "shop", type: type) + connection.maskingRules = [MaskingRule(pattern: "*.email")] + let driver = ScriptAnsweringDriver( + connection: connection, + sendsBatchesWhole: sendsBatchesWhole, + answer: { _ in ScriptAnsweringDriver.batch([Self.customers, Self.customers]) }, + statementAnswer: { _ in Self.customers } + ) + var session = ConnectionSession(connection: connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: connection.id) + return (driver, DatabaseScope(connectionId: connection.id, database: "shop", schema: nil)) + } + + private static func leaks(_ result: QueryResult) -> Bool { + result.rows.flatMap { $0 }.contains { $0.asText?.contains("@example.com") == true } + } + + @Test("A statement read for MCP, the assistant or a script comes back masked") + func statementIsMasked() async throws { + let (driver, scope) = install() + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + + let outcome = try await DatabaseAccessBridge().runStatement( + scope: scope, query: "SELECT id, email FROM customers", maxRows: 10, timeoutSeconds: 30, cancellation: nil + ) + + #expect(!Self.leaks(outcome.result)) + #expect(outcome.result.rows.map { $0[0] } == [.text("1"), .text("2")]) + } + + @Test("Every result set of a script comes back masked") + func scriptIsMasked() async throws { + let (driver, scope) = install(type: .mssql) + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + + let outcome = try await DatabaseAccessBridge().runScript( + scope: scope, + query: "SELECT * FROM customers\nGO\nSELECT * FROM customers", + maxRows: 10, + timeoutSeconds: 30, + cancellation: nil + ) + + #expect(!outcome.resultSets.isEmpty) + #expect(!outcome.resultSets.contains(where: Self.leaks)) + #expect(!Self.leaks(outcome.primary)) + } + + @Test("What an MCP client receives as JSON holds no masked value") + func mcpPayloadIsMasked() async throws { + let (driver, scope) = install() + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + + let payload = try await MCPConnectionBridge().executeQuery( + scope: scope, query: "SELECT id, email FROM customers", maxRows: 10, timeoutSeconds: 30, cancellation: nil + ) + + let json = payload.jsonString(prettyPrinted: false) + #expect(!json.contains("@example.com")) + #expect(json.contains("••••")) + } + + @Test("A table.column rule matches by table when the caller names the table") + func maskingTableNarrowsTheMatch() async throws { + var connection = TestFixtures.makeConnection(database: "shop") + connection.maskingRules = [MaskingRule(pattern: "users.email")] + let driver = ScriptAnsweringDriver(connection: connection, statementAnswer: { _ in Self.customers }) + var session = ConnectionSession(connection: connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: nil) + let bridge = DatabaseAccessBridge() + + let other = try await bridge.runStatement( + scope: scope, query: "SELECT * FROM customers", maxRows: 10, timeoutSeconds: 30, cancellation: nil, + maskingTable: "customers" + ) + let unnamed = try await bridge.runStatement( + scope: scope, query: "SELECT * FROM customers", maxRows: 10, timeoutSeconds: 30, cancellation: nil + ) + + #expect(Self.leaks(other.result)) + #expect(!Self.leaks(unnamed.result)) + } +} diff --git a/apple/TableProTests/Core/Services/Masking/MaskingExportTests.swift b/apple/TableProTests/Core/Services/Masking/MaskingExportTests.swift new file mode 100644 index 0000000000..6d2ff85d69 --- /dev/null +++ b/apple/TableProTests/Core/Services/Masking/MaskingExportTests.swift @@ -0,0 +1,130 @@ +// +// MaskingExportTests.swift +// TableProTests +// +// File > Export and Export Results hand rows to a format plugin. A masked column must reach no format raw. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class FixedRowsDataSource: PluginExportDataSource, @unchecked Sendable { + let databaseTypeId = "mysql" + private let elements: [PluginStreamElement] + + init(_ elements: [PluginStreamElement]) { + self.elements = elements + } + + static let customers = FixedRowsDataSource([ + .header(PluginStreamHeader(columns: ["id", "email"], columnTypeNames: ["int", "varchar"])), + .rows([[.text("1"), .text("jane@example.com")], [.text("2"), .text("john@example.com")]]), + ]) + + func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { + let elements = elements + return AsyncThrowingStream { continuation in + for element in elements { continuation.yield(element) } + continuation.finish() + } + } + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult( + columns: ["email"], + columnTypeNames: ["varchar"], + rows: [[.text("jane@example.com")]], + rowsAffected: 0, + executionTime: 0 + ) + } + + func fetchTableDDL(table: String, databaseName: String) async throws -> String { "" } + func quoteIdentifier(_ identifier: String) -> String { identifier } + func escapeStringLiteral(_ value: String) -> String { value } + func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { 2 } +} + +struct MaskingExportTests { + private let policy = MaskingPolicy(rules: [MaskingRule(pattern: "*.email")]) + + private static func collect(_ stream: AsyncThrowingStream) async throws -> [PluginRow] { + var rows: [PluginRow] = [] + for try await element in stream { + if case .rows(let chunk) = element { rows += chunk } + } + return rows + } + + @Test("File > Export streams a masked column masked, by table and by object") + func streamedRowsAreMasked() async throws { + let source = MaskingExportDataSource.wrapping(FixedRowsDataSource.customers, policy: policy) + + let byTable = try await Self.collect(source.streamRows(table: "customers", databaseName: "shop")) + let object = PluginExportTable(name: "customers", databaseName: "shop", tableType: "table") + let byObject = try await Self.collect(source.streamRows(for: object)) + + for rows in [byTable, byObject] { + #expect(rows == [[.text("1"), .text("••••")], [.text("2"), .text("••••")]]) + } + } + + @Test("A query export named after its file matches a table.column rule by column, not by the file name") + func queryExportIgnoresTheFileName() async throws { + let usersOnly = MaskingPolicy(rules: [MaskingRule(pattern: "users.email")]) + let source = MaskingExportDataSource.wrapping(FixedRowsDataSource.customers, policy: usersOnly, streamsTables: false) + + let rows = try await Self.collect(source.streamRows(table: "results", databaseName: "")) + + #expect(rows.allSatisfy { $0[1] == .text("••••") }) + } + + @Test("A format's own query comes back masked") + func executedRowsAreMasked() async throws { + let source = MaskingExportDataSource.wrapping(FixedRowsDataSource.customers, policy: policy) + let result = try await source.execute(query: "SELECT email FROM customers") + #expect(result.rows == [[.text("••••")]]) + } + + @Test("No rule leaves the data source unwrapped") + func noRuleIsUnwrapped() { + let base = FixedRowsDataSource.customers + #expect(MaskingExportDataSource.wrapping(base, policy: .none) === base) + } + + @Test("Rows that arrive before their header are refused rather than let through") + func rowsBeforeHeaderAreRefused() async { + let headless = FixedRowsDataSource([.rows([[.text("jane@example.com")]])]) + let source = MaskingExportDataSource.wrapping(headless, policy: policy) + await #expect(throws: (any Error).self) { + _ = try await Self.collect(source.streamRows(table: "customers", databaseName: "shop")) + } + } + + @Test("Export Results writes the grid's masked rows and masks a value it loads from the server") + @MainActor + func exportResultsMasksLoadedValues() async throws { + let masks = policy.columnMasks(table: "customers", columns: ["id", "email"], typeNames: [nil, nil]) + let tableRows = TableRows.from( + queryRows: [[.text("1"), .text("jane@example.com")], [.text("2"), .null]], + columns: ["id", "email"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)], + unloadedCells: [1: [1: 4_000]], + masks: masks + ) + let source = QueryResultExportDataSource( + tableRows: tableRows, + databaseType: .mysql, + driver: nil, + fillUnloaded: { rows in + Dictionary(uniqueKeysWithValues: rows.map { ($0.id, [1: PluginCellValue.text("john@example.com")]) }) + } + ) + + let rows = try await Self.collect(source.streamRows(table: "customers", databaseName: "")) + + #expect(rows == [[.text("1"), .text("••••")], [.text("2"), .text("••••")]]) + } +} diff --git a/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift b/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift new file mode 100644 index 0000000000..b88cce8e0e --- /dev/null +++ b/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift @@ -0,0 +1,167 @@ +// +// MaskingGridTests.swift +// TableProTests +// +// The grid, Copy and Copy As, the row inspector, charts and the assistant's result summary all read the tab's +// TableRows, so a masked value never written there cannot reach any of them. +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private func leaks(_ rows: TableRows) -> Bool { + rows.rows.contains { row in row.values.contains { $0.asText?.contains("@example.com") == true } } +} + +struct MaskingTableRowsTests { + private let masks = MaskingPolicy(rules: [MaskingRule(pattern: "*.email")]) + .columnMasks(table: "users", columns: ["id", "email"], typeNames: [nil, nil]) + + @Test("Every write of fetched rows masks: the first page, a reload, a page appended and a value loaded later") + func everyFetchedWriteIsMasked() { + var rows = TableRows.from( + queryRows: [[.text("1"), .text("jane@example.com")], [.text("2"), .null]], + columns: ["id", "email"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)], + unloadedCells: [1: [1: 4_000]], + masks: masks + ) + #expect(!leaks(rows)) + + rows.fillUnloaded([.existing(1): [1: .text("john@example.com")]]) + #expect(!leaks(rows)) + #expect(rows.value(at: 1, column: 1) == .text("••••")) + + rows.appendPage([[.text("3"), .text("ann@example.com")]], startingAt: 2) + #expect(!leaks(rows)) + + rows.replace(rows: [[.text("4"), .text("bob@example.com")]]) + #expect(!leaks(rows)) + #expect(rows.value(at: 0, column: 0) == .text("4")) + } + + @Test("A rule saved while rows are loaded masks them in place and keeps an existing mask") + func addedMasksApplyToLoadedRows() { + var rows = TableRows.from( + queryRows: [[.text("1"), .text("jane@example.com")]], + columns: ["id", "email"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)] + ) + #expect(rows.addMasks(masks) == .fullReplace) + #expect(!leaks(rows)) + #expect(rows.masks.isMasked(1)) + + let restyled = ColumnMasks(styles: [1: .hash]) + #expect(rows.addMasks(restyled) == .none) + #expect(rows.value(at: 0, column: 1) == .text("••••")) + } + + @Test("A masked cell takes no edit, so a mask is never staged as a new value") + func maskedCellRefusesEdits() { + var rows = TableRows.from( + queryRows: [[.text("1"), .text("jane@example.com")]], + columns: ["id", "email"], + columnTypes: [.integer(rawType: nil), .text(rawType: nil)], + masks: masks + ) + #expect(rows.edit(row: 0, column: 1, value: .text("new@example.com")) == .none) + #expect(rows.value(at: 0, column: 1) == .text("••••")) + #expect(rows.edit(row: 0, column: 0, value: .text("9")) != .none) + #expect(rows.maskedColumnNames == ["email"]) + } +} + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct MaskingGridTests { + private let fixture: CoordinatorFixture = { + var connection = TestFixtures.makeConnection(database: "shop") + connection.maskingRules = [MaskingRule(pattern: "*.email", style: .email)] + return CoordinatorFixture(connection: connection) + }() + + private func loadUsers() throws -> QueryTab { + let tab = fixture.addTab(QueryTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users")) + fixture.coordinator.applyPhase1Result( + tabId: tab.id, + columns: ["id", "email"], + columnTypes: [.integer(rawType: "int"), .text(rawType: "varchar")], + rows: [[.text("1"), .text("jane@example.com")], [.text("2"), .text("john@example.com")]], + executionTime: 0, + rowsAffected: 0, + statusMessage: nil, + tableName: "users", + isEditable: true, + metadata: nil, + hasSchema: false, + read: TableFreshness.Read(startedAt: .now, includesDefinition: false), + sql: "SELECT * FROM users", + connection: fixture.connection + ) + return try #require(fixture.tabManager.tabs.first { $0.id == tab.id }) + } + + @Test("A fetched result lands in the grid masked") + func fetchedRowsAreMasked() throws { + let tab = try loadUsers() + let rows = fixture.coordinator.tabSessionRegistry.tableRows(for: tab.id) + #expect(!leaks(rows)) + #expect(rows.value(at: 0, column: 1) == .text("j•••@example.com")) + #expect(fixture.coordinator.gridModel(forTab: tab.id).rows.masks.isMasked(1)) + } + + @Test("Copy and Copy as JSON copy the mask") + func copiesAreMasked() throws { + let clipboard = try #require(RecordingClipboard.current) + _ = try loadUsers() + + fixture.coordinator.copyRowsAsJson([.existing(0), .existing(1)]) + + let copied = try #require(clipboard.text) + #expect(!copied.contains("jane@example.com")) + #expect(copied.contains("j•••@example.com")) + } + + @Test("A grid holding masked values is read-only and says why") + func maskedGridIsReadOnly() throws { + _ = try loadUsers() + #expect(fixture.coordinator.activeResultEditRefusal == .masked) + #expect(!fixture.coordinator.canEditActiveResult) + } + + @Test("Reveal is refused without authentication and shows raw values for that tab once granted") + func revealNeedsAuthentication() async throws { + let tab = try loadUsers() + // Unselected, so a granted reveal records the choice without starting a query on a tab with no driver. + fixture.tabManager.selectedTabId = nil + + await fixture.coordinator.revealMaskedValues(tabId: tab.id, authenticator: StubAuthenticating(answer: false)) + #expect(fixture.tabManager.tabs.first { $0.id == tab.id }?.revealsMaskedValues == false) + + await fixture.coordinator.revealMaskedValues(tabId: tab.id, authenticator: StubAuthenticating(answer: true)) + #expect(fixture.tabManager.tabs.first { $0.id == tab.id }?.revealsMaskedValues == true) + let masks = fixture.coordinator.columnMasks( + forTabId: tab.id, table: "users", columns: ["email"], columnTypes: [.text(rawType: nil)] + ) + #expect(masks.isEmpty) + } + + @Test("Another tab stays masked after one tab reveals") + func revealIsPerTab() async throws { + let revealed = try loadUsers() + let other = fixture.addTab(QueryTab(title: "Q", query: "SELECT email FROM users", tabType: .query), select: false) + fixture.tabManager.selectedTabId = nil + + await fixture.coordinator.revealMaskedValues(tabId: revealed.id, authenticator: StubAuthenticating(answer: true)) + + let masks = fixture.coordinator.columnMasks( + forTabId: other.id, table: nil, columns: ["email"], columnTypes: [.text(rawType: nil)] + ) + #expect(masks.isMasked(0)) + } + } +} diff --git a/apple/TableProTests/Core/Services/Masking/MaskingPolicyTests.swift b/apple/TableProTests/Core/Services/Masking/MaskingPolicyTests.swift new file mode 100644 index 0000000000..6ba8ee668b --- /dev/null +++ b/apple/TableProTests/Core/Services/Masking/MaskingPolicyTests.swift @@ -0,0 +1,143 @@ +// +// MaskingPolicyTests.swift +// TableProTests +// + +import CloudKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct MaskingPolicyTests { + private let emailRule = MaskingPolicy(rules: [MaskingRule(pattern: "*.email", style: .email)]) + + @Test("A table.column pattern matches its table and column, ignoring case") + func tableColumnPatternMatches() { + let policy = MaskingPolicy(rules: [MaskingRule(pattern: "users.ssn")]) + #expect(policy.style(table: "Users", column: "SSN", typeName: nil) == .full) + #expect(policy.style(table: "public.users", column: "ssn", typeName: nil) == .full) + #expect(policy.style(table: "orders", column: "ssn", typeName: nil) == nil) + #expect(policy.style(table: "users", column: "name", typeName: nil) == nil) + } + + @Test("Without a source table a table.column pattern matches by its column alone") + func unknownTableMatchesByColumn() { + let policy = MaskingPolicy(rules: [MaskingRule(pattern: "users.ssn")]) + #expect(policy.style(table: nil, column: "ssn", typeName: nil) == .full) + } + + @Test("A bare pattern with wildcards matches any table's column") + func wildcardColumnPattern() { + let policy = MaskingPolicy(rules: [MaskingRule(pattern: "*password*", style: .null)]) + #expect(policy.style(table: "users", column: "password_hash", typeName: nil) == .null) + #expect(policy.style(table: nil, column: "old_password", typeName: nil) == .null) + #expect(policy.style(table: "users", column: "name", typeName: nil) == nil) + } + + @Test("A type rule matches the column's declared type") + func typeRuleMatches() { + let policy = MaskingPolicy(rules: [MaskingRule(pattern: "inet*", target: .type)]) + #expect(policy.style(table: "hosts", column: "address", typeName: "inet") == .full) + #expect(policy.style(table: "hosts", column: "address", typeName: "text") == nil) + #expect(policy.style(table: "hosts", column: "address", typeName: nil) == nil) + } + + @Test("The first matching rule wins, and an empty pattern is ignored") + func firstRuleWins() { + let policy = MaskingPolicy(rules: [ + MaskingRule(pattern: " "), + MaskingRule(pattern: "*.email", style: .hash), + MaskingRule(pattern: "email", style: .full), + ]) + #expect(policy.rules.count == 2) + #expect(policy.style(table: "users", column: "email", typeName: nil) == .hash) + } + + @Test("Each style hides the value as the issue describes") + func styles() { + let email: PluginCellValue = .text("jane.doe@example.com") + #expect(MaskedValue.mask(email, style: .full) == .text("••••")) + #expect(MaskedValue.mask(email, style: .email) == .text("j•••@example.com")) + #expect(MaskedValue.mask(.text("4111111111111234"), style: .lastFour) == .text("••••1234")) + #expect(MaskedValue.mask(.text("1234"), style: .lastFour) == .text("••••")) + #expect(MaskedValue.mask(.text("not-an-email"), style: .email) == .text("••••")) + #expect(MaskedValue.mask(email, style: .null) == .null) + #expect(MaskedValue.mask(.null, style: .full) == .null) + #expect(MaskedValue.mask(.bytes(Data([1, 2, 3])), style: .full) == .text("••••")) + } + + @Test("A stable hash keeps equal values equal without showing them") + func stableHash() throws { + let first = try #require(MaskedValue.mask(.text("jane@example.com"), style: .hash).asText) + let again = MaskedValue.mask(.text("jane@example.com"), style: .hash).asText + let other = MaskedValue.mask(.text("john@example.com"), style: .hash).asText + #expect(first == again) + #expect(first != other) + #expect(!first.contains("jane")) + #expect(first.count == 12) + } + + @Test("Column masks hide only the matched columns of a row") + func columnMasksRow() { + let masks = emailRule.columnMasks(table: "users", columns: ["id", "email"], typeNames: [nil, nil]) + #expect(masks.maskedColumns == [1]) + #expect(masks.row([.text("7"), .text("jane@example.com")]) == [.text("7"), .text("j•••@example.com")]) + } + + @Test("Rules round-trip through the connection's synced fields, and clearing them removes the key") + func connectionRoundTrip() { + var connection = TestFixtures.makeConnection() + connection.maskingRules = [MaskingRule(pattern: "*.email", style: .email), MaskingRule(pattern: "")] + #expect(connection.additionalFields[DatabaseConnection.maskingRulesKey] != nil) + #expect(connection.maskingRules.map(\.pattern) == ["*.email"]) + #expect(connection.maskingPolicy.style(table: "users", column: "email", typeName: nil) == .email) + + connection.maskingRules = [] + #expect(connection.additionalFields[DatabaseConnection.maskingRulesKey] == nil) + } + + @Test("Rules travel with the connection through iCloud sync") + func rulesSync() throws { + var connection = TestFixtures.makeConnection() + connection.maskingRules = [MaskingRule(pattern: "users.ssn", style: .lastFour)] + let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + + let synced = try SyncRecordMapper.toConnection(SyncRecordMapper.toCKRecord(connection, in: zoneID)) + + #expect(synced.maskingRules == connection.maskingRules) + } + + @Test("An unknown style masks fully, and an unknown target or a missing pattern masks every column") + func unreadableRulesFailClosed() throws { + let json = #"[{"pattern":"*.email","style":"later"},{"pattern":"^ssn$","target":"regex"},{"style":"full"}]"# + let rules = try JSONDecoder().decode([MaskingRule].self, from: Data(json.utf8)) + #expect(rules.map { $0.style } == [.full, .full, .full]) + #expect(rules.map { $0.pattern } == ["*.email", "*", "*"]) + #expect(MaskingPolicy(rules: rules).style(table: "users", column: "name", typeName: nil) == .full) + } + + @Test("Stored rules that cannot be read at all mask every column") + func corruptRulesFailClosed() { + var connection = TestFixtures.makeConnection() + connection.additionalFields[DatabaseConnection.maskingRulesKey] = "{not json" + #expect(connection.maskingPolicy.style(table: "users", column: "name", typeName: nil) == .full) + } + + @Test("QueryResult masking keeps everything but the masked values") + func queryResultMasking() { + var result = QueryResult( + columns: ["id", "email"], + columnTypes: [.integer(rawType: "int"), .text(rawType: "varchar")], + rows: [[.text("1"), .text("jane@example.com")]], + rowsAffected: 0, + executionTime: 0.5, + error: nil + ) + result.isTruncated = true + let masked = result.masked(by: emailRule) + #expect(masked.rows == [[.text("1"), .text("j•••@example.com")]]) + #expect(masked.isTruncated) + #expect(masked.columns == result.columns) + } +} diff --git a/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift b/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift index 3c5783a2dd..7015f6246f 100644 --- a/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift +++ b/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift @@ -24,6 +24,7 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { private let transactionState: PluginSessionTransactionState private let transactionStateAfterBatches: PluginSessionTransactionState? private let answer: @Sendable (String) -> QueryBatchResult + private let statementAnswer: @Sendable (String) -> QueryResult private let lock = NSLock() private var batches: [SentBatch] = [] private var statements: [String] = [] @@ -40,7 +41,10 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { transactionState: PluginSessionTransactionState = .idle, transactionStateAfterBatches: PluginSessionTransactionState? = nil, sessionLexicalState: PluginSessionLexicalState? = nil, - answer: @escaping @Sendable (String) -> QueryBatchResult = { _ in .empty } + answer: @escaping @Sendable (String) -> QueryBatchResult = { _ in .empty }, + statementAnswer: @escaping @Sendable (String) -> QueryResult = { _ in + ScriptAnsweringDriver.resultSet(columns: ["n"], rows: [["1"]]) + } ) { self.connection = connection self.sessionLexicalState = sessionLexicalState @@ -48,6 +52,7 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { self.transactionState = transactionState self.transactionStateAfterBatches = transactionStateAfterBatches self.answer = answer + self.statementAnswer = statementAnswer } var sentBatches: [SentBatch] { @@ -79,7 +84,7 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { private func record(_ query: String) -> QueryResult { lock.withLock { statements.append(query) } - return Self.resultSet(columns: ["n"], rows: [["1"]]) + return statementAnswer(query) } func explainPlan(statement: String, variantId: String) async throws -> QueryResult { diff --git a/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift b/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift index 74f9d02bfe..aae879f871 100644 --- a/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift +++ b/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift @@ -18,7 +18,8 @@ struct ConnectionFormEditsTests { queryTimeoutSeconds: Int? = nil, completionIdentifierCase: SQLIdentifierCase = .default, additionalFields: [String: String] = [:], - ownedAdditionalFieldIDs: Set = [] + ownedAdditionalFieldIDs: Set = [], + maskingRules: [MaskingRule] = [] ) -> ConnectionFormEdits { ConnectionFormEdits( name: "Edited", @@ -51,10 +52,33 @@ struct ConnectionFormEditsTests { startupCommands: nil, localOnly: false, additionalFields: additionalFields, - ownedAdditionalFieldIDs: ownedAdditionalFieldIDs + ownedAdditionalFieldIDs: ownedAdditionalFieldIDs, + maskingRules: maskingRules ) } + @Test("The form saves masking rules and removing every rule clears them, beside the driver's own fields") + func maskingRulesAreSaved() { + var original = populatedConnection() + original.additionalFields["sslmode"] = "require" + let rules = [MaskingRule(pattern: "*.email", style: .email)] + + let saved = edits( + additionalFields: ["sslmode": "require"], + ownedAdditionalFieldIDs: ConnectionFormEdits.appManagedAdditionalFieldIDs, + maskingRules: rules + ).applied(to: original) + #expect(saved.maskingRules == rules) + #expect(saved.additionalFields["sslmode"] == "require") + + let cleared = edits( + additionalFields: ["sslmode": "require"], + ownedAdditionalFieldIDs: ConnectionFormEdits.appManagedAdditionalFieldIDs + ).applied(to: saved) + #expect(cleared.maskingRules.isEmpty) + #expect(cleared.additionalFields[DatabaseConnection.maskingRulesKey] == nil) + } + private func populatedConnection() -> DatabaseConnection { var connection = DatabaseConnection( id: UUID(), From 5d57f47e5f881547af317a133de1819745572eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:24:32 +0700 Subject: [PATCH 2/4] docs(changelog): add the masking rules entry --- apple/changelog.d/3978.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3978.added.md diff --git a/apple/changelog.d/3978.added.md b/apple/changelog.d/3978.added.md new file mode 100644 index 0000000000..83f0fbedad --- /dev/null +++ b/apple/changelog.d/3978.added.md @@ -0,0 +1 @@ +Masking rules hide matching column values in the grid, copies, exports, the AI assistant and MCP. From 9d29a312c495e153d861ec9942b061144fd8c615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:04:41 +0700 Subject: [PATCH 3/4] test(masking): build the bridge fixture outside the main actor --- .../Services/Masking/MaskingBridgeTests.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift b/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift index a38ab604ba..deb7d89f5b 100644 --- a/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift +++ b/apple/TableProTests/Core/Services/Masking/MaskingBridgeTests.swift @@ -14,10 +14,12 @@ import Testing @Suite(.serialized) @MainActor struct MaskingBridgeTests { - private static let customers = ScriptAnsweringDriver.resultSet( - columns: ["id", "email"], - rows: [["1", "jane@example.com"], ["2", "john@example.com"]] - ) + nonisolated private static func customers() -> QueryResult { + ScriptAnsweringDriver.resultSet( + columns: ["id", "email"], + rows: [["1", "jane@example.com"], ["2", "john@example.com"]] + ) + } private func install(type: DatabaseType = .mysql, sendsBatchesWhole: Bool = true) -> (ScriptAnsweringDriver, DatabaseScope) { var connection = TestFixtures.makeConnection(database: "shop", type: type) @@ -25,8 +27,8 @@ struct MaskingBridgeTests { let driver = ScriptAnsweringDriver( connection: connection, sendsBatchesWhole: sendsBatchesWhole, - answer: { _ in ScriptAnsweringDriver.batch([Self.customers, Self.customers]) }, - statementAnswer: { _ in Self.customers } + answer: { _ in ScriptAnsweringDriver.batch([Self.customers(), Self.customers()]) }, + statementAnswer: { _ in Self.customers() } ) var session = ConnectionSession(connection: connection) session.driver = driver @@ -87,7 +89,7 @@ struct MaskingBridgeTests { func maskingTableNarrowsTheMatch() async throws { var connection = TestFixtures.makeConnection(database: "shop") connection.maskingRules = [MaskingRule(pattern: "users.email")] - let driver = ScriptAnsweringDriver(connection: connection, statementAnswer: { _ in Self.customers }) + let driver = ScriptAnsweringDriver(connection: connection, statementAnswer: { _ in Self.customers() }) var session = ConnectionSession(connection: connection) session.driver = driver DatabaseManager.shared.injectSession(session, for: connection.id) From 3bf4dc50011af7bc15114bc3701336bc82c40e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:16:00 +0700 Subject: [PATCH 4/4] test(masking): check grid rows for the raw addresses, not their domain --- .../Core/Services/Masking/MaskingGridTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift b/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift index b88cce8e0e..d01f5b3132 100644 --- a/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift +++ b/apple/TableProTests/Core/Services/Masking/MaskingGridTests.swift @@ -12,8 +12,10 @@ import Foundation import TableProPluginKit import Testing +private let rawEmails: Set = ["jane@example.com", "john@example.com", "ann@example.com", "bob@example.com"] + private func leaks(_ rows: TableRows) -> Bool { - rows.rows.contains { row in row.values.contains { $0.asText?.contains("@example.com") == true } } + rows.rows.contains { row in row.values.contains { $0.asText.map(rawEmails.contains) == true } } } struct MaskingTableRowsTests {