Skip to content

Commit 4524a2f

Browse files
authored
Merge pull request #250 from datlechin/feat/safe-mode-levels
feat: add per-connection safe mode levels
2 parents 9046ce1 + 027f254 commit 4524a2f

31 files changed

Lines changed: 1311 additions & 94 deletions

CHANGELOG.md

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

1010
### Added
1111

12+
- Safe mode levels: per-connection setting with 6 levels (Silent, Alert, Alert Full, Safe Mode, Safe Mode Full, Read-Only) replacing the boolean read-only toggle, with confirmation dialogs and Touch ID/password authentication for stricter levels
1213
- Preview tabs: single-click opens a temporary preview tab, double-click or editing promotes it to a permanent tab
1314
- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture
1415
- `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins

TablePro/ContentView.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ struct ContentView: View {
112112
)
113113
}
114114
AppState.shared.isConnected = true
115-
AppState.shared.isReadOnly = session.connection.isReadOnly
115+
AppState.shared.safeModeLevel = session.connection.safeModeLevel
116116
AppState.shared.isMongoDB = session.connection.type == .mongodb
117117
AppState.shared.isRedis = session.connection.type == .redis
118118
}
@@ -137,7 +137,7 @@ struct ContentView: View {
137137
currentSession = nil
138138
columnVisibility = .detailOnly
139139
AppState.shared.isConnected = false
140-
AppState.shared.isReadOnly = false
140+
AppState.shared.safeModeLevel = .silent
141141
AppState.shared.isMongoDB = false
142142
AppState.shared.isRedis = false
143143

@@ -168,7 +168,7 @@ struct ContentView: View {
168168
)
169169
}
170170
AppState.shared.isConnected = true
171-
AppState.shared.isReadOnly = newSession.connection.isReadOnly
171+
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
172172
AppState.shared.isMongoDB = newSession.connection.type == .mongodb
173173
AppState.shared.isRedis = newSession.connection.type == .redis
174174
}
@@ -196,12 +196,12 @@ struct ContentView: View {
196196

197197
if let session = DatabaseManager.shared.activeSessions[connectionId] {
198198
AppState.shared.isConnected = true
199-
AppState.shared.isReadOnly = session.connection.isReadOnly
199+
AppState.shared.safeModeLevel = session.connection.safeModeLevel
200200
AppState.shared.isMongoDB = session.connection.type == .mongodb
201201
AppState.shared.isRedis = session.connection.type == .redis
202202
} else {
203203
AppState.shared.isConnected = false
204-
AppState.shared.isReadOnly = false
204+
AppState.shared.safeModeLevel = .silent
205205
AppState.shared.isMongoDB = false
206206
AppState.shared.isRedis = false
207207
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// SafeModeGuard.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
import LocalAuthentication
8+
import os
9+
10+
@MainActor
11+
internal final class SafeModeGuard {
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "SafeModeGuard")
13+
14+
internal enum Permission {
15+
case allowed
16+
case blocked(String)
17+
}
18+
19+
internal static func checkPermission(
20+
level: SafeModeLevel,
21+
isWriteOperation: Bool,
22+
sql: String,
23+
operationDescription: String,
24+
window: NSWindow?,
25+
databaseType: DatabaseType? = nil
26+
) async -> Permission {
27+
let effectiveIsWrite: Bool
28+
if let dbType = databaseType, dbType == .mongodb || dbType == .redis {
29+
effectiveIsWrite = true
30+
} else {
31+
effectiveIsWrite = isWriteOperation
32+
}
33+
34+
switch level {
35+
case .silent:
36+
return .allowed
37+
38+
case .readOnly:
39+
if effectiveIsWrite {
40+
return .blocked(String(localized: "Cannot execute write queries: connection is read-only"))
41+
}
42+
return .allowed
43+
44+
case .alert:
45+
if effectiveIsWrite {
46+
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
47+
return .blocked(String(localized: "Operation cancelled by user"))
48+
}
49+
}
50+
return .allowed
51+
52+
case .alertFull:
53+
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
54+
return .blocked(String(localized: "Operation cancelled by user"))
55+
}
56+
return .allowed
57+
58+
case .safeMode:
59+
if effectiveIsWrite {
60+
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
61+
return .blocked(String(localized: "Operation cancelled by user"))
62+
}
63+
guard await authenticateUser() else {
64+
return .blocked(String(localized: "Authentication required to execute write operations"))
65+
}
66+
}
67+
return .allowed
68+
69+
case .safeModeFull:
70+
guard await showConfirmationAlert(sql: sql, operationDescription: operationDescription, window: window) else {
71+
return .blocked(String(localized: "Operation cancelled by user"))
72+
}
73+
guard await authenticateUser() else {
74+
return .blocked(String(localized: "Authentication required to execute operations"))
75+
}
76+
return .allowed
77+
}
78+
}
79+
80+
private static func showConfirmationAlert(
81+
sql: String,
82+
operationDescription: String,
83+
window: NSWindow?
84+
) async -> Bool {
85+
let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines)
86+
let preview: String
87+
if (trimmed as NSString).length > 200 {
88+
preview = String(trimmed.prefix(200)) + "..."
89+
} else {
90+
preview = trimmed
91+
}
92+
93+
return await AlertHelper.confirmDestructive(
94+
title: operationDescription,
95+
message: String(localized: "Are you sure you want to execute this query?\n\n\(preview)"),
96+
confirmButton: String(localized: "Execute"),
97+
cancelButton: String(localized: "Cancel"),
98+
window: window
99+
)
100+
}
101+
102+
private static func authenticateUser() async -> Bool {
103+
await Task.detached {
104+
let context = LAContext()
105+
do {
106+
return try await context.evaluatePolicy(
107+
.deviceOwnerAuthentication,
108+
localizedReason: String(localized: "Authenticate to execute database operations")
109+
)
110+
} catch {
111+
await MainActor.run {
112+
logger.warning("Biometric authentication failed: \(error.localizedDescription)")
113+
}
114+
return false
115+
}
116+
}.value
117+
}
118+
}

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ final class ConnectionStorage {
119119
color: connection.color,
120120
tagId: connection.tagId,
121121
groupId: connection.groupId,
122-
isReadOnly: connection.isReadOnly,
122+
safeModeLevel: connection.safeModeLevel,
123123
aiPolicy: connection.aiPolicy,
124124
mongoReadPreference: connection.mongoReadPreference,
125125
mongoWriteConcern: connection.mongoWriteConcern,
@@ -362,8 +362,8 @@ private struct StoredConnection: Codable {
362362
let tagId: String?
363363
let groupId: String?
364364

365-
// Read-only mode
366-
let isReadOnly: Bool
365+
// Safe mode level
366+
let safeModeLevel: String
367367

368368
// AI policy
369369
let aiPolicy: String?
@@ -407,8 +407,8 @@ private struct StoredConnection: Codable {
407407
self.tagId = connection.tagId?.uuidString
408408
self.groupId = connection.groupId?.uuidString
409409

410-
// Read-only mode
411-
self.isReadOnly = connection.isReadOnly
410+
// Safe mode level
411+
self.safeModeLevel = connection.safeModeLevel.rawValue
412412

413413
// AI policy
414414
self.aiPolicy = connection.aiPolicy?.rawValue
@@ -423,6 +423,48 @@ private struct StoredConnection: Codable {
423423
self.startupCommands = connection.startupCommands
424424
}
425425

426+
private enum CodingKeys: String, CodingKey {
427+
case id, name, host, port, database, username, type
428+
case sshEnabled, sshHost, sshPort, sshUsername, sshAuthMethod, sshPrivateKeyPath
429+
case sshUseSSHConfig, sshAgentSocketPath
430+
case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath
431+
case color, tagId, groupId
432+
case safeModeLevel
433+
case isReadOnly // Legacy key for migration reading only
434+
case aiPolicy, mssqlSchema, oracleServiceName, startupCommands
435+
}
436+
437+
func encode(to encoder: Encoder) throws {
438+
var container = encoder.container(keyedBy: CodingKeys.self)
439+
try container.encode(id, forKey: .id)
440+
try container.encode(name, forKey: .name)
441+
try container.encode(host, forKey: .host)
442+
try container.encode(port, forKey: .port)
443+
try container.encode(database, forKey: .database)
444+
try container.encode(username, forKey: .username)
445+
try container.encode(type, forKey: .type)
446+
try container.encode(sshEnabled, forKey: .sshEnabled)
447+
try container.encode(sshHost, forKey: .sshHost)
448+
try container.encode(sshPort, forKey: .sshPort)
449+
try container.encode(sshUsername, forKey: .sshUsername)
450+
try container.encode(sshAuthMethod, forKey: .sshAuthMethod)
451+
try container.encode(sshPrivateKeyPath, forKey: .sshPrivateKeyPath)
452+
try container.encode(sshUseSSHConfig, forKey: .sshUseSSHConfig)
453+
try container.encode(sshAgentSocketPath, forKey: .sshAgentSocketPath)
454+
try container.encode(sslMode, forKey: .sslMode)
455+
try container.encode(sslCaCertificatePath, forKey: .sslCaCertificatePath)
456+
try container.encode(sslClientCertificatePath, forKey: .sslClientCertificatePath)
457+
try container.encode(sslClientKeyPath, forKey: .sslClientKeyPath)
458+
try container.encode(color, forKey: .color)
459+
try container.encodeIfPresent(tagId, forKey: .tagId)
460+
try container.encodeIfPresent(groupId, forKey: .groupId)
461+
try container.encode(safeModeLevel, forKey: .safeModeLevel)
462+
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
463+
try container.encodeIfPresent(mssqlSchema, forKey: .mssqlSchema)
464+
try container.encodeIfPresent(oracleServiceName, forKey: .oracleServiceName)
465+
try container.encodeIfPresent(startupCommands, forKey: .startupCommands)
466+
}
467+
426468
// Custom decoder to handle migration from old format
427469
init(from decoder: Decoder) throws {
428470
let container = try decoder.container(keyedBy: CodingKeys.self)
@@ -456,7 +498,13 @@ private struct StoredConnection: Codable {
456498
color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue
457499
tagId = try container.decodeIfPresent(String.self, forKey: .tagId)
458500
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
459-
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
501+
// Migration: read new safeModeLevel first, fall back to old isReadOnly boolean
502+
if let levelString = try container.decodeIfPresent(String.self, forKey: .safeModeLevel) {
503+
safeModeLevel = levelString
504+
} else {
505+
let wasReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
506+
safeModeLevel = wasReadOnly ? SafeModeLevel.readOnly.rawValue : SafeModeLevel.silent.rawValue
507+
}
460508
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
461509
mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema)
462510
oracleServiceName = try container.decodeIfPresent(String.self, forKey: .oracleServiceName)
@@ -500,7 +548,7 @@ private struct StoredConnection: Codable {
500548
color: parsedColor,
501549
tagId: parsedTagId,
502550
groupId: parsedGroupId,
503-
isReadOnly: isReadOnly,
551+
safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent,
504552
aiPolicy: parsedAIPolicy,
505553
mssqlSchema: mssqlSchema,
506554
oracleServiceName: oracleServiceName,

TablePro/Models/Connection/ConnectionToolbarState.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,10 @@ final class ConnectionToolbarState {
165165

166166
// MARK: - Future Expansion
167167

168-
/// Whether the connection is read-only
169-
var isReadOnly: Bool = false
168+
/// Safe mode level for this connection
169+
var safeModeLevel: SafeModeLevel = .silent
170+
171+
var isReadOnly: Bool { safeModeLevel == .readOnly }
170172

171173
/// Whether the current tab is a table tab (enables filter/sort actions)
172174
var isTableTab: Bool = false
@@ -210,8 +212,8 @@ final class ConnectionToolbarState {
210212
parts.append(String(localized: "Replication lag: \(lag)s"))
211213
}
212214

213-
if isReadOnly {
214-
parts.append(String(localized: "Read-only"))
215+
if safeModeLevel != .silent {
216+
parts.append(safeModeLevel.displayName)
215217
}
216218

217219
return parts.joined(separator: "")
@@ -246,7 +248,7 @@ final class ConnectionToolbarState {
246248
databaseType = connection.type
247249
displayColor = connection.displayColor
248250
tagId = connection.tagId
249-
isReadOnly = connection.isReadOnly
251+
safeModeLevel = connection.safeModeLevel
250252
}
251253

252254
/// Update connection state from ConnectionStatus
@@ -276,7 +278,7 @@ final class ConnectionToolbarState {
276278
lastQueryDuration = nil
277279
clickHouseProgress = nil
278280
lastClickHouseProgress = nil
279-
isReadOnly = false
281+
safeModeLevel = .silent
280282
isTableTab = false
281283
latencyMs = nil
282284
replicationLagSeconds = nil

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ struct DatabaseConnection: Identifiable, Hashable {
398398
var color: ConnectionColor
399399
var tagId: UUID?
400400
var groupId: UUID?
401-
var isReadOnly: Bool
401+
var safeModeLevel: SafeModeLevel
402402
var aiPolicy: AIConnectionPolicy?
403403
var mongoReadPreference: String?
404404
var mongoWriteConcern: String?
@@ -420,7 +420,7 @@ struct DatabaseConnection: Identifiable, Hashable {
420420
color: ConnectionColor = .none,
421421
tagId: UUID? = nil,
422422
groupId: UUID? = nil,
423-
isReadOnly: Bool = false,
423+
safeModeLevel: SafeModeLevel = .silent,
424424
aiPolicy: AIConnectionPolicy? = nil,
425425
mongoReadPreference: String? = nil,
426426
mongoWriteConcern: String? = nil,
@@ -441,7 +441,7 @@ struct DatabaseConnection: Identifiable, Hashable {
441441
self.color = color
442442
self.tagId = tagId
443443
self.groupId = groupId
444-
self.isReadOnly = isReadOnly
444+
self.safeModeLevel = safeModeLevel
445445
self.aiPolicy = aiPolicy
446446
self.mongoReadPreference = mongoReadPreference
447447
self.mongoWriteConcern = mongoWriteConcern

0 commit comments

Comments
 (0)