settings: enforce administrator-managed lock settings (#3897)

port 2.0

(cherry picked from commit 404c9672d7)
This commit is contained in:
alvarosth authored and dux-ci[bot] committed 2026-10-26 17:41:50 +00:00
1 parent 4e0985fbbb
commit 5ef5a7391b
18 files changed
+526 -56

No files matched your search

+1 -1
View File
@@ -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
+64 -3
View File
@@ -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_<KEY> 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_<KEY>=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_<KEY>=1 or 0 and the
// managed lock settings as DMS_POLICY_LOCK_SETTINGS=<JSON object>, 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
}
+85 -2
View File
@@ -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"))
})
}
}
+25 -2
View File
@@ -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
}
+8 -2
View File
@@ -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"})
}
@@ -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())
})
}
}
+3 -2
View File
@@ -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
@@ -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())
})
}
}
+21
View File
@@ -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
}
+8 -1
View File
@@ -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
+4 -1
View File
@@ -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)
}
+3 -26
View File
@@ -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") {
+13 -3
View File
@@ -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];
}
}
@@ -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;
}
+2
View File
@@ -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";
+15 -9
View File
@@ -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;
+11 -1
View File
@@ -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())
+126 -3
View File
@@ -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 --"]]);
});