diff --git a/core/cmd/dms/shellapp.go b/core/cmd/dms/shellapp.go index 3fe0c7708..b8543e95d 100644 --- a/core/cmd/dms/shellapp.go +++ b/core/cmd/dms/shellapp.go @@ -125,7 +125,7 @@ func dmsExtraEnv(string) []string { env = append(env, gpu.EGLVendorEnv()...) policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs()) if err != nil { - log.Errorf("CLI policy unreadable, enforcing every shell switch: %v", err) + log.Errorf("CLI policy unreadable, enforcing every shell switch and ignoring its lock settings: %v", err) } env = append(env, policyEnv...) // systemd user services miss the login shell PATH, so tools in ~/.local/bin diff --git a/core/internal/clipolicy/clipolicy.go b/core/internal/clipolicy/clipolicy.go index 8c135871c..8da4ce17f 100644 --- a/core/internal/clipolicy/clipolicy.go +++ b/core/internal/clipolicy/clipolicy.go @@ -4,8 +4,10 @@ import ( "encoding/json" "fmt" "os" + "slices" "strings" + "github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/spf13/afero" ) @@ -25,6 +27,8 @@ type File struct { DisableCustomLockPAM *bool `json:"disable_custom_lock_pam"` DisableUserPlugins *bool `json:"disable_user_plugins"` DisableSettingsIPCSet *bool `json:"disable_settings_ipc_set"` + + LockSettings map[string]json.RawMessage `json:"lock_settings"` } // shellSwitches reach the shell as DMS_POLICY_ in its launch environment. @@ -39,6 +43,22 @@ var shellSwitches = []struct { {"disable_settings_ipc_set", func(p *File) *bool { return p.DisableSettingsIPCSet }}, } +// lockSettingKeys are the shell settings lock_settings may manage. +var lockSettingKeys = []string{ + "customPowerActionLock", + "lockBeforeSuspend", + "loginctlLockIntegration", + "lockAtStartup", + "acLockTimeout", + "batteryLockTimeout", + "fadeToLockEnabled", + "fadeToLockGracePeriod", + "enableFprint", + "maxFprintTries", + "enableU2f", + "u2fMode", +} + // LoadFile returns nil without an error when the file does not exist. func LoadFile(fs afero.Fs, path string) (*File, error) { data, err := afero.ReadFile(fs, path) @@ -104,12 +124,50 @@ func LogindUnlockDisabled(fs afero.Fs) (bool, error) { return flag(policies, func(p *File) *bool { return p.DisableLogindUnlock }), nil } -// ShellEnv returns every shell switch as DMS_POLICY_=1 or 0, so a value +// lockSettings merges lock_settings per key, AdminPath over PackagedPath, +// without null values and keys outside lockSettingKeys. +func lockSettings(policies []*File) map[string]json.RawMessage { + settings := make(map[string]json.RawMessage) + for _, policy := range policies { + for key, value := range policy.LockSettings { + if !slices.Contains(lockSettingKeys, key) { + log.Warnf("CLI policy: ignoring lock_settings key %q, which is not a managed lock setting", key) + continue + } + if string(value) == "null" { + continue + } + settings[key] = value + } + } + return settings +} + +// LockSettings returns the managed lock settings as raw JSON by key. A policy +// file that fails to load manages none, and its error is returned. +func LockSettings(fs afero.Fs) (map[string]json.RawMessage, error) { + policies, err := load(fs) + return lockSettings(policies), err +} + +// LockSettingBool returns a managed boolean lock setting, or nil when it is +// not managed or not a boolean, which the shell ignores too. +func LockSettingBool(settings map[string]json.RawMessage, key string) *bool { + var value *bool + if err := json.Unmarshal(settings[key], &value); err != nil { + return nil + } + return value +} + +// ShellEnv returns every shell switch as DMS_POLICY_=1 or 0 and the +// managed lock settings as DMS_POLICY_LOCK_SETTINGS=, so a value // inherited by the shell never counts. A policy file that fails to load turns -// every switch on, and its error is returned with them. +// every switch on and manages no lock setting, and its error is returned with +// them. func ShellEnv(fs afero.Fs) ([]string, error) { policies, err := load(fs) - env := make([]string, 0, len(shellSwitches)) + env := make([]string, 0, len(shellSwitches)+1) for _, s := range shellSwitches { value := "0" if err != nil || flag(policies, s.value) { @@ -117,5 +175,8 @@ func ShellEnv(fs afero.Fs) ([]string, error) { } env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value) } + // The values come from json.Unmarshal, so they marshal. + settings, _ := json.Marshal(lockSettings(policies)) + env = append(env, "DMS_POLICY_LOCK_SETTINGS="+string(settings)) return env, err } diff --git a/core/internal/clipolicy/clipolicy_test.go b/core/internal/clipolicy/clipolicy_test.go index 4b0e99230..9592c0b1c 100644 --- a/core/internal/clipolicy/clipolicy_test.go +++ b/core/internal/clipolicy/clipolicy_test.go @@ -1,6 +1,7 @@ package clipolicy import ( + "encoding/json" "strings" "testing" @@ -124,6 +125,7 @@ func TestShellEnv(t *testing.T) { want = append(want, other+"=0") } } + want = append(want, "DMS_POLICY_LOCK_SETTINGS={}") env, err := ShellEnv(fs) require.NoError(t, err) @@ -133,11 +135,12 @@ func TestShellEnv(t *testing.T) { } } -func TestShellEnvMalformedPolicyTurnsEverySwitchOn(t *testing.T) { +func TestShellEnvMalformedPolicyTurnsEverySwitchOnAndIgnoresLockSettings(t *testing.T) { fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true}}`), 0o644)) require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644)) - var want []string + want := []string{"DMS_POLICY_LOCK_SETTINGS={}"} for _, name := range shellSwitchEnv { want = append(want, name+"=1") } @@ -146,3 +149,83 @@ func TestShellEnvMalformedPolicyTurnsEverySwitchOn(t *testing.T) { require.ErrorContains(t, err, "failed to parse "+AdminPath) assert.ElementsMatch(t, want, env) } + +func TestLockSettings(t *testing.T) { + tests := []struct { + name string + packaged string + admin string + want string + wantErr string + }{ + {name: "no policy files", want: `{}`}, + {name: "no lock settings", admin: `{"disable_registries": true}`, want: `{}`}, + {name: "packaged policy", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": ""}}`, want: `{"lockBeforeSuspend": true, "customPowerActionLock": ""}`}, + {name: "admin policy overrides packaged per key", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "acLockTimeout": 300}}`, admin: `{"lock_settings": {"lockBeforeSuspend": false}}`, want: `{"lockBeforeSuspend": false, "acLockTimeout": 300}`}, + {name: "admin policy without lock settings keeps packaged", packaged: `{"lock_settings": {"enableFprint": false}}`, admin: `{"disable_registries": true}`, want: `{"enableFprint": false}`}, + {name: "null leaves a key unset", packaged: `{"lock_settings": {"u2fMode": "and"}}`, admin: `{"lock_settings": {"u2fMode": null, "lockAtStartup": null}}`, want: `{"u2fMode": "and"}`}, + { + name: "only lock settings are managed", + admin: `{"lock_settings": {"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and", "lockScreenShowPowerActions": false, "lockPamPath": "/tmp/pam", "unknownSetting": 1}}`, + want: `{"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and"}`, + }, + {name: "values are passed as written", admin: `{"lock_settings": {"lockBeforeSuspend": "yes", "acLockTimeout": [1]}}`, want: `{"lockBeforeSuspend": "yes", "acLockTimeout": [1]}`}, + {name: "malformed admin policy manages nothing", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: "{not json", want: `{}`, wantErr: "failed to parse " + AdminPath}, + {name: "lock settings not an object", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: `{"lock_settings": ["lockBeforeSuspend"]}`, want: `{}`, wantErr: "failed to parse " + AdminPath}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tt.packaged != "" { + require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644)) + } + if tt.admin != "" { + require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644)) + } + + settings, err := LockSettings(fs) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + got, err := json.Marshal(settings) + require.NoError(t, err) + assert.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestShellEnvLockSettings(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": false, "acLockTimeout": 300}}`), 0o644)) + require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": "", "lockScreenShowWeather": false}}`), 0o644)) + + env, err := ShellEnv(fs) + require.NoError(t, err) + assert.Contains(t, env, `DMS_POLICY_LOCK_SETTINGS={"acLockTimeout":300,"customPowerActionLock":"","lockBeforeSuspend":true}`) +} + +func TestLockSettingBool(t *testing.T) { + on, off := true, false + tests := []struct { + name string + settings string + want *bool + }{ + {name: "not managed", settings: `{"lockAtStartup": true}`}, + {name: "managed on", settings: `{"lockBeforeSuspend": true}`, want: &on}, + {name: "managed off", settings: `{"lockBeforeSuspend": false}`, want: &off}, + {name: "null", settings: `{"lockBeforeSuspend": null}`}, + {name: "not a boolean", settings: `{"lockBeforeSuspend": "true"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var settings map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(tt.settings), &settings)) + assert.Equal(t, tt.want, LockSettingBool(settings, "lockBeforeSuspend")) + }) + } +} diff --git a/core/internal/server/loginctl/actions.go b/core/internal/server/loginctl/actions.go index 5a6180040..55e5be3f5 100644 --- a/core/internal/server/loginctl/actions.go +++ b/core/internal/server/loginctl/actions.go @@ -1,9 +1,15 @@ package loginctl import ( + "errors" "fmt" ) +var ( + errLockBeforeSuspendManaged = errors.New("lock before suspend is managed by administrator policy") + errSleepInhibitorManaged = errors.New("the sleep inhibitor is managed by administrator policy") +) + func (m *Manager) Lock() error { if m.sessionObj == nil { return fmt.Errorf("session object not available") @@ -85,8 +91,21 @@ func (m *Manager) Terminate() error { return nil } -func (m *Manager) SetLockBeforeSuspend(enabled bool) { +// applyLockPolicy starts from the managed settings, before the shell connects. +func (m *Manager) applyLockPolicy() { + if managed := m.lockPolicy.LockBeforeSuspend; managed != nil { + m.lockBeforeSuspend.Store(*managed) + } + enabled, managed := m.lockPolicy.sleepInhibitor() + m.sleepInhibitorEnabled.Store(enabled || !managed) +} + +func (m *Manager) SetLockBeforeSuspend(enabled bool) error { + if managed := m.lockPolicy.LockBeforeSuspend; managed != nil && *managed != enabled { + return errLockBeforeSuspendManaged + } m.lockBeforeSuspend.Store(enabled) + return nil } func (m *Manager) markLockerReady() { @@ -107,7 +126,10 @@ func (m *Manager) markLockerReady() { m.prelockedReady.Store(true) } -func (m *Manager) SetSleepInhibitorEnabled(enabled bool) { +func (m *Manager) SetSleepInhibitorEnabled(enabled bool) error { + if managedValue, managed := m.lockPolicy.sleepInhibitor(); managed && managedValue != enabled { + return errSleepInhibitorManaged + } m.sleepInhibitorEnabled.Store(enabled) if enabled { // Re-acquire inhibitor if enabled @@ -116,4 +138,5 @@ func (m *Manager) SetSleepInhibitorEnabled(enabled bool) { // Release inhibitor if disabled m.releaseSleepInhibitor() } + return nil } diff --git a/core/internal/server/loginctl/handlers.go b/core/internal/server/loginctl/handlers.go index c7e93ebe7..3699c55fb 100644 --- a/core/internal/server/loginctl/handlers.go +++ b/core/internal/server/loginctl/handlers.go @@ -100,7 +100,10 @@ func handleSetLockBeforeSuspend(conn *ipc.ConnWriter, req ipc.Request, manager * return } - manager.SetLockBeforeSuspend(enabled) + if err := manager.SetLockBeforeSuspend(enabled); err != nil { + conn.RespondError(req.ID, err.Error()) + return + } conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"}) } @@ -111,7 +114,10 @@ func handleSetSleepInhibitorEnabled(conn *ipc.ConnWriter, req ipc.Request, manag return } - manager.SetSleepInhibitorEnabled(enabled) + if err := manager.SetSleepInhibitorEnabled(enabled); err != nil { + conn.RespondError(req.ID, err.Error()) + return + } conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"}) } diff --git a/core/internal/server/loginctl/handlers_test.go b/core/internal/server/loginctl/handlers_test.go index 25a094846..438a1b2b8 100644 --- a/core/internal/server/loginctl/handlers_test.go +++ b/core/internal/server/loginctl/handlers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "net" + "os" "sync" "testing" "time" @@ -424,3 +425,85 @@ func TestHandleRequest(t *testing.T) { assert.Empty(t, resp.Error) }) } + +func TestHandleSetLockBeforeSuspend(t *testing.T) { + on, off := true, false + tests := []struct { + name string + managed *bool + request bool + want bool + wantErr bool + }{ + {name: "unmanaged", request: true, want: true}, + {name: "managed, same value", managed: &on, request: true, want: true}, + {name: "managed on, request off", managed: &on, request: false, want: true, wantErr: true}, + {name: "managed off, request on", managed: &off, request: true, want: false, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{lockPolicy: LockPolicy{LockBeforeSuspend: tt.managed}} + manager.applyLockPolicy() + + mc := newMockNetConn() + conn := ipc.NewConnWriter(mc) + HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setLockBeforeSuspend", Params: map[string]any{"enabled": tt.request}}, manager) + + var resp ipc.Response[models.SuccessResult] + require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp)) + if tt.wantErr { + assert.Equal(t, "lock before suspend is managed by administrator policy", resp.Error) + } else { + assert.Empty(t, resp.Error) + } + assert.Equal(t, tt.want, manager.lockBeforeSuspend.Load()) + }) + } +} + +func TestHandleSetSleepInhibitorEnabled(t *testing.T) { + on, off := true, false + tests := []struct { + name string + policy LockPolicy + request bool + want bool + wantErr bool + }{ + {name: "unmanaged", request: false, want: false}, + {name: "both managed on, request off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: false, want: true, wantErr: true}, + {name: "both managed on, request on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: true, want: true}, + {name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}, request: true, want: false, wantErr: true}, + {name: "integration managed off", policy: LockPolicy{LoginctlLockIntegration: &off}, request: true, want: false, wantErr: true}, + {name: "lock before suspend managed on, integration left to the user", policy: LockPolicy{LockBeforeSuspend: &on}, request: false, want: false}, + {name: "integration managed on, lock before suspend left to the user", policy: LockPolicy{LoginctlLockIntegration: &on}, request: false, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{lockPolicy: tt.policy} + manager.applyLockPolicy() + inhibit, err := os.CreateTemp(t.TempDir(), "inhibit") + require.NoError(t, err) + held := manager.sleepInhibitorEnabled.Load() + if held { + manager.inhibitFile = inhibit + } + + mc := newMockNetConn() + conn := ipc.NewConnWriter(mc) + HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setSleepInhibitorEnabled", Params: map[string]any{"enabled": tt.request}}, manager) + + var resp ipc.Response[models.SuccessResult] + require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp)) + if tt.wantErr { + assert.Equal(t, "the sleep inhibitor is managed by administrator policy", resp.Error) + assert.Equal(t, held, manager.inhibitFile != nil, "a refused request leaves the inhibitor as it was") + } else { + assert.Empty(t, resp.Error) + } + assert.Equal(t, tt.want, manager.sleepInhibitorEnabled.Load()) + }) + } +} diff --git a/core/internal/server/loginctl/manager.go b/core/internal/server/loginctl/manager.go index dbbf3e6e4..b78f7a434 100644 --- a/core/internal/server/loginctl/manager.go +++ b/core/internal/server/loginctl/manager.go @@ -11,7 +11,7 @@ import ( "github.com/godbus/dbus/v5" ) -func NewManager(keepLockOnUnlock bool) (*Manager, error) { +func NewManager(keepLockOnUnlock bool, lockPolicy LockPolicy) (*Manager, error) { conn, err := dbus.SystemBus() if err != nil { return nil, fmt.Errorf("failed to connect to system bus: %w", err) @@ -27,8 +27,9 @@ func NewManager(keepLockOnUnlock bool) (*Manager, error) { signals: make(chan *dbus.Signal, 256), keepLockOnUnlock: keepLockOnUnlock, + lockPolicy: lockPolicy, } - m.sleepInhibitorEnabled.Store(true) + m.applyLockPolicy() if err := m.initialize(); err != nil { return nil, err diff --git a/core/internal/server/loginctl/manager_test.go b/core/internal/server/loginctl/manager_test.go index fb852ba9a..aa4c30819 100644 --- a/core/internal/server/loginctl/manager_test.go +++ b/core/internal/server/loginctl/manager_test.go @@ -126,3 +126,28 @@ func TestStateChangedMeaningfully(t *testing.T) { }) } } + +func TestManager_ApplyLockPolicy(t *testing.T) { + on, off := true, false + tests := []struct { + name string + policy LockPolicy + lockBeforeSuspend bool + sleepInhibitorEnabled bool + }{ + {name: "unmanaged", sleepInhibitorEnabled: true}, + {name: "lock before suspend managed on", policy: LockPolicy{LockBeforeSuspend: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true}, + {name: "both managed on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true}, + {name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}}, + {name: "integration managed off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &off}, lockBeforeSuspend: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{lockPolicy: tt.policy} + manager.applyLockPolicy() + assert.Equal(t, tt.lockBeforeSuspend, manager.lockBeforeSuspend.Load()) + assert.Equal(t, tt.sleepInhibitorEnabled, manager.sleepInhibitorEnabled.Load()) + }) + } +} diff --git a/core/internal/server/loginctl/types.go b/core/internal/server/loginctl/types.go index 31811b72f..2ef0af9ec 100644 --- a/core/internal/server/loginctl/types.go +++ b/core/internal/server/loginctl/types.go @@ -70,4 +70,25 @@ type Manager struct { sleepInhibitorEnabled atomic.Bool fallbackDelay time.Duration keepLockOnUnlock bool + lockPolicy LockPolicy +} + +// LockPolicy holds the lock settings an administrator policy manages; nil +// leaves a setting to the shell. +type LockPolicy struct { + LockBeforeSuspend *bool + LoginctlLockIntegration *bool +} + +// sleepInhibitor returns the inhibitor state the shell asks for, +// loginctlLockIntegration && lockBeforeSuspend, when the managed settings +// decide it on their own. +func (p LockPolicy) sleepInhibitor() (enabled, managed bool) { + switch { + case p.LockBeforeSuspend != nil && !*p.LockBeforeSuspend, p.LoginctlLockIntegration != nil && !*p.LoginctlLockIntegration: + return false, true + case p.LockBeforeSuspend != nil && p.LoginctlLockIntegration != nil: + return true, true + } + return false, false } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index e4d980c3e..3b7387334 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -115,7 +115,14 @@ func InitializeLoginctlManager() error { log.Errorf("CLI policy unreadable, keeping locks through logind Unlock: %v", err) keepLockOnUnlock = true } - manager, err := loginctl.NewManager(keepLockOnUnlock) + lockSettings, err := clipolicy.LockSettings(afero.NewOsFs()) + if err != nil { + log.Errorf("CLI policy unreadable, ignoring its lock settings: %v", err) + } + manager, err := loginctl.NewManager(keepLockOnUnlock, loginctl.LockPolicy{ + LockBeforeSuspend: clipolicy.LockSettingBool(lockSettings, "lockBeforeSuspend"), + LoginctlLockIntegration: clipolicy.LockSettingBool(lockSettings, "loginctlLockIntegration"), + }) if err != nil { log.Warnf("Failed to initialize loginctl manager: %v", err) return err diff --git a/quickshell/Common/AdminPolicy.qml b/quickshell/Common/AdminPolicy.qml index 35e71312a..79b044e94 100644 --- a/quickshell/Common/AdminPolicy.qml +++ b/quickshell/Common/AdminPolicy.qml @@ -1,12 +1,15 @@ pragma Singleton import Quickshell +import qs.Services +import "settings/ManagedSettings.js" as ManagedSettings -// cli-policy.json switches, set by `dms run` on each launch (core/internal/clipolicy). +// cli-policy.json switches and lock settings, set by `dms run` on each launch (core/internal/clipolicy). Singleton { readonly property bool disableLockIpcUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK") === "1" readonly property bool disableLogindUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOGIND_UNLOCK") === "1" readonly property bool disableCustomLockPam: Quickshell.env("DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM") === "1" readonly property bool disableUserPlugins: Quickshell.env("DMS_POLICY_DISABLE_USER_PLUGINS") === "1" readonly property bool disableSettingsIpcSet: Quickshell.env("DMS_POLICY_DISABLE_SETTINGS_IPC_SET") === "1" + readonly property var lockSettings: ManagedSettings.parse(Quickshell.env("DMS_POLICY_LOCK_SETTINGS"), Log.scoped("AdminPolicy").warn) } diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 0bd6dd483..bb3817a37 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -762,32 +762,9 @@ Singleton { function migrateFromUndefinedToV1(settings) { if (typeof SettingsData !== "undefined") { - if (settings.acMonitorTimeout !== undefined) { - SettingsData.set("acMonitorTimeout", settings.acMonitorTimeout); - } - if (settings.acLockTimeout !== undefined) { - SettingsData.set("acLockTimeout", settings.acLockTimeout); - } - if (settings.acSuspendTimeout !== undefined) { - SettingsData.set("acSuspendTimeout", settings.acSuspendTimeout); - } - if (settings.batteryMonitorTimeout !== undefined) { - SettingsData.set("batteryMonitorTimeout", settings.batteryMonitorTimeout); - } - if (settings.batteryLockTimeout !== undefined) { - SettingsData.set("batteryLockTimeout", settings.batteryLockTimeout); - } - if (settings.batterySuspendTimeout !== undefined) { - SettingsData.set("batterySuspendTimeout", settings.batterySuspendTimeout); - } - if (settings.lockBeforeSuspend !== undefined) { - SettingsData.set("lockBeforeSuspend", settings.lockBeforeSuspend); - } - if (settings.loginctlLockIntegration !== undefined) { - SettingsData.set("loginctlLockIntegration", settings.loginctlLockIntegration); - } - if (settings.launchPrefix !== undefined) { - SettingsData.set("launchPrefix", settings.launchPrefix); + for (const key of ["acMonitorTimeout", "acLockTimeout", "acSuspendTimeout", "batteryMonitorTimeout", "batteryLockTimeout", "batterySuspendTimeout", "lockBeforeSuspend", "loginctlLockIntegration", "launchPrefix"]) { + if (settings[key] !== undefined && !SettingsData.isManaged(key)) + SettingsData.set(key, settings[key]); } } if (typeof CacheData !== "undefined") { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 3eabac2d2..89dd9d566 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -1739,6 +1739,10 @@ Singleton { }) function set(key, value) { + if (isManaged(key)) { + log.warn("Refusing to change a setting managed by administrator policy:", key); + return; + } if (key === "cornerRadius") { setCornerRadius(value); return; @@ -1754,6 +1758,10 @@ Singleton { return key in Spec.SPEC; } + function isManaged(key) { + return key in AdminPolicy.lockSettings; + } + function specDefault(key) { return SpecUtil.cloneDef(Spec.SPEC[key]?.def); } @@ -1764,7 +1772,7 @@ Singleton { function resetToDefault(keys) { for (const key of keys) { - if (key in Spec.SPEC) + if (key in Spec.SPEC && !isManaged(key)) Spec.set(root, key, null, saveSettings, _hooks); } } @@ -1876,7 +1884,7 @@ Singleton { const prevFrameEnabled = frameEnabled; const prevFrameMode = frameMode; unsavedChanges = loadedSettings !== JSON.stringify(obj); - Store.parse(root, obj); + Store.parse(root, Object.assign(obj, AdminPolicy.lockSettings)); // set() enforces this pair, but a hand-edited settings bypass set() entirely. if (frameEnabled) @@ -2031,6 +2039,8 @@ Singleton { const file = _settingsFiles.get(path); const fileSettings = file.getSettings(); for (const setting in fileSettings) { + if (isManaged(setting)) + continue; if (!(setting in settings)) { delete fileSettings[setting]; continue; @@ -2045,7 +2055,7 @@ Singleton { } for (const setting in settings) { - if (!savedSettings.has(setting)) { + if (!savedSettings.has(setting) && !isManaged(setting)) { splitSettings[defaultSettingsFile.filePath][setting] = settings[setting]; } } diff --git a/quickshell/Common/settings/ManagedSettings.js b/quickshell/Common/settings/ManagedSettings.js new file mode 100644 index 000000000..27eca1f97 --- /dev/null +++ b/quickshell/Common/settings/ManagedSettings.js @@ -0,0 +1,29 @@ +.pragma library +.import "SettingsSpec.js" as SpecModule + +function parse(text, warn) { + const settings = {}; + if (!text) + return settings; + let policy; + try { + policy = JSON.parse(text); + } catch (e) { + warn("Ignoring unreadable managed settings:", e.message); + return settings; + } + for (const key in policy) { + const spec = SpecModule.SPEC[key]; + if (!spec || spec.persist === false) { + warn("Ignoring managed setting that does not exist:", key); + continue; + } + const value = policy[key]; + if (typeof value !== typeof spec.def || Array.isArray(value) !== Array.isArray(spec.def)) { + warn("Ignoring managed setting of the wrong type:", key, JSON.stringify(value)); + continue; + } + settings[key] = value; + } + return settings; +} diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 0534ae61e..e5ab4e8cb 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1104,6 +1104,8 @@ Item { function set(key: string, value: string): string { if (AdminPolicy.disableSettingsIpcSet) return "SETTINGS_SET_DISABLED"; + if (SettingsData.isManaged(key)) + return "SETTINGS_SET_MANAGED"; if (!(key in SettingsData)) { log.warn("Cannot set property, not found:", key); return "SETTINGS_INVALID_KEY"; diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 771a08d32..1586a5f5e 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -34,6 +34,7 @@ Item { property string u2fPendingApplyPath: "" property bool u2fShowCustom: false + readonly property string managedNote: I18n.tr("Managed by administrator policy", "settings note: the administrator lock policy sets this value") readonly property string authAutoLabel: I18n.tr("Auto", "automatic PAM authentication source option") readonly property string authCustomLabel: I18n.tr("Custom", "custom PAM authentication source option") + "…" @@ -416,6 +417,8 @@ Item { settingKey: "lockAtStartup" tags: ["lock", "screen", "startup", "start", "boot", "login", "automatic"] text: I18n.tr("Lock at startup") + description: SettingsData.isManaged("lockAtStartup") ? root.managedNote : "" + enabled: !SettingsData.isManaged("lockAtStartup") checked: SettingsData.lockAtStartup onToggled: checked => SettingsData.set("lockAtStartup", checked) } @@ -424,6 +427,8 @@ Item { settingKey: "lockBeforeSuspend" tags: ["lock", "screen", "suspend", "hibernate", "sleep", "automatic"] text: I18n.tr("Lock before suspend") + description: SettingsData.isManaged("lockBeforeSuspend") ? root.managedNote : "" + enabled: !SettingsData.isManaged("lockBeforeSuspend") checked: SettingsData.lockBeforeSuspend visible: SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration onToggled: checked => SettingsData.set("lockBeforeSuspend", checked) @@ -499,10 +504,10 @@ Item { settingKey: "enableFprint" tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"] text: I18n.tr("Fingerprint", "lock screen fingerprint authentication toggle") - description: root.lockFprintControlledByPrimary ? I18n.tr("Managed by the primary PAM source", "factor managed by PAM source status") : root.lockFingerprintDescription() - descriptionColor: root.lockFprintControlledByPrimary || SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning + description: SettingsData.isManaged("enableFprint") ? root.managedNote : root.lockFprintControlledByPrimary ? I18n.tr("Managed by the primary PAM source", "factor managed by PAM source status") : root.lockFingerprintDescription() + descriptionColor: SettingsData.isManaged("enableFprint") || root.lockFprintControlledByPrimary || SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning checked: SettingsData.enableFprint || root.primaryPamHasFprint - enabled: root.lockFprintToggleAvailable && !root.lockFprintControlledByPrimary + enabled: root.lockFprintToggleAvailable && !root.lockFprintControlledByPrimary && !SettingsData.isManaged("enableFprint") onToggled: checked => SettingsData.set("enableFprint", checked) } @@ -510,10 +515,10 @@ Item { settingKey: "enableU2f" tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"] text: I18n.tr("Security key") - description: root.lockU2fControlledByPrimary ? I18n.tr("Managed by the primary PAM source", "factor managed by PAM source status") : root.lockU2fDescription() - descriptionColor: root.lockU2fControlledByPrimary || SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning + description: SettingsData.isManaged("enableU2f") ? root.managedNote : root.lockU2fControlledByPrimary ? I18n.tr("Managed by the primary PAM source", "factor managed by PAM source status") : root.lockU2fDescription() + descriptionColor: SettingsData.isManaged("enableU2f") || root.lockU2fControlledByPrimary || SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning checked: SettingsData.enableU2f || root.primaryPamHasU2f - enabled: root.lockU2fToggleAvailable && !root.lockU2fControlledByPrimary + enabled: root.lockU2fToggleAvailable && !root.lockU2fControlledByPrimary && !SettingsData.isManaged("enableU2f") onToggled: checked => SettingsData.set("enableU2f", checked) } @@ -521,7 +526,8 @@ Item { settingKey: "u2fMode" tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"] text: I18n.tr("Security key mode", "lock screen U2F security key mode setting") - description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting") + description: SettingsData.isManaged("u2fMode") ? root.managedNote : I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting") + enabled: !SettingsData.isManaged("u2fMode") visible: SettingsData.enableU2f && !root.lockU2fControlledByPrimary options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second factor (AND)", "U2F mode option: key required after password or fingerprint")] currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method") @@ -852,9 +858,9 @@ Item { settingKey: "loginctlLockIntegration" tags: ["lock", "screen", "loginctl", "dbus", "integration", "external"] text: I18n.tr("loginctl integration") - description: I18n.tr("Disable when using an external lock screen") + description: SettingsData.isManaged("loginctlLockIntegration") ? root.managedNote : I18n.tr("Disable when using an external lock screen") checked: SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration - enabled: SessionService.loginctlAvailable + enabled: SessionService.loginctlAvailable && !SettingsData.isManaged("loginctlLockIntegration") onToggled: checked => { if (!SessionService.loginctlAvailable) return; diff --git a/quickshell/Modules/Settings/PowerSleepTab.qml b/quickshell/Modules/Settings/PowerSleepTab.qml index d642adf48..c0376a0be 100644 --- a/quickshell/Modules/Settings/PowerSleepTab.qml +++ b/quickshell/Modules/Settings/PowerSleepTab.qml @@ -15,6 +15,7 @@ Item { readonly property var timeoutOptions: timeoutValues.map(seconds => seconds === 0 ? I18n.tr("Never", "timeout option meaning the action never happens") : I18n.duration(seconds)) readonly property var gracePeriodValues: [1, 2, 3, 4, 5, 10, 15, 20, 30] readonly property var gracePeriodOptions: gracePeriodValues.map(seconds => I18n.duration(seconds)) + readonly property string managedNote: I18n.tr("Managed by administrator policy", "settings note: the administrator lock policy sets this value") function sourceKey(suffix) { return (onAc ? "ac" : "battery") + suffix; @@ -70,6 +71,8 @@ Item { settingKey: "lockTimeout" tags: ["lock", "timeout", "idle", "automatic", "security"] text: I18n.tr("Automatically lock after") + description: SettingsData.isManaged(root.sourceKey("LockTimeout")) ? root.managedNote : "" + enabled: !SettingsData.isManaged(root.sourceKey("LockTimeout")) resetKeys: [root.sourceKey("LockTimeout")] options: root.timeoutOptions currentValue: root.timeoutText(root.sourceKey("LockTimeout")) @@ -126,6 +129,8 @@ Item { settingKey: "lockBeforeSuspend" tags: ["lock", "suspend", "hibernate", "sleep", "security"] text: I18n.tr("Lock before suspend") + description: SettingsData.isManaged("lockBeforeSuspend") ? root.managedNote : "" + enabled: !SettingsData.isManaged("lockBeforeSuspend") checked: SettingsData.lockBeforeSuspend visible: SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration onToggled: checked => SettingsData.set("lockBeforeSuspend", checked) @@ -143,7 +148,8 @@ Item { settingKey: "fadeToLockEnabled" tags: ["fade", "lock", "screen", "idle", "grace period"] text: I18n.tr("Fade to lock screen") - description: I18n.tr("Fades the screen out first. Input during the fade cancels it", "idle fade toggle description") + description: SettingsData.isManaged("fadeToLockEnabled") ? root.managedNote : I18n.tr("Fades the screen out first. Input during the fade cancels it", "idle fade toggle description") + enabled: !SettingsData.isManaged("fadeToLockEnabled") checked: SettingsData.fadeToLockEnabled onToggled: checked => SettingsData.set("fadeToLockEnabled", checked) } @@ -152,6 +158,8 @@ Item { settingKey: "fadeToLockGracePeriod" tags: ["fade", "grace", "period", "timeout", "lock"] text: I18n.tr("Lock fade grace period") + description: SettingsData.isManaged("fadeToLockGracePeriod") ? root.managedNote : "" + enabled: !SettingsData.isManaged("fadeToLockGracePeriod") visible: SettingsData.fadeToLockEnabled options: root.gracePeriodOptions currentValue: root.gracePeriodText("fadeToLockGracePeriod") @@ -453,6 +461,8 @@ Item { tags: ["power", "command", "override", modelData.label] text: modelData.label leftIconName: modelData.icon + description: SettingsData.isManaged(modelData.key) ? root.managedNote : "" + enabled: !SettingsData.isManaged(modelData.key) value: SettingsData[modelData.key] || "" placeholderText: modelData.placeholder onValueEdited: value => SettingsData.set(modelData.key, value.trim()) diff --git a/quickshell/tests/admin-policy.test.mjs b/quickshell/tests/admin-policy.test.mjs index f6623b47e..52f5aca23 100644 --- a/quickshell/tests/admin-policy.test.mjs +++ b/quickshell/tests/admin-policy.test.mjs @@ -58,12 +58,16 @@ test("the user plugin switch keeps the user tree out of a scan and a resync", () } }); -test("the settings IPC switch refuses set and keeps get", () => { +function settingsIpcHandlers() { const source = readFileSync(new URL("../DMSShellIPC.qml", import.meta.url), "utf8"); const block = source.slice(source.indexOf(" function get(key: string)"), source.indexOf(' target: "settings"')); - const handlers = [...block.matchAll(/^ {8}function \w+\([^)]*\)(?:: \w+)? \{\n[\s\S]*?^ {8}\}$/gm)].map(match => match[0].replace(/\((.*?)\)(?:: \w+)? \{/, (_, params) => "(" + params.replace(/: \w+/g, "") + ") {")); + return [...block.matchAll(/^ {8}function \w+\([^)]*\)(?:: \w+)? \{\n[\s\S]*?^ {8}\}$/gm)].map(match => match[0].replace(/\((.*?)\)(?:: \w+)? \{/, (_, params) => "(" + params.replace(/: \w+/g, "") + ") {")); +} + +test("the settings IPC switch refuses set and keeps get", () => { + const handlers = settingsIpcHandlers(); for (const disableSettingsIpcSet of [false, true]) { - const SettingsData = { lockBeforeSuspend: false, saveSettings() {} }; + const SettingsData = { lockBeforeSuspend: false, saveSettings() {}, isManaged: () => false }; const context = vm.createContext({ AdminPolicy: { disableSettingsIpcSet }, SettingsData, log: { warn() {} } }); for (const handler of handlers) vm.runInContext(handler, context); @@ -72,3 +76,122 @@ test("the settings IPC switch refuses set and keeps get", () => { assert.equal(context.get("lockBeforeSuspend"), String(!disableSettingsIpcSet)); } }); + +const ManagedSettings = loadScript(new URL("../Common/settings/ManagedSettings.js", import.meta.url)); +const SettingsStore = loadScript(new URL("../Common/settings/SettingsStore.js", import.meta.url)); +const SPEC = loadScript(new URL("../Common/settings/SettingsSpec.js", import.meta.url)).SPEC; + +function settingsDataContext(globals, names) { + const context = vm.createContext(globals); + context.root = context; + for (const name of names) + vm.runInContext(functionSource("../Common/SettingsData.qml", name), context); + return context; +} + +test("managed lock settings keep only values of the default's type", () => { + const warnings = []; + const settings = ManagedSettings.parse(JSON.stringify({ + lockBeforeSuspend: true, + customPowerActionLock: "", + acLockTimeout: 300, + u2fMode: null, + enableFprint: "yes", + maxFprintTries: [3], + notASetting: true + }), (...args) => warnings.push(args[1])); + assert.deepEqual({ ...settings }, { lockBeforeSuspend: true, customPowerActionLock: "", acLockTimeout: 300 }); + assert.deepEqual(warnings.sort(), ["enableFprint", "maxFprintTries", "notASetting", "u2fMode"]); + assert.deepEqual({ ...ManagedSettings.parse("", () => assert.fail("no policy, no warning")) }, {}); +}); + +test("managed settings win over every settings file, on the first load and on each reload", () => { + const version = Number(readFileSync(new URL("../Common/SettingsData.qml", import.meta.url), "utf8").match(/settingsConfigVersion: (\d+)/)[1]); + let files = { configVersion: version, lockBeforeSuspend: false, customPowerActionLock: "true", acLockTimeout: 60 }; + const context = settingsDataContext({ + AdminPolicy: { lockSettings: { lockBeforeSuspend: true, customPowerActionLock: "" } }, + SettingsData: { Stage: { Partial: 2, Ready: 3 } }, + Store: SettingsStore, + settingsConfigVersion: version, + _hasLoaded: false, + _settingsStage: 3, + _parseError: false, + frameEnabled: false, + frameMode: "", + _getSettingsObjectFromFiles: () => JSON.parse(JSON.stringify(files)), + _mergeSessionState() {}, + Qt: { callLater() {} }, + checkIconThemeDrift() {}, + applyStoredTheme() {}, + updateCompositorCursor() {}, + updateFrameCompositorLayout() {}, + loadPluginSettings() {}, + clearIslandBars() {} + }, ["_loadSettings"]); + for (const userValue of [false, true]) { + files = Object.assign({}, files, { lockBeforeSuspend: userValue, acLockTimeout: files.acLockTimeout * 2 }); + assert.equal(context._loadSettings(), false, "managed values are no unsaved change"); + assert.equal(context.lockBeforeSuspend, true); + assert.equal(context.customPowerActionLock, ""); + assert.equal(context.acLockTimeout, files.acLockTimeout); + } +}); + +test("a save leaves managed keys out of the settings files, and a user's own value in place", () => { + const main = "/home/u/.config/dms/settings.json"; + const overlay = "/home/u/.config/dms/config.d/lock.json"; + const fileSettings = { [main]: { lockBeforeSuspend: false, acLockTimeout: 60 }, [overlay]: { customPowerActionLock: "true", fadeToLockEnabled: false } }; + const context = settingsDataContext({ + AdminPolicy: { lockSettings: { lockBeforeSuspend: true, customPowerActionLock: "", batteryLockTimeout: 300 } }, + _settingsFilesPaths: [main, overlay], + _settingsFiles: new Map(Object.keys(fileSettings).map(path => [path, { getSettings: () => Object.assign({}, fileSettings[path]) }])), + defaultSettingsFile: { filePath: main } + }, ["isManaged", "_splitSettingsByFile"]); + const split = context._splitSettingsByFile({ configVersion: 42, lockBeforeSuspend: true, customPowerActionLock: "", batteryLockTimeout: 300, acLockTimeout: 120, fadeToLockEnabled: true, lockAtStartup: true }); + assert.deepEqual(JSON.parse(JSON.stringify(split)), { + [main]: { lockBeforeSuspend: false, acLockTimeout: 120, configVersion: 42, lockAtStartup: true }, + [overlay]: { customPowerActionLock: "true", fadeToLockEnabled: true } + }); +}); + +test("set and reset refuse managed keys and change the others", () => { + const changes = []; + const warnings = []; + const context = settingsDataContext({ + AdminPolicy: { lockSettings: { lockBeforeSuspend: true } }, + Spec: { SPEC, set: (root, key, value) => changes.push([key, value]) }, + log: { warn: (...args) => warnings.push(args.at(-1)) }, + saveSettings() {}, + _hooks: {}, + lockBeforeSuspend: true + }, ["isManaged", "set", "resetToDefault"]); + context.set("lockBeforeSuspend", false); + context.set("lockAtStartup", true); + context.resetToDefault(["lockBeforeSuspend", "acLockTimeout"]); + assert.deepEqual(changes, [["lockAtStartup", true], ["acLockTimeout", null]]); + assert.deepEqual(warnings, ["lockBeforeSuspend"]); +}); + +test("settings IPC set refuses a managed key, also with the settings IPC switch off", () => { + const handlers = settingsIpcHandlers(); + const managed = new Set(["lockBeforeSuspend"]); + for (const disableSettingsIpcSet of [false, true]) { + const SettingsData = { lockBeforeSuspend: true, lockAtStartup: false, saveSettings() {}, isManaged: key => managed.has(key) }; + const context = vm.createContext({ AdminPolicy: { disableSettingsIpcSet }, SettingsData, log: { warn() {} } }); + for (const handler of handlers) + vm.runInContext(handler, context); + assert.equal(context.set("lockBeforeSuspend", "false"), disableSettingsIpcSet ? "SETTINGS_SET_DISABLED" : "SETTINGS_SET_MANAGED"); + assert.equal(SettingsData.lockBeforeSuspend, true); + assert.equal(context.set("lockAtStartup", "true"), disableSettingsIpcSet ? "SETTINGS_SET_DISABLED" : "SETTINGS_SET_SUCCESS"); + assert.equal(SettingsData.lockAtStartup, !disableSettingsIpcSet); + } +}); + +test("the legacy session migration leaves managed lock settings alone", () => { + const managed = new Set(["acLockTimeout", "lockBeforeSuspend"]); + const changes = []; + const context = vm.createContext({ SettingsData: { isManaged: key => managed.has(key), set: (key, value) => changes.push([key, value]) } }); + vm.runInContext(functionSource("../Common/SessionData.qml", "migrateFromUndefinedToV1"), context); + context.migrateFromUndefinedToV1({ acMonitorTimeout: 30, acLockTimeout: 60, lockBeforeSuspend: false, loginctlLockIntegration: false, launchPrefix: "uwsm app --" }); + assert.deepEqual(changes, [["acMonitorTimeout", 30], ["loginctlLockIntegration", false], ["launchPrefix", "uwsm app --"]]); +});