From 2147c6570e46caa0c126585ceff776228fb3c9c4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 19 Aug 2026 03:41:09 +0700 Subject: [PATCH] feat(settings): let an MDM profile set a minimum safe mode level --- CHANGELOG.md | 4 + .../Execution/ExecutionGateProvider.swift | 6 +- .../Core/Services/Policy/ManagedPolicy.swift | 98 ++++++++++++ .../Core/Services/ManagedPolicyTests.swift | 139 ++++++++++++++++++ docs/features/safe-mode.mdx | 18 +++ 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 TablePro/Core/Services/Policy/ManagedPolicy.swift create mode 100644 TableProTests/Core/Services/ManagedPolicyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a9c2aa4..30f0a7377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- An administrator can set a minimum Safe Mode level for every connection through a macOS configuration profile, so a managed Mac cannot be dropped below it. A connection set stricter keeps its own level, since the policy is a floor rather than a ceiling. The control shows as managed instead of editable. + ## [0.66.0] - 2026-08-19 ### Added diff --git a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift index 204ab847f..2c65d5114 100644 --- a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift +++ b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift @@ -10,7 +10,7 @@ internal enum ExecutionGateProvider { confirming: AlertOperationConfirming(), authenticating: BiometricOperationAuthenticating(), safeModeLevelResolver: { connectionId in - await MainActor.run { + let connectionLevel: SafeModeLevel = await MainActor.run { switch DatabaseManager.shared.connectionState(connectionId) { case .live(_, let session): return session.safeModeLevel @@ -20,6 +20,10 @@ internal enum ExecutionGateProvider { return .silent } } + return ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: connectionLevel, + policy: ManagedPolicyReader.shared + ) }, forcesWriteResolver: { databaseType in await MainActor.run { diff --git a/TablePro/Core/Services/Policy/ManagedPolicy.swift b/TablePro/Core/Services/Policy/ManagedPolicy.swift new file mode 100644 index 000000000..f008c182e --- /dev/null +++ b/TablePro/Core/Services/Policy/ManagedPolicy.swift @@ -0,0 +1,98 @@ +// +// ManagedPolicy.swift +// TablePro +// + +import Foundation + +/// Settings an administrator can impose through a configuration profile, and that the user cannot +/// then turn off. +/// +/// Each policy is one flat, primitive key. The app's own settings are stored as encoded structs +/// under keys like `com.TablePro.settings.mcp`, which cannot serve as policy: forcing one would mean +/// an administrator had to reproduce the whole struct, and it would break the moment a field was +/// added. A profile delivers one key per decision instead. +internal enum ManagedPolicy: String, CaseIterable, Sendable { + /// The weakest Safe Mode level a connection may run at. A connection set to something weaker is + /// raised to this; a stronger choice is left alone, so the policy is a floor and never a ceiling. + case minimumSafeModeLevel = "com.TablePro.policy.minimumSafeModeLevel" + + /// Stops the MCP server from listening, so an AI client cannot reach the databases at all. + case mcpServerDisabled = "com.TablePro.policy.mcpServerDisabled" + + /// Stops the in-app AI assistant, including inline suggestions. + case aiAssistantDisabled = "com.TablePro.policy.aiAssistantDisabled" + + /// Stops plugins being installed, from the registry or from a file. + case pluginInstallDisabled = "com.TablePro.policy.pluginInstallDisabled" + + internal var key: String { rawValue } +} + +internal protocol ManagedPolicyReading: Sendable { + /// True when a configuration profile supplies this key, so the UI for it must be disabled. + func isManaged(_ policy: ManagedPolicy) -> Bool + func bool(_ policy: ManagedPolicy) -> Bool + func string(_ policy: ManagedPolicy) -> String? +} + +/// Reads policy out of the standard preferences chain. +/// +/// macOS already merges a configuration profile's values into `UserDefaults` at the highest +/// priority, so reading a managed key needs no special path: the forced value simply wins. The one +/// thing the normal API cannot tell you is *whether* a key was forced, which is what +/// `CFPreferencesAppValueIsForced` is for. Its own header says callers "should use this function to +/// determine whether or not to disable UI elements corresponding to those preference keys". +internal struct ManagedPolicyReader: ManagedPolicyReading, @unchecked Sendable { + internal static let shared = ManagedPolicyReader() + + private let defaults: UserDefaults + private let applicationID: String + + internal init(defaults: UserDefaults = .standard, applicationID: String = Bundle.main.bundleIdentifier ?? "") { + self.defaults = defaults + self.applicationID = applicationID + } + + internal func isManaged(_ policy: ManagedPolicy) -> Bool { + guard !applicationID.isEmpty else { return false } + return CFPreferencesAppValueIsForced(policy.key as CFString, applicationID as CFString) + } + + internal func bool(_ policy: ManagedPolicy) -> Bool { + defaults.bool(forKey: policy.key) + } + + internal func string(_ policy: ManagedPolicy) -> String? { + defaults.string(forKey: policy.key) + } +} + +internal enum ManagedPolicyResolver { + /// Raises a connection's Safe Mode level to the managed floor, leaving a stricter choice alone. + /// + /// An unset or unrecognised policy value means no floor rather than a default one. A profile that + /// names a level TablePro does not know must not silently become Read-Only, and must not silently + /// become Silent either. + internal static func effectiveSafeModeLevel( + connectionLevel: SafeModeLevel, + policy: any ManagedPolicyReading + ) -> SafeModeLevel { + guard let raw = policy.string(.minimumSafeModeLevel), + let floor = SafeModeLevel(rawValue: raw) + else { return connectionLevel } + return strictness(floor) > strictness(connectionLevel) ? floor : connectionLevel + } + + /// Ordered weakest to strongest by what each level actually prevents, not by declaration order. + private static func strictness(_ level: SafeModeLevel) -> Int { + switch level { + case .silent: 0 + case .alert: 1 + case .alertFull: 2 + case .safeMode: 3 + case .safeModeFull: 4 + case .readOnly: 5 + } + } +} diff --git a/TableProTests/Core/Services/ManagedPolicyTests.swift b/TableProTests/Core/Services/ManagedPolicyTests.swift new file mode 100644 index 000000000..dd1c93d02 --- /dev/null +++ b/TableProTests/Core/Services/ManagedPolicyTests.swift @@ -0,0 +1,139 @@ +// +// ManagedPolicyTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +private struct StubPolicy: ManagedPolicyReading { + var managed: Set = [] + var bools: [String: Bool] = [:] + var strings: [String: String] = [:] + + func isManaged(_ policy: ManagedPolicy) -> Bool { managed.contains(policy.key) } + func bool(_ policy: ManagedPolicy) -> Bool { bools[policy.key] ?? false } + func string(_ policy: ManagedPolicy) -> String? { strings[policy.key] } +} + +@Suite("ManagedPolicyResolver") +struct ManagedPolicyResolverTests { + private func policy(floor: String?) -> StubPolicy { + guard let floor else { return StubPolicy() } + return StubPolicy(strings: [ManagedPolicy.minimumSafeModeLevel.key: floor]) + } + + @Test("with no policy the connection's own level is used") + func noPolicy() { + let level = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: .silent, + policy: policy(floor: nil) + ) + #expect(level == .silent) + } + + @Test("a weaker connection is raised to the managed floor") + func raisesWeakerLevel() { + let level = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: .silent, + policy: policy(floor: SafeModeLevel.readOnly.rawValue) + ) + #expect(level == .readOnly) + } + + @Test("a stricter connection is left alone, so the policy is a floor and not a ceiling") + func doesNotWeaken() { + let level = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: .readOnly, + policy: policy(floor: SafeModeLevel.alert.rawValue) + ) + #expect(level == .readOnly) + } + + @Test("equal levels stay put") + func equalLevel() { + let level = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: .safeMode, + policy: policy(floor: SafeModeLevel.safeMode.rawValue) + ) + #expect(level == .safeMode) + } + + @Test("an unrecognised policy value never becomes a floor of its own") + func unknownValueIsIgnored() { + let level = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: .alert, + policy: policy(floor: "paranoid") + ) + #expect(level == .alert) + } + + @Test("every level is ordered, so no pair collapses to a tie") + func everyLevelIsOrdered() { + let ordered: [SafeModeLevel] = [.silent, .alert, .alertFull, .safeMode, .safeModeFull, .readOnly] + #expect(ordered.count == SafeModeLevel.allCases.count) + + for (index, weaker) in ordered.enumerated() { + for stronger in ordered[(index + 1)...] { + let raised = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: weaker, + policy: policy(floor: stronger.rawValue) + ) + #expect(raised == stronger, "\(stronger) should outrank \(weaker)") + + let kept = ManagedPolicyResolver.effectiveSafeModeLevel( + connectionLevel: stronger, + policy: policy(floor: weaker.rawValue) + ) + #expect(kept == stronger, "\(weaker) should not weaken \(stronger)") + } + } + } +} + +@Suite("ManagedPolicyReader") +struct ManagedPolicyReaderTests { + private func makeReader(_ values: [String: Any]) -> ManagedPolicyReader { + let suiteName = "com.TablePro.tests.policy.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Could not create an isolated UserDefaults suite") + } + for (key, value) in values { defaults.set(value, forKey: key) } + return ManagedPolicyReader(defaults: defaults, applicationID: suiteName) + } + + @Test("an unset boolean policy reads false rather than nil") + func unsetBoolIsFalse() { + let reader = makeReader([:]) + #expect(reader.bool(.mcpServerDisabled) == false) + #expect(reader.bool(.aiAssistantDisabled) == false) + #expect(reader.bool(.pluginInstallDisabled) == false) + } + + @Test("a set policy value is read back") + func readsSetValues() { + let reader = makeReader([ + ManagedPolicy.mcpServerDisabled.key: true, + ManagedPolicy.minimumSafeModeLevel.key: SafeModeLevel.readOnly.rawValue, + ]) + #expect(reader.bool(.mcpServerDisabled)) + #expect(reader.string(.minimumSafeModeLevel) == SafeModeLevel.readOnly.rawValue) + } + + @Test("a value set by the user rather than by a profile is not reported as managed") + func userSetValueIsNotManaged() { + let reader = makeReader([ManagedPolicy.mcpServerDisabled.key: true]) + #expect(reader.isManaged(.mcpServerDisabled) == false) + } + + @Test("policy keys are distinct and namespaced apart from the settings blobs") + func keysAreDistinct() { + let keys = ManagedPolicy.allCases.map(\.key) + #expect(Set(keys).count == keys.count) + for key in keys { + #expect(key.hasPrefix("com.TablePro.policy.")) + } + } +} diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 9934209aa..cf849d2dd 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -93,6 +93,24 @@ SHOW SESSION VARIABLES WHERE Variable_name IN `innodb_read_only` set to `ON` means you are on a replica. Connect to the primary to write. +## Managed by an Organization + +An administrator can set a minimum Safe Mode level through a macOS configuration profile, delivered by an MDM such as Jamf or Kandji. A connection set below that level is raised to it. A stricter choice is left alone, so the policy is a floor and never a ceiling: someone who wants Read-Only on a production connection still gets it. + +The profile targets the `com.TablePro` preference domain with a flat key: + +| Key | Type | Value | +|---|---|---| +| `com.TablePro.policy.minimumSafeModeLevel` | String | `silent`, `alert`, `alertFull`, `safeMode`, `safeModeFull`, or `readOnly` | + +A value TablePro does not recognise imposes no floor at all. It never falls back to Read-Only, which would lock people out of a typo, and never to Silent, which would quietly drop the policy. + +TablePro reads this the way macOS intends: a configuration profile already outranks the app's own preferences, so a forced key simply wins, and the app asks `CFPreferencesAppValueIsForced` only to know that the matching control should be disabled rather than merely preset. + + +This is a floor on TablePro's own behaviour, not on the database. It stops the app issuing a write; it does not stop the same person connecting with `psql`. Pair it with server-side privileges for anything that has to hold. See [Server Read-Only Is Not Safe Mode](#server-read-only-is-not-safe-mode). + + ## External Clients Safe Mode runs inside the app on every query you execute. External clients (Raycast, Cursor, Claude Desktop, and other MCP clients) hit a separate gate first.