max / alloy
4 files changed,
+220 insertions,
-1 deletion
| @@ -435,6 +435,37 @@ | |||
| 435 | 435 | COPY etc/ /etc/ | |
| 436 | 436 | COPY usr/ /usr/ | |
| 437 | 437 | ||
| 438 | + | # ===================================================================== | |
| 439 | + | # polkit rules — assert the grant is not inert. | |
| 440 | + | # | |
| 441 | + | # usr/share/polkit-1/rules.d/50-alloy-settings.rules turns five actions | |
| 442 | + | # into silent yeses so `alloy settings` does not raise a password prompt | |
| 443 | + | # to change a timezone. Two ways that file can be shipped and do | |
| 444 | + | # nothing at all, both silent: | |
| 445 | + | # | |
| 446 | + | # - polkit is not in the base image, so nothing reads rules.d | |
| 447 | + | # - an action id was renamed upstream, so the grant names something | |
| 448 | + | # that no longer exists and the console prompts anyway | |
| 449 | + | # | |
| 450 | + | # Both are checked here rather than discovered on a booted machine, | |
| 451 | + | # where the symptom is a password prompt nobody can explain. The action | |
| 452 | + | # ids are read back out of the shipped rule so this cannot drift from | |
| 453 | + | # it. Grant rationale is in the rule's own header and in wiki note | |
| 454 | + | # `alloy-privilege`. | |
| 455 | + | # ===================================================================== | |
| 456 | + | RUN set -eux; \ | |
| 457 | + | rules=/usr/share/polkit-1/rules.d/50-alloy-settings.rules; \ | |
| 458 | + | [ -d /usr/share/polkit-1/actions ] \ | |
| 459 | + | || { echo "no polkit in the base image; $rules would never be read" >&2; exit 1; }; \ | |
| 460 | + | [ -f "$rules" ] || { echo "$rules did not land" >&2; exit 1; }; \ | |
| 461 | + | granted=$(sed -n '/var granted = \[/,/]/p' "$rules" | grep -o '"[a-z0-9.-]*"' | tr -d '"'); \ | |
| 462 | + | [ -n "$granted" ] || { echo "read no action ids out of $rules" >&2; exit 1; }; \ | |
| 463 | + | for action in $granted; do \ | |
| 464 | + | grep -qr "action id=\"$action\"" /usr/share/polkit-1/actions/ \ | |
| 465 | + | || { echo "granted action $action is not one this image defines" >&2; exit 1; }; \ | |
| 466 | + | done; \ | |
| 467 | + | echo "polkit: granted $(echo "$granted" | wc -l) actions, all defined" | |
| 468 | + | ||
| 438 | 469 | # ===================================================================== | |
| 439 | 470 | # Systemd presets — shipped via etc/systemd/{system,user}-preset/ | |
| 440 | 471 | # in the config tree above. Split across system-preset (greetd, |
| @@ -68,7 +68,7 @@ | |||
| 68 | 68 | 2. **Third-party repos:** Tailscale, any COPRs Alloy depends on for packages not in Fedora main. | |
| 69 | 69 | 3. **Package additions:** the full Alloy stack from [STACK.md](STACK.md): compositor, bar, launcher, notifications, terminal, editor, shell, viewers, utilities, continuity daemons, fonts, themes. | |
| 70 | 70 | 4. **Package removals:** stock Silverblue desktop pieces Alloy replaces (gnome-shell, gdm; the latter gated on the greeter pick). | |
| 71 | - | 5. **Config tree:** the tree at `etc/skel/.config/*` and `etc/skel/.mozilla/*` (new-user defaults, including the Firefox first-launch profile seed), `etc/*` (system-wide, including `etc/firefox/policies/policies.json`), and `usr/lib64/firefox/*` (Firefox autoconfig + `mozilla.cfg` default prefs) in the repo maps 1:1 into the image. | |
| 71 | + | 5. **Config tree:** the tree at `etc/skel/.config/*` and `etc/skel/.mozilla/*` (new-user defaults, including the Firefox first-launch profile seed), `etc/*` (system-wide, including `etc/firefox/policies/policies.json`), `usr/lib64/firefox/*` (Firefox autoconfig + `mozilla.cfg` default prefs), and `usr/share/polkit-1/rules.d/*` (which system settings the console may change without a prompt) in the repo maps 1:1 into the image. The polkit rule is asserted at build time against the actions the image actually defines, since a grant naming a renamed action is inert and silent about it. | |
| 72 | 72 | 6. **Systemd presets:** which services are enabled by default (syncthing off by default, gammastep off until enrolled, alloy-hinged conditionally on FW12, etc.). | |
| 73 | 73 | 7. **Branding:** os-release, plymouth splash. | |
| 74 | 74 | 8. **Validation:** `bootc container lint` runs at build. |
| @@ -1,0 +1,117 @@ | |||
| 1 | + | //! The shipped polkit rules are a security artifact, so the grant list is | |
| 2 | + | //! asserted rather than reviewed. | |
| 3 | + | //! | |
| 4 | + | //! `usr/share/polkit-1/rules.d/50-alloy-settings.rules` turns five | |
| 5 | + | //! `auth_admin_keep` actions into silent yeses for an active local session in | |
| 6 | + | //! wheel, so that `alloy settings` does not raise a password prompt to change a | |
| 7 | + | //! timezone. The reasoning is in the file's own header and in wiki note | |
| 8 | + | //! `alloy-privilege`. | |
| 9 | + | //! | |
| 10 | + | //! What this guards against is the failure mode that file exists to avoid: | |
| 11 | + | //! quiet broadening. A grant that grows by one line during an unrelated change | |
| 12 | + | //! is exactly how Omarchy ended up shipping `NOPASSWD: /usr/bin/systemctl` to | |
| 13 | + | //! enable one unit. Changing the list here should take a second commit and a | |
| 14 | + | //! moment's thought, which is what this test costs. | |
| 15 | + | //! | |
| 16 | + | //! It reads the file rather than executing it: polkit's rules are mozjs and | |
| 17 | + | //! there is no JS engine in this dependency tree. The shape being asserted is | |
| 18 | + | //! one Alloy authors and controls. | |
| 19 | + | ||
| 20 | + | use std::path::PathBuf; | |
| 21 | + | ||
| 22 | + | fn rules() -> String { | |
| 23 | + | let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 24 | + | .join("../../usr/share/polkit-1/rules.d/50-alloy-settings.rules"); | |
| 25 | + | std::fs::read_to_string(&path) | |
| 26 | + | .unwrap_or_else(|error| panic!("reading {}: {error}", path.display())) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// The action ids inside the rule's `granted` array literal. | |
| 30 | + | fn granted() -> Vec<String> { | |
| 31 | + | let text = rules(); | |
| 32 | + | let body = text | |
| 33 | + | .split_once("var granted = [") | |
| 34 | + | .expect("the rule declares a `granted` array") | |
| 35 | + | .1 | |
| 36 | + | .split_once(']') | |
| 37 | + | .expect("the array is closed") | |
| 38 | + | .0; | |
| 39 | + | body.split(',') | |
| 40 | + | .map(str::trim) | |
| 41 | + | .filter(|line| !line.is_empty()) | |
| 42 | + | .map(|line| line.trim_matches('"').to_string()) | |
| 43 | + | .collect() | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Every action the file grants, spelled out. Kept here rather than derived so | |
| 47 | + | /// that adding one to the rules file fails until it is added here too. | |
| 48 | + | const EXPECTED: [&str; 5] = [ | |
| 49 | + | "org.freedesktop.timedate1.set-timezone", | |
| 50 | + | "org.freedesktop.timedate1.set-ntp", | |
| 51 | + | "org.freedesktop.hostname1.set-static-hostname", | |
| 52 | + | "org.freedesktop.locale1.set-locale", | |
| 53 | + | "org.freedesktop.locale1.set-keyboard", | |
| 54 | + | ]; | |
| 55 | + | ||
| 56 | + | #[test] | |
| 57 | + | fn the_grant_list_is_exactly_what_was_agreed() { | |
| 58 | + | assert_eq!( | |
| 59 | + | granted(), | |
| 60 | + | EXPECTED, | |
| 61 | + | "the shipped polkit grant changed. If that was deliberate, update \ | |
| 62 | + | EXPECTED and say why in the commit; if it was not, this is a widening \ | |
| 63 | + | of what the console can do without asking.", | |
| 64 | + | ); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // The three that make "the person sitting at the machine" true. Without local | |
| 68 | + | // the grant reaches SSH, where the justification does not hold at all; without | |
| 69 | + | // active it reaches a session left open on another VT; without wheel it reaches | |
| 70 | + | // every account on the machine. | |
| 71 | + | #[test] | |
| 72 | + | fn the_grant_is_conditioned_on_an_active_local_administrator() { | |
| 73 | + | let text = rules(); | |
| 74 | + | for guard in [ | |
| 75 | + | "subject.active", | |
| 76 | + | "subject.local", | |
| 77 | + | "subject.isInGroup(\"wheel\")", | |
| 78 | + | ] { | |
| 79 | + | assert!(text.contains(guard), "the rule dropped `{guard}`"); | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // The file grants and never denies. A rule returning NOT would override a later | |
| 84 | + | // one with a better reason to allow something, which is a way to break an | |
| 85 | + | // unrelated screen from a file nobody thought to look in. | |
| 86 | + | #[test] | |
| 87 | + | fn the_rule_never_returns_a_denial() { | |
| 88 | + | let text = rules(); | |
| 89 | + | assert!(text.contains("polkit.Result.YES")); | |
| 90 | + | assert!( | |
| 91 | + | !text.contains("polkit.Result.NOT") && !text.contains("polkit.Result.NO"), | |
| 92 | + | "this file is not the place to deny anything", | |
| 93 | + | ); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Four that were considered and left prompting. Naming them individually means | |
| 97 | + | // the test fails if one is quietly added, rather than only if the count changes. | |
| 98 | + | #[test] | |
| 99 | + | fn the_actions_held_back_are_still_held_back() { | |
| 100 | + | let granted = granted(); | |
| 101 | + | for action in [ | |
| 102 | + | // The console's clock row is display-only, and moving a system clock | |
| 103 | + | // invalidates certificates and reorders logs. | |
| 104 | + | "org.freedesktop.timedate1.set-time", | |
| 105 | + | // Pretty hostname, icon, chassis, deployment, and location. | |
| 106 | + | "org.freedesktop.hostname1.set-machine-info", | |
| 107 | + | // Saving a new connection is a real administrative act. | |
| 108 | + | "org.freedesktop.NetworkManager.settings.modify.system", | |
| 109 | + | // Changing the system image should be asked about; the prompt is the point. | |
| 110 | + | "org.projectatomic.rpmostree1.upgrade", | |
| 111 | + | ] { | |
| 112 | + | assert!( | |
| 113 | + | !granted.contains(&action.to_string()), | |
| 114 | + | "`{action}` is granted, and the file's own header says it is not", | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | } |
| @@ -1,0 +1,71 @@ | |||
| 1 | + | /* Alloy: the settings a laptop owner changes about their own machine. | |
| 2 | + | * | |
| 3 | + | * `alloy settings` fronts timedatectl, hostnamectl and localectl. Every one of | |
| 4 | + | * those actions ships as auth_admin_keep, so without this file the System tab | |
| 5 | + | * would raise a password prompt to change a timezone. Granting them outright to | |
| 6 | + | * an active local session in wheel is the honest reading: the person sitting at | |
| 7 | + | * the machine, already logged into it, setting its clock is not performing an | |
| 8 | + | * administrative act that a password would clarify. See wiki note | |
| 9 | + | * `alloy-privilege` for the ladder this is the first rung of, and for what is | |
| 10 | + | * deliberately left prompting. | |
| 11 | + | * | |
| 12 | + | * The list is short on purpose. A rules file is a security artifact and should | |
| 13 | + | * be readable in one sitting; the failure mode to avoid is a grant that quietly | |
| 14 | + | * covers more than anyone remembers asking for. Omarchy issue 5708 is the | |
| 15 | + | * cautionary case, a first-run sudoers line of NOPASSWD on all of | |
| 16 | + | * /usr/bin/systemctl written to enable one unit. | |
| 17 | + | * | |
| 18 | + | * Not granted, deliberately: | |
| 19 | + | * | |
| 20 | + | * timedate1.set-time the clock row is display-only; the console does | |
| 21 | + | * not set the time by hand, and moving a system | |
| 22 | + | * clock invalidates certificates and reorders logs | |
| 23 | + | * timedate1.set-local-rtc nothing in Alloy offers it | |
| 24 | + | * hostname1.set-machine-info pretty hostname, icon, chassis, deployment and | |
| 25 | + | * location. Location in particular is not a thing | |
| 26 | + | * to hand over for a screen that sets a hostname | |
| 27 | + | * hostname1.get-product-uuid, .get-hardware-serial identifiers, not settings | |
| 28 | + | * NetworkManager.settings.modify.system saving a new connection is a real | |
| 29 | + | * administrative act and keeps its prompt | |
| 30 | + | * rpmostree1.* changing the system image should be asked about; | |
| 31 | + | * the prompt there is the point | |
| 32 | + | * | |
| 33 | + | * CALLER CONSTRAINT. `hostnamectl hostname NAME` with no scope flag sets the | |
| 34 | + | * static, transient and pretty names, and the pretty one needs | |
| 35 | + | * set-machine-info, which is not granted here. The console must call | |
| 36 | + | * `hostnamectl --static hostname NAME`; hostnamed applies the static name to | |
| 37 | + | * the running kernel hostname on its own. Granting set-machine-info to avoid | |
| 38 | + | * one flag would trade a wider grant for less typing. | |
| 39 | + | * | |
| 40 | + | * Ordering: polkit reads /etc/polkit-1/rules.d and /usr/share/polkit-1/rules.d | |
| 41 | + | * as one lexically sorted set, and the first rule to return a value wins. This | |
| 42 | + | * file ships at 50- so anything a user drops in /etc with a lower number | |
| 43 | + | * overrides it outright. | |
| 44 | + | */ | |
| 45 | + | ||
| 46 | + | polkit.addRule(function (action, subject) { | |
| 47 | + | var granted = [ | |
| 48 | + | "org.freedesktop.timedate1.set-timezone", | |
| 49 | + | "org.freedesktop.timedate1.set-ntp", | |
| 50 | + | "org.freedesktop.hostname1.set-static-hostname", | |
| 51 | + | "org.freedesktop.locale1.set-locale", | |
| 52 | + | "org.freedesktop.locale1.set-keyboard" | |
| 53 | + | ]; | |
| 54 | + | ||
| 55 | + | /* active: the session in front of the screen, not one left open on another | |
| 56 | + | * VT. local: not over SSH, where "the person sitting at the machine" is the | |
| 57 | + | * whole justification and does not hold. wheel: the group Fedora's polkit | |
| 58 | + | * already treats as administrators, and the one `alloy install` puts the | |
| 59 | + | * first account in. | |
| 60 | + | * | |
| 61 | + | * Returning nothing rather than NOT for everything else, so this file | |
| 62 | + | * grants and never denies: a rule that returned NOT would override a later | |
| 63 | + | * one that had a better reason to allow something. | |
| 64 | + | */ | |
| 65 | + | if (granted.indexOf(action.id) < 0) { | |
| 66 | + | return; | |
| 67 | + | } | |
| 68 | + | if (subject.active && subject.local && subject.isInGroup("wheel")) { | |
| 69 | + | return polkit.Result.YES; | |
| 70 | + | } | |
| 71 | + | }); |